Compare commits

...
17 Commits
Author SHA1 Message Date
ThePendulum 678b2991ad 1.31.3 2026-10-18 02:15:24 +02:00
ThePendulum f048c1d077 Escaping trivia dataset before passing it into regex. 2026-10-18 02:15:23 +02:00
ThePendulum 1572e74ebf 1.31.2 2026-10-18 01:37:38 +02:00
ThePendulumandClaude Opus 5.5 fc5f8572cd Replaced unmaintained irc library with irc-framework, logging in with SASL and reclaiming ghosted nicks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:28:47 +02:00
ThePendulumandClaude Opus 5.5 1101a5fc3e Removed unused dependencies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:17:20 +02:00
ThePendulum e610f4212c 1.31.1 2026-10-18 01:14:25 +02:00
ThePendulumandClaude Opus 5.5 01f356c0b1 Upgraded to Node 26: updated better-sqlite3 and config, replaced simple-node-logger with local logger.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:08:03 +02:00
ThePendulum 24696f6585 1.31.0 2026-10-18 00:59:38 +02:00
ThePendulum 9040fc7df9 Updated OpenAI models for chat, using proper context prompt instead of prefixing message. 2026-10-18 00:59:35 +02:00
ThePendulum 1360fbb5c7 1.30.12 2026-10-08 05:16:37 +02:00
ThePendulum 9a802b358d Fixed game reminder spacing during short timeouts. 2026-10-08 05:16:34 +02:00
ThePendulum b1f3f955c1 1.30.11 2026-10-08 04:33:22 +02:00
ThePendulum 0cea6efa15 Added missing timeout commands and help. 2026-10-08 04:33:20 +02:00
ThePendulum 3a678130a1 1.30.10 2026-10-07 02:48:10 +02:00
ThePendulum 81c728bd9d Fixed IRC message and error listeners reinitializing on reconnect, causing 'split personality' bug. 2026-10-07 02:48:08 +02:00
ThePendulum 529e8338d6 1.30.9 2026-09-13 21:58:56 +02:00
ThePendulum 9d77c948e2 Fixed ~duck crashing Clive because it's not an actual duck game command. 2026-09-13 21:58:54 +02:00
15 changed files with 1696 additions and 2665 deletions
+1
View File
@@ -5,4 +5,5 @@ config/*
!ecosystem.config.js !ecosystem.config.js
*.sqlite *.sqlite
points/* points/*
points-*
assets/mash-words.json assets/mash-words.json
+1 -1
View File
@@ -1 +1 @@
v18.15.0 v26.12.0
+5 -4
View File
@@ -15,6 +15,7 @@ module.exports = {
operators: ['admin'], operators: ['admin'],
server: 'irc.libera.chat', server: 'irc.libera.chat',
port: 6697, port: 6697,
tls: true,
socket: 'ws://127.0.0.1:3000/socket/', socket: 'ws://127.0.0.1:3000/socket/',
api: 'http://127.0.0.1:3000/api', api: 'http://127.0.0.1:3000/api',
reconnectDelay: 10, // seconds reconnectDelay: 10, // seconds
@@ -69,11 +70,11 @@ module.exports = {
chat: { chat: {
apiKey: null, // OpenAI apiKey: null, // OpenAI
validModels: [ validModels: [
'gpt-3.5-turbo', 'gpt-6-luna',
'text-davinci-003', 'gpt-6.1-sol',
'gpt-4',
], ],
model: 'gpt-3.5-turbo', model: 'gpt-6-luna',
reasoningEffort: 'none', // none, low, medium, high; reasoning tokens count towards the user token limit
userTokenLimit: 20000, // daily, roughly 100+ messages or $0.04 per user userTokenLimit: 20000, // daily, roughly 100+ messages or $0.04 per user
userTokenPeriod: 24, // hours userTokenPeriod: 24, // hours
replyTokenLimit: 1000, replyTokenLimit: 1000,
+1427 -2576
View File
File diff suppressed because it is too large Load Diff
+8 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "schat2-clive", "name": "schat2-clive",
"version": "1.30.8", "version": "1.31.3",
"description": "Game host for SChat 2-powered chat sites", "description": "Game host for SChat 2-powered chat sites",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
@@ -18,23 +18,18 @@
"author": "Niels Simenon", "author": "Niels Simenon",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3": "^8.3.0", "better-sqlite3": "^13.0.3",
"bhttp": "^1.2.8", "bhttp": "^1.2.8",
"bottleneck": "^2.19.5", "config": "^5.0.1",
"config": "^3.3.6",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"html-entities": "^2.3.2", "html-entities": "^2.3.2",
"inflect": "^0.5.0", "inflect": "^0.5.0",
"irc": "^0.5.2",
"irc-colors": "^1.5.0", "irc-colors": "^1.5.0",
"irc-upd": "^0.11.0", "irc-framework": "^4.14.0",
"jsdom": "^18.1.0",
"knex": "^2.4.2", "knex": "^2.4.2",
"linkify-it": "^3.0.3", "linkify-it": "^3.0.3",
"markov-strings": "^3.0.1", "markov-strings": "^3.0.1",
"mathjs": "^11.8.0", "mathjs": "^11.8.0",
"simple-node-logger": "^21.8.12",
"socket.io-client": "^4.7.2",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"tensify": "^0.0.4", "tensify": "^0.0.4",
"vm2": "^3.9.11", "vm2": "^3.9.11",
@@ -43,7 +38,10 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.16.0", "@babel/eslint-parser": "^7.16.0",
"eslint": "^7.2.0", "eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1" "eslint-config-airbnb-base": "^14.2.1"
},
"allowScripts": {
"better-sqlite3@13.0.3": true
} }
} }
+38 -17
View File
@@ -2,6 +2,7 @@
const config = require('config'); const config = require('config');
const bhttp = require('bhttp'); const bhttp = require('bhttp');
const { format } = require('date-fns');
const style = require('../utils/style'); const style = require('../utils/style');
const knex = require('../knex'); const knex = require('../knex');
@@ -145,6 +146,20 @@ function setRule(rule, context) {
context.sendMessage(`Chat rule must be at least 3 characters long, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username); context.sendMessage(`Chat rule must be at least 3 characters long, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
} }
function getInstructions(context) {
return {
role: 'developer',
content: [
`You are ${config.user.username}, ${settings.rule}, chatting in a group chat.`,
`You are talking to ${context.user.username}. Today is ${format(new Date(), 'EEEE d MMMM yyyy')}.`,
config.platform === 'irc'
? 'Reply in plain text on a single line, IRC does not support any formatting.'
: 'Reply in a single paragraph without headings or lists. You may use **bold** and *italic*.',
settings.replyWordLimit && `Use ${settings.replyWordLimit} words or fewer.`,
].filter(Boolean).join(' '),
};
}
async function getTokens(username) { async function getTokens(username) {
const { used_tokens: usedTokens } = await knex('chat_tokens') const { used_tokens: usedTokens } = await knex('chat_tokens')
.sum('tokens as used_tokens') .sum('tokens as used_tokens')
@@ -229,21 +244,17 @@ async function onCommand(args, context, isConversation) {
return; return;
} }
const message = { const message = { role: 'user', content: prompt };
role: 'user',
content: settings.replyWordLimit
? `Using ${settings.replyWordLimit} words or fewer, answer as if you're ${settings.rule}. ${prompt}`
: `Answer as if you're ${settings.rule}. ${prompt}`,
};
const userHistory = (history.get(context.user.username) || []).concat(message); const userHistory = (history.get(context.user.username) || []).concat(message);
context.logger.info(`${context.user.username} asked OpenAI '${config.chat.model}' (${userHistory.length}): ${message.content}`); context.logger.info(`${context.user.username} asked OpenAI '${settings.model}' (${userHistory.length}) to be ${settings.rule}: ${prompt}`);
const res = await bhttp.post('https://api.openai.com/v1/chat/completions', JSON.stringify({ const res = await bhttp.post('https://api.openai.com/v1/chat/completions', JSON.stringify({
model: settings.model, model: settings.model,
max_tokens: config.chat.replyTokenLimit, max_completion_tokens: config.chat.replyTokenLimit,
messages: userHistory, reasoning_effort: config.chat.reasoningEffort,
temperature: settings.temperature,
messages: [getInstructions(context), ...userHistory], // instructions are rebuilt for every request and not stored in history, so rule changes apply immediately
user: `${context.user.id}:${context.user.username}`, user: `${context.user.id}:${context.user.username}`,
}), { }), {
headers: { headers: {
@@ -252,11 +263,20 @@ async function onCommand(args, context, isConversation) {
}, },
}); });
const reply = res.body.choices?.[0].message; if (res.statusCode !== 200) {
throw new Error(`OpenAI responded with ${res.statusCode}: ${res.body.error?.message || JSON.stringify(res.body)}`);
}
const reply = res.body.choices?.[0]?.message;
if (!reply?.content) {
throw new Error(`OpenAI returned no content (finish reason '${res.body.choices?.[0]?.finish_reason}', ${res.body.usage?.total_tokens} tokens)`);
}
const target = prompt.match(/@\s*[\w-]+\s*$/)?.[0].slice(1).trim(); const target = prompt.match(/@\s*[\w-]+\s*$/)?.[0].slice(1).trim();
const curatedContent = reply.content.replace(/\n+/g, '. '); const curatedContent = reply.content.replace(/\n+/g, '. ');
context.logger.info(`OpenAI ${config.chat.model} replied to ${target || context.user.username} (${res.body.usage.total_tokens} tokens, ${(usedTokens || 0) + res.body.usage.total_tokens}/${config.chat.userTokenLimit} used): ${curatedContent}`); context.logger.info(`OpenAI ${settings.model} replied to ${target || context.user.username} (${res.body.usage.total_tokens} tokens, ${(usedTokens || 0) + res.body.usage.total_tokens}/${config.chat.userTokenLimit} used): ${curatedContent}`);
if (isConversation) { if (isConversation) {
context.sendMessage(`${curatedContent}`, context.room?.id, { label: false }, context.user.username); context.sendMessage(`${curatedContent}`, context.room?.id, { label: false }, context.user.username);
@@ -270,15 +290,16 @@ async function onCommand(args, context, isConversation) {
created: knex.raw("datetime('now')"), created: knex.raw("datetime('now')"),
}); });
if (config.chat.history > 0) { if (settings.history > 0) {
history.set(context.user.username, userHistory.concat(reply).slice(-settings.history)); // only keep the fields the API accepts as input, newer models return extras like refusal and annotations
history.set(context.user.username, userHistory.concat({ role: reply.role, content: reply.content }).slice(-settings.history));
} }
} catch (error) { } catch (error) {
context.logger.error(error.response ? `${error.response.status}: ${JSON.stringify(error.response.data)}` : error.message); context.logger.error(error.message);
context.sendMessage('Sorry, I can\'t think right now', context.room?.id, { label: false }, context.user.username); context.sendMessage('Sorry, I can\'t think right now', context.room?.id, { label: false }, context.user.username);
} finally {
processing.delete(context.user.username);
} }
processing.delete(context.user.username);
} }
function onMessage(message, context) { function onMessage(message, context) {
+5
View File
@@ -56,6 +56,11 @@ function onCommand(args, context) {
return; return;
} }
if (!duck) {
// unrecognized command
return;
}
const hit = Math.random() > config.duck.missRatio; const hit = Math.random() > config.duck.missRatio;
const time = new Date().getTime() - duck.getTime(); const time = new Date().getTime() - duck.getTime();
+36 -14
View File
@@ -18,6 +18,7 @@ const availableLetters = {
const types = { v: 'vowel', c: 'consonant' }; const types = { v: 'vowel', c: 'consonant' };
const settings = { ...config.letters }; const settings = { ...config.letters };
const minReminderInterval = 10; // seconds
const games = new Map(); const games = new Map();
function getBoard(context) { function getBoard(context) {
@@ -64,6 +65,24 @@ function playWord(rawWord, context) {
context.sendMessage(`${context.user.username} played ${style.bold(style.pink(word))} for ${style.bold(word.length)} points!`, context.room.id); context.sendMessage(`${context.user.username} played ${style.bold(style.pink(word))} for ${style.bold(word.length)} points!`, context.room.id);
} }
function setTimeout(timeout, context) {
if (!timeout) {
context.sendMessage(`Timeout is set to ${style.bold(settings.timeout)}`, context.room.id);
return;
}
const parsedTimeout = Number(timeout);
if (Number.isNaN(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 300) {
context.sendMessage('Timeout must be a number between 10 and 300', context.room.id);
return;
}
settings.timeout = parsedTimeout;
context.sendMessage(`Timeout set to ${style.bold(settings.timeout)} by ${context.user.prefixedUsername}`, context.room.id);
}
function stop(context, aborted) { function stop(context, aborted) {
const game = games.get(context.room.id); const game = games.get(context.room.id);
@@ -88,22 +107,20 @@ async function play(context) {
game.state = 'words'; game.state = 'words';
game.counts = countLetters(game.word); game.counts = countLetters(game.word);
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(settings.timeout))} seconds, let's start!`, context.room.id); // lock in the timeout so changing it mid-round doesn't skew the countdown
const { timeout } = settings;
const steps = Math.max(1, Math.min(5, Math.floor(timeout / minReminderInterval))); // don't repeat the board too often on short timeouts
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(timeout))} seconds, let's start!`, context.room.id);
try { try {
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal }); for (let step = 1; step <= steps; step += 1) {
await timers.setTimeout((timeout / steps) * 1000, null, { signal: game.ac.signal }); // eslint-disable-line no-await-in-loop
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 4)} seconds`))} left`, context.room.id); if (step < steps) {
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal }); context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((timeout / steps) * (steps - step))} seconds`))} left`, context.room.id);
}
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 3)} seconds`))} left`, context.room.id); }
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 2)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round(settings.timeout / 5)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
stop(context); stop(context);
} catch (error) { } catch (error) {
@@ -163,6 +180,11 @@ function onCommand(args, context) {
return; return;
} }
if (context.subcommand === 'timeout') {
setTimeout(args[0], context);
return;
}
if (context.command === 'letsgo' || ['start', 'go', 'auto', 'random'].includes(context.subcommand)) { if (context.command === 'letsgo' || ['start', 'go', 'auto', 'random'].includes(context.subcommand)) {
start(context, Array.from({ length: config.letters.length }, () => (Math.random() < config.letters.consonantBias ? 'c' : 'v')).join('')); // a slight bias towards consonants seems to give better boards start(context, Array.from({ length: config.letters.length }, () => (Math.random() < config.letters.consonantBias ? 'c' : 'v')).join('')); // a slight bias towards consonants seems to give better boards
return; return;
@@ -206,7 +228,7 @@ function onMessage(message, context) {
module.exports = { module.exports = {
name: 'Letters', name: 'Letters',
help: `Make the longest word from the ${config.letters.length} letters on the board. Quick-start with ${config.prefix}letsgo, or use ${config.prefix}letters to fill the board by manually selecting a vow(el), a con(sonant), or multiple at once like CCVVCCVVC.`, help: `Make the longest word from the ${config.letters.length} letters on the board. Quick-start with ${config.prefix}letsgo, or use ${config.prefix}letters to fill the board by manually selecting a vow(el), a con(sonant), or multiple at once like CCVVCCVVC. Change the time limit with ${config.prefix}letters:timeout [10-300].`,
commands: ['letsgo'], commands: ['letsgo'],
onCommand, onCommand,
onMessage, onMessage,
+13 -8
View File
@@ -41,6 +41,7 @@ limitedMath.import({
}, { override: true }); }, { override: true });
const settings = { ...config.numbers }; const settings = { ...config.numbers };
const minReminderInterval = 10; // seconds
const games = new Map(); const games = new Map();
/* eslint-disable no-irregular-whitespace */ /* eslint-disable no-irregular-whitespace */
@@ -177,17 +178,21 @@ async function play(context) {
game.state = 'solutions'; game.state = 'solutions';
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(settings.timeout))} seconds, let's start!`, context.room.id); // lock in the timeout so changing it mid-round doesn't skew the countdown
const { timeout } = settings;
const steps = Math.max(1, Math.min(3, Math.floor(timeout / minReminderInterval))); // don't repeat the board too often on short timeouts
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(timeout))} seconds, let's start!`, context.room.id);
context.logger.info(`Numbers game started by ${context.user.username}, numbers ${game.numbers.join('|')}, target ${game.target}, ${game.solution.answer === game.target ? 'exact' : 'closest'} solution for ${game.solution.answer} is: ${game.solution.solution}`); context.logger.info(`Numbers game started by ${context.user.username}, numbers ${game.numbers.join('|')}, target ${game.target}, ${game.solution.answer === game.target ? 'exact' : 'closest'} solution for ${game.solution.answer} is: ${game.solution.solution}`);
try { try {
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); for (let step = 1; step <= steps; step += 1) {
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(`${Math.round((settings.timeout / 3) * 2)} seconds`))} left`, context.room.id); await timers.setTimeout((timeout / steps) * 1000, null, { signal: game.ac.signal }); // eslint-disable-line no-await-in-loop
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); if (step < steps) {
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(`${Math.round(settings.timeout / 3)} seconds`))} left`, context.room.id); context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(`${Math.round((timeout / steps) * (steps - step))} seconds`))} left`, context.room.id);
}
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); }
stop(context); stop(context);
} catch (error) { } catch (error) {
@@ -455,5 +460,5 @@ module.exports = {
onCommand, onCommand,
onMessage, onMessage,
commands: ['nums', 'numsgo', 'numgo', 'calculate', 'calc', 'solve'], commands: ['nums', 'numsgo', 'numgo', 'calculate', 'calc', 'solve'],
help: `Reach the target number using only the ${config.numbers.length} numbers on the board. You can use each number only once, but do not have to use all of them. You may use addition, subtraction, multiplication and division, but each intermediate calculation must result in a whole number. You can score ${Array.from(new Set(config.numbers.points)).slice(0, -1).join(', ')} or ${config.numbers.points.at(-1)} points for solutions up to ${config.numbers.points.length - 1} away, but only the first best solution gets the points. Quick-start with ${config.prefix}numsgo, or use ${config.prefix}numbers to fill the board by manually selecting a random *large* or *big* number (100, 75, 50, 25), a random *small* number (1-10), or multiple at once like LLSSSS.`, // eslint-disable-line max-len help: `Reach the target number using only the ${config.numbers.length} numbers on the board. You can use each number only once, but do not have to use all of them. You may use addition, subtraction, multiplication and division, but each intermediate calculation must result in a whole number. You can score ${Array.from(new Set(config.numbers.points)).slice(0, -1).join(', ')} or ${config.numbers.points.at(-1)} points for solutions up to ${config.numbers.points.length - 1} away, but only the first best solution gets the points. Quick-start with ${config.prefix}numsgo, or use ${config.prefix}numbers to fill the board by manually selecting a random *large* or *big* number (100, 75, 50, 25), a random *small* number (1-10), or multiple at once like LLSSSS. Change the time limit with ${config.prefix}numbers:timeout [10-300].`, // eslint-disable-line max-len
}; };
+26 -1
View File
@@ -7,11 +7,35 @@ const timers = require('timers/promises');
const riddles = require('../../assets/riddles.json'); const riddles = require('../../assets/riddles.json');
const style = require('../utils/style'); const style = require('../utils/style');
const settings = { ...config.riddle };
const open = new Map(); const open = new Map();
const emojis = ['😆', '🤣', '😂', '😅', '🤪', '😛', '😝', '🙃', '🥲', '🤭', '😏', '🥴', '💀', '🤦']; const emojis = ['😆', '🤣', '😂', '😅', '🤪', '😛', '😝', '🙃', '🥲', '🤭', '😏', '🥴', '💀', '🤦'];
function setTimeout(timeout, context) {
if (!timeout) {
context.sendMessage(`Timeout is set to ${style.bold(settings.timeout)}`, context.room.id);
return;
}
const parsedTimeout = Number(timeout);
if (Number.isNaN(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 300) {
context.sendMessage('Timeout must be a number between 10 and 300', context.room.id);
return;
}
settings.timeout = parsedTimeout;
context.sendMessage(`Timeout set to ${style.bold(settings.timeout)} by ${context.user.prefixedUsername}`, context.room.id);
}
async function onCommand(args, context) { async function onCommand(args, context) {
if (context.subcommand === 'timeout') {
setTimeout(args[0], context);
return;
}
if (open.has(context.room.id)) { if (open.has(context.room.id)) {
context.sendMessage(open.get(context.room.id).riddle, context.room.id); context.sendMessage(open.get(context.room.id).riddle, context.room.id);
return; return;
@@ -26,7 +50,7 @@ async function onCommand(args, context) {
context.logger.info(`Riddle asked "${riddle.riddle}" with answer: ${riddle.answers[0]}`); context.logger.info(`Riddle asked "${riddle.riddle}" with answer: ${riddle.answers[0]}`);
try { try {
await timers.setTimeout(config.riddle.timeout * 1000, null, { signal: ac.signal }); await timers.setTimeout(settings.timeout * 1000, null, { signal: ac.signal });
open.delete(context.room.id); open.delete(context.room.id);
context.sendMessage(`No one guessed the riddle: ${style.bold(riddle.fullAnswer)}! ${emojis[crypto.randomInt(0, emojis.length)]}`, context.room.id); context.sendMessage(`No one guessed the riddle: ${style.bold(riddle.fullAnswer)}! ${emojis[crypto.randomInt(0, emojis.length)]}`, context.room.id);
@@ -54,6 +78,7 @@ function onMessage(message, context) {
} }
module.exports = { module.exports = {
help: `Solve the riddle before time runs out! Ask for one with ${config.prefix}riddle, and answer by typing it in the chat. Change the time limit with ${config.prefix}riddle:timeout [10-300].`,
onCommand, onCommand,
onMessage, onMessage,
}; };
+10 -1
View File
@@ -226,6 +226,15 @@ function onCommand(args, context) {
} }
} }
// answers are plain text and may contain regex characters, like '*batteries not included'
function getAnswerRegexp(answer) {
const pattern = answer
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\[.?*]|!/g, '$&?'); // punctuation is optional, so T.S. Eliot also matches TS Eliot, and *NSYNC matches NSYNC
return new RegExp(pattern, 'i');
}
async function onMessage(message, context) { async function onMessage(message, context) {
const game = games.get(context.room?.id); const game = games.get(context.room?.id);
@@ -235,7 +244,7 @@ async function onMessage(message, context) {
const { answer, fullAnswer } = game.questions[game.round]; const { answer, fullAnswer } = game.questions[game.round];
if (new RegExp(answer, 'i').test(decode(message.originalBody || message.body)) && !game.answers.has(context.user.id)) { // resolve HTML entities in case original body is not available, such as &amp; to & if (getAnswerRegexp(answer).test(decode(message.originalBody || message.body)) && !game.answers.has(context.user.id)) { // resolve HTML entities in case original body is not available, such as &amp; to &
game.answers.set(context.user.id, { game.answers.set(context.user.id, {
user: context.user, user: context.user,
answer: message.body, answer: message.body,
+92 -31
View File
@@ -1,10 +1,9 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('config');
const irc = require('irc'); const { Client } = require('irc-framework');
const logger = require('simple-node-logger').createSimpleLogger();
const { argv } = require('yargs'); const { argv } = require('yargs');
// const timers = require('timers/promises'); const logger = require('./utils/logger')();
const { const {
onMessage, onMessage,
@@ -13,15 +12,22 @@ const {
logger.setLevel(argv.level || 'info'); logger.setLevel(argv.level || 'info');
const client = new irc.Client(config.server, config.user.id, { function reclaimNick(client, currentNick) {
userName: config.user.username, logger.info(`Connected as '${currentNick}' because '${config.user.id}' was taken, asking NickServ to release it`);
realName: config.user.realName,
password: config.user.password, client.say('NickServ', `GHOST ${config.user.id}`);
port: config.port,
secure: true, // NickServ disconnects the stale session, give it a moment before taking the nick back
}); setTimeout(() => {
if (client.connected && client.user.nick !== config.user.id) {
client.changeNick(config.user.id);
}
}, 3000);
}
async function init() { async function init() {
const client = new Client();
const bot = { const bot = {
client, client,
rooms: config.channels.map((channel) => ({ rooms: config.channels.map((channel) => ({
@@ -32,33 +38,88 @@ async function init() {
const games = await getGames(bot, config.user.id); const games = await getGames(bot, config.user.id);
client.addListener('registered', () => { // client.connected is true as soon as the socket opens, not after registration as documented
logger.info('Connected!'); let registered = false;
logger.info('Identifying with NickServ');
client.say('nickserv', `IDENTIFY ${config.user.username} ${config.user.password}`); client.on('socket connected', () => { registered = false; });
// only plain messages, replying to notices is bad IRC bot etiquette and risks loops with services and other bots
client.on('privmsg', (event) => onMessage({
from: event.nick,
to: event.target,
recipient: /^#/.test(event.target) ? null : event.target,
body: event.message,
type: 'message',
}, bot, games));
client.on('nick in use', (event) => {
// registration stalls until we pick another nick
if (!registered) {
logger.warn(`Nick '${event.nick}' is in use, trying '${event.nick}_'`);
client.changeNick(`${event.nick}_`);
return;
}
logger.warn(`Nick '${event.nick}' is still in use: ${event.reason}`);
});
client.on('nick', (event) => event.new_nick === config.user.id && logger.info(`Reclaimed nick '${config.user.id}'`));
client.on('loggedin', (event) => logger.info(`Logged in to account '${event.account}'`));
client.on('sasl failed', (event) => logger.error(`SASL authentication failed: ${event.reason} ${event.message || ''}`));
client.on('irc error', (event) => logger.error(`IRC error: ${JSON.stringify(event)}`));
client.on('kick', (event) => event.kicked === client.user.nick && logger.warn(`Kicked from ${event.channel} by ${event.nick}: ${event.message}`));
client.on('reconnecting', (event) => logger.warn(`Disconnected, reconnect attempt ${event.attempt} in ${Math.round(event.wait / 1000)} seconds`));
// the library only auto-reconnects after a successful registration, cover failed (initial) connections too
client.on('close', () => {
logger.error(`Connection closed, reconnecting in ${config.reconnectDelay} seconds`);
setTimeout(() => client.connect(), config.reconnectDelay * 1000);
});
client.on('join', (event) => {
if (event.nick !== client.user.nick) {
return;
}
logger.info(`Joined ${event.channel}`);
if (config.greeting) {
client.say(event.channel, config.greeting);
}
});
// fires again on every reconnect
client.on('registered', (event) => {
registered = true;
// client.user.nick is only updated after this listener runs, use the nick from the event
logger.info(`Connected to ${config.server} as '${event.nick}'`);
if (event.nick !== config.user.id && config.user.password) {
reclaimNick(client, event.nick);
}
config.channels.forEach((channel) => { config.channels.forEach((channel) => {
logger.info(`Joining ${channel}`); logger.info(`Joining ${channel}`);
client.join(channel);
client.join(channel, () => logger.info(`Joined ${channel}`));
if (config.greeting) {
client.say(channel, config.greeting);
}
}); });
});
client.addListener('message', (from, to, body) => onMessage({ client.connect({
from, host: config.server,
to, port: config.port,
recipient: /^#/.test(to) ? null : to, tls: config.tls,
body, nick: config.user.id,
type: 'message', username: config.user.username,
}, bot, games)); gecos: config.user.realName,
// SASL logs in before registration completes, so channels requiring an identified user can be joined straight away
client.addListener('error', (error) => { account: config.user.password
logger.error(error); ? { account: config.user.username, password: config.user.password }
}); : {},
auto_reconnect: true,
auto_reconnect_max_retries: Infinity,
auto_reconnect_max_wait: 60000,
}); });
} }
+1 -1
View File
@@ -2,9 +2,9 @@
const config = require('config'); const config = require('config');
const fs = require('fs').promises; const fs = require('fs').promises;
const logger = require('simple-node-logger').createSimpleLogger();
const { getWeek } = require('date-fns'); const { getWeek } = require('date-fns');
const { argv } = require('yargs'); const { argv } = require('yargs');
const logger = require('./utils/logger')();
// const timers = require('timers/promises'); // const timers = require('timers/promises');
const getLeaders = require('./utils/get-leaders'); const getLeaders = require('./utils/get-leaders');
+1 -1
View File
@@ -4,8 +4,8 @@ const config = require('config');
const { setTimeout: delay } = require('timers/promises'); const { setTimeout: delay } = require('timers/promises');
const bhttp = require('bhttp'); const bhttp = require('bhttp');
const WebSocket = require('ws'); const WebSocket = require('ws');
const logger = require('simple-node-logger').createSimpleLogger();
const { argv } = require('yargs'); const { argv } = require('yargs');
const logger = require('./utils/logger')();
const { const {
onMessage, onMessage,
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const util = require('util');
// drop-in for simple-node-logger's createSimpleLogger, which no longer runs on recent Node versions
const levels = ['debug', 'info', 'warn', 'error'];
function createLogger() {
let minLevel = levels.indexOf('info');
function log(level, args) {
if (levels.indexOf(level) < minLevel) {
return;
}
const time = new Date().toTimeString().slice(0, 8);
const ms = String(new Date().getMilliseconds()).padStart(3, '0');
// everything goes to stdout like before, so pm2 keeps writing it all to the same log file
console.log(`${time}.${ms} ${level.toUpperCase().padEnd(5)} ${util.format(...args)}`);
}
return {
setLevel: (level) => {
if (levels.includes(level)) {
minLevel = levels.indexOf(level);
}
},
...Object.fromEntries(levels.map((level) => [level, (...args) => log(level, args)])),
};
}
module.exports = createLogger;