/** * 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'; const config = {}; interface CommandClient extends Client { commands?: Collection Promise }> } 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] config: 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( 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 && !(response in [ '@Today Man-San(1990)πŸπŸ‚', '@1981 Celical ManπŸπŸ‚', '@Exiled Sammy πŸ”’πŸβ±' ]); } 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: config["llmconf"].llmSettings.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 ]; await message.channel.sendTyping(); try { const response = await requestLLMResponse(cleanHistoryList); // 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 { 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 requestLLMResponse(messages) { const queryParams = new URLSearchParams(); queryParams.append("token", process.env.LLM_TOKEN); for (const field of Object.keys(config["llmconf"].llmSettings)) { queryParams.append(field, config["llmconf"].llmSettings[field]); } const llmEndpoint = `${process.env.LLM_HOST}/?${queryParams.toString()}`; const messageList = messages.map((m: Message) => ({ role: m.author.bot ? "assistant" : "user", content: m.cleanContent, })); const reqBody = [ { "role": "system", "content": config["llmconf"].sys_prompt }, ...messageList ]; logInfo("[bot] Requesting LLM response with message list: " + reqBody.map(m => m.content)); const res = await fetch(llmEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(reqBody) }); const txt = await res.json(); const txtRaw: string = txt["raw"][0]; // Depends on chat template used const prefix = "<|start_header_id|>assistant<|end_header_id|>\n\n"; const suffix = "<|reserved_special_token_"; const txtStart = txtRaw.lastIndexOf(prefix); const txtEnd = txtRaw.slice(txtStart + prefix.length); const txtStop = txtEnd.indexOf(suffix) !== -1 ? txtEnd.indexOf(suffix) : txtEnd.length; return txtEnd.slice(0, txtStop); } async function scheduleRandomMessage(firstTime = false) { if (!firstTime) { const channel = 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); } 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); for (const folder of commandFolders) { const commandsPath = path.join(foldersPath, folder); 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); config[command.data.name] = command.config; 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); } })();