From 622dfe79eae83378797dd510d61a73f4a88450ea Mon Sep 17 00:00:00 2001 From: donuts-are-good <96031819+donuts-are-good@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:06:36 -0500 Subject: [PATCH 1/5] Add Uproar (uproar.chat) as a second chat platform alongside Discord Bot now runs on Discord and Uproar simultaneously. New lib/Uproar.ts is a plain REST + WebSocket-dial-out client that wraps each inbound Uproar message in a Message-like shim, so existing command implementations run unchanged: replies, embeds, attachments (upload -> send), reactions/pagination, typing, mentions, member/avatar lookups, and message history. Gated off unless uproarBotId + uproarBotToken are set in Config. ws is resolved transitively (node-modules linker) like the existing @types/ws usage. Also adds the missing geminiApiKey/grokApiKey to Config.ts.example. --- lib/Config.ts.example | 13 + lib/Uproar.ts | 733 ++++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 3 + 3 files changed, 749 insertions(+) create mode 100644 lib/Uproar.ts diff --git a/lib/Config.ts.example b/lib/Config.ts.example index 36849eb..20e022c 100644 --- a/lib/Config.ts.example +++ b/lib/Config.ts.example @@ -32,6 +32,10 @@ class Config { public claudeApiKey: string = 'enter claude api key here'; + public geminiApiKey: string = 'enter gemini api key here'; + + public grokApiKey: string = 'enter grok/xai api key here'; + public gabApiKey: string = 'enter gab ai api key here'; public gabModel: string = 'arya'; @@ -50,6 +54,15 @@ class Config { /* coingecko coin ids to monitor e.g. [{id: 'bitcoin', label: 'bitcoin' }], see coingecko api docs for details */ public coins: string[] = []; + + /* Uproar (uproar.chat) integration. Leave the id/token blank to disable it; + * the bot then runs on Discord only. Get the id + token from Uproar when you + * create the bot (Settings > Bots). */ + public uproarBaseUrl: string = 'https://uproar.chat'; + + public uproarBotId: string = ''; + + public uproarBotToken: string = ''; } export let config = new Config(); diff --git a/lib/Uproar.ts b/lib/Uproar.ts new file mode 100644 index 0000000..d797bef --- /dev/null +++ b/lib/Uproar.ts @@ -0,0 +1,733 @@ +import WebSocket from 'ws'; +import fetch from 'node-fetch'; +import FormData from 'form-data'; +import { EventEmitter } from 'events'; +import { Database } from 'sqlite3'; + +import { config } from './Config.js'; +import { canAccessCommand } from './Utilities.js'; +import { Args, Command, CommandFunc } from './Types.js'; +import { Commands, handleHelp } from './CommandDeclarations.js'; + +/* Uproar (uproar.chat) integration. + * + * dave talks to Uproar over the plain bot HTTP API with a single bearer token: + * - receive: a dial-out WebSocket at GET /api/bots/{id}/stream delivers + * message_create + reaction events in realtime (mirrors the Discord gateway). + * - act: POST /api/bots/{id}/{token} with {action, ...} to send/edit/ + * delete/react/typing. + * - upload: multipart POST /api/bots/{id}/attachments?channel_id=… (bearer), + * then reference the returned /uploads/ url(s) in a send action's attachments. + * - read: GET /api/bots/{id}/{messages|members|…} with Authorization: Bearer. + * + * Each inbound message is wrapped in an object presenting the slice of the + * discord.js Message surface the commands use, so the existing command + * implementations run unchanged. */ + +interface UproarUser { + id: string; + bot: boolean; + username: string; + displayName: string; + displayAvatarURL: (opts?: any) => string; +} + +interface UproarMessageData { + id: string; + channel_id: string; + server_id?: string; + user_id: string; + content: string; + reply_to: string | null; + mentions_everyone?: boolean; + created_at: string; + edited_at: string | null; + username: string; + display_name: string; + avatar_url: string | null; + is_bot: boolean; + attachments?: any; + embeds?: any; + mentions?: Array<{ user_id: string; username?: string; display_name?: string }>; +} + +interface UploadedAttachment { + url: string; + thumb_url?: string; +} + +type SendPayload = string | { content?: string; embeds?: any[]; files?: any[] }; + +function toUproarEmbeds(embeds?: any[]): any[] | undefined { + if (!embeds || embeds.length === 0) { + return undefined; + } + return embeds.map((e) => (e && typeof e.toJSON === 'function' ? e.toJSON() : e)); +} + +function normalizeSend(payload: SendPayload): { content: string; embeds?: any[]; files?: any[] } { + if (typeof payload === 'string') { + return { content: payload }; + } + return { + content: payload.content ?? '', + embeds: toUproarEmbeds(payload.embeds), + files: Array.isArray(payload.files) ? payload.files : undefined, + }; +} + +function absoluteURL(baseUrl: string, url: string | null | undefined): string { + if (!url) { + return ''; + } + return /^https?:\/\//i.test(url) ? url : baseUrl + url; +} + +/* discord.js exposes message.attachments as a Collection; the commands use + * .forEach / .size / .values, all of which a Map provides. Each value carries + * the `url`, `contentType`, `name` fields the image extractor reads. */ +function buildAttachments(baseUrl: string, raw: any): Map { + const map = new Map(); + if (Array.isArray(raw)) { + raw.forEach((a, i) => { + map.set(String(i), { + url: absoluteURL(baseUrl, a?.url), + contentType: a?.content_type ?? null, + name: a?.filename ?? '', + }); + }); + } + return map; +} + +function buildMentions(raw: UproarMessageData['mentions']): Map { + const map = new Map(); + if (Array.isArray(raw)) { + for (const m of raw) { + map.set(m.user_id, { + id: m.user_id, + bot: false, + username: m.username ?? '', + displayName: m.display_name || m.username || '', + displayAvatarURL: () => '', + }); + } + } + return map; +} + +function makeAuthor(baseUrl: string, data: UproarMessageData): UproarUser { + return { + id: data.user_id, + bot: data.is_bot, + username: data.username, + displayName: data.display_name || data.username, + displayAvatarURL: () => absoluteURL(baseUrl, data.avatar_url), + }; +} + +export class UproarClient { + private readonly baseUrl: string; + private readonly botId: string; + private readonly token: string; + private readonly db: Database; + + private ws: WebSocket | null = null; + private reconnectAttempt = 0; + private botUserId: string | null = null; + + private reactionCollectors = new Map>(); + private memberCache = new Map>(); + + constructor(db: Database) { + this.baseUrl = config.uproarBaseUrl.replace(/\/$/, ''); + this.botId = config.uproarBotId; + this.token = config.uproarBotToken; + this.db = db; + } + + public getBaseUrl(): string { + return this.baseUrl; + } + + /* --- transport --- */ + + public connect(): void { + const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/api/bots/${this.botId}/stream`; + + const ws = new WebSocket(wsUrl, { + headers: { Authorization: `Bearer ${this.token}` }, + }); + this.ws = ws; + + ws.on('open', () => { + this.reconnectAttempt = 0; + console.log('[Uproar] Stream connected'); + ws.send(JSON.stringify({ + type: 'subscribe', + data: { events: ['message_create', 'reaction_add', 'reaction_remove'] }, + })); + }); + + ws.on('message', (raw: any) => { + let evt: any; + try { + evt = JSON.parse(raw.toString()); + } catch { + return; + } + + switch (evt.type) { + case 'ready': + console.log(`[Uproar] Ready (bot ${evt.data?.bot_id})`); + break; + case 'message_create': + if (evt.data) { + this.handleMessage(evt.data as UproarMessageData).catch((err) => { + console.error(`[Uproar] Error handling message: ${err?.stack ?? err}`); + }); + } + break; + case 'reaction_add': + case 'reaction_remove': + if (evt.data) { + this.handleReaction(evt.type, evt.data); + } + break; + } + }); + + ws.on('close', () => this.scheduleReconnect('closed')); + ws.on('error', (err: Error) => { + console.error(`[Uproar] Socket error: ${err.message}`); + }); + } + + private scheduleReconnect(reason: string): void { + const delay = Math.min(60000, 5000 * Math.pow(2, this.reconnectAttempt)); + this.reconnectAttempt += 1; + console.log(`[Uproar] Stream ${reason}; reconnecting in ${delay / 1000}s`); + setTimeout(() => this.connect(), delay); + } + + /* --- act (execute endpoint) --- */ + + public async exec(body: Record): Promise { + const url = `${this.baseUrl}/api/bots/${this.botId}/${this.token}`; + + for (let attempt = 0; attempt < 2; attempt++) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.status === 429) { + const retry = Number(res.headers.get('retry-after') ?? '1'); + await new Promise((r) => setTimeout(r, (retry + 1) * 1000)); + continue; + } + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Uproar ${body.action ?? 'send'} failed (${res.status}): ${text}`); + } + const result = text ? JSON.parse(text) : {}; + if (!this.botUserId && result && result.is_bot && result.user_id) { + this.botUserId = result.user_id; + } + return result; + } + + throw new Error(`Uproar ${body.action ?? 'send'} rate limited`); + } + + /* --- read API --- */ + + public async readGet(path: string): Promise { + const res = await fetch(`${this.baseUrl}/api/bots/${this.botId}${path}`, { + headers: { Authorization: `Bearer ${this.token}` }, + }); + if (!res.ok) { + throw new Error(`Uproar read ${path} failed (${res.status})`); + } + return res.json(); + } + + /* --- uploads (attachments) --- */ + public async uploadFiles(channelId: string, files: any[]): Promise { + const form = new FormData(); + let count = 0; + + for (const file of files.slice(0, 4)) { + const resolved = await resolveFileData(file); + if (!resolved) { + continue; + } + form.append('files', resolved.data, { filename: resolved.name }); + count += 1; + } + if (count === 0) { + return []; + } + + const res = await fetch(`${this.baseUrl}/api/bots/${this.botId}/attachments?channel_id=${encodeURIComponent(channelId)}`, { + method: 'POST', + headers: { Authorization: `Bearer ${this.token}`, ...form.getHeaders() }, + body: form as any, + }); + if (!res.ok) { + throw new Error(`Uproar upload failed (${res.status}): ${await res.text()}`); + } + + const arr = (await res.json()) as any[]; + return arr.map((a) => ({ url: a.url, thumb_url: a.thumb_url })); + } + + /* --- members / guild resolution --- */ + + private serverMembers(serverId: string): Promise { + let cached = this.memberCache.get(serverId); + if (!cached) { + cached = this.readGet(`/members?server_id=${encodeURIComponent(serverId)}`) + .then((m) => (Array.isArray(m) ? m : [])) + .catch(() => []); + this.memberCache.set(serverId, cached); + } + return cached; + } + + public async fetchMember(serverId: string, userId: string): Promise { + const members = await this.serverMembers(serverId); + const m = members.find((x) => (x.user_id ?? x.id) === userId); + if (!m) { + return undefined; + } + const displayName = m.display_name || m.nickname || m.username || ''; + const avatar = absoluteURL(this.baseUrl, m.avatar_url); + return { + id: userId, + displayName, + displayAvatarURL: () => avatar, + roles: { cache: { some: () => false, has: () => false } }, + }; + } + + public makeGuild(serverId: string | undefined): any | null { + if (!serverId) { + return null; + } + return { + id: serverId, + members: { fetch: (userId: string) => this.fetchMember(serverId, userId) }, + }; + } + + /* --- reaction collectors --- */ + + public registerCollector(messageId: string, c: UproarReactionCollector): void { + let set = this.reactionCollectors.get(messageId); + if (!set) { + set = new Set(); + this.reactionCollectors.set(messageId, set); + } + set.add(c); + } + + public unregisterCollector(messageId: string, c: UproarReactionCollector): void { + const set = this.reactionCollectors.get(messageId); + if (set) { + set.delete(c); + if (set.size === 0) { + this.reactionCollectors.delete(messageId); + } + } + } + + private handleReaction(type: string, data: any): void { + const set = this.reactionCollectors.get(data.message_id); + if (!set || set.size === 0) { + return; + } + if (this.botUserId && data.user_id === this.botUserId) { + return; + } + + const user = { id: data.user_id, bot: false }; + const reaction = { + emoji: { name: data.emoji }, + users: { remove: async () => { /* bot API can't remove others' reactions */ } }, + }; + + for (const c of Array.from(set)) { + if (type === 'reaction_add') { + c.handleAdd(reaction, user); + } else { + c.handleRemove(reaction, user); + } + } + } + + /* --- read helpers --- */ + + public async fetchMessage(messageId: string): Promise { + const m = await this.readGet(`/messages/${messageId}`); + return new UproarFetchedMessage(this, m as UproarMessageData); + } + + public async fetchHistory(channelId: string, before?: string): Promise> { + let path = `/messages?channel_id=${encodeURIComponent(channelId)}&limit=100`; + if (before) { + path += `&before=${encodeURIComponent(before)}`; + } + const arr = await this.readGet(path); + const map = new Map(); + if (Array.isArray(arr)) { + for (const m of arr) { + map.set(m.id, new UproarFetchedMessage(this, m as UproarMessageData)); + } + } + return map; + } + + /* --- dispatch --- */ + + private async handleMessage(data: UproarMessageData): Promise { + if (data.is_bot) { + return; + } + if (this.botUserId && data.user_id === this.botUserId) { + return; + } + if (!data.content || !data.content.startsWith(config.prefix)) { + return; + } + + const msg = new UproarMessage(this, data); + + const [tmp, ...args] = data.content.trim().split(/\s+/); + const command = tmp.substring(tmp.indexOf(config.prefix) + 1).toLowerCase(); + + try { + await dispatchByCommand(msg, command, args, this.db); + } catch (err) { + console.error(`[Uproar] Command '${command}' threw: ${(err as any)?.stack ?? err}`); + try { + await msg.react('🔥'); + await msg.reply(`Error: ${(err as any).toString()}`); + } catch { + /* best effort */ + } + } + } +} + +async function dispatchByCommand( + msg: UproarMessage, + command: string, + args: string[], + db: Database, +): Promise { + for (const c of Commands as Command[]) { + if (!c.aliases.includes(command)) { + continue; + } + + if (c.hidden && !canAccessCommand(msg as any, true)) { + return; + } + + if (args.length === 1 && args[0] === 'help') { + handleHelp(msg as any, c.aliases[0]); + return; + } + + if (c.commandGates) { + for (const gate of c.commandGates) { + const { canAccess, error } = gate(msg as any); + if (!canAccess) { + await msg.reply(error!); + return; + } + } + } + + if (args.length > 0 && c.subCommands && c.subCommands.length > 0) { + for (const subCommand of c.subCommands) { + if (subCommand.aliases && subCommand.aliases.includes(args[0])) { + if (!subCommand.disabled) { + await runCommand(subCommand, msg, db, args.slice(1)); + } + return; + } + } + } + + if (!c.primaryCommand.disabled) { + await runCommand(c.primaryCommand, msg, db, args); + } + return; + } +} + +async function runCommand(command: CommandFunc, msg: UproarMessage, db: Database, args: string[]): Promise { + const impl = command.implementation as any; + switch (command.argsFormat) { + case Args.DontNeed: + await (command.needDb ? impl(msg, db) : impl(msg)); + break; + case Args.Split: + await (command.needDb ? impl(msg, args, db) : impl(msg, args)); + break; + case Args.Combined: + await (command.needDb ? impl(msg, args.join(' '), db) : impl(msg, args.join(' '))); + break; + } +} + +async function resolveFileData(file: any): Promise<{ data: Buffer; name: string } | null> { + const raw = file && file.attachment !== undefined ? file.attachment : file; + const name: string = (file && file.name) || 'file'; + + if (Buffer.isBuffer(raw)) { + return { data: raw, name }; + } + if (typeof raw === 'string') { + if (/^https?:\/\//i.test(raw)) { + const res = await fetch(raw); + return { data: await res.buffer(), name }; + } + const fs = await import('fs/promises'); + return { data: await fs.readFile(raw), name }; + } + if (raw && typeof raw.pipe === 'function') { + const chunks: Buffer[] = []; + for await (const chunk of raw as any) { + chunks.push(Buffer.from(chunk)); + } + return { data: Buffer.concat(chunks), name }; + } + return null; +} + +/* --- message shims --- */ + +export class UproarMessage { + public readonly id: string; + public readonly content: string; + public readonly author: UproarUser; + public readonly guild: any | null; + public readonly member: null = null; + public readonly reference: { messageId: string } | null; + public readonly mentions: { users: Map; channels: Map }; + public readonly attachments: Map; + public readonly embeds: any[]; + public readonly createdTimestamp: number; + public readonly channel: UproarChannel; + public readonly client: { user: { id: string | null } }; + + constructor(private readonly bot: UproarClient, data: UproarMessageData) { + const baseUrl = bot.getBaseUrl(); + this.id = data.id; + this.content = data.content; + this.author = makeAuthor(baseUrl, data); + this.guild = bot.makeGuild(data.server_id); + this.reference = data.reply_to ? { messageId: data.reply_to } : null; + this.mentions = { users: buildMentions(data.mentions), channels: new Map() }; + this.attachments = buildAttachments(baseUrl, data.attachments); + this.embeds = Array.isArray(data.embeds) ? data.embeds : []; + this.createdTimestamp = Date.parse(data.created_at) || 0; + this.channel = new UproarChannel(bot, data.channel_id); + this.client = { user: { id: null } }; + } + + public reply(payload: SendPayload): Promise { + return this.channel.sendInternal(payload, this.id); + } + + public async react(emoji: string): Promise { + await this.bot.exec({ action: 'react', message_id: this.id, emoji }); + } + + public async delete(): Promise { + await this.bot.exec({ action: 'delete', message_id: this.id }); + } + + public async suppressEmbeds(): Promise { + /* not settable on another user's message via the bot API; no-op */ + } +} + +/* A message read back from the API (reply context, purge history): read fields + * plus delete/react so the deletion + image-extraction paths work on it. */ +export class UproarFetchedMessage { + public readonly id: string; + public readonly content: string; + public readonly author: UproarUser; + public readonly guild: any | null; + public readonly reference: { messageId: string } | null; + public readonly mentions: { users: Map; channels: Map }; + public readonly attachments: Map; + public readonly embeds: any[]; + public readonly createdTimestamp: number; + public readonly channel: UproarChannel; + + constructor(private readonly bot: UproarClient, data: UproarMessageData) { + const baseUrl = bot.getBaseUrl(); + this.id = data.id; + this.content = data.content; + this.author = makeAuthor(baseUrl, data); + this.guild = bot.makeGuild(data.server_id); + this.reference = data.reply_to ? { messageId: data.reply_to } : null; + this.mentions = { users: buildMentions(data.mentions), channels: new Map() }; + this.attachments = buildAttachments(baseUrl, data.attachments); + this.embeds = Array.isArray(data.embeds) ? data.embeds : []; + this.createdTimestamp = Date.parse(data.created_at) || 0; + this.channel = new UproarChannel(bot, data.channel_id); + } + + public async delete(): Promise { + await this.bot.exec({ action: 'delete', message_id: this.id }); + } + + public async react(emoji: string): Promise { + await this.bot.exec({ action: 'react', message_id: this.id, emoji }); + } +} + +export class UproarChannel { + public readonly messages: { fetch: (idOrOptions: any) => Promise }; + + constructor(private readonly bot: UproarClient, public readonly id: string) { + this.messages = { + fetch: (idOrOptions: any) => { + if (typeof idOrOptions === 'string') { + return this.bot.fetchMessage(idOrOptions); + } + const before = idOrOptions && idOrOptions.before ? idOrOptions.before : undefined; + return this.bot.fetchHistory(this.id, before); + }, + }; + } + + public send(payload: SendPayload): Promise { + return this.sendInternal(payload, null); + } + + public async sendTyping(): Promise { + await this.bot.exec({ action: 'typing', channel_id: this.id }); + } + + public async sendInternal(payload: SendPayload, replyTo: string | null): Promise { + const { content, embeds, files } = normalizeSend(payload); + + const body: Record = { action: 'send', channel_id: this.id, content }; + if (embeds) { + body.embeds = embeds; + } + if (replyTo) { + body.reply_to = replyTo; + } + if (files && files.length > 0) { + try { + const attachments = await this.bot.uploadFiles(this.id, files); + if (attachments.length > 0) { + body.attachments = attachments; + } + } catch (err) { + console.error(`[Uproar] Attachment upload failed: ${(err as any)?.message ?? err}`); + } + } + + const created = await this.bot.exec(body); + return new UproarSentMessage(this.bot, created.id, this.id); + } +} + +export class UproarSentMessage { + constructor(private readonly bot: UproarClient, public readonly id: string, public readonly channelId: string) {} + + public async edit(payload: SendPayload): Promise { + const { content, embeds } = normalizeSend(payload); + const body: Record = { action: 'edit', message_id: this.id, content }; + if (embeds) { + body.embeds = embeds; + } + await this.bot.exec(body); + return this; + } + + public async delete(): Promise { + await this.bot.exec({ action: 'delete', message_id: this.id }); + } + + public async react(emoji: string): Promise { + await this.bot.exec({ action: 'react', message_id: this.id, emoji }); + } + + public createReactionCollector(options: any): UproarReactionCollector { + const collector = new UproarReactionCollector(this.bot, this.id, options); + this.bot.registerCollector(this.id, collector); + return collector; + } +} + +/* Emulates discord.js ReactionCollector over Uproar's reaction_add/remove + * events: filter + time window, emitting 'collect'/'remove'/'end' with the same + * (reaction, user) shape Paginate and the poll commands expect. */ +export class UproarReactionCollector extends EventEmitter { + private collected = new Map(); + private timer: any = null; + private ended = false; + + constructor(private readonly bot: UproarClient, private readonly messageId: string, private readonly options: any) { + super(); + const time = options?.time; + if (time) { + this.timer = setTimeout(() => this.stop('time'), time); + } + } + + public handleAdd(reaction: any, user: any): void { + if (this.ended) { + return; + } + const filter = this.options?.filter; + if (filter && !filter(reaction, user)) { + return; + } + this.collected.set(`${reaction.emoji.name}:${user.id}`, reaction); + this.emit('collect', reaction, user); + if (this.options?.max && this.collected.size >= this.options.max) { + this.stop('limit'); + } + } + + public handleRemove(reaction: any, user: any): void { + if (this.ended || !this.options?.dispose) { + return; + } + this.emit('remove', reaction, user); + } + + public stop(reason: string = 'user'): void { + if (this.ended) { + return; + } + this.ended = true; + if (this.timer) { + clearTimeout(this.timer); + } + this.bot.unregisterCollector(this.messageId, this); + this.emit('end', this.collected, reason); + } +} + +export function startUproar(db: Database): void { + if (!config.uproarBotId || !config.uproarBotToken) { + console.log('[Uproar] Not configured; skipping'); + return; + } + const client = new UproarClient(db); + client.connect(); +} diff --git a/lib/index.ts b/lib/index.ts index 1deec24..b6c30d2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -54,6 +54,7 @@ import { userChannelRestrictions, } from './UserChannelRestrictions.js'; import { externalBotReplyRestrictions } from './ExternalBotReplyRestrictions.js'; +import { startUproar } from './Uproar.js'; async function handleRestrictedExternalBotReply(msg: Message): Promise { if (!msg.reference?.messageId) { @@ -311,6 +312,8 @@ async function main() { db.on('error', console.error); await loginWithRetry(db); + + startUproar(db); } main().catch(error => { From 16561222db9c770b453994a1978f77e489053141 Mon Sep 17 00:00:00 2001 From: donuts-are-good <96031819+donuts-are-good@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:13:22 -0500 Subject: [PATCH 2/5] docs: document Uproar setup in README --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5323ccf..01fbf9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dave -Dave is a discord bot. +Dave is a chat bot. It runs on Discord and Uproar (uproar.chat) at the same time; the Uproar side is optional and stays off until you configure it (see [Uproar](#uproar-uproarchat) below). ## Prerequisites @@ -41,6 +41,31 @@ You can disable functions in `lib/CommandDefinitions.ts` if you can't be bothere * Youtube API: https://developers.google.com/youtube/v3/docs * Weather API: https://openweathermap.org/price +## Uproar (uproar.chat) + +Dave can run on Uproar alongside Discord. It stays Discord-only until you set both +`uproarBotId` and `uproarBotToken` in `lib/Config.ts`; leave them blank to disable it. + +Dave connects to Uproar by **dialing out** over a WebSocket, the same way it connects to +Discord's gateway, so there is nothing to host: no public URL, webhook, or open port. + +To wire it up: + +* Create a bot on Uproar: either an **account-level agent** (Settings > Bots > *My Agents*), + which you then admit into any server, or a **server-owned** bot (a server's + Settings > Bots). The bot's **token is shown once**, at creation, inside the returned URL + (`https://uproar.chat/api/bots//`). Copy it immediately; it is stored hashed and + never shown again. If you lose it, regenerate a new one. +* Add the bot to the server(s) and channel(s) you want it in. For an account-level agent, + share its handle (`bot.xxxxxxxx`) and have a server admin admit it (Server Settings > Bots). + Give the bot **View Channel** and **Send Messages** in those channels, plus **Attach Files** + (for the image commands) and **Add Reactions** (for polls and paginated replies). +* Fill in the Uproar fields in `lib/Config.ts`: + * `uproarBotId` — the bot's id (the `` in the URL above) + * `uproarBotToken` — the execute token (the `` shown once) + * `uproarBaseUrl` — defaults to `https://uproar.chat`; only change it if you self-host Uproar +* Build and start as usual (below). Commands work exactly as on Discord, with the same `prefix`. + ## Installation `yarn install` From 4f38a2ed35a9a89beaf087b38e9af1da6e1a0543 Mon Sep 17 00:00:00 2001 From: zpalmtree <22151537+zpalmtree@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:40:23 -0400 Subject: [PATCH 3/5] Fix Uproar command and timer integration --- lib/CommandDispatcher.ts | 147 +++++++++++++++++++++++++ lib/Database.ts | 12 +++ lib/Timer.ts | 106 ++++++++++++------ lib/Uproar.ts | 151 +++++++++++++------------- lib/index.ts | 155 ++------------------------- tests/uproar.test.mjs | 225 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 542 insertions(+), 254 deletions(-) create mode 100644 lib/CommandDispatcher.ts create mode 100644 tests/uproar.test.mjs diff --git a/lib/CommandDispatcher.ts b/lib/CommandDispatcher.ts new file mode 100644 index 0000000..b568cfc --- /dev/null +++ b/lib/CommandDispatcher.ts @@ -0,0 +1,147 @@ +import moment from 'moment'; +import { Message } from 'discord.js'; +import { Database } from 'sqlite3'; + +import { config } from './Config.js'; +import { insertQuery } from './Database.js'; +import { Commands, handleHelp } from './CommandDeclarations.js'; +import { + Args, + CombinedArgsCommand, + CombinedArgsCommandDb, + CommandFunc, + DontNeedArgsCommand, + DontNeedArgsCommandDb, + SplitArgsCommand, + SplitArgsCommandDb, +} from './Types.js'; +import { runWithTokenSpendContext } from './TokenSpend.js'; +import { + isAllowedByUserChannelRestriction, + userChannelRestrictions, +} from './UserChannelRestrictions.js'; +import { canAccessCommand } from './Utilities.js'; + +export function isMessageAllowed(msg: Message): boolean { + if (config.devEnv && !config.devChannels.includes(msg.channel.id)) { + return false; + } + + const userChannelRestriction = userChannelRestrictions.find( + ({ userId }) => userId === msg.author.id + ); + + return !userChannelRestriction || isAllowedByUserChannelRestriction( + userChannelRestriction, + msg.channel.id, + msg.guild?.id ?? null + ); +} + +export async function dispatchPrefixedCommand(msg: Message, db: Database): Promise { + if (!isMessageAllowed(msg) || !msg.content.startsWith(config.prefix)) { + return; + } + + const [tmp, ...args] = msg.content.trim().split(/\s+/); + const command = tmp.substring(tmp.indexOf(config.prefix) + 1).toLowerCase(); + + for (const c of Commands) { + if (!c.aliases.includes(command)) { + continue; + } + + if (c.hidden && !canAccessCommand(msg, true)) { + return; + } + + insertQuery( + `INSERT INTO logs + (user_id, channel_id, guild_id, command, args, timestamp) + VALUES + (?, ?, ?, ?, ?, ?)`, + db, + [ + msg.author.id, + msg.channel.id, + msg.guild?.id ?? null, + c.aliases[0], + args.join(' '), + moment.utc().format('YYYY-MM-DD HH:mm:ss'), + ] + ); + + if (args.length === 1 && args[0] === 'help') { + handleHelp(msg, c.aliases[0]); + return; + } + + if (c.commandGates) { + for (const gate of c.commandGates) { + const { canAccess, error } = gate(msg); + + if (!canAccess) { + await msg.reply(error!); + return; + } + } + } + + if (args.length > 0 && c.subCommands && c.subCommands.length > 0) { + for (const subCommand of c.subCommands) { + if (subCommand.aliases && subCommand.aliases.includes(args[0])) { + if (!subCommand.disabled) { + await runWithTokenSpendContext(msg, c.aliases[0], () => + dispatchCommand(subCommand, msg, db, args.slice(1))); + } + + return; + } + } + } + + if (!c.primaryCommand.disabled) { + await runWithTokenSpendContext(msg, c.aliases[0], () => + dispatchCommand(c.primaryCommand, msg, db, args)); + } + + return; + } +} + +async function dispatchCommand( + command: CommandFunc, + msg: Message, + db: Database, + args: string[], +): Promise { + switch (command.argsFormat) { + case Args.DontNeed: { + if (command.needDb) { + await (command.implementation as DontNeedArgsCommandDb)(msg, db); + } else { + await (command.implementation as DontNeedArgsCommand)(msg); + } + + break; + } + case Args.Split: { + if (command.needDb) { + await (command.implementation as SplitArgsCommandDb)(msg, args, db); + } else { + await (command.implementation as SplitArgsCommand)(msg, args); + } + + break; + } + case Args.Combined: { + if (command.needDb) { + await (command.implementation as CombinedArgsCommandDb)(msg, args.join(' '), db); + } else { + await (command.implementation as CombinedArgsCommand)(msg, args.join(' ')); + } + + break; + } + } +} diff --git a/lib/Database.ts b/lib/Database.ts index dea48c2..6af55f7 100644 --- a/lib/Database.ts +++ b/lib/Database.ts @@ -154,10 +154,22 @@ export async function createTablesIfNeeded(db: Database) { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR(255) NOT NULL, channel_id VARCHAR(255) NOT NULL, + platform VARCHAR(16) NOT NULL DEFAULT 'discord', message VARCHAR(2000), expire_time TIMESTAMP )`, db); + /* Existing private databases predate multi-platform timers. SQLite does + * not apply new columns from CREATE TABLE IF NOT EXISTS, so migrate them + * in place and treat all existing rows as Discord timers. */ + const timerColumns = await selectQuery(`PRAGMA table_info(timer)`, db); + if (!timerColumns.some((column) => column.name === 'platform')) { + await executeQuery( + `ALTER TABLE timer ADD COLUMN platform VARCHAR(16) NOT NULL DEFAULT 'discord'`, + db, + ); + } + /* This table stores every time a command is called for statistics and * logging purposes */ await executeQuery(`CREATE TABLE IF NOT EXISTS logs ( diff --git a/lib/Timer.ts b/lib/Timer.ts index bf7507e..0795b40 100644 --- a/lib/Timer.ts +++ b/lib/Timer.ts @@ -27,6 +27,20 @@ import { import { config } from './Config.js'; +export type TimerPlatform = 'discord' | 'uproar'; + +interface TimerChannel { + send: (payload: any) => unknown; +} + +type TimerChannelResolver = (channelId: string) => Promise; + +export function getMessagePlatform(msg: Message): TimerPlatform { + return (msg as Message & { platform?: string }).platform === 'uproar' + ? 'uproar' + : 'discord'; +} + export async function deleteTimer(msg: Message, args: string[], db: Database) { if (args.length === 0) { msg.reply('No timer ID given'); @@ -53,9 +67,10 @@ export async function deleteTimer(msg: Message, args: string[], db: Database) { WHERE id = ? AND channel_id = ? - AND user_id = ?`, + AND user_id = ? + AND platform = ?`, db, - [ args[0], msg.channel.id, msg.author.id ] + [ args[0], msg.channel.id, msg.author.id, getMessagePlatform(msg) ] ); if (changes === 1) { @@ -111,11 +126,17 @@ export async function handleTimer(msg: Message, args: string[], db: Database) { const timerID = await insertQuery( `INSERT INTO timer - (user_id, channel_id, message, expire_time) + (user_id, channel_id, platform, message, expire_time) VALUES - (?, ?, ?, ?)`, + (?, ?, ?, ?, ?)`, db, - [ msg.author.id, msg.channel.id, description, time.format('YYYY-MM-DD HH:mm:ss') ], + [ + msg.author.id, + msg.channel.id, + getMessagePlatform(msg), + description, + time.format('YYYY-MM-DD HH:mm:ss'), + ], ); sendTimer( @@ -142,11 +163,12 @@ export async function handleTimers(msg: Message, db: Database): Promise { timer WHERE channel_id = ? + AND platform = ? AND expire_time >= STRFTIME('%Y-%m-%d %H:%M:%S', 'NOW') ORDER BY expire_time ASC` , db, - [ msg.channel.id ] + [ msg.channel.id, getMessagePlatform(msg) ] ); if (!timers || timers.length === 0) { @@ -199,43 +221,41 @@ function formatDiscordTimestamp(time: moment.Moment): string { } export async function restoreTimers(db: Database, client: Client) { - const timers = await selectQuery( - `SELECT - id, - user_id, - channel_id, - message, - expire_time - FROM - timer - WHERE - expire_time > STRFTIME('%Y-%m-%d %H:%M:%S', 'NOW') - ORDER BY - expire_time ASC`, + await restoreTimersForPlatform( db, + 'discord', + async (channelId) => await client.channels.fetch(channelId) as TextChannel | null, ); +} - if (!timers || timers.length === 0) { +export async function restoreTimersForPlatform( + db: Database, + platform: TimerPlatform, + resolveChannel: TimerChannelResolver, +): Promise { + const timers = await getActiveTimersForPlatform(db, platform); + + if (timers.length === 0) { return; } - const channels = new Map(); + const channels = new Map(); for (const timer of timers) { let channel = channels.get(timer.channel_id); if (channel === undefined) { try { - channel = await client.channels.fetch(timer.channel_id) as TextChannel; + channel = await resolveChannel(timer.channel_id) ?? undefined; if (!channel) { - console.log(`Failed to get channel ${timer.channel_id}`); + console.log(`Failed to get ${platform} channel ${timer.channel_id}`); continue; } channels.set(timer.channel_id, channel); } catch (err) { - console.log(`Failed to get channel ${timer.channel_id}`); + console.log(`Failed to get ${platform} channel ${timer.channel_id}`); continue; } } @@ -256,8 +276,31 @@ export async function restoreTimers(db: Database, client: Client) { } } +export async function getActiveTimersForPlatform( + db: Database, + platform: TimerPlatform, +): Promise { + return selectQuery( + `SELECT + id, + user_id, + channel_id, + message, + expire_time + FROM + timer + WHERE + expire_time > STRFTIME('%Y-%m-%d %H:%M:%S', 'NOW') + AND platform = ? + ORDER BY + expire_time ASC`, + db, + [ platform ], + ); +} + export function sendTimer( - channel: TextChannel, + channel: TimerChannel, milliseconds: number, timerID: number, userID: string, @@ -277,11 +320,14 @@ export function sendTimer( const mention = `<@${userID}>,`; const timeoutID = setTimeout(() => { - if (description) { - channel.send(`${mention} Your ${description} timer has elapsed.`); - } else { - channel.send(`${mention} Your timer has elapsed.`); - } + const content = description + ? `${mention} Your ${description} timer has elapsed.` + : `${mention} Your timer has elapsed.`; + + Promise.resolve(channel.send(content)).catch((err) => { + console.error(`Failed to send timer #${timerID}: ${(err as any)?.stack ?? err}`); + }); + runningTimers.delete(timerID); }, milliseconds); runningTimers.set(timerID, timeoutID); diff --git a/lib/Uproar.ts b/lib/Uproar.ts index d797bef..a87022e 100644 --- a/lib/Uproar.ts +++ b/lib/Uproar.ts @@ -3,11 +3,11 @@ import fetch from 'node-fetch'; import FormData from 'form-data'; import { EventEmitter } from 'events'; import { Database } from 'sqlite3'; +import { Message } from 'discord.js'; import { config } from './Config.js'; -import { canAccessCommand } from './Utilities.js'; -import { Args, Command, CommandFunc } from './Types.js'; -import { Commands, handleHelp } from './CommandDeclarations.js'; +import { dispatchPrefixedCommand } from './CommandDispatcher.js'; +import { restoreTimersForPlatform } from './Timer.js'; /* Uproar (uproar.chat) integration. * @@ -56,7 +56,35 @@ interface UploadedAttachment { thumb_url?: string; } -type SendPayload = string | { content?: string; embeds?: any[]; files?: any[] }; +interface UproarConfig { + uproarBaseUrl?: string; + uproarBotId?: string; + uproarBotToken?: string; +} + +interface NormalizedSendPayload { + content: string; + embeds?: any[]; + files?: any[]; + attachments?: any[]; +} + +type SendPayload = string | { + content?: string; + embeds?: any[]; + files?: any[]; + attachments?: any[]; +}; + +function getUproarConfig(): Required { + const uproarConfig = config as typeof config & UproarConfig; + + return { + uproarBaseUrl: uproarConfig.uproarBaseUrl ?? 'https://uproar.chat', + uproarBotId: uproarConfig.uproarBotId ?? '', + uproarBotToken: uproarConfig.uproarBotToken ?? '', + }; +} function toUproarEmbeds(embeds?: any[]): any[] | undefined { if (!embeds || embeds.length === 0) { @@ -65,7 +93,7 @@ function toUproarEmbeds(embeds?: any[]): any[] | undefined { return embeds.map((e) => (e && typeof e.toJSON === 'function' ? e.toJSON() : e)); } -function normalizeSend(payload: SendPayload): { content: string; embeds?: any[]; files?: any[] } { +function normalizeSend(payload: SendPayload): NormalizedSendPayload { if (typeof payload === 'string') { return { content: payload }; } @@ -73,6 +101,7 @@ function normalizeSend(payload: SendPayload): { content: string; embeds?: any[]; content: payload.content ?? '', embeds: toUproarEmbeds(payload.embeds), files: Array.isArray(payload.files) ? payload.files : undefined, + attachments: Array.isArray(payload.attachments) ? payload.attachments : undefined, }; } @@ -140,9 +169,10 @@ export class UproarClient { private memberCache = new Map>(); constructor(db: Database) { - this.baseUrl = config.uproarBaseUrl.replace(/\/$/, ''); - this.botId = config.uproarBotId; - this.token = config.uproarBotToken; + const uproarConfig = getUproarConfig(); + this.baseUrl = uproarConfig.uproarBaseUrl.replace(/\/$/, ''); + this.botId = uproarConfig.uproarBotId; + this.token = uproarConfig.uproarBotToken; this.db = db; } @@ -405,11 +435,11 @@ export class UproarClient { const msg = new UproarMessage(this, data); - const [tmp, ...args] = data.content.trim().split(/\s+/); + const [tmp] = data.content.trim().split(/\s+/); const command = tmp.substring(tmp.indexOf(config.prefix) + 1).toLowerCase(); try { - await dispatchByCommand(msg, command, args, this.db); + await dispatchPrefixedCommand(msg as unknown as Message, this.db); } catch (err) { console.error(`[Uproar] Command '${command}' threw: ${(err as any)?.stack ?? err}`); try { @@ -422,69 +452,6 @@ export class UproarClient { } } -async function dispatchByCommand( - msg: UproarMessage, - command: string, - args: string[], - db: Database, -): Promise { - for (const c of Commands as Command[]) { - if (!c.aliases.includes(command)) { - continue; - } - - if (c.hidden && !canAccessCommand(msg as any, true)) { - return; - } - - if (args.length === 1 && args[0] === 'help') { - handleHelp(msg as any, c.aliases[0]); - return; - } - - if (c.commandGates) { - for (const gate of c.commandGates) { - const { canAccess, error } = gate(msg as any); - if (!canAccess) { - await msg.reply(error!); - return; - } - } - } - - if (args.length > 0 && c.subCommands && c.subCommands.length > 0) { - for (const subCommand of c.subCommands) { - if (subCommand.aliases && subCommand.aliases.includes(args[0])) { - if (!subCommand.disabled) { - await runCommand(subCommand, msg, db, args.slice(1)); - } - return; - } - } - } - - if (!c.primaryCommand.disabled) { - await runCommand(c.primaryCommand, msg, db, args); - } - return; - } -} - -async function runCommand(command: CommandFunc, msg: UproarMessage, db: Database, args: string[]): Promise { - const impl = command.implementation as any; - switch (command.argsFormat) { - case Args.DontNeed: - await (command.needDb ? impl(msg, db) : impl(msg)); - break; - case Args.Split: - await (command.needDb ? impl(msg, args, db) : impl(msg, args)); - break; - case Args.Combined: - await (command.needDb ? impl(msg, args.join(' '), db) : impl(msg, args.join(' '))); - break; - } -} - async function resolveFileData(file: any): Promise<{ data: Buffer; name: string } | null> { const raw = file && file.attachment !== undefined ? file.attachment : file; const name: string = (file && file.name) || 'file'; @@ -513,6 +480,7 @@ async function resolveFileData(file: any): Promise<{ data: Buffer; name: string /* --- message shims --- */ export class UproarMessage { + public readonly platform = 'uproar'; public readonly id: string; public readonly content: string; public readonly author: UproarUser; @@ -561,6 +529,7 @@ export class UproarMessage { /* A message read back from the API (reply context, purge history): read fields * plus delete/react so the deletion + image-extraction paths work on it. */ export class UproarFetchedMessage { + public readonly platform = 'uproar'; public readonly id: string; public readonly content: string; public readonly author: UproarUser; @@ -618,8 +587,15 @@ export class UproarChannel { await this.bot.exec({ action: 'typing', channel_id: this.id }); } + /* Uproar performs permission checks at the API boundary. This compatibility + * surface lets commands that preflight Discord channel permissions proceed + * and receive the authoritative result from the execute endpoint. */ + public permissionsFor(_member: unknown): { has: () => boolean } { + return { has: () => true }; + } + public async sendInternal(payload: SendPayload, replyTo: string | null): Promise { - const { content, embeds, files } = normalizeSend(payload); + const { content, embeds, files, attachments: retainedAttachments } = normalizeSend(payload); const body: Record = { action: 'send', channel_id: this.id, content }; if (embeds) { @@ -637,6 +613,8 @@ export class UproarChannel { } catch (err) { console.error(`[Uproar] Attachment upload failed: ${(err as any)?.message ?? err}`); } + } else if (retainedAttachments) { + body.attachments = retainedAttachments; } const created = await this.bot.exec(body); @@ -648,11 +626,17 @@ export class UproarSentMessage { constructor(private readonly bot: UproarClient, public readonly id: string, public readonly channelId: string) {} public async edit(payload: SendPayload): Promise { - const { content, embeds } = normalizeSend(payload); + const { content, embeds, files, attachments } = normalizeSend(payload); const body: Record = { action: 'edit', message_id: this.id, content }; if (embeds) { body.embeds = embeds; } + if (files && files.length > 0) { + const uploaded = await this.bot.uploadFiles(this.channelId, files); + body.attachments = uploaded; + } else if (attachments) { + body.attachments = attachments; + } await this.bot.exec(body); return this; } @@ -724,10 +708,23 @@ export class UproarReactionCollector extends EventEmitter { } export function startUproar(db: Database): void { - if (!config.uproarBotId || !config.uproarBotToken) { + const uproarConfig = getUproarConfig(); + if (!uproarConfig.uproarBotId || !uproarConfig.uproarBotToken) { console.log('[Uproar] Not configured; skipping'); return; } const client = new UproarClient(db); - client.connect(); + try { + client.connect(); + } catch (err) { + console.error(`[Uproar] Failed to start stream: ${(err as any)?.stack ?? err}`); + return; + } + restoreTimersForPlatform( + db, + 'uproar', + async (channelId) => new UproarChannel(client, channelId), + ).catch((err) => { + console.error(`[Uproar] Failed to restore timers: ${(err as any)?.stack ?? err}`); + }); } diff --git a/lib/index.ts b/lib/index.ts index 823461e..68ad616 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,49 +1,23 @@ -import moment from 'moment'; import sqlite3 from 'sqlite3'; -import { LAMPORTS_PER_SOL } from '@solana/web3.js'; import { Message, Client, GatewayIntentBits, - GuildChannel, } from 'discord.js'; -import { evaluate } from 'mathjs'; - import { config } from './Config.js'; import { - canAccessCommand, tryDeleteMessage, tryReactMessage, - numberWithCommas } from './Utilities.js'; import { getDiscordLoginRetryDelay } from './DiscordRetry.js'; import { - insertQuery, createTablesIfNeeded, deleteTablesIfNeeded, } from './Database.js'; -import { - Command, - CommandFunc, - Args, - DontNeedArgsCommandDb, - DontNeedArgsCommand, - SplitArgsCommandDb, - SplitArgsCommand, - CombinedArgsCommandDb, - CombinedArgsCommand, - Quote, -} from './Types.js'; - -import { - Commands, - handleHelp, -} from './CommandDeclarations.js'; - import { restoreTimers } from './Timer.js'; import { cacheMessageForSummarization } from './Summarize.js'; import { convertTwitterLinks } from './ConvertTwitterLinks.js'; @@ -53,6 +27,10 @@ import { initTokenSpend, runWithTokenSpendContext, } from './TokenSpend.js'; +import { + dispatchPrefixedCommand, + isMessageAllowed, +} from './CommandDispatcher.js'; import { isAllowedByUserChannelRestriction, userChannelRestrictions, @@ -117,19 +95,7 @@ async function handleMessage(msg: Message, db: sqlite3.Database): Promise return; } - if (config.devEnv && !config.devChannels.includes(msg.channel.id)) { - return; - } - - const userChannelRestriction = userChannelRestrictions.find( - ({ userId }) => userId === msg.author.id - ); - - if (userChannelRestriction && !isAllowedByUserChannelRestriction( - userChannelRestriction, - msg.channel.id, - msg.guild?.id ?? null - )) { + if (!isMessageAllowed(msg)) { return; } @@ -144,113 +110,8 @@ async function handleMessage(msg: Message, db: sqlite3.Database): Promise return; } - - /* Get the command with prefix, and any args */ - const [ tmp, ...args ] = msg.content.trim().split(/\s+/); - - /* Get the actual command after the prefix is removed */ - const command: string = tmp.substring(tmp.indexOf(config.prefix) + 1, tmp.length).toLowerCase(); - - for (const c of Commands) { - if (c.aliases.includes(command)) { - if (c.hidden) { - if (!canAccessCommand(msg, true)) { - return; - } - } - - insertQuery( - `INSERT INTO logs - (user_id, channel_id, guild_id, command, args, timestamp) - VALUES - (?, ?, ?, ?, ?, ?)`, - db, - [ - msg.author.id, - msg.channel.id, - msg.guild?.id ?? null, - c.aliases[0], - args.join(' '), - moment.utc().format('YYYY-MM-DD HH:mm:ss'), - ] - ); - - if (args.length === 1 && args[0] === 'help') { - handleHelp(msg, c.aliases[0]); - return; - } - - if (c.commandGates) { - for (const gate of c.commandGates) { - const { canAccess, error } = gate(msg); - - if (!canAccess) { - await msg.reply(error!); - return; - } - } - } - - /* Check if the user is calling a sub command instead of the main - * function. */ - if (args.length > 0 && c.subCommands && c.subCommands.length > 0) { - for (const subCommand of c.subCommands) { - if (subCommand.aliases && subCommand.aliases.includes(args[0])) { - if (!subCommand.disabled) { - await runWithTokenSpendContext(msg, c.aliases[0], () => - dispatchCommand(subCommand, msg, db, args.slice(1))); - } - - return; - } - } - } - - if (!c.primaryCommand.disabled) { - await runWithTokenSpendContext(msg, c.aliases[0], () => - dispatchCommand(c.primaryCommand, msg, db, args)); - } - - return; - } - } -} - -async function dispatchCommand( - command: CommandFunc, - msg: Message, - db: sqlite3.Database, - args: string[]) { - - switch (command.argsFormat) { - case Args.DontNeed: { - if (command.needDb) { - await (command.implementation as DontNeedArgsCommandDb)(msg, db); - } else { - await (command.implementation as DontNeedArgsCommand)(msg); - } - break; - } - case Args.Split: { - if (command.needDb) { - await (command.implementation as SplitArgsCommandDb)(msg, args, db); - } else { - await (command.implementation as SplitArgsCommand)(msg, args); - } - - break; - } - case Args.Combined: { - if (command.needDb) { - await (command.implementation as CombinedArgsCommandDb)(msg, args.join(' '), db); - } else { - await (command.implementation as CombinedArgsCommand)(msg, args.join(' ')); - } - - break; - } - } + await dispatchPrefixedCommand(msg, db); } function createDiscordClient(db: sqlite3.Database): Client { @@ -319,9 +180,9 @@ async function main() { db.on('error', console.error); - await loginWithRetry(db); - startUproar(db); + + await loginWithRetry(db); } main().catch(error => { diff --git a/tests/uproar.test.mjs b/tests/uproar.test.mjs new file mode 100644 index 0000000..8ea5e39 --- /dev/null +++ b/tests/uproar.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import sqlite3 from 'sqlite3'; + +import { config } from '../dist/Config.js'; +import { + dispatchPrefixedCommand, + isMessageAllowed, +} from '../dist/CommandDispatcher.js'; +import { + createTablesIfNeeded, + executeQuery, + selectQuery, +} from '../dist/Database.js'; +import { + getActiveTimersForPlatform, + getMessagePlatform, +} from '../dist/Timer.js'; +import { + UproarChannel, + UproarSentMessage, +} from '../dist/Uproar.js'; +import { Commands } from '../dist/CommandDeclarations.js'; +import { Args } from '../dist/Types.js'; +import { + initTokenSpend, + recordTokenSpend, +} from '../dist/TokenSpend.js'; + +function closeDatabase(db) { + return new Promise((resolve, reject) => { + db.close((err) => err ? reject(err) : resolve()); + }); +} + +test('Uproar message edits upload files and preserve attachment clearing', async () => { + const executions = []; + const uploads = []; + const bot = { + uploadFiles: async (channelId, files) => { + uploads.push({ channelId, files }); + return [{ url: '/uploads/result.png' }]; + }, + exec: async (body) => { + executions.push(body); + return {}; + }, + }; + + const message = new UproarSentMessage(bot, 'message-1', 'channel-1'); + const file = Buffer.from('image'); + + await message.edit({ content: 'done', files: [file], attachments: [] }); + await message.edit({ content: 'cleared', attachments: [] }); + + assert.deepEqual(uploads, [{ channelId: 'channel-1', files: [file] }]); + assert.deepEqual(executions, [ + { + action: 'edit', + message_id: 'message-1', + content: 'done', + attachments: [{ url: '/uploads/result.png' }], + }, + { + action: 'edit', + message_id: 'message-1', + content: 'cleared', + attachments: [], + }, + ]); +}); + +test('Uproar channel exposes the permission preflight used by GIF commands', () => { + const channel = new UproarChannel({}, 'channel-1'); + assert.equal(channel.permissionsFor(null).has(), true); +}); + +test('shared message policy applies development and user restrictions', () => { + const originalDevEnv = config.devEnv; + const originalDevChannels = config.devChannels; + + try { + config.devEnv = true; + config.devChannels = ['allowed-channel']; + + const baseMessage = { + author: { id: 'ordinary-user' }, + channel: { id: 'blocked-channel' }, + guild: { id: 'guild-1' }, + }; + + assert.equal(isMessageAllowed(baseMessage), false); + assert.equal(isMessageAllowed({ + ...baseMessage, + channel: { id: 'allowed-channel' }, + }), true); + + config.devEnv = false; + assert.equal(isMessageAllowed({ + ...baseMessage, + author: { id: '1307359331724824744' }, + }), false); + } finally { + config.devEnv = originalDevEnv; + config.devChannels = originalDevChannels; + } +}); + +test('shared dispatch records command logs and token-spend context', async () => { + const db = new sqlite3.Database(':memory:'); + const originalPrefix = config.prefix; + const originalDevEnv = config.devEnv; + let commandAdded = false; + const testCommand = { + aliases: ['uproar-context-test'], + primaryCommand: { + argsFormat: Args.DontNeed, + implementation: () => { + recordTokenSpend({ + model: 'test-model', + inputTokens: 12, + outputTokens: 3, + }); + }, + description: 'test command', + }, + }; + + try { + await createTablesIfNeeded(db); + initTokenSpend(db); + config.prefix = '$'; + config.devEnv = false; + Commands.push(testCommand); + commandAdded = true; + + await dispatchPrefixedCommand({ + content: '$uproar-context-test', + author: { id: 'uproar-user' }, + channel: { id: 'uproar-channel' }, + guild: { id: 'uproar-server' }, + }, db); + + const logs = await selectQuery( + `SELECT user_id, channel_id, guild_id, command FROM logs`, + db, + ); + const usage = await selectQuery( + `SELECT user_id, channel_id, guild_id, command, input_tokens, output_tokens + FROM token_usage`, + db, + ); + + assert.deepEqual(logs, [{ + user_id: 'uproar-user', + channel_id: 'uproar-channel', + guild_id: 'uproar-server', + command: 'uproar-context-test', + }]); + assert.deepEqual(usage, [{ + user_id: 'uproar-user', + channel_id: 'uproar-channel', + guild_id: 'uproar-server', + command: 'uproar-context-test', + input_tokens: 12, + output_tokens: 3, + }]); + } finally { + if (commandAdded) { + Commands.splice(Commands.indexOf(testCommand), 1); + } + config.prefix = originalPrefix; + config.devEnv = originalDevEnv; + await closeDatabase(db); + } +}); + +test('timer migration and queries keep Discord and Uproar channels separate', async () => { + const db = new sqlite3.Database(':memory:'); + + try { + await executeQuery( + `CREATE TABLE timer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id VARCHAR(255) NOT NULL, + channel_id VARCHAR(255) NOT NULL, + message VARCHAR(2000), + expire_time TIMESTAMP + )`, + db, + ); + await createTablesIfNeeded(db); + + const columns = await selectQuery(`PRAGMA table_info(timer)`, db); + assert.ok(columns.some(({ name }) => name === 'platform')); + + await executeQuery( + `INSERT INTO timer + (user_id, channel_id, message, expire_time) + VALUES + ('discord-user', 'shared-channel', 'discord timer', + STRFTIME('%Y-%m-%d %H:%M:%S', 'NOW', '+1 day'))`, + db, + ); + await executeQuery( + `INSERT INTO timer + (user_id, channel_id, platform, message, expire_time) + VALUES + ('uproar-user', 'shared-channel', 'uproar', 'uproar timer', + STRFTIME('%Y-%m-%d %H:%M:%S', 'NOW', '+1 day'))`, + db, + ); + + const discordTimers = await getActiveTimersForPlatform(db, 'discord'); + const uproarTimers = await getActiveTimersForPlatform(db, 'uproar'); + + assert.deepEqual(discordTimers.map(({ user_id }) => user_id), ['discord-user']); + assert.deepEqual(uproarTimers.map(({ user_id }) => user_id), ['uproar-user']); + assert.equal(getMessagePlatform({}), 'discord'); + assert.equal(getMessagePlatform({ platform: 'uproar' }), 'uproar'); + } finally { + await closeDatabase(db); + } +}); From 66dbcdd2c5fa2e1179c1315405dd7f9393934c70 Mon Sep 17 00:00:00 2001 From: zpalmtree <22151537+zpalmtree@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:59:09 -0400 Subject: [PATCH 4/5] Fix Uproar reaction and cache behavior --- lib/Exchange.ts | 16 +++++++------ lib/Uproar.ts | 44 +++++++++++++++++++++++++++-------- lib/index.ts | 2 ++ tests/uproar.test.mjs | 54 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/lib/Exchange.ts b/lib/Exchange.ts index 790dd23..9921a47 100644 --- a/lib/Exchange.ts +++ b/lib/Exchange.ts @@ -7,10 +7,6 @@ interface Rates { } export class Exchange { - public constructor() { - this.init(); - } - public getCurrencies(): string[] { return Object.keys(this.rates); } @@ -61,10 +57,16 @@ export class Exchange { private rates: Rates = {}; private mapping: { [index: string]: string } = {}; private initialized: boolean = false; + private started: boolean = false; + + public start(): void { + if (this.started) { + return; + } - private async init() { - this.fetchCurrencyMapping(); - this.fetchRates(); + this.started = true; + void this.fetchCurrencyMapping(); + void this.fetchRates(); } private async fetchCurrencyMapping() { diff --git a/lib/Uproar.ts b/lib/Uproar.ts index a87022e..4ac0386 100644 --- a/lib/Uproar.ts +++ b/lib/Uproar.ts @@ -62,6 +62,11 @@ interface UproarConfig { uproarBotToken?: string; } +interface MemberCacheEntry { + expiresAt: number; + members: Promise; +} + interface NormalizedSendPayload { content: string; embeds?: any[]; @@ -156,6 +161,8 @@ function makeAuthor(baseUrl: string, data: UproarMessageData): UproarUser { } export class UproarClient { + private static readonly memberCacheTtlMs = 5 * 60 * 1000; + private readonly baseUrl: string; private readonly botId: string; private readonly token: string; @@ -166,7 +173,7 @@ export class UproarClient { private botUserId: string | null = null; private reactionCollectors = new Map>(); - private memberCache = new Map>(); + private memberCache = new Map(); constructor(db: Database) { const uproarConfig = getUproarConfig(); @@ -317,14 +324,25 @@ export class UproarClient { /* --- members / guild resolution --- */ private serverMembers(serverId: string): Promise { - let cached = this.memberCache.get(serverId); - if (!cached) { - cached = this.readGet(`/members?server_id=${encodeURIComponent(serverId)}`) - .then((m) => (Array.isArray(m) ? m : [])) - .catch(() => []); - this.memberCache.set(serverId, cached); + const cached = this.memberCache.get(serverId); + if (cached && cached.expiresAt > Date.now()) { + return cached.members; } - return cached; + + const members = this.readGet(`/members?server_id=${encodeURIComponent(serverId)}`) + .then((m) => (Array.isArray(m) ? m : [])) + .catch(() => { + if (this.memberCache.get(serverId)?.members === members) { + this.memberCache.delete(serverId); + } + return []; + }); + + this.memberCache.set(serverId, { + expiresAt: Date.now() + UproarClient.memberCacheTtlMs, + members, + }); + return members; } public async fetchMember(serverId: string, userId: string): Promise { @@ -688,10 +706,16 @@ export class UproarReactionCollector extends EventEmitter { } public handleRemove(reaction: any, user: any): void { - if (this.ended || !this.options?.dispose) { + if (this.ended) { return; } - this.emit('remove', reaction, user); + + /* Uproar bots cannot remove another user's reaction, so reactions act + * as toggle buttons: adding and removing the same reaction must both + * trigger the command. This preserves repeated pagination and poll + * interactions without requiring Discord's remove-user-reaction API. */ + this.collected.delete(`${reaction.emoji.name}:${user.id}`); + this.handleAdd(reaction, user); } public stop(reason: string = 'user'): void { diff --git a/lib/index.ts b/lib/index.ts index 68ad616..7fee381 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -37,6 +37,7 @@ import { } from './UserChannelRestrictions.js'; import { externalBotReplyRestrictions } from './ExternalBotReplyRestrictions.js'; import { startUproar } from './Uproar.js'; +import { exchangeService } from './Exchange.js'; async function handleRestrictedExternalBotReply(msg: Message): Promise { if (!msg.reference?.messageId) { @@ -177,6 +178,7 @@ async function main() { await createTablesIfNeeded(db); initTokenSpend(db); + exchangeService.start(); db.on('error', console.error); diff --git a/tests/uproar.test.mjs b/tests/uproar.test.mjs index 8ea5e39..fee1d08 100644 --- a/tests/uproar.test.mjs +++ b/tests/uproar.test.mjs @@ -19,6 +19,8 @@ import { } from '../dist/Timer.js'; import { UproarChannel, + UproarClient, + UproarReactionCollector, UproarSentMessage, } from '../dist/Uproar.js'; import { Commands } from '../dist/CommandDeclarations.js'; @@ -76,6 +78,58 @@ test('Uproar channel exposes the permission preflight used by GIF commands', () assert.equal(channel.permissionsFor(null).has(), true); }); +test('Uproar reaction add and remove events both act as button presses', () => { + const bot = { + unregisterCollector: () => {}, + }; + const collector = new UproarReactionCollector(bot, 'message-1', { + filter: (reaction, user) => reaction.emoji.name === '➡️' && !user.bot, + dispose: true, + }); + const reactions = []; + let removeEvents = 0; + const reaction = { emoji: { name: '➡️' } }; + const user = { id: 'user-1', bot: false }; + + collector.on('collect', (collectedReaction, collectedUser) => { + reactions.push(`${collectedReaction.emoji.name}:${collectedUser.id}`); + }); + collector.on('remove', () => { + removeEvents += 1; + }); + + collector.handleAdd(reaction, user); + collector.handleRemove(reaction, user); + collector.stop(); + + assert.deepEqual(reactions, ['➡️:user-1', '➡️:user-1']); + assert.equal(removeEvents, 0); +}); + +test('Uproar member lookup retries after a transient read failure', async () => { + const client = new UproarClient({}); + let attempts = 0; + + client.readGet = async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('temporary failure'); + } + return [{ + user_id: 'user-1', + display_name: 'Fresh Member', + avatar_url: '/avatars/user-1.png', + }]; + }; + + assert.equal(await client.fetchMember('server-1', 'user-1'), undefined); + const member = await client.fetchMember('server-1', 'user-1'); + + assert.equal(attempts, 2); + assert.equal(member.displayName, 'Fresh Member'); + assert.equal(member.displayAvatarURL(), 'https://uproar.chat/avatars/user-1.png'); +}); + test('shared message policy applies development and user restrictions', () => { const originalDevEnv = config.devEnv; const originalDevChannels = config.devChannels; From 218914543f50038590dfc30bbc657afa8909c183 Mon Sep 17 00:00:00 2001 From: zpalmtree <22151537+zpalmtree@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:03:18 -0400 Subject: [PATCH 5/5] Skip unsupported Uproar typing requests --- lib/Uproar.ts | 6 +----- tests/uproar.test.mjs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/Uproar.ts b/lib/Uproar.ts index 4ac0386..93c4d4e 100644 --- a/lib/Uproar.ts +++ b/lib/Uproar.ts @@ -15,7 +15,7 @@ import { restoreTimersForPlatform } from './Timer.js'; * - receive: a dial-out WebSocket at GET /api/bots/{id}/stream delivers * message_create + reaction events in realtime (mirrors the Discord gateway). * - act: POST /api/bots/{id}/{token} with {action, ...} to send/edit/ - * delete/react/typing. + * delete/react. * - upload: multipart POST /api/bots/{id}/attachments?channel_id=… (bearer), * then reference the returned /uploads/ url(s) in a send action's attachments. * - read: GET /api/bots/{id}/{messages|members|…} with Authorization: Bearer. @@ -601,10 +601,6 @@ export class UproarChannel { return this.sendInternal(payload, null); } - public async sendTyping(): Promise { - await this.bot.exec({ action: 'typing', channel_id: this.id }); - } - /* Uproar performs permission checks at the API boundary. This compatibility * surface lets commands that preflight Discord channel permissions proceed * and receive the authoritative result from the execute endpoint. */ diff --git a/tests/uproar.test.mjs b/tests/uproar.test.mjs index fee1d08..4fd165e 100644 --- a/tests/uproar.test.mjs +++ b/tests/uproar.test.mjs @@ -29,6 +29,7 @@ import { initTokenSpend, recordTokenSpend, } from '../dist/TokenSpend.js'; +import { trySendTyping } from '../dist/Typing.js'; function closeDatabase(db) { return new Promise((resolve, reject) => { @@ -78,6 +79,19 @@ test('Uproar channel exposes the permission preflight used by GIF commands', () assert.equal(channel.permissionsFor(null).has(), true); }); +test('Uproar channels skip unsupported typing requests', async () => { + let executions = 0; + const channel = new UproarChannel({ + exec: async () => { + executions += 1; + }, + }, 'channel-1'); + + assert.equal('sendTyping' in channel, false); + await trySendTyping(channel); + assert.equal(executions, 0); +}); + test('Uproar reaction add and remove events both act as button presses', () => { const bot = { unregisterCollector: () => {},