From 74d99d4df8436c20878ac38cd5fffc78d06d1314 Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Fri, 12 Jul 2024 00:43:42 -0700 Subject: [PATCH] wip 2: even more in progress --- LOUDBOT/src/index.d.ts | 3 +- LOUDBOT/src/index.ts | 4 +- LOUDBOT/src/main.ts | 16 +++- LOUDBOT/src/pdk.ts | 39 ++++----- migrations/0005-interests.sql | 4 +- package-lock.json | 2 +- plugin.yaml | 14 +--- src/client.ts | 59 +++++++------- src/db.ts | 27 +++++-- src/domain/interests.ts | 147 ++++++++++++++++++++++++++++++++-- src/index.ts | 7 +- 11 files changed, 236 insertions(+), 86 deletions(-) diff --git a/LOUDBOT/src/index.d.ts b/LOUDBOT/src/index.d.ts index 5301f04..f06c723 100644 --- a/LOUDBOT/src/index.d.ts +++ b/LOUDBOT/src/index.d.ts @@ -3,8 +3,9 @@ declare module "main" { } declare module "extism:host" { interface user { - sendMessage(ptr: I64): I64; react(ptr: I64): I64; request(ptr: I64): I64; + sendMessage(ptr: I64): I64; + watchMessage(ptr: I64): I64; } } diff --git a/LOUDBOT/src/index.ts b/LOUDBOT/src/index.ts index f043e4a..dfc091d 100644 --- a/LOUDBOT/src/index.ts +++ b/LOUDBOT/src/index.ts @@ -1,9 +1,9 @@ import { IncomingEvent, - OutgoingMessage, + OutgoingReaction, Result, - IncomingReaction, OutgoingRequest, + OutgoingMessage, } from "./pdk"; import * as main from "./main"; diff --git a/LOUDBOT/src/main.ts b/LOUDBOT/src/main.ts index 830bba3..de14cac 100644 --- a/LOUDBOT/src/main.ts +++ b/LOUDBOT/src/main.ts @@ -11,6 +11,18 @@ import { sendMessage, react, request } from "./pdk"; * @param input An incoming event */ export function handleImpl(input: IncomingEvent) { - sendMessage({} as any) - console.log('received message ' + JSON.stringify(input)) + if (input.message) { + react({ messageId: input.message.id, channel: input.channel, with: '🎤' } as any) + const result = sendMessage({ + message: 'WOW TURN IT DOWN OKAY' + }) + + if (result.id) { + react({ messageId: result.id, channel: input.channel, with: '🎤' } as any) + } else { + console.log(JSON.stringify(result)) + } + } else { + console.log('received message ' + JSON.stringify(input)) + } } diff --git a/LOUDBOT/src/pdk.ts b/LOUDBOT/src/pdk.ts index 0fd8103..c42f08f 100644 --- a/LOUDBOT/src/pdk.ts +++ b/LOUDBOT/src/pdk.ts @@ -11,11 +11,6 @@ export class IncomingEvent { */ // @ts-expect-error TS2564 channel: string; - /** - * The server the message was received in - */ - // @ts-expect-error TS2564 - guild: string; } /** @@ -113,13 +108,11 @@ export class IncomingMessage { /** * The message text */ - // @ts-expect-error TS2564 - message: string; + content?: string; /** * The author of the message */ - // @ts-expect-error TS2564 - author: string; + author: any; } /** @@ -134,13 +127,7 @@ export class OutgoingMessage { /** * The channel the message was received in */ - // @ts-expect-error TS2564 - channel: string; - /** - * The server the message was received in - */ - // @ts-expect-error TS2564 - guild: string; + channel?: string; } /** @@ -157,26 +144,34 @@ export class Result { errorCode?: number; } -export function sendMessage(input: OutgoingMessage): Result { +export function react(input: OutgoingReaction): Result { const mem = Memory.fromJsonObject(input as any); - const ptr = hostFunctions.sendMessage(mem.offset); + const ptr = hostFunctions.react(mem.offset); return Memory.find(ptr).readJsonObject(); } -export function react(input: IncomingReaction): Result { +export function request(input: OutgoingRequest): Result { const mem = Memory.fromJsonObject(input as any); - const ptr = hostFunctions.react(mem.offset); + const ptr = hostFunctions.request(mem.offset); return Memory.find(ptr).readJsonObject(); } -export function request(input: OutgoingRequest): Result { +export function sendMessage(input: OutgoingMessage): Result { const mem = Memory.fromJsonObject(input as any); - const ptr = hostFunctions.request(mem.offset); + const ptr = hostFunctions.sendMessage(mem.offset); + + return Memory.find(ptr).readJsonObject(); +} + +export function watchMessage(input: string): Result { + const mem = Memory.fromString(input); + + const ptr = hostFunctions.watchMessage(mem.offset); return Memory.find(ptr).readJsonObject(); } diff --git a/migrations/0005-interests.sql b/migrations/0005-interests.sql index 0224799..593a4db 100644 --- a/migrations/0005-interests.sql +++ b/migrations/0005-interests.sql @@ -1,11 +1,10 @@ create table if not exists "handlers" ( id uuid primary key default gen_random_uuid(), - user_id uuid not null references "users" ("id") on delete cascade, guild text not null, + user_id uuid not null references "users" ("id") on delete cascade, plugin_name text not null default 'default', allowed_channels jsonb not null default '[]'::jsonb, allowed_hosts jsonb not null default '[]'::jsonb, - commands jsonb not null default '[]'::jsonb, ratelimiting_max_tokens int, ratelimiting_current_tokens int, @@ -14,6 +13,7 @@ create table if not exists "handlers" ( created_at timestamp(3) not null default now(), updated_at timestamp(3) not null default now() ); +create unique index "handlers_uniq" on "handlers" ("guild", "user_id", "plugin_name"); create table if not exists "interest_message_content" ( id uuid primary key default gen_random_uuid(), diff --git a/package-lock.json b/package-lock.json index 7cc600e..c77d4f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,7 +215,7 @@ "node_modules/@dylibso/xtp": { "version": "0.0.0-replaced-by-ci", "resolved": "file:../xtp/sdks/js-sdk/dist/dylibso-xtp-0.0.0-replaced-by-ci.tgz", - "integrity": "sha512-RxR/nd5qTZCd1PNut1HGmUXbPxlqU6HdC+L70GuxLS54EyIyjVs3/CaT1y+JCE+63hHM2YSkNr/+yDpUsKd5Nw==", + "integrity": "sha512-OsF+nynYvbc7wTr8Hyk6HPJnBqsn6ue2KPoF/KRMXh4FptImoMpeDxpnhclfUA7lLUuGvLI2cTM4QoZUC+Ji2w==", "license": "BSD-3-Clause", "dependencies": { "@extism/extism": "^1.0.2", diff --git a/plugin.yaml b/plugin.yaml index c1e8bf7..e0561d7 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -32,14 +32,14 @@ imports: input: type: string description: the id of a message to watch - contentType: text/plain + contentType: text/plain; charset=UTF-8 output: $ref: "#/schemas/Result" schemas: - name: IncomingEvent description: An incoming event - required: ['channel', 'guild'] + required: ['channel'] contentType: application/json properties: - name: message @@ -50,9 +50,6 @@ schemas: - name: channel type: string description: The channel the message was received in - - name: guild - type: string - description: The server the message was received in - name: OutgoingRequest description: An HTTP request @@ -76,7 +73,7 @@ schemas: - name: OutgoingReaction description: send a reaction - required: ['messageId', 'emoji'] + required: ['messageId', 'channel', 'emoji'] contentType: application/json properties: - name: messageId @@ -138,7 +135,7 @@ schemas: - name: OutgoingMessage description: An outgoing message - required: ['message', 'channel', 'guild'] + required: ['message'] contentType: application/json properties: - name: message @@ -147,9 +144,6 @@ schemas: - name: channel type: string description: The channel the message was received in - - name: guild - type: string - description: The server the message was received in - name: Result description: a result diff --git a/src/client.ts b/src/client.ts index 3f29783..81d5eb3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,20 @@ import { ChannelType, Client, CommandInteraction, GatewayIntentBits, REST, Routes } from 'discord.js'; import safe from 'safe-regex'; -import { DISCORD_BOT_TOKEN, DISCORD_BOT_CLIENT_ID, DISCORD_GUILD_FILTER, DISCORD_CHANNEL_FILTER } from './config'; +import { DISCORD_BOT_TOKEN, DISCORD_BOT_CLIENT_ID, DISCORD_GUILD_FILTER } from './config'; import { findUserByUsername, getXtpData, registerUser } from './domain/users'; -import { executeHandlers, fetchByContentInterest } from './domain/interests'; -import { registerMessageHandler } from './domain/message-handlers'; +import { executeHandlers, fetchByContentInterest, registerMessageContentInterest } from './domain/interests'; +import { getLogger } from './logger'; -export async function startDiscordClient() { +type Logger = ReturnType + +export async function startDiscordClient(logger: Logger) { if (!DISCORD_BOT_TOKEN) { return; } const rest = new REST({ version: '9' }).setToken(DISCORD_BOT_TOKEN); - await refreshCommands(rest); + await refreshCommands(rest, logger); const client = new Client({ intents: [ @@ -25,7 +27,7 @@ export async function startDiscordClient() { }); client.on('ready', () => { - console.log(`Logged in as ${client.user!.tag}!`); + logger.info(`Logged in as ${client.user!.tag}!`); }); client.on('messageCreate', async message => { @@ -36,31 +38,26 @@ export async function startDiscordClient() { const guild = message.guild || { name: "", id: "" }; if (message.channel.type !== ChannelType.GuildText) { - console.log(`skipping message; channel type was not GuildText`) + logger.info(`skipping message; channel type was not GuildText`) return } if (DISCORD_GUILD_FILTER.size && !DISCORD_GUILD_FILTER.has(guild.name)) { - console.log(`skipping message; not in guild filter (got="${guild.name}"; valid="${[...DISCORD_GUILD_FILTER].join('", "')}")`) + logger.info(`skipping message; not in guild filter (got="${guild.name}"; valid="${[...DISCORD_GUILD_FILTER].join('", "')}")`) return } - if (DISCORD_CHANNEL_FILTER.size && !DISCORD_CHANNEL_FILTER.has(message.channel.name)) { - console.log(`skipping message; not in channel filter (guild="${guild.name}"; channel="${message.channel.name}")`) - return - } - - console.log(`Incoming message in "${guild.name}" "#${message.channel.name}" (${guild.id}): `, message.content); + logger.info(`Incoming message in "${guild.name}" "#${message.channel.name}" (${guild.id}): `, message.content); const handlers = await fetchByContentInterest({ guild: guild.id, channel: message.channel.name, content: message.content }); await executeHandlers(client, handlers, { channel: message.channel.name, - guild: guild.name, + guild: guild.id, message: { id: message.id, content: message.content, author: message.author } - }, {}) + }, {}, message.channel.name) }); /* @@ -129,8 +126,10 @@ export async function startDiscordClient() { await handleRegisterCommand(command); break; + case 'subscribe': - await handleSubscribeCommand(command); + case 'listen': + await handleListenCommand(command); break; } }) @@ -182,7 +181,7 @@ async function handleRegisterCommand(command: CommandInteraction) { }); } -async function refreshCommands(rest: REST) { +async function refreshCommands(rest: REST, logger: Logger) { if (!DISCORD_BOT_CLIENT_ID) { return; } @@ -209,8 +208,8 @@ async function refreshCommands(rest: REST) { description: 'Register your account with the bot' }, { - name: 'subscribe', - description: 'Subscribes to messages', + name: 'listen', + description: 'Listen for message content', options: [ { name: 'regex', @@ -229,19 +228,19 @@ async function refreshCommands(rest: REST) { ]; try { - console.log('Started refreshing application (/) commands.'); + logger.info('Started refreshing application (/) commands.'); await rest.put( Routes.applicationCommands(DISCORD_BOT_CLIENT_ID), { body: commands }, ); - console.log('Successfully reloaded application (/) commands.'); + logger.info('Successfully reloaded application (/) commands.'); } catch (error) { - console.error(error); + logger.error(error); } } -async function handleSubscribeCommand(command: CommandInteraction) { +async function handleListenCommand(command: CommandInteraction) { const regex = command.options.get('regex')?.value as string; const plugin = command.options.get('plugin')?.value as string; const guild = command.guildId; @@ -279,15 +278,17 @@ async function handleSubscribeCommand(command: CommandInteraction) { return; } - await registerMessageHandler({ - plugin_name: plugin, + console.log(command.channel) + await registerMessageContentInterest({ + pluginName: plugin, regex: regex, - user_id: dbUser.id, - guild: guild, + userId: dbUser.id, + guild, + isAdmin: false }); await command.reply({ - content: `Subscribed to messages matching \`${regex}\` with plugin \`${plugin}\``, + content: `Subscribed for messages matching \`${regex}\` with plugin \`${plugin}\``, ephemeral: true, }); } diff --git a/src/db.ts b/src/db.ts index 5201439..16d05a6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,18 +43,33 @@ export async function getXtp(): ReturnType { logger: getLogger(), functions: { 'extism:host/user': { - react(context: CurrentPlugin, outgoingReaction: bigint) { - return 0n + async react(context: CurrentPlugin, outgoingReaction: bigint) { + try { + const arg = context.read(outgoingReaction)!.json() + const hostContext = context.hostContext(); + const result = await hostContext.react(arg) + + return context.store(JSON.stringify(result)) + } catch (error: any) { + console.error(error.stack) + return context.store(JSON.stringify({ errorCode: -1, error })) + } }, request(context: CurrentPlugin, outgoingRequest: bigint) { return 0n }, - sendMessage(context: CurrentPlugin, outgoingMessage: bigint) { - const hostContext = context.hostContext(); - console.log(hostContext.handler) - return context.store(JSON.stringify({})) + async sendMessage(context: CurrentPlugin, outgoingMessage: bigint) { + try { + const arg = context.read(outgoingMessage)!.json() + const hostContext = context.hostContext(); + const result = await hostContext.sendMessage(arg) + + return context.store(JSON.stringify(result)) + } catch (error) { + return context.store(JSON.stringify({ errorCode: -1, error })) + } }, watchMessage(context: CurrentPlugin, outgoingRequest: bigint) { diff --git a/src/domain/interests.ts b/src/domain/interests.ts index 23117b9..25f60aa 100644 --- a/src/domain/interests.ts +++ b/src/domain/interests.ts @@ -1,14 +1,22 @@ -import { Client } from "discord.js"; +import { Client, Message, TextBasedChannel } from "discord.js"; + import { getDatabaseConnection, getXtp } from "../db"; +import { getLogger } from "../logger"; + +const logger = getLogger() // every 50ms of runtime costs 1 token const TOKEN_COST_PER_MILLISECOND = 1 / 50; const TOKEN_ERROR_COST = 100 +const TOKEN_COST_PER_SENDMESSAGE = 10 +const TOKEN_COST_PER_REACTION = 30 export interface Handler { id: string userId: string pluginName: string + guild: string + allowedChannels: string allowedHosts: string[] ratelimitingMaxTokens: number ratelimitingLastReset: Date @@ -23,9 +31,71 @@ export interface FetchBy { export class HostContext { client: Client handler: Handler - constructor(client: Client, handler: Handler) { + currentChannel: string | null + constructor(client: Client, handler: Handler, currentChannel: string | null) { this.client = client this.handler = handler + this.currentChannel = currentChannel + } + + async react(reaction: any) { + this.handler.ratelimitingCurrentTokens = Math.max(0, this.handler.ratelimitingCurrentTokens - TOKEN_COST_PER_REACTION) + if (this.handler.ratelimitingCurrentTokens === 0) { + logger.warn(`hostFunction.react: handler ran out of tokens (handler=${this.handler.id})`) + return { errorCode: -999, error: new Error('not enough tokens') } + } + + const { messageId, channel = this.currentChannel, with: emoji } = reaction || {} + + const chan = this.client.channels.cache.find(xs => ( + xs.type === 0 && + xs.guildId === this.handler.guild && + (xs.name === channel || String(xs.id) === String(channel)) + )) as TextBasedChannel + if (!chan) { + return { errorCode: -3, error: new Error('no such channel') } + } + + const msg = chan.messages.cache.find(xs => xs.id === messageId) as Message + if (!msg) { + return { errorCode: -4, error: new Error('no such message') } + } + + const [err, result] = await msg.react(emoji).then( + res => [, res], + err => [err,] + ) + + if (err) { + return { errorCode: err.code, error: new Error('discord error') } + } + return { id: result.message.id } + } + + async sendMessage(msg: any) { + this.handler.ratelimitingCurrentTokens = Math.max(0, this.handler.ratelimitingCurrentTokens - TOKEN_COST_PER_SENDMESSAGE) + if (this.handler.ratelimitingCurrentTokens === 0) { + logger.warn(`hostFunction.sendMessage: handler ran out of tokens (handler=${this.handler.id})`) + return { errorCode: -999, error: new Error('not enough tokens') } + } + + const { message, channel = this.currentChannel } = msg || {} + + if (!this.handler.allowedChannels.includes(channel)) { + return { errorCode: -3, error: new Error('disallowed channel') } + } + + const chan = this.client.channels.cache.find(xs => ( + xs.type === 0 && + xs.guildId === this.handler.guild && + (xs.name === channel || xs.id === channel) + )) as TextBasedChannel + if (!chan) { + return { errorCode: -3, error: new Error('no such channel') } + } + const result = await chan.send(message) + + return { id: result.id } } } @@ -40,9 +110,11 @@ export async function fetchByContentInterest(opts: FetchByContentInterest) { SELECT now() as "now", "handlers"."id", + "guild", "user_id" as "userId", "plugin_name" as "pluginName", "allowed_hosts" as "allowedHosts", + "allowed_channels" as "allowedChannels", "ratelimiting_max_tokens" as "ratelimitingMaxTokens", "ratelimiting_current_tokens" as "ratelimitingCurrentTokens", "ratelimiting_last_reset"::timestamptz as "ratelimitingLastReset" @@ -60,15 +132,16 @@ export async function fetchByContentInterest(opts: FetchByContentInterest) { const elapsedSeconds = (row.now.getTime() - row.ratelimitingLastReset.getTime()) / 1000 const addedTokens = Math.floor(elapsedSeconds * (row.ratelimitingMaxTokens / 60)) row.ratelimitingCurrentTokens = Math.min(row.ratelimitingMaxTokens, row.ratelimitingCurrentTokens + addedTokens) - - console.log(row.id, addedTokens) + if (row.ratelimitingCurrentTokens === 0) { + logger.warn(`skipping handler due to token exhaustion; hander=${row.id}`) + } return row.ratelimitingCurrentTokens > 0 }) as Handler[]; return handlers } -export async function executeHandlers(client: Client, handlers: Handler[], arg: T, defaultValue: T) { +export async function executeHandlers(client: Client, handlers: Handler[], arg: T, defaultValue: T, currentChannel: string | null) { if (!handlers.length) { return } @@ -82,7 +155,7 @@ export async function executeHandlers(client: Client, handlers: Handler[], ar promises.push(xtp.extensionPoints.events.handle(handler.userId, arg, { bindingName: handler.pluginName, default: defaultValue, - hostContext: new HostContext(client, handler) + hostContext: new HostContext(client, handler, currentChannel) }).then( _ => [, Date.now() - start], err => [err, Date.now() - start] @@ -94,7 +167,6 @@ export async function executeHandlers(client: Client, handlers: Handler[], ar const [err, elapsed]: [Error | null, number] = result as any let cost = 0 if (err) { - console.error(`handler errored: message="${err.message}"; id="${handlers[idx].id}"; pluginName=${handlers[idx].pluginName}`) cost += TOKEN_ERROR_COST } cost += Math.floor(elapsed * TOKEN_COST_PER_MILLISECOND) @@ -103,7 +175,6 @@ export async function executeHandlers(client: Client, handlers: Handler[], ar ids.push(handlers[idx].id) tokens.push(handlers[idx].ratelimitingCurrentTokens) - console.log({ id: handlers[idx].id, cost, elapsed }) ++idx; } @@ -118,4 +189,64 @@ export async function executeHandlers(client: Client, handlers: Handler[], ar `, [ids, tokens]); } +export interface RegisterInterest { + userId: string + isAdmin: boolean + guild: string + pluginName: string +} + +export interface RegisterMessageContentInterest extends RegisterInterest { + regex: string +} + +export async function registerMessageContentInterest(opts: RegisterMessageContentInterest) { + const db = await getDatabaseConnection() + + return await db.transaction(async (db: any) => { + const { rows: [{ id = null }] = [] } = await db.query(` + insert into "handlers" ( + guild, + user_id, + plugin_name, + allowed_channels, + allowed_hosts, + ratelimiting_max_tokens, + ratelimiting_current_tokens, + ratelimiting_last_reset + ) values ( + $1, + $2, + $3, + $4::jsonb, + $5::jsonb, + $6, + $6, + now() + ) on conflict (guild, user_id, plugin_name) do update set updated_at = now() + returning id; + `, [ + opts.guild, + opts.userId, + opts.pluginName, + JSON.stringify(opts.isAdmin ? ['general'] : ['bots']), + JSON.stringify(opts.isAdmin ? ['*'] : []), + opts.isAdmin ? 10_000 : 500 + ]) + + if (!id) { + throw new Error('failed to insert') + } + + const contentResult = await db.query(` + insert into "interest_message_content" ( + handler_id, + regex + ) values ($1, $2) on conflict(handler_id, regex) do nothing returning id; + `, [id, opts.regex]) + + return contentResult.rows.length > 1 + }) +} + export async function fetchByMessageIdInterest(guild: string, channel: string, id: string) { } diff --git a/src/index.ts b/src/index.ts index d6ca86b..54d08bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,10 @@ declare module 'fastify' { } export default async function server() { - const server = fastify({ logger: getLogger(), trustProxy: true }) + const logger = getLogger() + const server = fastify({ logger, trustProxy: true }) + + await startDiscordClient(logger); server.register(fstatic, { root: path.join(__dirname, '..', 'dist', 'static'), @@ -66,8 +69,6 @@ export default async function server() { }) } -startDiscordClient(); - server().then(({ address }: any) => { console.log(`Server listening at ${address}`) }).catch(err => {