Compare commits

...
13 Commits
13 changed files with 1686 additions and 2662 deletions
+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.10", "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) {
+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,
+88 -28
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,35 +38,89 @@ async function init() {
const games = await getGames(bot, config.user.id); const games = await getGames(bot, config.user.id);
// registered fires again on every reconnect, so these must only be bound once // client.connected is true as soon as the socket opens, not after registration as documented
client.addListener('message', (from, to, body) => onMessage({ let registered = false;
from,
to, client.on('socket connected', () => { registered = false; });
recipient: /^#/.test(to) ? null : to,
body, // 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', type: 'message',
}, bot, games)); }, bot, games));
client.addListener('error', (error) => { client.on('nick in use', (event) => {
logger.error(error); // 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.addListener('registered', () => { client.on('nick', (event) => event.new_nick === config.user.id && logger.info(`Reclaimed nick '${config.user.id}'`));
logger.info('Connected!'); client.on('loggedin', (event) => logger.info(`Logged in to account '${event.account}'`));
logger.info('Identifying with NickServ'); 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`));
client.say('nickserv', `IDENTIFY ${config.user.username} ${config.user.password}`); // 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.connect({
host: config.server,
port: config.port,
tls: config.tls,
nick: config.user.id,
username: config.user.username,
gecos: config.user.realName,
// SASL logs in before registration completes, so channels requiring an identified user can be joined straight away
account: config.user.password
? { account: config.user.username, password: config.user.password }
: {},
auto_reconnect: true,
auto_reconnect_max_retries: Infinity,
auto_reconnect_max_wait: 60000,
});
} }
module.exports = init; module.exports = init;
+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;