/** * helpers.ts * Shared helper functions for Discord commands */ import { EmbedBuilder, MessageType, Client, Guild, GuildTextBasedChannel, Collection, } from 'discord.js'; import { logInfo, logWarn, logError } from '../../logging'; import { REAL_NAMES, LOSER_WHITELIST } from '../util'; import path = require('node:path'); import fs = require('node:fs'); /** * Kawaii loading phrases used in status embeds */ export const KAWAII_PHRASES = [ 'Hmm... let me think~ ♪', 'Processing nyaa~', 'Miku is thinking...', 'Calculating with magic ✨', 'Pondering desu~', 'Umm... one moment! ♪', 'Brain go brrr~', 'Assembling thoughts... ♪', 'Loading Miku-brain...', 'Thinking hard senpai~', ]; /** * Miku's theme color (teal) */ export const MIKU_COLOR = 0x39c5bb; /** * Parse loading emojis from environment variable * Format: "<:clueless:123>,,..." */ export function parseLoadingEmojis(): string[] { const emojiStr = process.env.LOADING_EMOJIS || ''; if (!emojiStr.trim()) { // Default fallback emojis if not configured return ['🤔', '✨', '🎵']; } return emojiStr .split(',') .map((e) => e.trim()) .filter((e) => e.length > 0); } /** * Pick a random loading emoji from the configured list */ export function getRandomLoadingEmoji(): string { const emojis = parseLoadingEmojis(); return emojis[Math.floor(Math.random() * emojis.length)]; } /** * Pick a random kawaii phrase */ export function getRandomKawaiiPhrase(): string { return KAWAII_PHRASES[Math.floor(Math.random() * KAWAII_PHRASES.length)]; } /** * Create an embed for status updates during generation */ export function createStatusEmbed(emoji: string, phrase: string, status: string): EmbedBuilder { return new EmbedBuilder() .setColor(MIKU_COLOR) .setAuthor({ name: phrase }) .setDescription(`${emoji}\n${status}`) .setTimestamp(); } /** * Create a simple status embed (without emoji/phrase) */ export function createSimpleStatusEmbed(status: string): EmbedBuilder { const emoji = getRandomLoadingEmoji(); const phrase = getRandomKawaiiPhrase(); return createStatusEmbed(emoji, phrase, status); } /** * Convert a Date to a Discord snowflake ID (approximate) */ export function dateToSnowflake(date: Date): string { const DISCORD_EPOCH = 1420070400000n; const timestamp = BigInt(date.getTime()); const snowflake = (timestamp - DISCORD_EPOCH) << 22n; return snowflake.toString(); } /** * Fetch MOTD from configured source */ export async function fetchMotd(): Promise { try { const { JSDOM } = await import('jsdom'); const fetch = (await import('node-fetch')).default; 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('[helpers] Failed to fetch MOTD; is the booru down?'); return null; } } /** * Send biggest loser announcement to a channel * Returns the declaration string * @param client - Discord client * @param targetChannel - Channel to send the announcement to * @param sourceGuildId - Optional guild ID to fetch message history from (defaults to all configured guilds) */ export async function sendBiggestLoserAnnouncement( client: Client, targetChannel: any, sourceGuildId?: string ): Promise { const yesterdayStart = new Date(); yesterdayStart.setDate(yesterdayStart.getDate() - 1); yesterdayStart.setHours(0, 0, 0, 0); const yesterdayEnd = new Date(); yesterdayEnd.setHours(0, 0, 0, 0); const startId = dateToSnowflake(yesterdayStart); const endId = dateToSnowflake(yesterdayEnd); const realNameToCount = new Map(); for (const realName of new Set(Object.values(REAL_NAMES))) { if (LOSER_WHITELIST.includes(realName as string)) { realNameToCount.set(realName as string, 0); } } // Parse REACTION_GUILDS or fall back to GUILD const guildsStr = process.env.REACTION_GUILDS || process.env.GUILD || ''; let guildIds = guildsStr .split(',') .map((id) => id.trim()) .filter((id) => id); // Override with source guild if specified if (sourceGuildId) { guildIds = [sourceGuildId]; } const fetchedGuilds: Guild[] = []; for (const guildId of guildIds) { const guild = await client.guilds.fetch(guildId); if (!guild) { logWarn(`[helpers] Guild ${guildId} not found, skipping.`); continue; } fetchedGuilds.push(guild); const channels = await guild.channels.fetch(); const textChannels = channels.filter((c: any) => c && c.isTextBased()); for (const [_, textChannel] of textChannels) { let lastId = startId; while (true) { try { const messages = await (textChannel as any).messages.fetch({ after: lastId, limit: 100, }); if (messages.size === 0) break; let maxId = lastId; for (const [msgId, msg] of messages) { if (BigInt(msgId) > BigInt(maxId)) maxId = msgId; if (BigInt(msgId) >= BigInt(endId)) continue; const realName = (REAL_NAMES as Record)[ msg.author.username ]; if (!msg.author.bot && realName) { if (realNameToCount.has(realName)) { realNameToCount.set(realName, realNameToCount.get(realName)! + 1); } } } lastId = maxId; if (BigInt(lastId) >= BigInt(endId) || messages.size < 100) break; } catch (e) { logWarn(`[helpers] Error fetching from channel: ${e}`); break; } } } } let minCount = Infinity; let biggestLosers: string[] = []; for (const [realName, count] of realNameToCount.entries()) { if (count < minCount) { minCount = count; biggestLosers = [realName]; } else if (count === minCount) { biggestLosers.push(realName); } } if (biggestLosers.length === 0 || minCount === Infinity) { throw new Error('No eligible losers found for yesterday.'); } biggestLosers.sort(); const streakFile = path.join(__dirname, 'biggest_loser_streaks.json'); let streaks: Record = {}; if (fs.existsSync(streakFile)) { try { streaks = JSON.parse(fs.readFileSync(streakFile, 'utf8')); } catch (e) { logWarn(`[helpers] Failed to read streak data: ${e}`); streaks = {}; } } const newStreaks: Record = {}; for (const name of biggestLosers) { newStreaks[name] = (streaks[name] || 0) + 1; } fs.writeFileSync(streakFile, JSON.stringify(newStreaks)); const firstNames = biggestLosers.map((n) => n.split(' ')[0]); let joinedNames = firstNames[0]; if (firstNames.length === 2) { joinedNames = `${firstNames[0]} and ${firstNames[1]}`; } else if (firstNames.length > 2) { joinedNames = `${firstNames.slice(0, -1).join(', ')}, and ${firstNames[firstNames.length - 1]}`; } const isPlural = biggestLosers.length > 1; const loserWord = process.env.LOSER_WORD || 'loser'; const isAre = isPlural ? 'are' : 'is'; let declaration: string; if (isPlural) { const streakParts = biggestLosers.map((name, idx) => { const firstName = firstNames[idx]; const dayWord = newStreaks[name] === 1 ? 'day' : 'days'; return `${firstName} (${newStreaks[name]} ${dayWord} in a row)`; }); let streakDetails = streakParts[0]; if (streakParts.length === 2) { streakDetails = `${streakParts[0]} and ${streakParts[1]}`; } else if (streakParts.length > 2) { streakDetails = `${streakParts.slice(0, -1).join(', ')}, and ${streakParts[streakParts.length - 1]}`; } declaration = `Yesterday's biggest ${loserWord} ${isAre} ${joinedNames} with only ${minCount} messages! Streaks: ${streakDetails}.`; } else { const dayWord = newStreaks[biggestLosers[0]] === 1 ? 'day' : 'days'; declaration = `Yesterday's biggest ${loserWord} ${isAre} ${joinedNames} with only ${minCount} messages! They have been the biggest ${loserWord} for ${newStreaks[biggestLosers[0]]} ${dayWord} in a row.`; } try { let pingTags: string[] = []; if (fetchedGuilds.length > 0) { const realNameToUserIds = new Map(); for (const [username, realName] of Object.entries(REAL_NAMES)) { const name = realName as string; if (!realNameToUserIds.has(name)) { realNameToUserIds.set(name, []); } realNameToUserIds.get(name)!.push(username); } const usernamesToCheck = new Set(); for (const realName of biggestLosers) { const usernames = realNameToUserIds.get(realName); if (usernames) { usernames.forEach((u) => usernamesToCheck.add(u)); } } for (const guild of fetchedGuilds) { try { const members = await guild.members.fetch({ time: 10000 }); for (const [_, member] of members) { const username = member.user.username; if (usernamesToCheck.has(username)) { const tag = `<@${member.user.id}>`; if (!pingTags.includes(tag)) { pingTags.push(tag); } } } } catch (e) { logWarn(`[helpers] Error fetching members from guild ${guild.id}: ${e}`); } } } if (pingTags.length > 0) { declaration += `\n${pingTags.join(' ')}`; } } catch (e) { logWarn(`[helpers] Error fetching members for ping: ${e}`); } return declaration; } export interface ThrowbackResult { originalMessage: string; author: string; response: string; } /** * Trigger a throwback message - fetch a message from 1 year ago and generate an LLM response * @param sourceChannel - Channel to fetch historical messages from (optional, defaults to targetChannel) * @param targetChannel - Channel to send the throwback reply to */ export async function triggerThrowback( client: Client, sourceChannel: any, targetChannel: any, provider: any, sysprompt: string, llmconf: any ): Promise { // Calculate date from 1 year ago const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const aroundSnowflake = dateToSnowflake(oneYearAgo); // Fetch messages around that time from source channel const messages = await sourceChannel.messages.fetch({ around: aroundSnowflake, limit: 50, }); // Filter to only text messages from non-bots const textMessages = messages.filter( (m: any) => !m.author.bot && m.cleanContent.length > 0 && (m.type === MessageType.Default || m.type === MessageType.Reply) ); if (textMessages.size === 0) { throw new Error('No messages found from 1 year ago.'); } // Pick a random message const messagesArray = [...textMessages.values()]; const randomMsg = messagesArray[Math.floor(Math.random() * messagesArray.length)]; logInfo( `[helpers] Selected throwback message from ${randomMsg.author.username}: "${randomMsg.cleanContent}"` ); // Fetch message history for context (like onNewMessage does) const history = await sourceChannel.messages.fetch({ limit: llmconf.msg_context - 1, before: randomMsg.id, }); const historyMessages = [...history.values()].reverse(); const cleanHistoryList = [...historyMessages, randomMsg]; // Generate LLM response with context const llmResponse = await provider.requestLLMResponse(cleanHistoryList, sysprompt, llmconf); // Send reply to the original message await randomMsg.reply(llmResponse); logInfo(`[helpers] Sent throwback reply: ${llmResponse}`); return { originalMessage: randomMsg.cleanContent, author: randomMsg.author.username, response: llmResponse, }; }