Formatting with Prettier

This commit is contained in:
2026-02-27 02:48:25 -08:00
parent aff421f5d5
commit 99eab5a28f
25 changed files with 6044 additions and 5885 deletions

8
.prettierignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
build
*.js
*.d.ts
coverage
.vscode
.idea

8
.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "always"
}

View File

@@ -1,6 +1,6 @@
# FemScoreboard
Web application/Discord bot which ranks users based on the reactions received on their messages.
Web application/Discord bot which ranks users based on the reactions received on their messages.
## Project Setup

View File

@@ -16,9 +16,10 @@ import {
MessageReaction,
MessageType,
PartialMessageReaction,
Partials, SlashCommandBuilder,
Partials,
SlashCommandBuilder,
TextChannel,
User
User,
} from 'discord.js';
import fs = require('node:fs');
import path = require('node:path');
@@ -35,25 +36,33 @@ import {
requestTTSResponse,
serializeMessageHistory,
sync,
REAL_NAMES
REAL_NAMES,
} from './util';
import 'dotenv/config';
import { LLMConfig } from './commands/types';
import { LLMProvider } from './provider/provider';
interface State {
llmconf?(): LLMConfig,
provider?(): LLMProvider,
sysprompt?(): string
llmconf?(): LLMConfig;
provider?(): LLMProvider;
sysprompt?(): string;
}
const state: State = {};
interface CommandClient extends Client {
commands?: Collection<string, { data: SlashCommandBuilder, execute: (interaction: Interaction) => Promise<void> }>
commands?: Collection<
string,
{ data: SlashCommandBuilder; execute: (interaction: Interaction) => Promise<void> }
>;
}
const client: CommandClient = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent],
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
client.commands = new Collection();
@@ -68,8 +77,10 @@ client.once(Events.ClientReady, async () => {
}
});
async function onMessageReactionChanged(reaction: MessageReaction | PartialMessageReaction, user: User) {
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
@@ -93,13 +104,17 @@ async function onMessageReactionChanged(reaction: MessageReaction | PartialMessa
}
// 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}`);
logInfo(
`[bot] ${reaction.message.author?.id}'s message reaction count changed: ${reaction.emoji.name}x${reaction.count}`
);
await recordReaction(<MessageReaction>reaction);
}
function textOnlyMessages(message: Message) {
return message.cleanContent.length > 0 &&
(message.type === MessageType.Default || message.type === MessageType.Reply);
return (
message.cleanContent.length > 0 &&
(message.type === MessageType.Default || message.type === MessageType.Reply)
);
}
function isGoodResponse(response: string) {
@@ -118,7 +133,7 @@ async function onNewMessage(message: Message) {
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
await message.reply({
files: [audioFile]
files: [audioFile],
});
} catch (err) {
logError(`[bot] Failed to generate audio message reply: ${err}`);
@@ -131,18 +146,20 @@ async function onNewMessage(message: Message) {
}
// Miku must reply when spoken to
const mustReply = message.mentions.has(process.env.CLIENT!) || message.cleanContent.toLowerCase().includes('miku');
const mustReply =
message.mentions.has(process.env.CLIENT!) ||
message.cleanContent.toLowerCase().includes('miku');
const history = await message.channel.messages.fetch({
limit: state.llmconf!().msg_context - 1,
before: message.id
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 replyChance = Math.floor((Math.random() * 1) / Number(process.env.REPLY_CHANCE)) === 0;
const willReply = mustReply || replyChance;
if (!willReply) {
@@ -163,7 +180,11 @@ async function onNewMessage(message: Message) {
await message.channel.sendTyping();
}
const response = await state.provider!().requestLLMResponse(cleanHistoryList, state.sysprompt!(), state.llmconf!());
const response = await state.provider!().requestLLMResponse(
cleanHistoryList,
state.sysprompt!(),
state.llmconf!()
);
// evaluate response
if (!isGoodResponse(response)) {
logWarn(`[bot] Burning bad response: "${response}"`);
@@ -198,7 +219,7 @@ async function requestRVCResponse(src: Attachment): Promise<Blob> {
logInfo(`[bot] Got audio file: ${srcbuf.size} bytes`);
const queryParams = new URLSearchParams();
queryParams.append("token", process.env.LLM_TOKEN || "");
queryParams.append('token', process.env.LLM_TOKEN || '');
const fd = new FormData();
fd.append('file', fs.readFileSync(tmpFileName), 'voice-message.ogg');
@@ -207,7 +228,7 @@ async function requestRVCResponse(src: Attachment): Promise<Blob> {
logInfo(`[bot] Requesting RVC response for ${src.id}`);
const res = await fetch(rvcEndpoint, {
method: 'POST',
body: fd
body: fd,
});
const resContents = await res.blob();
return resContents;
@@ -228,10 +249,12 @@ async function scheduleRandomMessage(firstTime = false) {
try {
const audio = await requestTTSResponse(randomMessage);
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName(
'mikuified.wav'
);
await channel.send({
content: randomMessage,
files: [audioFile]
files: [audioFile],
});
logInfo(`[bot] Sent MOTD + TTS: ${randomMessage}`);
} catch (err) {
@@ -271,7 +294,9 @@ async function scheduleThrowback(firstTime = false) {
const channel = <TextChannel>await client.channels.fetch(process.env.THROWBACK_CHANNEL);
if (!channel) {
logWarn(`[bot] Channel ${process.env.THROWBACK_CHANNEL} not found, disabling throwback.`);
logWarn(
`[bot] Channel ${process.env.THROWBACK_CHANNEL} not found, disabling throwback.`
);
return;
}
@@ -282,19 +307,22 @@ async function scheduleThrowback(firstTime = false) {
// Convert to approximate snowflake ID
const aroundSnowflake = dateToSnowflake(oneYearAgo);
logInfo(`[bot] Fetching messages around ${oneYearAgo.toISOString()} (snowflake: ${aroundSnowflake})`);
logInfo(
`[bot] Fetching messages around ${oneYearAgo.toISOString()} (snowflake: ${aroundSnowflake})`
);
// Fetch messages around that time
const messages = await channel.messages.fetch({
around: aroundSnowflake,
limit: 50
limit: 50,
});
// Filter to only text messages from non-bots
const textMessages = messages.filter(m =>
!m.author.bot &&
m.cleanContent.length > 0 &&
(m.type === MessageType.Default || m.type === MessageType.Reply)
const textMessages = messages.filter(
(m) =>
!m.author.bot &&
m.cleanContent.length > 0 &&
(m.type === MessageType.Default || m.type === MessageType.Reply)
);
if (textMessages.size === 0) {
@@ -304,7 +332,9 @@ async function scheduleThrowback(firstTime = false) {
const messagesArray = [...textMessages.values()];
const randomMsg = messagesArray[Math.floor(Math.random() * messagesArray.length)];
logInfo(`[bot] Selected throwback message from ${randomMsg.author.username}: "${randomMsg.cleanContent}"`);
logInfo(
`[bot] Selected throwback message from ${randomMsg.author.username}: "${randomMsg.cleanContent}"`
);
// Generate LLM response using the standard system prompt
if ('sendTyping' in channel) {
@@ -370,21 +400,30 @@ async function scheduleBiggestLoser(firstTime = false) {
let lastId = startId;
while (true) {
try {
const messages = await (textChannel as any).messages.fetch({ after: lastId, limit: 100 });
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;
if (!msg.author.bot && (REAL_NAMES as any)[msg.author.username]) {
if (
!msg.author.bot &&
(REAL_NAMES as any)[msg.author.username]
) {
const realName = (REAL_NAMES as any)[msg.author.username];
if (realNameToCount.has(realName)) {
realNameToCount.set(realName, realNameToCount.get(realName)! + 1);
realNameToCount.set(
realName,
realNameToCount.get(realName)! + 1
);
}
}
}
lastId = maxId;
if (BigInt(lastId) >= BigInt(endId) || messages.size < 100) break;
} catch (e) {
@@ -413,7 +452,9 @@ async function scheduleBiggestLoser(firstTime = false) {
if (fs.existsSync(streakFile)) {
try {
const streakData = JSON.parse(fs.readFileSync(streakFile, 'utf8'));
const prevNames = Array.isArray(streakData.names) ? streakData.names : [streakData.name];
const prevNames = Array.isArray(streakData.names)
? streakData.names
: [streakData.name];
prevNames.sort();
if (JSON.stringify(prevNames) === JSON.stringify(biggestLosers)) {
streakCount = streakData.count + 1;
@@ -422,16 +463,19 @@ async function scheduleBiggestLoser(firstTime = false) {
logWarn(`[bot] Failed to read streak data: ${e}`);
}
}
fs.writeFileSync(streakFile, JSON.stringify({ names: biggestLosers, count: streakCount }));
fs.writeFileSync(
streakFile,
JSON.stringify({ names: biggestLosers, count: streakCount })
);
const firstNames = biggestLosers.map(n => n.split(' ')[0]);
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 isAre = biggestLosers.length > 1 ? 'are' : 'is';
const theyHave = biggestLosers.length > 1 ? 'They have' : 'They have';
let declaration = `The biggest loser(s) of yesterday ${isAre} ${joinedNames} with only ${minCount} messages! ${theyHave} been the biggest loser(s) for ${streakCount} day(s) in a row.`;
@@ -479,14 +523,14 @@ async function scheduleBiggestLoser(firstTime = false) {
setTimeout(scheduleBiggestLoser, timeout);
}
client.on(Events.InteractionCreate, async interaction => {
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 => {
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const client: CommandClient = interaction.client;
@@ -502,9 +546,15 @@ client.on(Events.InteractionCreate, async interaction => {
} catch (error) {
logError(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
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 });
await interaction.reply({
content: 'There was an error while executing this command!',
ephemeral: true,
});
}
}
});
@@ -512,13 +562,13 @@ client.on(Events.InteractionCreate, async interaction => {
// startup
(async () => {
tmp.setGracefulCleanup();
logInfo("[db] Opening...");
logInfo('[db] Opening...');
await openDb();
logInfo("[db] Migrating...");
logInfo('[db] Migrating...');
await db.migrate();
logInfo("[db] Ready.");
logInfo('[db] Ready.');
logInfo("[bot] Loading commands...");
logInfo('[bot] Loading commands...');
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath, { withFileTypes: true });
for (const folder of commandFolders) {
@@ -526,7 +576,7 @@ client.on(Events.InteractionCreate, async interaction => {
continue;
}
const commandsPath = path.join(foldersPath, folder.name);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
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);
@@ -538,7 +588,7 @@ client.on(Events.InteractionCreate, async interaction => {
}
}
logInfo("[bot] Logging in...");
logInfo('[bot] Logging in...');
await client.login(process.env.TOKEN);
if (process.env.ENABLE_MOTD) {
await scheduleRandomMessage(true);

View File

@@ -1,7 +1,4 @@
import {
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { LLMConfig } from '../types';
import 'dotenv/config';
@@ -12,23 +9,26 @@ const config: LLMConfig = {
top_p: 0.6,
msg_context: 8,
frequency_penalty: 0.0,
presence_penalty: 0.0
presence_penalty: 0.0,
};
async function configCommand(interaction: ChatInputCommandInteraction)
{
async function configCommand(interaction: ChatInputCommandInteraction) {
if (interaction.user.id !== process.env.ADMIN) {
await interaction.reply("You are not authorized to change model settings");
return;
await interaction.reply('You are not authorized to change model settings');
return;
}
config.max_new_tokens = interaction.options.getInteger('max_new_tokens') ?? config.max_new_tokens;
config.min_new_tokens = interaction.options.getInteger('min_new_tokens') ?? config.min_new_tokens;
config.max_new_tokens =
interaction.options.getInteger('max_new_tokens') ?? config.max_new_tokens;
config.min_new_tokens =
interaction.options.getInteger('min_new_tokens') ?? config.min_new_tokens;
config.msg_context = interaction.options.getInteger('msg_context') ?? config.msg_context;
config.temperature = interaction.options.getNumber('temperature') ?? config.temperature;
config.top_p = interaction.options.getNumber('top_p') ?? config.top_p;
config.frequency_penalty = interaction.options.getNumber('frequency_penalty') ?? config.frequency_penalty;
config.presence_penalty = interaction.options.getNumber('presence_penalty') ?? config.presence_penalty;
config.frequency_penalty =
interaction.options.getNumber('frequency_penalty') ?? config.frequency_penalty;
config.presence_penalty =
interaction.options.getNumber('presence_penalty') ?? config.presence_penalty;
await interaction.reply(`
\`\`\`
max_new_tokens = ${config.max_new_tokens}
@@ -46,26 +46,40 @@ export = {
data: new SlashCommandBuilder()
.setName('llmconf')
.setDescription('Change model inference settings')
.addNumberOption(
opt => opt.setName('temperature').setDescription('Temperature; not recommended w/ top_p (default: 0.7)')
.addNumberOption((opt) =>
opt
.setName('temperature')
.setDescription('Temperature; not recommended w/ top_p (default: 0.7)')
)
.addNumberOption(
opt => opt.setName('top_p').setDescription('Cumulative prob. of min. token set to sample from; not recommended w/ temperature (default: 0.9)')
.addNumberOption((opt) =>
opt
.setName('top_p')
.setDescription(
'Cumulative prob. of min. token set to sample from; not recommended w/ temperature (default: 0.9)'
)
)
.addNumberOption(
opt => opt.setName('frequency_penalty').setDescription('[unused] Penalize tokens from reappearing multiple times; ranges from -2 to 2 (default: 0.0)')
.addNumberOption((opt) =>
opt
.setName('frequency_penalty')
.setDescription(
'[unused] Penalize tokens from reappearing multiple times; ranges from -2 to 2 (default: 0.0)'
)
)
.addNumberOption(
opt => opt.setName('presence_penalty').setDescription('[unused] Penalize a token from reappearing; ranges from -2 to 2 (default: 0.0)')
.addNumberOption((opt) =>
opt
.setName('presence_penalty')
.setDescription(
'[unused] Penalize a token from reappearing; ranges from -2 to 2 (default: 0.0)'
)
)
.addIntegerOption(
opt => opt.setName('max_new_tokens').setDescription('Max. new tokens (default: 100)')
.addIntegerOption((opt) =>
opt.setName('max_new_tokens').setDescription('Max. new tokens (default: 100)')
)
.addIntegerOption(
opt => opt.setName('min_new_tokens').setDescription('Min. new tokens (default: 1)')
.addIntegerOption((opt) =>
opt.setName('min_new_tokens').setDescription('Min. new tokens (default: 1)')
)
.addIntegerOption(
opt => opt.setName('msg_context').setDescription('Num. messages in context (default: 8)')
.addIntegerOption((opt) =>
opt.setName('msg_context').setDescription('Num. messages in context (default: 8)')
),
execute: configCommand,
state: () => config,

View File

@@ -1,7 +1,4 @@
import {
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import 'dotenv/config';
import fs = require('node:fs');
import path = require('node:path');
@@ -9,11 +6,10 @@ import path = require('node:path');
const syspromptCache = path.resolve(__dirname, 'sysprompt_cache');
const SAFE_NAME_REGEX = /^[\w\d]+$/;
async function editSyspromptCommand(interaction: ChatInputCommandInteraction)
{
async function editSyspromptCommand(interaction: ChatInputCommandInteraction) {
if (interaction.user.id !== process.env.ADMIN) {
await interaction.reply("You are not authorized to change model settings");
return;
await interaction.reply('You are not authorized to change model settings');
return;
}
const name = interaction.options.getString('name', true);
@@ -38,11 +34,14 @@ export = {
data: new SlashCommandBuilder()
.setName('edit')
.setDescription('Edit system prompts')
.addStringOption(
opt => opt.setName('name').setDescription('Name (must be alphanumeric)').setRequired(true)
.addStringOption((opt) =>
opt.setName('name').setDescription('Name (must be alphanumeric)').setRequired(true)
)
.addAttachmentOption(
opt => opt.setName('content').setDescription('Text file containing the system prompt').setRequired(true)
.addAttachmentOption((opt) =>
opt
.setName('content')
.setDescription('Text file containing the system prompt')
.setRequired(true)
),
execute: editSyspromptCommand
execute: editSyspromptCommand,
};

View File

@@ -1,7 +1,4 @@
import {
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import 'dotenv/config';
import { MikuAIProvider } from '../../provider/mikuai';
import { HuggingfaceProvider } from '../../provider/huggingface';
@@ -12,13 +9,13 @@ const PROVIDERS = {
mikuai: new MikuAIProvider(),
huggingface: new HuggingfaceProvider(),
openai: new OpenAIProvider(),
ollama: new OllamaProvider()
ollama: new OllamaProvider(),
};
let provider = PROVIDERS.openai;
async function providerCommand(interaction: ChatInputCommandInteraction) {
if (interaction.user.id !== process.env.ADMIN) {
await interaction.reply("You are not authorized to change model settings");
await interaction.reply('You are not authorized to change model settings');
return;
}
@@ -38,22 +35,19 @@ export = {
data: new SlashCommandBuilder()
.setName('provider')
.setDescription('Change model backend')
.addStringOption(
opt => opt.setName('name')
.addStringOption((opt) =>
opt
.setName('name')
.setDescription('Name of model backend')
.setRequired(true)
.addChoices(
...Object.keys(PROVIDERS)
.map(key => ({
name: PROVIDERS[key].name(),
value: key
}))
...Object.keys(PROVIDERS).map((key) => ({
name: PROVIDERS[key].name(),
value: key,
}))
)
)
.addStringOption(
opt => opt.setName('model')
.setDescription('Model ID')
),
.addStringOption((opt) => opt.setName('model').setDescription('Model ID')),
execute: providerCommand,
state: () => provider
state: () => provider,
};

View File

@@ -1,8 +1,4 @@
import {
AttachmentBuilder,
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { AttachmentBuilder, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import 'dotenv/config';
import fs = require('node:fs');
import path = require('node:path');
@@ -11,9 +7,7 @@ import { globSync } from 'glob';
const syspromptCache = path.resolve(__dirname, 'sysprompt_cache');
let sysprompt = fs.readFileSync(path.resolve(syspromptCache, 'nous.txt'), 'utf-8');
function removeTrailingNewlines(sysprompt: string)
{
function removeTrailingNewlines(sysprompt: string) {
// remove trailing '\n' or '\r\n' that editors like to insert
if (sysprompt[sysprompt.length - 1] == '\n') {
if (sysprompt[sysprompt.length - 2] == '\r') {
@@ -24,8 +18,7 @@ function removeTrailingNewlines(sysprompt: string)
return sysprompt;
}
function getSysPrompts()
{
function getSysPrompts() {
const absolutePaths = globSync(path.resolve(syspromptCache, '*.txt'));
const prompts = {};
for (const filepath of absolutePaths) {
@@ -36,11 +29,10 @@ function getSysPrompts()
return prompts;
}
async function syspromptCommand(interaction: ChatInputCommandInteraction)
{
async function syspromptCommand(interaction: ChatInputCommandInteraction) {
if (interaction.user.id !== process.env.ADMIN) {
await interaction.reply("You are not authorized to change model settings");
return;
await interaction.reply('You are not authorized to change model settings');
return;
}
const promptDict = getSysPrompts();
@@ -49,17 +41,23 @@ async function syspromptCommand(interaction: ChatInputCommandInteraction)
sysprompt = promptDict[chosenPrompt];
await interaction.reply({
content: `Current system prompt: \`${chosenPrompt}\``,
files: [new AttachmentBuilder(Buffer.from(sysprompt), {
name: `${chosenPrompt}.txt`
})]
files: [
new AttachmentBuilder(Buffer.from(sysprompt), {
name: `${chosenPrompt}.txt`,
}),
],
});
} else {
const warning = chosenPrompt ? `System prompt \`${chosenPrompt}\` not found!` : 'A new system prompt was not specified.';
const warning = chosenPrompt
? `System prompt \`${chosenPrompt}\` not found!`
: 'A new system prompt was not specified.';
await interaction.reply({
content: `${warning}\nCurrent system prompt:`,
files: [new AttachmentBuilder(Buffer.from(sysprompt), {
name: 'unknown.txt'
})]
files: [
new AttachmentBuilder(Buffer.from(sysprompt), {
name: 'unknown.txt',
}),
],
});
}
}
@@ -68,9 +66,7 @@ export = {
data: new SlashCommandBuilder()
.setName('sysprompt')
.setDescription('Get/set the system prompt being used')
.addStringOption(
opt => opt.setName('name').setDescription('Name of system prompt')
),
.addStringOption((opt) => opt.setName('name').setDescription('Name of system prompt')),
execute: syspromptCommand,
state: () => removeTrailingNewlines(sysprompt)
state: () => removeTrailingNewlines(sysprompt),
};

View File

@@ -1,8 +1,4 @@
import {
AttachmentBuilder,
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { AttachmentBuilder, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import 'dotenv/config';
import { logError } from '../../../logging';
import { requestTTSResponse } from '../../util';
@@ -10,23 +6,22 @@ import { requestTTSResponse } from '../../util';
const config = {
ttsSettings: {
pitch_change_oct: 1,
pitch_change_sem: 0
}
pitch_change_sem: 0,
},
};
async function ttsCommand(interaction: ChatInputCommandInteraction)
{
async function ttsCommand(interaction: ChatInputCommandInteraction) {
const text = interaction.options.getString('text');
await interaction.reply(`generating audio for "${text}"...`);
try {
const audio = await requestTTSResponse(text);
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
await interaction.editReply({
files: [audioFile]
});
const audio = await requestTTSResponse(text);
const audioBuf = await audio.arrayBuffer();
const audioFile = new AttachmentBuilder(Buffer.from(audioBuf)).setName('mikuified.wav');
await interaction.editReply({
files: [audioFile],
});
} catch (err) {
await interaction.editReply(`Error: ${err}`);
await interaction.editReply(`Error: ${err}`);
logError(`Error while generating TTS: ${err}`);
}
}
@@ -34,10 +29,8 @@ async function ttsCommand(interaction: ChatInputCommandInteraction)
export = {
data: new SlashCommandBuilder()
.setName('tts')
.setDescription('Read text in Miku\'s voice')
.addStringOption(
opt => opt.setName('text').setDescription('Text').setRequired(true)
),
.setDescription("Read text in Miku's voice")
.addStringOption((opt) => opt.setName('text').setDescription('Text').setRequired(true)),
execute: ttsCommand,
config: config
config: config,
};

View File

@@ -1,9 +1,9 @@
export interface LLMConfig {
max_new_tokens: number,
min_new_tokens: number,
temperature: number,
top_p: number,
frequency_penalty: number,
presence_penalty: number,
msg_context: number
max_new_tokens: number;
min_new_tokens: number;
temperature: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
msg_context: number;
}

View File

@@ -15,7 +15,7 @@ for (const folder of commandFolders) {
// Grab all the command files from the commands directory you created earlier
const commandsPath = path.join(foldersPath, folder.name);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
const commandFiles = fs.readdirSync(commandsPath).filter((file) => file.endsWith('.js'));
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
@@ -34,9 +34,8 @@ const rest = new REST().setToken(process.env.TOKEN);
console.log(`Started refreshing ${commands.length} application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = <string[]> await rest.put(
Routes.applicationCommands(process.env.CLIENT),
{ body: commands },
const data = <string[]>(
await rest.put(Routes.applicationCommands(process.env.CLIENT), { body: commands })
);
console.log(`Successfully reloaded ${data.length} application (/) commands.`);

5797
discord/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,33 @@
{
"name": "femscoreboardbot",
"version": "1.0.0",
"dependencies": {
"@huggingface/inference": "^3.1.3",
"discord.js": "^14.13.0",
"dotenv": "^16.3.1",
"emoji-unicode-map": "^1.1.11",
"form-data": "^4.0.0",
"glob": "^11.0.1",
"jsdom": "^22.1.0",
"modelfusion": "^0.135.1",
"node-fetch": "^2.7.0",
"ollama": "^0.5.12",
"openai": "^6.25.0",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.6",
"tmp": "^0.2.3"
},
"devDependencies": {
"typescript": "^5.2.2"
},
"scripts": {
"start": "npx tsc && node bot.js",
"sync": "npx tsc && node sync.js",
"deploy": "npx tsc && node deploy.js"
}
"name": "femscoreboardbot",
"version": "1.0.0",
"dependencies": {
"@huggingface/inference": "^3.1.3",
"discord.js": "^14.13.0",
"dotenv": "^16.3.1",
"emoji-unicode-map": "^1.1.11",
"form-data": "^4.0.0",
"glob": "^11.0.1",
"jsdom": "^22.1.0",
"modelfusion": "^0.135.1",
"node-fetch": "^2.7.0",
"ollama": "^0.5.12",
"openai": "^6.25.0",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.6",
"tmp": "^0.2.3"
},
"devDependencies": {
"prettier": "^3.5.3",
"typescript": "^5.2.2"
},
"scripts": {
"prebuild": "prettier --write .",
"build": "tsc",
"start": "npm run build && node bot.js",
"sync": "npm run build && node sync.js",
"deploy": "npm run build && node deploy.js",
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}

View File

@@ -1,64 +1,52 @@
import { Message } from 'discord.js';
import { LLMProvider } from './provider';
import { HfInference } from "@huggingface/inference"
import { HfInference } from '@huggingface/inference';
import 'dotenv/config';
import { serializeMessageHistory } from '../util';
import { logError, logInfo } from '../../logging';
import { LLMConfig } from '../commands/types';
const RESPONSE_REGEX = `\\{"timestamp":"(Sun|Mon|Tue|Wed|Thu|Fri|Sat), \\d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\d{4} \\d{2}:\\d{2}:\\d{2} GMT","author":"Hatsune Miku#1740","name":"Hatsune Miku","context":"([^"\\\\]|\\\\.)*","content":"([^"\\\\]|\\\\.)*"(,"reactions":("(:\\w+: \\(\\d+\\)(, )?)*"|null))?\\}`;
const RESPONSE_SCHEMA = {
"properties": {
"timestamp": {
"description": "When the message was sent, in RFC 7231 format",
"title": "Timestamp",
"type": "string"
properties: {
timestamp: {
description: 'When the message was sent, in RFC 7231 format',
title: 'Timestamp',
type: 'string',
},
"author": {
"description": "The author's username, which may be one of the following, or something else: \"vinso1445\", \"f0oby\", \"1thinker\", \"scoliono\", \"ahjc\", \"cinnaba\", \"M6481\", \"hypadrive\", \"need_correction\", \"Hatsune Miku#1740\" (You)",
"title": "Author",
"type": "string"
author: {
description:
'The author\'s username, which may be one of the following, or something else: "vinso1445", "f0oby", "1thinker", "scoliono", "ahjc", "cinnaba", "M6481", "hypadrive", "need_correction", "Hatsune Miku#1740" (You)',
title: 'Author',
type: 'string',
},
"name": {
"anyOf": [
{"type": "string"},
{"type": "null"}
],
"description": "The author's real name, which may be blank or one of the following: \"Vincent Iannelli\", \"Myles Linden\", \"Samuel Habib\", \"James Shiffer\", \"Alex\", \"Jinsung Park\", \"Lawrence Liu\", \"Nazar Khan\", \"Ethan Cheng\", \"Hatsune Miku\" (You)",
"title": "Name"
name: {
anyOf: [{ type: 'string' }, { type: 'null' }],
description:
'The author\'s real name, which may be blank or one of the following: "Vincent Iannelli", "Myles Linden", "Samuel Habib", "James Shiffer", "Alex", "Jinsung Park", "Lawrence Liu", "Nazar Khan", "Ethan Cheng", "Hatsune Miku" (You)',
title: 'Name',
},
"context": {
"anyOf": [
{"type": "string"},
{"type": "null"}
],
"default": null,
"description": "The contents of the message being replied to, if this message is a reply",
"title": "Context"
context: {
anyOf: [{ type: 'string' }, { type: 'null' }],
default: null,
description: 'The contents of the message being replied to, if this message is a reply',
title: 'Context',
},
"content": {
"description": "The text content of this message",
"title": "Content",
"type": "string"
content: {
description: 'The text content of this message',
title: 'Content',
type: 'string',
},
reactions: {
anyOf: [{ type: 'string' }, { type: 'null' }],
default: null,
description:
'Optional list of emoji reactions this message received, if any. The following comma-separated format is used: ":skull: (3), :100: (1)"',
title: 'Reactions',
},
"reactions": {
"anyOf": [
{"type": "string"},
{"type": "null"}
],
"default": null,
"description": "Optional list of emoji reactions this message received, if any. The following comma-separated format is used: \":skull: (3), :100: (1)\"",
"title": "Reactions"
}
},
"required": [
"timestamp",
"author",
"name",
"content"
]
required: ['timestamp', 'author', 'name', 'content'],
};
const USER_PROMPT = `Continue the following Discord conversation by completing the next message, playing the role of Hatsune Miku. The conversation must progress forward, and you must avoid repeating yourself.
@@ -69,16 +57,18 @@ The conversation is as follows. The last line is the message you have to complet
`;
export class HuggingfaceProvider implements LLMProvider
{
export class HuggingfaceProvider implements LLMProvider {
private client: HfInference;
private model: string;
constructor(hf_token: string | undefined = process.env.HF_TOKEN, model = "NousResearch/Hermes-3-Llama-3.1-8B")
{
constructor(
hf_token: string | undefined = process.env.HF_TOKEN,
model = 'NousResearch/Hermes-3-Llama-3.1-8B'
) {
if (!hf_token) {
throw new TypeError("Huggingface API token was not passed in, and environment variable HF_TOKEN was unset!");
throw new TypeError(
'Huggingface API token was not passed in, and environment variable HF_TOKEN was unset!'
);
}
this.client = new HfInference(hf_token);
this.model = model;
@@ -92,15 +82,16 @@ export class HuggingfaceProvider implements LLMProvider
this.model = id;
}
async requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string>
{
let messageList = await Promise.all(
history.map(serializeMessageHistory)
);
messageList = messageList.filter(x => !!x);
async requestLLMResponse(
history: Message[],
sysprompt: string,
params: LLMConfig
): Promise<string> {
let messageList = await Promise.all(history.map(serializeMessageHistory));
messageList = messageList.filter((x) => !!x);
if (messageList.length === 0) {
throw new TypeError("No messages with content provided in history!");
throw new TypeError('No messages with content provided in history!');
}
// dummy message for last line of prompt
@@ -112,21 +103,22 @@ export class HuggingfaceProvider implements LLMProvider
let templateMsgTxt = JSON.stringify({
timestamp: newDate.toUTCString(),
author: "Hatsune Miku",
name: "Hatsune Miku",
author: 'Hatsune Miku',
name: 'Hatsune Miku',
context: lastMsg!.content,
content: "..."
content: '...',
});
const messageHistoryTxt = messageList.map(msg => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
const messageHistoryTxt =
messageList.map((msg) => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
logInfo(`[hf] Requesting response for message history: ${messageHistoryTxt}`);
try {
const chatCompletion = await this.client.chatCompletion({
model: this.model,
messages: [
{ role: "system", content: sysprompt },
{ role: "user", content: USER_PROMPT + messageHistoryTxt }
{ role: 'system', content: sysprompt },
{ role: 'user', content: USER_PROMPT + messageHistoryTxt },
],
temperature: params?.temperature || 0.5,
top_p: params?.top_p || 0.9,
@@ -141,7 +133,7 @@ export class HuggingfaceProvider implements LLMProvider
logInfo(`[hf] API response: ${response}`);
if (!response) {
throw new TypeError("HuggingFace completion API returned no message.");
throw new TypeError('HuggingFace completion API returned no message.');
}
return response;

View File

@@ -5,14 +5,14 @@ import 'dotenv/config';
import { logInfo } from '../../logging';
import { LLMConfig } from '../commands/types';
export class MikuAIProvider implements LLMProvider
{
export class MikuAIProvider implements LLMProvider {
private llmToken: string;
constructor(llmToken: string | undefined = process.env.LLM_TOKEN)
{
constructor(llmToken: string | undefined = process.env.LLM_TOKEN) {
if (!llmToken) {
throw new TypeError("LLM token was not passed in, and environment variable LLM_TOKEN was unset!");
throw new TypeError(
'LLM token was not passed in, and environment variable LLM_TOKEN was unset!'
);
}
this.llmToken = llmToken;
}
@@ -25,29 +25,32 @@ export class MikuAIProvider implements LLMProvider
throw new TypeError('setModel() not implemented on MikuAIProvider.');
}
async requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string>
{
async requestLLMResponse(
history: Message[],
sysprompt: string,
params: LLMConfig
): Promise<string> {
const queryParams = new URLSearchParams();
queryParams.append("token", this.llmToken);
queryParams.append("sys_prompt", sysprompt);
queryParams.append('token', this.llmToken);
queryParams.append('sys_prompt', sysprompt);
if (params) {
for (const field of Object.keys(params)) {
queryParams.append(field, params[field]);
}
}
const llmEndpoint = `${process.env.LLM_HOST}/?${queryParams.toString()}`;
let messageList = await Promise.all(
history.map(serializeMessageHistory)
);
messageList = messageList.filter(x => !!x);
let messageList = await Promise.all(history.map(serializeMessageHistory));
messageList = messageList.filter((x) => !!x);
logInfo("[bot] Requesting LLM response with message list: " + messageList.map(m => m?.content));
logInfo(
'[bot] Requesting LLM response with message list: ' + messageList.map((m) => m?.content)
);
const res = await fetch(llmEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messageList)
body: JSON.stringify(messageList),
});
const botMsgTxt = await res.text();
logInfo(`[bot] Server returned LLM response: ${botMsgTxt}`);

View File

@@ -6,7 +6,6 @@ import { logError, logInfo } from '../../logging';
import { LLMConfig } from '../commands/types';
import { Ollama } from 'ollama';
const USER_PROMPT = `Continue the following Discord conversation by completing the next message, playing the role of Hatsune Miku. The conversation must progress forward, and you must avoid repeating yourself.
Each message is represented as a line of JSON. Refer to other users by their "name" instead of their "author" field whenever possible.
@@ -15,16 +14,18 @@ The conversation is as follows. The last line is the message you have to complet
`;
export class OllamaProvider implements LLMProvider
{
export class OllamaProvider implements LLMProvider {
private client: Ollama;
private model: string;
constructor(host: string | undefined = process.env.LLM_HOST, model = "socialnetwooky/hermes3-llama3.1-abliterated:8b-q5_k_m-64k")
{
constructor(
host: string | undefined = process.env.LLM_HOST,
model = 'socialnetwooky/hermes3-llama3.1-abliterated:8b-q5_k_m-64k'
) {
if (!host) {
throw new TypeError("Ollama host was not passed in, and environment variable LLM_HOST was unset!");
throw new TypeError(
'Ollama host was not passed in, and environment variable LLM_HOST was unset!'
);
}
this.client = new Ollama({ host });
this.model = model;
@@ -38,15 +39,16 @@ export class OllamaProvider implements LLMProvider
this.model = id;
}
async requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string>
{
let messageList = await Promise.all(
history.map(serializeMessageHistory)
);
messageList = messageList.filter(x => !!x);
async requestLLMResponse(
history: Message[],
sysprompt: string,
params: LLMConfig
): Promise<string> {
let messageList = await Promise.all(history.map(serializeMessageHistory));
messageList = messageList.filter((x) => !!x);
if (messageList.length === 0) {
throw new TypeError("No messages with content provided in history!");
throw new TypeError('No messages with content provided in history!');
}
// dummy message for last line of prompt
@@ -58,34 +60,35 @@ export class OllamaProvider implements LLMProvider
let templateMsgTxt = JSON.stringify({
timestamp: newDate.toUTCString(),
author: "Hatsune Miku",
name: "Hatsune Miku",
author: 'Hatsune Miku',
name: 'Hatsune Miku',
context: lastMsg!.content,
content: "..."
content: '...',
});
const messageHistoryTxt = messageList.map(msg => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
const messageHistoryTxt =
messageList.map((msg) => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
logInfo(`[ollama] Requesting response for message history: ${messageHistoryTxt}`);
try {
const chatCompletion = await this.client.chat({
model: this.model,
messages: [
{ role: "system", content: sysprompt },
{ role: "user", content: USER_PROMPT + messageHistoryTxt }
{ role: 'system', content: sysprompt },
{ role: 'user', content: USER_PROMPT + messageHistoryTxt },
],
options: {
temperature: params?.temperature || 0.5,
top_p: params?.top_p || 0.9,
num_predict: params?.max_new_tokens || 128,
}
},
});
let response = chatCompletion.message.content;
logInfo(`[ollama] API response: ${response}`);
if (!response) {
throw new TypeError("Ollama chat API returned no message.");
throw new TypeError('Ollama chat API returned no message.');
}
return response;

View File

@@ -18,15 +18,20 @@ export class OpenAIProvider implements LLMProvider {
private client: OpenAI;
private model: string;
constructor(token: string | undefined = process.env.LLM_TOKEN, model = "zai-org/glm-4.7-flash") {
constructor(
token: string | undefined = process.env.LLM_TOKEN,
model = 'zai-org/glm-4.7-flash'
) {
if (!token) {
throw new TypeError("LLM token was not passed in, and environment variable LLM_TOKEN was unset!");
throw new TypeError(
'LLM token was not passed in, and environment variable LLM_TOKEN was unset!'
);
}
this.client = new OpenAI({
baseURL: process.env.OPENAI_HOST,
apiKey: token,
apiKey: token,
});
this.model = model;
this.model = model;
}
name() {
@@ -34,17 +39,19 @@ export class OpenAIProvider implements LLMProvider {
}
setModel(model: string) {
this.model = model;
this.model = model;
}
async requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string> {
let messageList = await Promise.all(
history.map(serializeMessageHistory)
);
messageList = messageList.filter(x => !!x);
async requestLLMResponse(
history: Message[],
sysprompt: string,
params: LLMConfig
): Promise<string> {
let messageList = await Promise.all(history.map(serializeMessageHistory));
messageList = messageList.filter((x) => !!x);
if (messageList.length === 0) {
throw new TypeError("No messages with content provided in history!");
throw new TypeError('No messages with content provided in history!');
}
// dummy message for last line of prompt
@@ -56,35 +63,36 @@ export class OpenAIProvider implements LLMProvider {
let templateMsgTxt = JSON.stringify({
timestamp: newDate.toUTCString(),
author: "Hatsune Miku",
name: "Hatsune Miku",
author: 'Hatsune Miku',
name: 'Hatsune Miku',
context: lastMsg!.content,
content: "..."
content: '...',
});
const messageHistoryTxt = messageList.map(msg => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
const messageHistoryTxt =
messageList.map((msg) => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
logInfo(`[openai] Requesting response for message history: ${messageHistoryTxt}`);
try {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [
{ role: "system", content: sysprompt },
{ role: "user", content: USER_PROMPT + messageHistoryTxt }
],
temperature: params?.temperature || 0.5,
model: this.model,
messages: [
{ role: 'system', content: sysprompt },
{ role: 'user', content: USER_PROMPT + messageHistoryTxt },
],
temperature: params?.temperature || 0.5,
top_p: params?.top_p || 0.9,
max_tokens: params?.max_new_tokens || 128,
});
let content = response.choices[0].message.content;
if (content.lastIndexOf('</think>') > -1) {
content = content.slice(content.lastIndexOf('</think>') + 8);
}
if (content.lastIndexOf('</think>') > -1) {
content = content.slice(content.lastIndexOf('</think>') + 8);
}
logInfo(`[openai] API response: ${content}`);
if (!content) {
throw new TypeError("OpenAI API returned no message.");
throw new TypeError('OpenAI API returned no message.');
}
return content;

View File

@@ -1,19 +1,17 @@
import { Message } from "discord.js";
import { LLMConfig } from "../commands/types";
import { Message } from 'discord.js';
import { LLMConfig } from '../commands/types';
export interface LLMProvider
{
export interface LLMProvider {
name(): string;
requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string>;
setModel(id: string);
}
export interface LLMDiscordMessage
{
timestamp: string
author: string
name?: string
context?: string
content: string
reactions?: string
export interface LLMDiscordMessage {
timestamp: string;
author: string;
name?: string;
context?: string;
content: string;
reactions?: string;
}

View File

@@ -9,7 +9,11 @@ import { logInfo } from '../logging';
import { db, openDb, reactionEmojis, sync } from './util';
const client = new Client({
intents: [GatewayIntentBits.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages],
intents: [
GatewayIntentBits.MessageContent,
IntentsBitField.Flags.Guilds,
IntentsBitField.Flags.GuildMessages,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
@@ -20,12 +24,12 @@ client.once(Events.ClientReady, async () => {
});
async function startup() {
logInfo("[db] Opening...");
logInfo('[db] Opening...');
await openDb();
logInfo("[db] Migrating...");
logInfo('[db] Migrating...');
await db.migrate();
logInfo("[db] Ready.");
logInfo("[bot] Logging in...");
logInfo('[db] Ready.');
logInfo('[bot] Logging in...');
await client.login(process.env.TOKEN);
await sync(client.guilds);
process.exit(0);

View File

@@ -3,7 +3,15 @@
* Common helper functions
*/
import { Collection, GuildManager, GuildTextBasedChannel, Message, MessageReaction, MessageType, User } from 'discord.js';
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';
@@ -15,53 +23,49 @@ 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;
const REAL_NAMES = { // username to real name mapping
'vinso1445': 'Vincent Iannelli',
'scoliono': 'James Shiffer',
'drugseller88': 'James Shiffer',
'gnuwu': 'David Zheng',
'f0oby': 'Myles Linden',
'bapazheng': 'Myles Linden',
'bapabakshi': 'Myles Linden',
'keliande27': 'Myles Linden',
const REAL_NAMES = {
// username to real name mapping
vinso1445: 'Vincent Iannelli',
scoliono: 'James Shiffer',
drugseller88: 'James Shiffer',
gnuwu: 'David Zheng',
f0oby: 'Myles Linden',
bapazheng: 'Myles Linden',
bapabakshi: 'Myles Linden',
keliande27: 'Myles Linden',
'1thinker': 'Samuel Habib',
'adam28405': 'Adam Kazerounian',
adam28405: 'Adam Kazerounian',
'shibe.mp4': 'Jake Wong',
'Hatsune Miku': 'Hatsune Miku'
'Hatsune Miku': 'Hatsune Miku',
};
async function openDb() {
db = await open({
filename: 'db.sqlite',
driver: Database3
})
driver: Database3,
});
}
function clearDb() {
unlinkSync('db.sqlite');
}
function messageLink(message: ScoreboardMessageRow)
{
function messageLink(message: ScoreboardMessageRow) {
return `https://discord.com/channels/${message.guild}/${message.channel}/${message.id}`;
}
function userAvatarPath(user: User)
{
function userAvatarPath(user: User) {
return `../public/avatars/${user.id}.webp`;
}
async function downloadUserAvatar(user: User)
{
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 => {
return new Promise<void>((resolve) => {
httpGet(user.displayAvatarURL(), (res) => {
res.pipe(file);
file.on('finish', () => {
file.close();
@@ -72,9 +76,8 @@ async function downloadUserAvatar(user: User)
});
}
async function refreshUserReactionTotalCount(user: User, emoji_idx: number)
{
const result = await db.get<{sum: number}>(
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
);
@@ -98,8 +101,7 @@ async function refreshUserReactionTotalCount(user: User, emoji_idx: number)
logInfo(`[bot] Refreshed ${user.id}'s ${emojiName} count.`);
}
async function recordReaction(reaction: MessageReaction)
{
async function recordReaction(reaction: MessageReaction) {
// Match emoji by name (unicode) or by ID (custom emoji)
let emojiIdx = 0;
const emojiName = reaction.emoji.name;
@@ -107,7 +109,7 @@ async function recordReaction(reaction: MessageReaction)
if (emojiId) {
// Custom emoji - match by ID
emojiIdx = reactionEmojis.findIndex(e => e.includes(`:${emojiId}`)) + 1;
emojiIdx = reactionEmojis.findIndex((e) => e.includes(`:${emojiId}`)) + 1;
if (emojiIdx > 0) {
logInfo(`[bot] Custom emoji detected: ${emojiName} (ID: ${emojiId}), idx: ${emojiIdx}`);
}
@@ -146,8 +148,7 @@ async function recordReaction(reaction: MessageReaction)
}
}
async function serializeMessageHistory(m: Message): Promise<LLMDiscordMessage | undefined>
{
async function serializeMessageHistory(m: Message): Promise<LLMDiscordMessage | undefined> {
const stringifyReactions = (m: Message): string | undefined => {
const reacts = m.reactions.cache;
let serialized: string | undefined = undefined;
@@ -176,7 +177,7 @@ async function serializeMessageHistory(m: Message): Promise<LLMDiscordMessage |
name: REAL_NAMES[m.author.username] || null,
context: undefined,
content: m.cleanContent,
reactions: stringifyReactions(m)
reactions: stringifyReactions(m),
};
// fetch replied-to message, if there is one
@@ -192,7 +193,7 @@ async function serializeMessageHistory(m: Message): Promise<LLMDiscordMessage |
}
return msgDict;
};
}
async function sync(guilds: GuildManager) {
const guild = await guilds.fetch(process.env.GUILD);
@@ -202,7 +203,9 @@ async function sync(guilds: GuildManager) {
}
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);
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>(
@@ -228,11 +231,13 @@ async function sync(guilds: GuildManager) {
newMessagesAfter = await textChannel.messages.fetch({ after, limit: 100 });
messagesCount += newMessagesAfter.size;
logInfo(`[bot] [${id}] Fetched ${messagesCount} messages (+${newMessagesBefore.size} older, ${newMessagesAfter.size} newer)`);
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));
.flatMap<MessageReaction>((m) => m.reactions.cache)
.concat(newMessagesAfter.flatMap<MessageReaction>((m) => m.reactions.cache));
for (const [_, reaction] of reactions) {
await recordReaction(reaction);
}
@@ -253,19 +258,28 @@ async function sync(guilds: GuildManager) {
}
}
async function requestTTSResponse(txt: string): Promise<Blob>
{
async function requestTTSResponse(txt: string): Promise<Blob> {
const queryParams = new URLSearchParams();
queryParams.append("token", process.env.LLM_TOKEN);
queryParams.append("text", txt);
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'
method: 'POST',
});
const resContents = await res.blob();
return resContents;
}
export { db, clearDb, openDb, reactionEmojis, recordReaction, requestTTSResponse, serializeMessageHistory, sync, REAL_NAMES };
export {
db,
clearDb,
openDb,
reactionEmojis,
recordReaction,
requestTTSResponse,
serializeMessageHistory,
sync,
REAL_NAMES,
};

View File

@@ -4,26 +4,26 @@
*/
interface ScoreboardMessageRow {
id: number,
guild: number,
channel: number,
author: string,
content: string,
reaction_1_count: number,
reaction_2_count: number,
reaction_3_count: number,
reaction_4_count: number,
reaction_5_count: number
id: number;
guild: number;
channel: number;
author: string;
content: string;
reaction_1_count: number;
reaction_2_count: number;
reaction_3_count: number;
reaction_4_count: number;
reaction_5_count: number;
}
interface ScoreboardUserRow {
id: string,
username: string,
reaction_1_total: number,
reaction_2_total: number,
reaction_3_total: number,
reaction_4_total: number,
reaction_5_total: number
id: string;
username: string;
reaction_1_total: number;
reaction_2_total: number;
reaction_3_total: number;
reaction_4_total: number;
reaction_5_total: number;
}
export { ScoreboardMessageRow, ScoreboardUserRow };

5137
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,24 @@
{
"name": "femscoreboard",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "npx tsc && node server.js"
},
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pug": "^3.0.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/express": "^4.17.18",
"typescript": "^5.2.2"
}
"name": "femscoreboard",
"version": "1.0.0",
"private": true,
"scripts": {
"prebuild": "prettier --write .",
"build": "tsc",
"start": "npm run build && node server.js",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pug": "^3.0.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/express": "^4.17.18",
"prettier": "^3.5.3",
"typescript": "^5.2.2"
}
}

View File

@@ -19,25 +19,59 @@ let db: Database = null;
async function openDb() {
return open({
filename: 'discord/db.sqlite',
driver: Database3
driver: Database3,
});
}
app.get('/', async (req, res) => {
const msg1 = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages ORDER BY reaction_1_count DESC LIMIT 5');
const msg2 = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages ORDER BY reaction_2_count DESC LIMIT 5');
const msg3 = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages ORDER BY reaction_3_count DESC LIMIT 5');
const msg4 = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages ORDER BY reaction_4_count DESC LIMIT 5');
const msg5 = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages ORDER BY reaction_5_count DESC LIMIT 5');
const bestMsg = await db.all<[ScoreboardMessageRow]>('SELECT *, SUM(reaction_1_count)+SUM(reaction_2_count)+SUM(reaction_3_count)+SUM(reaction_4_count)+SUM(reaction_5_count) AS all_reacts FROM messages GROUP BY id ORDER BY all_reacts DESC LIMIT 5');
const msg1 = await db.all<[ScoreboardMessageRow]>(
'SELECT * FROM messages ORDER BY reaction_1_count DESC LIMIT 5'
);
const msg2 = await db.all<[ScoreboardMessageRow]>(
'SELECT * FROM messages ORDER BY reaction_2_count DESC LIMIT 5'
);
const msg3 = await db.all<[ScoreboardMessageRow]>(
'SELECT * FROM messages ORDER BY reaction_3_count DESC LIMIT 5'
);
const msg4 = await db.all<[ScoreboardMessageRow]>(
'SELECT * FROM messages ORDER BY reaction_4_count DESC LIMIT 5'
);
const msg5 = await db.all<[ScoreboardMessageRow]>(
'SELECT * FROM messages ORDER BY reaction_5_count DESC LIMIT 5'
);
const bestMsg = await db.all<[ScoreboardMessageRow]>(
'SELECT *, SUM(reaction_1_count)+SUM(reaction_2_count)+SUM(reaction_3_count)+SUM(reaction_4_count)+SUM(reaction_5_count) AS all_reacts FROM messages GROUP BY id ORDER BY all_reacts DESC LIMIT 5'
);
const funniest = await db.all<[ScoreboardUserRow]>('SELECT * FROM users ORDER BY reaction_1_total DESC LIMIT 15');
const realest = await db.all<[ScoreboardUserRow]>('SELECT * FROM users ORDER BY reaction_2_total DESC LIMIT 15');
const cunniest = await db.all<[ScoreboardUserRow]>('SELECT * FROM users ORDER BY reaction_3_total DESC LIMIT 15');
const based = await db.all<[ScoreboardUserRow]>('SELECT * FROM users ORDER BY reaction_4_total DESC LIMIT 15');
const agreeable = await db.all<[ScoreboardUserRow]>('SELECT * FROM users ORDER BY reaction_5_total DESC LIMIT 15');
const funniest = await db.all<[ScoreboardUserRow]>(
'SELECT * FROM users ORDER BY reaction_1_total DESC LIMIT 15'
);
const realest = await db.all<[ScoreboardUserRow]>(
'SELECT * FROM users ORDER BY reaction_2_total DESC LIMIT 15'
);
const cunniest = await db.all<[ScoreboardUserRow]>(
'SELECT * FROM users ORDER BY reaction_3_total DESC LIMIT 15'
);
const based = await db.all<[ScoreboardUserRow]>(
'SELECT * FROM users ORDER BY reaction_4_total DESC LIMIT 15'
);
const agreeable = await db.all<[ScoreboardUserRow]>(
'SELECT * FROM users ORDER BY reaction_5_total DESC LIMIT 15'
);
res.render('index', { funniest, realest, cunniest, based, agreeable, msg1, msg2, msg3, msg4, msg5, bestMsg });
res.render('index', {
funniest,
realest,
cunniest,
based,
agreeable,
msg1,
msg2,
msg3,
msg4,
msg5,
bestMsg,
});
});
app.listen(port, async () => {

View File

@@ -1,10 +1,8 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"sourceMap": true
},
"exclude": [
"discord/node_modules"
]
}
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"sourceMap": true
},
"exclude": ["discord/node_modules"]
}