Files
FemScoreboard/discord/bot.ts
2026-01-23 01:11:48 -08:00

400 lines
14 KiB
TypeScript

/**
* bot.ts
* Scans the chat for reactions and updates the leaderboard database.
*/
import {
Attachment,
AttachmentBuilder,
Client,
Collection,
Events,
GatewayIntentBits,
Interaction,
Message,
MessageFlags,
MessageReaction,
MessageType,
PartialMessageReaction,
Partials, SlashCommandBuilder,
TextChannel,
User
} from 'discord.js';
import fs = require('node:fs');
import path = require('node:path');
import fetch from 'node-fetch';
import FormData = require('form-data');
import tmp = require('tmp');
import { JSDOM } from 'jsdom';
import { logError, logInfo, logWarn } from '../logging';
import {
db,
openDb,
reactionEmojis,
recordReaction,
requestTTSResponse,
sync
} from './util';
import 'dotenv/config';
import { LLMConfig } from './commands/types';
import { LLMProvider } from './provider/provider';
interface State {
llmconf?(): LLMConfig,
provider?(): LLMProvider,
sysprompt?(): string
}
const state: State = {};
interface CommandClient extends Client {
commands?: Collection<string, { data: SlashCommandBuilder, execute: (interaction: Interaction) => Promise<void> }>
}
const client: CommandClient = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
client.commands = new Collection();
client.once(Events.ClientReady, async () => {
logInfo('[bot] Ready.');
for (let i = 0; i < reactionEmojis.length; ++i)
logInfo(`[bot] util: reaction_${i + 1} = ${reactionEmojis[i]}`);
});
async function onMessageReactionChanged(reaction: MessageReaction | PartialMessageReaction, user: User) {
// When a reaction is received, check if the structure is partial
if (reaction.partial) {
// If the message this reaction belongs to was removed, the fetching might result in an API error which should be handled
try {
await reaction.fetch();
} catch (error) {
logError('[bot] Something went wrong when fetching the reaction:', error);
// Return as `reaction.message.author` may be undefined/null
return;
}
}
if (reaction.message.partial) {
// If the message this reaction belongs to was removed, the fetching might result in an API error which should be handled
try {
await reaction.message.fetch();
} catch (error) {
logError('[bot] Something went wrong when fetching the message:', error);
// Return as `reaction.message.author` may be undefined/null
return;
}
}
// Now the message has been cached and is fully available
logInfo(`[bot] ${reaction.message.author?.id}'s message reaction count changed: ${reaction.emoji.name}x${reaction.count}`);
await recordReaction(<MessageReaction>reaction);
}
function textOnlyMessages(message: Message) {
return message.cleanContent.length > 0 &&
(message.type === MessageType.Default || message.type === MessageType.Reply);
}
function isGoodResponse(response: string) {
return response.length > 0;
}
async function onNewMessage(message: Message) {
if (message.author.bot) {
return;
}
/** First, handle audio messages */
if (message.flags.has(MessageFlags.IsVoiceMessage)) {
try {
const audio = await requestRVCResponse(message.attachments.first()!);
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
await message.reply({
files: [audioFile]
});
} catch (err) {
logError(`[bot] Failed to generate audio message reply: ${err}`);
}
}
/** Text messages */
if (!textOnlyMessages(message)) {
return;
}
// Miku must reply when spoken to
const mustReply = message.mentions.has(process.env.CLIENT!) || message.cleanContent.toLowerCase().includes('miku');
const history = await message.channel.messages.fetch({
limit: state.llmconf!().msg_context - 1,
before: message.id
});
// change Miku's message probability depending on current message frequency
const historyMessages = [...history.values()].reverse();
//const historyTimes = historyMessages.map((m: Message) => m.createdAt.getTime());
//const historyAvgDelayMins = (historyTimes[historyTimes.length - 1] - historyTimes[0]) / 60000;
const replyChance = Math.floor(Math.random() * 1 / Number(process.env.REPLY_CHANCE)) === 0;
const willReply = mustReply || replyChance;
if (!willReply) {
return;
}
/*
const cleanHistory = historyMessages.filter(textOnlyMessages);
const cleanHistoryList = [
...cleanHistory,
message
];
*/
const cleanHistoryList = [...historyMessages, message];
try {
if ('sendTyping' in message.channel) {
await message.channel.sendTyping();
}
const response = await state.provider!().requestLLMResponse(cleanHistoryList, state.sysprompt!(), state.llmconf!());
// evaluate response
if (!isGoodResponse(response)) {
logWarn(`[bot] Burning bad response: "${response}"`);
return;
}
await message.reply(response);
} catch (err) {
logError(`[bot] Error while generating LLM response: ${err}`);
}
}
async function fetchMotd() {
try {
const res = await fetch(process.env.MOTD_HREF);
const xml = await res.text();
const parser = new JSDOM(xml);
const doc = parser.window.document;
const el = doc.querySelector(process.env.MOTD_QUERY);
return el ? el.textContent : null;
} catch (err) {
logWarn('[bot] Failed to fetch MOTD; is the booru down?');
}
}
async function requestRVCResponse(src: Attachment): Promise<Blob> {
logInfo(`[bot] Downloading audio message ${src.url}`);
const srcres = await fetch(src.url);
const srcbuf = await srcres.arrayBuffer();
const tmpFile = tmp.fileSync();
const tmpFileName = tmpFile.name;
fs.writeFileSync(tmpFileName, Buffer.from(srcbuf));
logInfo(`[bot] Got audio file: ${srcbuf.size} bytes`);
const queryParams = new URLSearchParams();
queryParams.append("token", process.env.LLM_TOKEN || "");
const fd = new FormData();
fd.append('file', fs.readFileSync(tmpFileName), 'voice-message.ogg');
const rvcEndpoint = `${process.env.LLM_HOST}/rvc?${queryParams.toString()}`;
logInfo(`[bot] Requesting RVC response for ${src.id}`);
const res = await fetch(rvcEndpoint, {
method: 'POST',
body: fd
});
const resContents = await res.blob();
return resContents;
}
async function scheduleRandomMessage(firstTime = false) {
if (!firstTime) {
if (!process.env.MOTD_CHANNEL) {
return;
}
const channel = <TextChannel>await client.channels.fetch(process.env.MOTD_CHANNEL);
if (!channel) {
logWarn(`[bot] Channel ${process.env.MOTD_CHANNEL} not found, disabling MOTD.`);
return;
}
const randomMessage = await fetchMotd();
if (randomMessage) {
try {
const audio = await requestTTSResponse(randomMessage);
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
await channel.send({
content: randomMessage,
files: [audioFile]
});
logInfo(`[bot] Sent MOTD + TTS: ${randomMessage}`);
} catch (err) {
await channel.send(randomMessage);
logWarn(`[bot] Could not fetch MOTD TTS: ${err}`);
logInfo(`[bot] Send text MOTD: ${randomMessage}`);
}
} else {
logWarn(`[bot] Could not fetch MOTD.`);
}
}
// wait between 2-8 hours
const timeoutMins = Math.random() * 360 + 120;
const scheduledTime = new Date();
scheduledTime.setMinutes(scheduledTime.getMinutes() + timeoutMins);
logInfo(`[bot] Next MOTD: ${scheduledTime.toLocaleTimeString()}`);
setTimeout(scheduleRandomMessage, timeoutMins * 60 * 1000);
}
/**
* Convert a Date to a Discord snowflake ID (approximate)
* Discord epoch: 2015-01-01T00:00:00.000Z
*/
function dateToSnowflake(date: Date): string {
const DISCORD_EPOCH = 1420070400000n;
const timestamp = BigInt(date.getTime());
const snowflake = (timestamp - DISCORD_EPOCH) << 22n;
return snowflake.toString();
}
async function scheduleThrowback(firstTime = false) {
if (!firstTime) {
if (!process.env.THROWBACK_CHANNEL) {
logWarn('[bot] THROWBACK_CHANNEL not configured, disabling throwback.');
return;
}
const channel = <TextChannel>await client.channels.fetch(process.env.THROWBACK_CHANNEL);
if (!channel) {
logWarn(`[bot] Channel ${process.env.THROWBACK_CHANNEL} not found, disabling throwback.`);
return;
}
try {
// Calculate date from 1 year ago
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
// Convert to approximate snowflake ID
const aroundSnowflake = dateToSnowflake(oneYearAgo);
logInfo(`[bot] Fetching messages around ${oneYearAgo.toISOString()} (snowflake: ${aroundSnowflake})`);
// Fetch messages around that time
const messages = await channel.messages.fetch({
around: aroundSnowflake,
limit: 50
});
// Filter to only text messages from non-bots
const textMessages = messages.filter(m =>
!m.author.bot &&
m.cleanContent.length > 0 &&
(m.type === MessageType.Default || m.type === MessageType.Reply)
);
if (textMessages.size === 0) {
logWarn('[bot] No messages found from 1 year ago, skipping throwback.');
} else {
// Pick a random message
const messagesArray = [...textMessages.values()];
const randomMsg = messagesArray[Math.floor(Math.random() * messagesArray.length)];
logInfo(`[bot] Selected throwback message from ${randomMsg.author.username}: "${randomMsg.cleanContent}"`);
// Generate LLM response using the standard system prompt
if ('sendTyping' in channel) {
await channel.sendTyping();
}
const llmResponse = await state.provider!().requestLLMResponse(
[randomMsg],
state.sysprompt!(),
state.llmconf!()
);
// Reply directly to the original message
await randomMsg.reply(llmResponse);
logInfo(`[bot] Sent throwback reply: ${llmResponse}`);
}
} catch (err) {
logError(`[bot] Error fetching throwback message: ${err}`);
}
}
// Schedule next throwback in ~24 hours (with some randomness: 22-26 hours)
const timeoutHours = 22 + Math.random() * 4;
const scheduledTime = new Date();
scheduledTime.setHours(scheduledTime.getHours() + timeoutHours);
logInfo(`[bot] Next throwback: ${scheduledTime.toLocaleString()}`);
setTimeout(scheduleThrowback, timeoutHours * 60 * 60 * 1000);
}
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
});
client.on(Events.MessageCreate, onNewMessage);
client.on(Events.MessageReactionAdd, onMessageReactionChanged);
client.on(Events.MessageReactionRemove, onMessageReactionChanged);
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const client: CommandClient = interaction.client;
const command = client.commands?.get(interaction.commandName);
if (!command) {
logError(`[bot] No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
logError(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
}
}
});
// startup
(async () => {
tmp.setGracefulCleanup();
logInfo("[db] Opening...");
await openDb();
logInfo("[db] Migrating...");
await db.migrate();
logInfo("[db] Ready.");
logInfo("[bot] Loading commands...");
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath, { withFileTypes: true });
for (const folder of commandFolders) {
if (!folder.isDirectory()) {
continue;
}
const commandsPath = path.join(foldersPath, folder.name);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
client.commands?.set(command.data.name, command);
if (command.state) {
state[command.data.name] = command.state;
}
logInfo(`[bot] Found command: /${command.data.name}`);
}
}
logInfo("[bot] Logging in...");
await client.login(process.env.TOKEN);
await sync(client.guilds);
if (process.env.ENABLE_MOTD) {
await scheduleRandomMessage(true);
}
if (process.env.ENABLE_THROWBACK) {
await scheduleThrowback(true);
}
})();