import { createPublicKey, verify } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { ZodError } from 'zod'; import { publicUser, type AppDatabase } from '../auth'; import { users, discordReceipts } from '../db/schema'; import { ApiError, HabitService } from '../habits/service'; import { ControlStore } from './state'; import { interactionSchema, type Context, type Reply } from './types'; import { dailyAction, today } from './daily'; import { manageHabits, manageTasks, managementAction } from './management'; import { history, historyAction } from './history'; import { preferences, preferencesAction } from './preferences'; import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress'; import { message } from './ui'; import { help } from './help'; import { commandGraphicAction } from './command-graphics'; export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean }; export type DeferredDispatch = { progress?: ProgressOperations; complete?: (interaction: import('./types').Interaction, result: DeferredMessage) => Promise; schedule?: (job: () => Promise) => void; }; /** Edit the deferred original exactly once. Never retry an uncertain network result. */ export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) { const body = new FormData(); const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] }; body.set('payload_json', JSON.stringify(payload)); if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png'); const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) }); if (!response.ok) throw new Error('Deferred Discord response was rejected.'); } export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) { const app = new Hono(); const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null; app.use('*', bodyLimit({ maxSize: 64 * 1024 })); app.post('/', async c => { c.header('Cache-Control', 'no-store'); if (!key) return c.json({ error: 'Discord commands are not configured.' }, 503); const signature = c.req.header('X-Signature-Ed25519') ?? '', timestamp = c.req.header('X-Signature-Timestamp') ?? ''; const body = await c.req.text(); if (!/^\d{10,12}$/.test(timestamp) || Math.abs(now() - Number(timestamp) * 1000) > 300000 || !/^[a-f\d]{128}$/i.test(signature) || !verify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex'))) return c.json({ error: 'Invalid request signature.' }, 401); let value: unknown; try { value = JSON.parse(body); } catch { return c.json({ error: "Malformed JSON." }, 400); } const parsed = interactionSchema.safeParse(value); if (!parsed.success || parsed.data.application_id !== config.applicationId) return c.json({ error: 'Invalid interaction.' }, 400); const i = parsed.data; if (i.type === 1) return c.json({ type: 1 }); const profile = i.guild_id ? i.member?.user : i.user; if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400); // Help needs no account creation, habit synchronization, or saved UI state. if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin)); let pending: (() => Promise) | undefined; // Resolve the account before queueing catch-up; no transaction is held across yields. const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); const userId = existing?.id ?? crypto.randomUUID(); if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run(); const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); if (receipt && receipt.expiresAt > now()) return c.json(receipt.response); const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!); const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => { const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); if (receipt && receipt.expiresAt > now()) return receipt.response; const controls = new ControlStore(db, userId, now()); controls.prune(); const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined }; let result: Reply; try { if (i.type === 4) result = { type: 8, data: { choices: [] } }; else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx); else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx); else if (i.type === 2 && i.data?.name === 'progress') result = progress(ctx); else if (i.type === 2 && i.data?.name === 'today') result = today(ctx); else if (i.type === 2 && ['settings', 'reminders'].includes(i.data?.name ?? '')) result = preferences(ctx, i.data!.name!); else if (i.type === 2 && i.data?.name === 'history') result = history(ctx); else if ([3, 5].includes(i.type)) { const state = controls.read(i.data?.custom_id ?? ''); result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); } else result = message(ctx, 'Run /help to see available commands.', [], false); } catch (error) { if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false); } db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run(); return result; })); if (pending) { const work = pending; const job = async () => { let result: DeferredMessage; try { result = await work(); } catch (error) { result = { message: { content: error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', components: [], allowed_mentions: { parse: [] } } }; } try { await (dispatch.complete ?? completeDeferred)(i, result); } catch { console.error('Discord deferred reply failed; it will not be retried.'); } }; (dispatch.schedule ?? (job => { setTimeout(() => { void job(); }, 0); }))(job); } return c.json(reply); }); return app; }