Compare commits
13
Commits
3a678130a1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678b2991ad | ||
|
|
f048c1d077 | ||
|
|
1572e74ebf | ||
|
|
fc5f8572cd | ||
|
|
1101a5fc3e | ||
|
|
e610f4212c | ||
|
|
01f356c0b1 | ||
|
|
24696f6585 | ||
|
|
9040fc7df9 | ||
|
|
1360fbb5c7 | ||
|
|
9a802b358d | ||
|
|
b1f3f955c1 | ||
|
|
0cea6efa15 |
+5
-4
@@ -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,
|
||||||
|
|||||||
Generated
+1427
-2576
File diff suppressed because it is too large
Load Diff
+8
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user