Compare commits

..
7 Commits
9 changed files with 1569 additions and 2618 deletions
+1 -1
View File
@@ -1 +1 @@
v18.15.0 v26.12.0
+1
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
+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.31.0", "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
} }
} }
+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 & 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 & 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;