386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
|
* 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>,<a:hachune:456>,..."
|
|
*/
|
|
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<string | null> {
|
|
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<string> {
|
|
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<string, number>();
|
|
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<string, string>)[
|
|
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<string, number> = {};
|
|
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<string, number> = {};
|
|
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<string, string[]>();
|
|
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<string>();
|
|
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<ThrowbackResult> {
|
|
// 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,
|
|
};
|
|
}
|