From 4ce3f77c63c1bb8e2c551ea3b5b8db1ead352dd9 Mon Sep 17 00:00:00 2001 From: Niels Simenon Date: Mon, 17 Oct 2022 04:06:55 +0200 Subject: [PATCH] Added IRC support (WIP). --- config/default.js | 24 ++---- package-lock.json | 157 ++++++++++++++++++++++++++++++++++ package.json | 3 + src/games/duck.js | 24 +++--- src/games/mash.js | 60 +++++++------ src/games/say.js | 2 + src/games/trivia.js | 53 +++++++----- src/irc.js | 203 ++++++++++++++++++++++++++++++++++++++++++++ src/utils/style.js | 35 ++++++++ 9 files changed, 490 insertions(+), 71 deletions(-) create mode 100644 src/irc.js create mode 100644 src/utils/style.js diff --git a/config/default.js b/config/default.js index f441765..f2027ae 100644 --- a/config/default.js +++ b/config/default.js @@ -1,26 +1,19 @@ 'use strict'; module.exports = { + platform: 'irc', user: { - id: 'clive', - key: 'abcdefgh12345678', - username: 'Clive', - // optional - gender: 'male', - countryCode: 'GB', - birthdate: new Date(1952, 11, 10), - avatar: 'https://i.imgur.com/IZwrjjG.png', + nick: 'aisha', + username: 'Aisha', + realName: 'Aisha', }, operators: ['admin'], - uniqueUsername: false, - socket: 'ws://127.0.0.1:3000/socket', - api: 'http://127.0.0.1:3000/api', + server: 'irc.libera.chat', + port: 6697, reconnectDelay: 10, // seconds prefix: '~', - style: { - color: 'var(--message-56)', - }, - channels: ['GamesNight'], + usernamePrefix: '@', + channels: ['##pendulum'], games: ['mash', 'trivia', 'duck', 'ping', 'say', 'kill'], trivia: { mode: 'first', // first or timeout @@ -29,5 +22,6 @@ module.exports = { }, duck: { interval: [10, 3600], // seconds + duck: ':duck:', }, }; diff --git a/package-lock.json b/package-lock.json index 793fc0e..4cce8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "bottleneck": "^2.19.5", "config": "^3.3.6", "html-entities": "^2.3.2", + "irc": "^0.5.2", + "irc-colors": "^1.5.0", + "irc-upd": "^0.11.0", "jsdom": "^18.1.0", "linkify-it": "^3.0.3", "simple-node-logger": "^21.8.12", @@ -1979,6 +1982,19 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/iconv": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/iconv/-/iconv-2.2.3.tgz", + "integrity": "sha512-evIiYeKdt5nEGYKNkQcGPQy781sYgbBKi3gEkt1s4CwteCdOHSjGGRyyp6lP8inYFZwvzG3lgjXEvGUC8nqQ5A==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.3.5" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2078,6 +2094,62 @@ "node": ">= 0.4" } }, + "node_modules/irc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/irc/-/irc-0.5.2.tgz", + "integrity": "sha512-KnrvkV05Y71SWmRWHtnlWEIH7LA/YeDul6l7tncCGLNEw4B6Obtmkatb3ACnSLj0kOJ6UBiuhss9e+eRG3zlxw==", + "dependencies": { + "irc-colors": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "optionalDependencies": { + "iconv": "~2.2.1", + "node-icu-charset-detector": "~0.2.0" + } + }, + "node_modules/irc-colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/irc-colors/-/irc-colors-1.5.0.tgz", + "integrity": "sha512-HtszKchBQTcqw1DC09uD7i7vvMayHGM1OCo6AHt5pkgZEyo99ClhHTMJdf+Ezc9ovuNNxcH89QfyclGthjZJOw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/irc-upd": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/irc-upd/-/irc-upd-0.11.0.tgz", + "integrity": "sha512-A1hV5cUkl5HZsKWRYcszD2Usfz33hB8igSSox8dEmrMyfy8/Ra6T/o4jwzs7jYMZ7ljLquSIWzcvSZHZ/bEAZA==", + "dependencies": { + "irc-colors": "^1.5.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "optionalDependencies": { + "chardet": "^1.2.1", + "iconv-lite": "^0.6.2" + } + }, + "node_modules/irc-upd/node_modules/chardet": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz", + "integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg==", + "optional": true + }, + "node_modules/irc-upd/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", @@ -2561,12 +2633,31 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nan": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/node-icu-charset-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", + "integrity": "sha512-DYOFJ3NfKdxEi9hPbmoCss6WydGhJsxpSleUlZfAWEbZt3AU7JuxailgA9tnqQdsHiujfUY9VtDfWD9m0+ThtQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "^2.3.3" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/node-releases": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", @@ -5219,6 +5310,15 @@ } } }, + "iconv": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/iconv/-/iconv-2.2.3.tgz", + "integrity": "sha512-evIiYeKdt5nEGYKNkQcGPQy781sYgbBKi3gEkt1s4CwteCdOHSjGGRyyp6lP8inYFZwvzG3lgjXEvGUC8nqQ5A==", + "optional": true, + "requires": { + "nan": "^2.3.5" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5297,6 +5397,48 @@ "side-channel": "^1.0.4" } }, + "irc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/irc/-/irc-0.5.2.tgz", + "integrity": "sha512-KnrvkV05Y71SWmRWHtnlWEIH7LA/YeDul6l7tncCGLNEw4B6Obtmkatb3ACnSLj0kOJ6UBiuhss9e+eRG3zlxw==", + "requires": { + "iconv": "~2.2.1", + "irc-colors": "^1.1.0", + "node-icu-charset-detector": "~0.2.0" + } + }, + "irc-colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/irc-colors/-/irc-colors-1.5.0.tgz", + "integrity": "sha512-HtszKchBQTcqw1DC09uD7i7vvMayHGM1OCo6AHt5pkgZEyo99ClhHTMJdf+Ezc9ovuNNxcH89QfyclGthjZJOw==" + }, + "irc-upd": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/irc-upd/-/irc-upd-0.11.0.tgz", + "integrity": "sha512-A1hV5cUkl5HZsKWRYcszD2Usfz33hB8igSSox8dEmrMyfy8/Ra6T/o4jwzs7jYMZ7ljLquSIWzcvSZHZ/bEAZA==", + "requires": { + "chardet": "^1.2.1", + "iconv-lite": "^0.6.2", + "irc-colors": "^1.5.0" + }, + "dependencies": { + "chardet": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz", + "integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg==", + "optional": true + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", @@ -5645,12 +5787,27 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "nan": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "optional": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node-icu-charset-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", + "integrity": "sha512-DYOFJ3NfKdxEi9hPbmoCss6WydGhJsxpSleUlZfAWEbZt3AU7JuxailgA9tnqQdsHiujfUY9VtDfWD9m0+ThtQ==", + "optional": true, + "requires": { + "nan": "^2.3.3" + } + }, "node-releases": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", diff --git a/package.json b/package.json index 033bcc2..0de8931 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "bottleneck": "^2.19.5", "config": "^3.3.6", "html-entities": "^2.3.2", + "irc": "^0.5.2", + "irc-colors": "^1.5.0", + "irc-upd": "^0.11.0", "jsdom": "^18.1.0", "linkify-it": "^3.0.3", "simple-node-logger": "^21.8.12", diff --git a/src/games/duck.js b/src/games/duck.js index aa04873..60a2758 100644 --- a/src/games/duck.js +++ b/src/games/duck.js @@ -2,6 +2,8 @@ const config = require('config'); +const style = require('../utils/style'); + const ducks = new Map(); let shots = new Map(); @@ -22,7 +24,7 @@ function launchDuck(context) { const room = rooms[Math.floor(Math.random() * rooms.length)]; ducks.set(room.id, new Date()); - context.sendMessage('Quack! :duck:', room.id); + context.sendMessage(`Quack! ${config.duck.duck}`, room.id); }, interval); } @@ -30,7 +32,7 @@ function onCommand(args, context) { const duck = ducks.get(context.room.id); if (!duck) { - context.sendMessage(`There is no duck, what are you shooting at, @${context.user.username}?!`, context.room.id); + context.sendMessage(`There is no duck, what are you shooting at, ${config.usernamePrefix}${context.user.username}?!`, context.room.id); return; } @@ -39,7 +41,7 @@ function onCommand(args, context) { if (context.command === 'bang') { if (hit) { - context.sendMessage(`You shot a duck in **${time} seconds**, @${context.user.username}`, context.room.id); + context.sendMessage(`You shot a duck in ${style.bold(`${time} seconds`)}, ${config.usernamePrefix}${context.user.username}`, context.room.id); launchDuck(context); context.setPoints(context.user, 1, { key: 'bang' }); @@ -48,10 +50,10 @@ function onCommand(args, context) { } const messages = [ - `How could you miss *that*, @${context.user.username}...?!`, - `That's a miss! Better luck next time, @${context.user.username}.`, - `The duck outsmarted you, @${context.user.username}`, - `Channeling Gareth Southgate, @${context.user.username}? You missed!`, + `How could you miss *that*, ${config.usernamePrefix}${context.user.username}...?!`, + `That's a miss! Better luck next time, ${config.usernamePrefix}${context.user.username}.`, + `The duck outsmarted you, ${config.usernamePrefix}${context.user.username}`, + `Channeling Gareth Southgate, ${config.usernamePrefix}${context.user.username}? You missed!`, ]; shots.set(context.user.id, new Date()); @@ -62,7 +64,7 @@ function onCommand(args, context) { if (['bef', 'befriend'].includes(context.command)) { if (hit) { - context.sendMessage(`You befriended a duck in **${time} seconds**, @${context.user.username}`, context.room.id); + context.sendMessage(`You befriended a duck in ${style.bold(`${time} seconds`)}, ${config.usernamePrefix}${context.user.username}`, context.room.id); launchDuck(context); context.setPoints(context.user, 1, { key: 'befriend' }); @@ -71,9 +73,9 @@ function onCommand(args, context) { } const messages = [ - `The duck does not want to be your friend right now, @${context.user.username}`, - `The duck would like some time for itself, @${context.user.username}`, - `The duck isn't in the mood right now, @${context.user.username}`, + `The duck does not want to be your friend right now, ${config.usernamePrefix}${context.user.username}`, + `The duck would like some time for itself, ${config.usernamePrefix}${context.user.username}`, + `The duck isn't in the mood right now, ${config.usernamePrefix}${context.user.username}`, ]; shots.set(context.user.id, new Date()); diff --git a/src/games/mash.js b/src/games/mash.js index 4459e93..b68ca49 100644 --- a/src/games/mash.js +++ b/src/games/mash.js @@ -2,15 +2,17 @@ const config = require('config'); +const style = require('../utils/style'); const words = require('../../assets/mash-words.json'); -let mash = null; +const mashes = new Map(); function getWordKey(word) { return word.split('').sort().join(''); } function start(length, context, attempt = 0) { + const mash = mashes.get(context.room.id); const lengthWords = words[length]; if (!lengthWords) { @@ -20,10 +22,10 @@ function start(length, context, attempt = 0) { } if (mash) { - context.sendMessage(`The mash **${mash.anagram}** was not guessed, possible answers: ${mash.answers.map((answer) => `**${answer.word}**`).join(', ')}`, context.room.id); + context.sendMessage(`The mash ${style.bold(mash.anagram)} was not guessed, possible answers: ${mash.answers.map((answer) => style.bold(answer.word)).join(', ')}`, context.room.id); context.logger.info(`Mash '${mash.anagram}' discarded`); - mash = null; + mashes.delete(context.room.id); } const wordEntries = Object.entries(lengthWords); @@ -42,45 +44,48 @@ function start(length, context, attempt = 0) { return; } - mash = { key, anagram, answers }; + mashes.set(context.room.id, { key, anagram, answers }); - context.sendMessage(`Stomp stomp, here's your mash: **${mash.anagram}**`, context.room.id); + const newMash = mashes.get(context.room.id); + + context.sendMessage(`Stomp stomp, here's your mash: ${style.bold(style.purple(newMash.anagram))}`, context.room.id); context.logger.info(`Mash started, '${anagram}' with answers ${answers.map((answer) => `'${answer.word}'`).join(', ')}`); } function play(rawWord, context, shouted) { + const mash = mashes.get(context.room.id); const word = rawWord.toLowerCase(); const key = getWordKey(word); const answer = mash.answers.find((answerX) => answerX.word === word); if (!shouted) { if (word.length !== mash.key.length) { - context.sendMessage(`Your answer needs to be ${mash.key.length} letters, @${context.user.username}`, context.room.id); + context.sendMessage(`Your answer needs to be ${mash.key.length} letters, ${config.usernamePrefix}${context.user.username}`, context.room.id); return; } if (key !== mash.key) { - context.sendMessage(`You are not using the letters in **${mash.anagram}**, @${context.user.username}`, context.room.id); + context.sendMessage(`You are not using the letters in ${style.bold(mash.anagram)}, ${config.usernamePrefix}${context.user.username}`, context.room.id); return; } if (word === mash.anagram) { - context.sendMessage(`@${context.user.username}... :expressionless:`, context.room.id); + context.sendMessage(`${config.usernamePrefix}${context.user.username}... :expressionless:`, context.room.id); return; } } if (answer) { - const definition = answer.definitions[0] ? `: *${answer.definitions[0].slice(0, 100)}${mash.answers[0].definitions[0].length > 100 ? '...*' : '*'}` : ''; + const definition = answer.definitions[0] ? `: ${style.italic(`${answer.definitions[0].slice(0, 100)}${mash.answers[0].definitions[0].length > 100 ? '...' : ''}`)}` : ''; context.sendMessage(mash.answers.length === 1 - ? `**${word}** is the right answer${definition}, @${context.user.username} now has **${context.user.points + 1} ${context.user.points === 0 ? 'point' : 'points'}**! There were no other options for **${mash.anagram}**.` - : `**${word}** is the right answer${definition}, @${context.user.username} now has **${context.user.points + 1} ${context.user.points === 0 ? 'point' : 'points'}**! Other options for **${mash.anagram}**: ${mash.answers.filter((answerX) => answerX.word !== word).map((answerX) => `*${answerX.word}*`).join(', ')}`, context.room.id); + ? `${style.bold(style.yellow(word))} is the right answer${definition}, ${style.bold(style.cyan(`${config.usernamePrefix}${context.user.username}`))} now has ${style.bold(`${context.user.points + 1} ${context.user.points === 0 ? 'point' : 'points'}`)}! There were no other options for ${style.bold(mash.anagram)}.` + : `${style.bold(style.yellow(word))} is the right answer${definition}, ${style.bold(style.cyan(context.user.username))} now has ${style.bold(`${context.user.points + 1} ${context.user.points === 0 ? 'point' : 'points'}`)}! Other options for ${style.bold(mash.anagram)}: ${mash.answers.filter((answerX) => answerX.word !== word).map((answerX) => style.italic(answerX.word)).join(', ')}`, context.room.id); // eslint-disable-line max-len context.logger.info(`Mash '${mash.anagram}' guessed by '${context.user.username}' with '${word}'`); context.setPoints(context.user, 1); - mash = null; + mashes.delete(context.room.id); setTimeout(() => start(word.length, context), 2000); } @@ -88,7 +93,7 @@ function play(rawWord, context, shouted) { function resolve(word, context) { if (!word) { - context.sendMessage(`Please specify an anagram you would like to resolve, @${context.user.username}`, context.room.id); + context.sendMessage(`Please specify an anagram you would like to resolve, ${config.usernamePrefix}${context.user.username}`, context.room.id); return; } @@ -96,26 +101,26 @@ function resolve(word, context) { const answers = words[word.length]?.[anagram]; if (answers?.length > 1 && answers.some((answer) => answer.word === word)) { - context.sendMessage(`**${word}** is a valid word in itself, and has the following anagrams, @${context.user.username}: ${answers.filter((answer) => answer.word !== word).map((answer) => `*${answer.word}*`).join(', ')}`, context.room.id); + context.sendMessage(`${style.bold(word)} is a valid word in itself, and has the following anagrams, ${config.usernamePrefix}${context.user.username}: ${answers.filter((answer) => answer.word !== word).map((answer) => style.italic(answer.word)).join(', ')}`, context.room.id); return; } if (answers?.length === 1 && answers[0].word === word) { - context.sendMessage(`**${word}** is a valid word in itself, but has no anagrams, @${context.user.username}`, context.room.id); + context.sendMessage(`${style.bold(word)} is a valid word in itself, but has no anagrams, ${config.usernamePrefix}${context.user.username}`, context.room.id); return; } if (answers?.length > 0) { - context.sendMessage(`Anagrams of **${word}**, @${context.user.username}: ${answers.map((answer) => `*${answer.word}*`).join(', ')}`, context.room.id); + context.sendMessage(`Anagrams of ${style.bold(word)}, ${config.usernamePrefix}${context.user.username}: ${answers.map((answer) => style.italic(answer.word)).join(', ')}`, context.room.id); return; } - context.sendMessage(`No anagrams found for **${word}**, @${context.user.username}`, context.room.id); + context.sendMessage(`No anagrams found for ${style.bold(word)}, ${config.usernamePrefix}${context.user.username}`, context.room.id); } function define(word, context) { if (!word) { - context.sendMessage(`Please specify word you would like to define, @${context.user.username}`, context.room.id); + context.sendMessage(`Please specify word you would like to define, ${config.usernamePrefix}${context.user.username}`, context.room.id); return; } @@ -124,33 +129,36 @@ function define(word, context) { const answer = answers?.find((answerX) => answerX.word === word); if (answer && answer.definitions?.length > 0) { - context.sendMessage(`${word} can be defined as follows, @${context.user.username}: *${answer.definitions[0]}*`, context.room.id); + context.sendMessage(`${word} can be defined as follows, ${config.usernamePrefix}${context.user.username}: ${style.italic(answer.definitions[0])}`, context.room.id); return; } - context.sendMessage(`No definition available for **${word}**, @${context.user.username}`, context.room.id); + context.sendMessage(`No definition available for ${style.bold(word)}, ${config.usernamePrefix}${context.user.username}`, context.room.id); } function hint(context) { + const mash = mashes.get(context.room.id); + if (!mash) { - context.sendMessage(`There is no mash going on right now, @${context.user.username}. Start one with ${config.prefix}mash {length}`, context.room.id); + context.sendMessage(`There is no mash going on right now, ${config.usernamePrefix}${context.user.username}. Start one with ${config.prefix}mash {length}`, context.room.id); return; } if (mash.anagram.length <= 3) { - context.sendMessage(`The mash **${mash.anagram}** is too short for a hint, @${context.user.username}.`, context.room.id); + context.sendMessage(`The mash ${style.bold(mash.anagram)} is too short for a hint, ${config.usernamePrefix}${context.user.username}.`, context.room.id); return; } if (mash.anagram.length === 4) { - context.sendMessage(`Hints for **${mash.anagram}**, @${context.user.username}: ${mash.answers.map((answer) => `**${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 1).trim()}** (${answer.definitions[0]})`).join(', ')}`, context.room.id); + context.sendMessage(`Hints for ${style.bold(style.purple(mash.anagram))}, ${config.usernamePrefix}${context.user.username}: ${mash.answers.map((answer) => `${style.bold(`${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 1).trim()}`)} (${answer.definitions[0]})`).join(', ')}`, context.room.id); return; } - context.sendMessage(`Hints for **${mash.anagram}**, @${context.user.username}: ${mash.answers.map((answer) => `**${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 2)}${answer.word.slice(-1)}** (${answer.definitions[0]})`).join(', ')}`, context.room.id); + context.sendMessage(`Hints for ${style.bold(mash.anagram)}, ${config.usernamePrefix}${context.user.username}: ${mash.answers.map((answer) => `${style.bold(`${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 2)}${answer.word.slice(-1)}`)} (${answer.definitions[0]})`).join(', ')}`, context.room.id); } function onCommand(args, context) { + const mash = mashes.get(context.room.id); const word = args[0]; const length = Number(word); @@ -175,7 +183,7 @@ function onCommand(args, context) { } if (!word && mash) { - context.sendMessage(`The current mash is: **${mash.anagram}**`, context.room.id); + context.sendMessage(`The current mash is: ${style.bold(style.purple(mash.anagram))}`, context.room.id); return; } @@ -188,6 +196,8 @@ function onCommand(args, context) { } function onMessage(message, context) { + const mash = mashes.get(context.room.id); + if (mash && context.user?.id !== config.user.id) { play(message.body, context, true); } diff --git a/src/games/say.js b/src/games/say.js index 9e7d7d3..271fe90 100644 --- a/src/games/say.js +++ b/src/games/say.js @@ -11,6 +11,8 @@ function onCommand(args, context) { const roomName = args[0].replace(/#+/, ''); const room = context.room || Object.values(context.bot.rooms).find((botRoom) => botRoom.name === roomName); + console.log(message, roomName, room); + if (!room) { return; } diff --git a/src/games/trivia.js b/src/games/trivia.js index 0c939c2..25dfae6 100644 --- a/src/games/trivia.js +++ b/src/games/trivia.js @@ -6,6 +6,7 @@ const { decode } = require('html-entities'); const questions = require('../../assets/jeopardy.json'); const shuffle = require('../utils/shuffle'); +const style = require('../utils/style'); const settings = { ...config.trivia }; const help = { @@ -14,9 +15,12 @@ const help = { timeout: 'seconds as a number', }; -let game = null; +// let game = null; +const games = new Map(); function scoreRound(context, round) { + const game = games.get(context.room.id); + if (game.answers.size === 0) { return `No one scored in round ${round + 1}, better luck next time!`; } @@ -26,24 +30,27 @@ function scoreRound(context, round) { context.setPoints(user, 1); game.points[user.username] = (game.points[user.username] || 0) + 1; - return `**@${user.username}** gets a point`; + return `${style.bold(style.cyan(`${config.usernamePrefix}${user.username}`))} gets a point`; } return null; }).filter(Boolean).join(', '); } -function getLeaders() { +function getLeaders(context) { + const game = games.get(context.room.id); + return Object.entries(game.points).sort(([, scoreA], [, scoreB]) => scoreB - scoreA).map(([username, score], index) => { if (index === 0) { - return `**@${username}** with **${score}** points`; + return `${style.bold(`${config.usernamePrefix}${username}`)} with ${style.bold(`${score}`)} points`; } - return `**@${username}** with **${score}** points`; + return `${style.bold(style.cyan(`${config.usernamePrefix}${username}`))} with ${style.bold(`${score}`)} points`; }).join(', '); } async function playRound(context, round = 0) { + const game = games.get(context.room.id); const ac = new AbortController(); // eslint-disable-line no-undef const now = new Date(); @@ -53,7 +60,7 @@ async function playRound(context, round = 0) { const question = game.questions[round]; - context.sendMessage(`**Question ${round + 1}/${game.questions.length}** (${question.category}): ${question.question}`, context.room.id); + context.sendMessage(`${style.bold(style.purple(`Question ${round + 1}/${game.questions.length}`))} ${style.gray(`(${question.category})`)}: ${question.question}`, context.room.id); context.logger.info(`Trivia asked "${question.question}" with answer: ${question.answer}`); try { @@ -62,14 +69,14 @@ async function playRound(context, round = 0) { }); // replace space with U+2003 Em Space to separate words, since a single space separates the placeholders, and double spaces are removed during Markdown render - context.sendMessage(`**${Math.floor(game.timeout / 3) * 2} seconds** left, first hint for **question ${round + 1}/${game.questions.length}**: **${question.answer.slice(0, 1)} ${question.answer.slice(1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ').trim()}**`, context.room.id); + context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3) * 2} seconds`))} left, first hint for ${style.bold(style.purple(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ').trim()}`)}`, context.room.id); await timers.setTimeout((game.timeout / 3) * 1000, null, { signal: ac.signal, }); if (question.answer.length > 3) { - context.sendMessage(`**${Math.floor(game.timeout / 3)} seconds** left, second hint for **question ${round + 1}/${game.questions.length}**: **${question.answer.slice(0, 1)} ${question.answer.slice(1, -1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ')}${question.answer.slice(-1)}**`, context.room.id); + context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3)} seconds`))} left, second hint for ${style.bold(style.purple(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1, -1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ')}${question.answer.slice(-1)}`)}`, context.room.id); } await timers.setTimeout((game.timeout / 3) * 1000, null, { @@ -84,23 +91,23 @@ async function playRound(context, round = 0) { } if (game.stopped) { - context.sendMessage(`The game was stopped by @${game.stopped.username}. The answer to the last question was: **${question.answer}**. Best players: ${getLeaders()}`, context.room.id); - game = null; + context.sendMessage(`The game was stopped by ${style.cyan(`${config.usernamePrefix}${game.stopped.username}`)}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(context)}`, context.room.id); + games.delete(context.room.id); return; } if (game.answers.size === 0) { - context.sendMessage(`**TIME'S UP!** No one guessed the answer: **${question.answer}**`, context.room.id); + context.sendMessage(`${style.bold(style.red('TIME\'S UP!'))} No one guessed the answer: ${style.bold(question.answer)}`, context.room.id); } else { const scores = scoreRound(context, round); if (game.mode === 'first') { - context.sendMessage(`**${question.fullAnswer || question.answer}** is the right answer, played in **${((new Date() - now) / 1000).toFixed(3)}s**! ${scores}`, context.room.id); + context.sendMessage(`${style.bold(style.yellow(question.fullAnswer || question.answer))} is the right answer, played in ${style.bold(style.green(`${((new Date() - now) / 1000).toFixed(3)}s`))}! ${scores}`, context.room.id); } if (game.mode === 'timeout') { - context.sendMessage(`**STOP!** The correct answer is **${question.fullAnswer || question.answer}**. ${scores}`, context.room.id); + context.sendMessage(`${style.bold(style.red('STOP!'))} The correct answer is ${style.bold(style.green(question.fullAnswer || question.answer))}. ${scores}`, context.room.id); } } @@ -108,8 +115,8 @@ async function playRound(context, round = 0) { await timers.setTimeout(5000); if (game.stopped) { - context.sendMessage(`The game was stopped by @${game.stopped.username}. The answer to the last question was: **${question.answer}**. Best players: ${getLeaders()}`, context.room.id); - game = null; + context.sendMessage(`The game was stopped by ${config.usernamePrefix}${game.stopped.username}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(context)}`, context.room.id); + games.delete(context.room.id); return; } @@ -122,29 +129,33 @@ async function playRound(context, round = 0) { context.sendMessage(`That's the end of the game! Best players: ${getLeaders()}`, context.room.id); - game = null; + games.delete(context.room.id); } async function start(context) { const roundQuestions = shuffle(questions, settings.rounds); - game = { + games.set(context.room.id, { round: 0, questions: roundQuestions, answers: [], points: {}, ...settings, - }; + }); playRound(context, 0); } async function stop(context) { + const game = games.get(context.room.id); + game.stopped = context.user; game.ac.abort(); } function onCommand(args, context) { + const game = games.get(context.room.id); + if (!context.subcommand && !game) { start(context); return; @@ -181,6 +192,8 @@ function onCommand(args, context) { } async function onMessage(message, context) { + const game = games.get(context.room.id); + if (!game || context.user?.id === config.user?.id) { return; } @@ -199,9 +212,9 @@ async function onMessage(message, context) { if (settings.mode === 'timeout' && !game.ac.signal.aborted) { if (message.type === 'message') { - context.sendMessage(`**${fullAnswer || answer}** is the correct answer! You might want to **/whisper** the answer to me instead, so others can't leech off your impeccable knowledge. You will receive a point at the end of the round.`, context.room.id, null, context.user.username); + context.sendMessage(`${style.bold(fullAnswer || answer)} is the correct answer! You might want to ${style.bold('/whisper')} the answer to me instead, so others can't leech off your impeccable knowledge. You will receive a point at the end of the round.`, context.room.id, null, context.user.username); } else { - context.sendMessage(`**${fullAnswer || answer}** is the correct answer! You will receive a point at the end of the round.`, context.room.id, null, context.user.username); + context.sendMessage(`${style.bold(fullAnswer || answer)} is the correct answer! You will receive a point at the end of the round.`, context.room.id, null, context.user.username); } } } diff --git a/src/irc.js b/src/irc.js new file mode 100644 index 0000000..3a0b2ec --- /dev/null +++ b/src/irc.js @@ -0,0 +1,203 @@ +'use strict'; + +const config = require('config'); +const fs = require('fs').promises; +const irc = require('irc-upd'); +const logger = require('simple-node-logger').createSimpleLogger(); +const { argv } = require('yargs'); +// const timers = require('timers/promises'); + +const style = require('./utils/style'); + +logger.setLevel(argv.level || 'info'); + +const points = {}; + +const client = new irc.Client(config.server, config.user.nick, { + userName: config.user.username, + realName: config.user.realName, + password: config.user.password, + port: config.port, + secure: true, +}); + +async function setPoints(defaultKey, user, value, { mode = 'add', key } = {}) { + const gameKey = key || defaultKey; + + if (!user) { + logger.warn(`Failed to set ${gameKey} points for missing user`); + return; + } + + const userKey = `${user.id}:${user.username}`; + + if (!points[gameKey]) { + points[gameKey] = {}; + } + + if (mode === 'add') { + points[gameKey][userKey] = (points[gameKey][userKey] || 0) + value; + } + + if (mode === 'set') { + points[gameKey][userKey] = value; + } + + await fs.writeFile(`./points-${config.user.nick}.json`, JSON.stringify(points, null, 4)); +} + +function getPoints(game, rawUsername, { user, room, command }) { + const username = rawUsername?.replace(new RegExp(`^${config.usernamePrefix}`), ''); + const gamePoints = points[command] || points[game.key]; + + const userPoints = username + ? Object.entries(gamePoints || {}).find(([identifier]) => identifier.split(':')[1] === username)?.[1] + : gamePoints?.[`${user?.id}:${user?.username}`]; + + game.sendMessage(`${username ? `${style.bold(username)} has` : 'You have'} scored ${style.bold(userPoints || 0)} points in ${game.name}, ${config.usernamePrefix}${user.username}`, room.id); +} + +function getLeaderboard(game, { user, room, command }) { + const leaderboard = points[command] || points[game.key]; + + if (!leaderboard || Object.keys(leaderboard).length === 0) { + game.sendMessage(`No points scored in ${game.name} yet!`, room.id); + + return; + } + + const curatedLeaderboard = Object.entries(leaderboard) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .map(([userKey, score]) => { + const username = userKey.split(':')[1]; + return `${style.bold(`${username === user.username ? config.usernamePrefix : ''}${username}`)} at ${style.bold(score)} points`; + }) + .slice(0, 10) + .join(', '); + + game.sendMessage(`The top ${Math.min(Object.keys(leaderboard).length, 10)} ${style.italic(game.name)} players are: ${curatedLeaderboard}`, room.id); +} + +function getGames(bot) { + const games = config.games.reduce((acc, key) => { + const game = require(`./games/${key.game || key}`); // eslint-disable-line global-require, import/no-dynamic-require + + const sendMessage = (body, roomId, options, recipient) => { + console.log(roomId || recipient, body); + client.say(roomId || recipient, body); + }; + + const setGamePoints = (userId, score, options) => setPoints(key, userId, score, options); + + const curatedGame = { + ...game, + ...(key.game && key), + name: game.name || key, + key, + sendMessage, + setPoints: setGamePoints, + }; + + if (game.onStart) { + game.onStart({ ...curatedGame, bot }); + } + + return { + ...acc, + [key]: curatedGame, + ...game.commands?.reduce((commandAcc, command) => ({ ...commandAcc, [command]: curatedGame }), {}), + }; + }, {}); + + return games; +} + +function onMessage(message, bot, games) { + const body = message.originalBody || message.body; + const [, command, subcommand] = body?.match(new RegExp(`^${config.prefix}(\\w+)(?:\\:(\\w+))?`)) || []; + // const user = bot.users[message.userId] || message.user; + const user = { username: message.from, id: message.from }; + const room = { id: message.to, name: message.to }; + + if (command) { + const args = body.split(/\s+/).slice(1); + const game = games[command]; + + if (['leaderboard', 'lead', 'leader', 'leaders', 'scoreboard', 'best'].includes(subcommand) && games[command]) { + getLeaderboard(games[command], { user, room, command }); + return; + } + + if (['points', 'score'].includes(subcommand) && games[command]) { + getPoints(games[command], args[0], { user, room, command }); + return; + } + + if (game && game.onCommand) { + if (user) { + user.points = points[game.key]?.[`${user.id}:${user.username}`] || 0; + } + + game.onCommand(args, { + ...game, + command, + subcommand, + bot, + message, + user, + room, + points: points[game.key] || {}, + logger, + }); + } + } + + Object.values(games).forEach((game) => game.onMessage?.(message, { + ...game, + bot, + message, + user: user && { + ...user, + points: points[game.key]?.[`${user.id}:${user.username}`] || 0, + }, + room, + logger, + })); +} + +async function init() { + const bot = { + rooms: config.channels.map((channel) => ({ + id: channel, + name: channel, + })), + }; + + const games = getGames(bot); + + client.addListener('registered', () => { + logger.info('Connected!'); + logger.info('Identifying with NickServ'); + + client.say('nickserv', `IDENTIFY ${config.user.username} ${config.user.password}`); + + config.channels.forEach((channel) => { + logger.info(`Joining ${channel}`); + + client.join(channel, () => logger.info(`Joined ${channel}`)); + client.say(channel, `Hi, I am ${config.user.username}, your game host!`); + }); + + client.addListener('message', (from, to, body) => onMessage({ + from, + to, + body, + }, bot, games)); + + client.addListener('error', (error) => { + console.error(error); + }); + }); +} + +init(); diff --git a/src/utils/style.js b/src/utils/style.js new file mode 100644 index 0000000..b3212c7 --- /dev/null +++ b/src/utils/style.js @@ -0,0 +1,35 @@ +'use strict'; + +const config = require('config'); +const styles = require('irc-colors'); + +function schatBold(text) { + return `**${text}**`; +} + +function schatItalic(text) { + return `*${text}*`; +} + +function bypass(text) { + return text; +} + +module.exports = (() => { + if (config.platform === 'irc') { + return styles; + } + + if (config.platform === 'schat') { + return { + bold: schatBold, + italic: schatItalic, + red: bypass, + getter(...args) { + console.log(args); + }, + }; + } + + return null; +})();