315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
/**
|
|
* 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<string, string> {
|
|
const realNamesStr = input !== undefined ? input : process.env.REAL_NAMES || '';
|
|
if (!realNamesStr.trim()) {
|
|
return {};
|
|
}
|
|
const realNames: Record<string, string> = {};
|
|
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<void>((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<LLMDiscordMessage | undefined> {
|
|
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) {
|
|
const guild = await guilds.fetch(process.env.GUILD);
|
|
if (!guild) {
|
|
logError(`[bot] FATAL: guild ${guild.id} not found!`);
|
|
return 1;
|
|
}
|
|
logInfo(`[bot] Entered guild ${guild.id}`);
|
|
const channels = await guild.channels.fetch();
|
|
const textChannels = <Collection<string, GuildTextBasedChannel>>(
|
|
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<ScoreboardMessageRow>(
|
|
'SELECT * FROM messages WHERE guild = ? AND channel = ? ORDER BY id ASC LIMIT 1',
|
|
guild.id,
|
|
id
|
|
);
|
|
const newestMsg = await db.get<ScoreboardMessageRow>(
|
|
'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<string, Message<true>>;
|
|
let newMessagesAfter: Collection<string, Message<true>>;
|
|
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<MessageReaction>((m) => m.reactions.cache)
|
|
.concat(newMessagesAfter.flatMap<MessageReaction>((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): Promise<NodeFetchBlob> {
|
|
const queryParams = new URLSearchParams();
|
|
queryParams.append('token', process.env.LLM_TOKEN);
|
|
queryParams.append('text', txt);
|
|
|
|
const ttsEndpoint = `${process.env.LLM_HOST}/tts?${queryParams.toString()}`;
|
|
logInfo(`[bot] Requesting TTS response for "${txt}"`);
|
|
const res = await fetch(ttsEndpoint, {
|
|
method: 'POST',
|
|
});
|
|
const resContents = await res.blob();
|
|
return resContents;
|
|
}
|
|
|
|
export {
|
|
db,
|
|
clearDb,
|
|
openDb,
|
|
reactionEmojis,
|
|
recordReaction,
|
|
requestTTSResponse,
|
|
serializeMessageHistory,
|
|
sync,
|
|
REAL_NAMES,
|
|
LOSER_WHITELIST,
|
|
parseRealNames,
|
|
parseLoserWhitelist,
|
|
};
|