/** * util.ts * Common helper functions */ import { Collection, GuildManager, GuildTextBasedChannel, Message, MessageReaction, MessageType, User, } from 'discord.js'; import { get as getEmojiName } from 'emoji-unicode-map'; import { createWriteStream, existsSync, unlinkSync } from 'fs'; import { get as httpGet } from 'https'; import { Database, open } from 'sqlite'; import { Database as Database3 } from 'sqlite3'; import 'dotenv/config'; import fetch, { Blob as NodeFetchBlob } from 'node-fetch'; import { logError, logInfo, logWarn } from '../logging'; import { ScoreboardMessageRow } from '../models'; import { LLMDiscordMessage } from './provider/provider'; const reactionEmojis: string[] = process.env.REACTIONS.split(','); let db: Database = null; /** * Parse REAL_NAMES from environment variable * Format: "username:Name,username2:Name2,..." */ function parseRealNames(input?: string): Record { const realNamesStr = input !== undefined ? input : process.env.REAL_NAMES || ''; if (!realNamesStr.trim()) { return {}; } const realNames: Record = {}; realNamesStr.split(',').forEach((entry) => { const parts = entry.split(':'); if (parts.length === 2) { const username = parts[0].trim(); const name = parts[1].trim(); if (username && name) { realNames[username] = name; } } }); return realNames; } const REAL_NAMES = parseRealNames(); /** * Parse LOSER_WHITELIST from environment variable * Format: "Name1,Name2,Name3,..." */ function parseLoserWhitelist(input?: string): string[] { const whitelistStr = input !== undefined ? input : process.env.LOSER_WHITELIST || ''; if (!whitelistStr.trim()) { return []; } return whitelistStr .split(',') .map((name) => name.trim()) .filter((name) => name.length > 0); } const LOSER_WHITELIST = parseLoserWhitelist(); async function openDb() { db = await open({ filename: 'db.sqlite', driver: Database3, }); } function clearDb() { unlinkSync('db.sqlite'); } function messageLink(message: ScoreboardMessageRow) { return `https://discord.com/channels/${message.guild}/${message.channel}/${message.id}`; } function userAvatarPath(user: User) { return `../public/avatars/${user.id}.webp`; } async function downloadUserAvatar(user: User) { logInfo(`[bot] Downloading ${user.id}'s avatar...`); const file = createWriteStream(userAvatarPath(user)); return new Promise((resolve) => { httpGet(user.displayAvatarURL(), (res) => { res.pipe(file); file.on('finish', () => { file.close(); logInfo(`[bot] Finished downloading ${user.id}'s avatar.`); resolve(); }); }); }); } async function refreshUserReactionTotalCount(user: User, emoji_idx: number) { const result = await db.get<{ sum: number }>( `SELECT sum(reaction_${emoji_idx}_count) AS sum FROM messages WHERE author = ?`, user.id ); const emojiTotal = result.sum; await db.run( `INSERT INTO users(id, username, reaction_${emoji_idx}_total) VALUES(?, ?, ?) ON CONFLICT(id) DO UPDATE SET reaction_${emoji_idx}_total = ?, username = ? WHERE id = ?`, user.id, user.displayName, emojiTotal, emojiTotal, user.displayName, user.id ); if (!existsSync(userAvatarPath(user))) { await downloadUserAvatar(user); } // Extract emoji name from config (handle both unicode and custom emoji formats) const emojiConfig = reactionEmojis[emoji_idx - 1]; const emojiName = emojiConfig.includes(':') ? emojiConfig.split(':')[1] : emojiConfig; logInfo(`[bot] Refreshed ${user.id}'s ${emojiName} count.`); } async function recordReaction(reaction: MessageReaction) { // Match emoji by name (unicode) or by ID (custom emoji) let emojiIdx = 0; const emojiName = reaction.emoji.name; const emojiId = reaction.emoji.id; if (emojiId) { // Custom emoji - match by ID emojiIdx = reactionEmojis.findIndex((e) => e.includes(`:${emojiId}`)) + 1; if (emojiIdx > 0) { logInfo(`[bot] Custom emoji detected: ${emojiName} (ID: ${emojiId}), idx: ${emojiIdx}`); } } if (emojiIdx === 0) { // Unicode emoji - match by name emojiIdx = reactionEmojis.indexOf(emojiName) + 1; if (emojiIdx > 0) { logInfo(`[bot] Unicode emoji detected: ${emojiName}, idx: ${emojiIdx}`); } } if (emojiIdx === 0) { logWarn(`[bot] Unknown emoji: ${emojiName} (ID: ${emojiId || 'none'})`); return; } try { await db.run( `INSERT INTO messages(id, guild, channel, author, content, reaction_${emojiIdx}_count) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET reaction_${emojiIdx}_count = ? WHERE id = ?`, reaction.message.id, reaction.message.guildId, reaction.message.channelId, reaction.message.author.id, reaction.message.content, reaction.count, reaction.count, reaction.message.id ); await refreshUserReactionTotalCount(reaction.message.author, emojiIdx); logInfo(`[bot] Recorded ${reaction.emoji.name}x${reaction.count} in database.`); } catch (error) { logError('[bot] Something went wrong when updating the database:', error); return; } } async function serializeMessageHistory(m: Message): Promise { const stringifyReactions = (m: Message): string | undefined => { const reacts = m.reactions.cache; let serialized: string | undefined = undefined; for (const react of reacts.values()) { // "emoji.name" still returns us unicode, we want plaintext name const emojiTextName = getEmojiName(react.emoji.name) || react.emoji.name; if (emojiTextName) { if (serialized === null) { serialized = ''; } else { serialized += ', '; } serialized += `:${emojiTextName}: (${react.count})`; } } return serialized; }; if (!m.cleanContent) { return; } let msgDict: LLMDiscordMessage = { timestamp: m.createdAt.toUTCString(), author: m.author.username, name: REAL_NAMES[m.author.username] || null, context: undefined, content: m.cleanContent, reactions: stringifyReactions(m), }; // fetch replied-to message, if there is one if (m.type == MessageType.Reply && m.reference) { try { const repliedToMsg = await m.fetchReference(); if (repliedToMsg) { msgDict.context = repliedToMsg.cleanContent; } } catch (err) { logWarn(`[bot] Error fetching replied-to message: ` + err); } } return msgDict; } async function sync(guilds: GuildManager) { // Parse REACTION_GUILDS or fall back to GUILD for backwards compatibility const guildsStr = process.env.REACTION_GUILDS || process.env.GUILD || ''; if (!guildsStr.trim()) { logError('[bot] FATAL: No REACTION_GUILDS or GUILD configured!'); return 1; } const guildIds = guildsStr .split(',') .map((id) => id.trim()) .filter((id) => id); for (const guildId of guildIds) { const guild = await guilds.fetch(guildId); if (!guild) { logError(`[bot] FATAL: guild ${guildId} not found!`); continue; } logInfo(`[bot] Entered guild ${guild.id}`); const channels = await guild.channels.fetch(); const textChannels = >( channels.filter((c) => c && 'messages' in c && c.isTextBased) ); for (const [id, textChannel] of textChannels) { logInfo(`[bot] Found text channel ${id}`); const oldestMsg = await db.get( 'SELECT * FROM messages WHERE guild = ? AND channel = ? ORDER BY id ASC LIMIT 1', guild.id, id ); const newestMsg = await db.get( 'SELECT * FROM messages WHERE guild = ? AND channel = ? ORDER BY id DESC LIMIT 1', guild.id, id ); let before: string = oldestMsg && String(oldestMsg.id); let after: string = newestMsg && String(newestMsg.id); let messagesCount = 0; let reactionsCount = 0; let newMessagesBefore: Collection>; let newMessagesAfter: Collection>; try { do { newMessagesBefore = await textChannel.messages.fetch({ before, limit: 100 }); messagesCount += newMessagesBefore.size; newMessagesAfter = await textChannel.messages.fetch({ after, limit: 100 }); messagesCount += newMessagesAfter.size; logInfo( `[bot] [${id}] Fetched ${messagesCount} messages (+${newMessagesBefore.size} older, ${newMessagesAfter.size} newer)` ); const reactions = newMessagesBefore .flatMap((m) => m.reactions.cache) .concat( newMessagesAfter.flatMap((m) => m.reactions.cache) ); for (const [_, reaction] of reactions) { await recordReaction(reaction); } reactionsCount += reactions.size; logInfo( `[bot] [${id}] Recorded ${reactionsCount} reactions (+${reactions.size}).` ); if (newMessagesBefore.size > 0) { before = newMessagesBefore.last().id; } if (newMessagesAfter.size > 0) { after = newMessagesAfter.first().id; } } while (newMessagesBefore.size === 100 || newMessagesAfter.size === 100); logInfo(`[bot] [${id}] Done.`); } catch (err) { logWarn(`[bot] [${id}] Failed to fetch messages and reactions: ${err}`); } } } } async function requestTTSResponse( txt: string, speaker?: string, pitch?: number, instruct?: string ): Promise { const ttsEndpoint = `${process.env.RVC_HOST}/tts-inference`; logInfo(`[bot] Requesting TTS response for "${txt}"`); const requestBody = { text: txt, language: 'English', speaker: speaker || 'Ono_Anna', instruct: instruct || 'Speak in a friendly and enthusiastic tone', modelpath: 'model.pth', f0_up_key: pitch ?? 0, }; const res = await fetch(ttsEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); const resContents = await res.blob(); return resContents; } export { db, clearDb, openDb, reactionEmojis, recordReaction, requestTTSResponse, serializeMessageHistory, sync, REAL_NAMES, LOSER_WHITELIST, parseRealNames, parseLoserWhitelist, };