Compare commits

...

3 Commits

Author SHA1 Message Date
syntaxbullet
2671fe0f34 feat: add Discord help command for servers and DMs 2026-09-08 08:33:34 +02:00
syntaxbullet
0a34142b3e feat: add safe global Discord command registration 2026-09-08 08:17:26 +02:00
syntaxbullet
4f38bf1431 feat: add signed Discord command foundation and daily check-ins 2026-09-08 08:15:22 +02:00
22 changed files with 1674 additions and 3 deletions

View File

@@ -25,3 +25,6 @@ BACKUP_RETAIN=7
BACKUP_REPLICA_DIR= BACKUP_REPLICA_DIR=
# Optional public HTTPS health URL for the independent monitor. # Optional public HTTPS health URL for the independent monitor.
MONITOR_URL= MONITOR_URL=
# Discord Developer Portal > General Information > Public Key.
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
DISCORD_PUBLIC_KEY=

View File

@@ -0,0 +1,20 @@
CREATE TABLE `discord_controls` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`action` text NOT NULL,
`payload` text NOT NULL,
`expires_at` integer NOT NULL,
`used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_controls_expiry_idx` ON `discord_controls` (`expires_at`);--> statement-breakpoint
CREATE TABLE `discord_receipts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`response` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_receipts_expiry_idx` ON `discord_receipts` (`expires_at`);

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1788845281801, "when": 1788845281801,
"tag": "0011_multiple_reminders", "tag": "0011_multiple_reminders",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1788847978168,
"tag": "0012_discord_commands",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,10 @@
import { commands } from '../src/discord/commands';
import { registerCommands } from '../src/discord/registration';
if (process.argv.includes('--apply')) {
const registered = await registerCommands(process.env.DISCORD_CLIENT_ID ?? '', process.env.DISCORD_BOT_TOKEN ?? '', commands);
console.log(`Registered global commands for servers and bot DMs: ${registered.map(name => `/${name}`).join(', ')}`);
} else {
console.log(JSON.stringify(commands, null, 2));
console.log('Preview only. Add --apply to register these commands with Discord.');
}

View File

@@ -1,3 +1,4 @@
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
import { createLiveDiscord } from "./sharing/live-discord"; import { createLiveDiscord } from "./sharing/live-discord";
import { createReminderRoutes } from "./reminders/routes"; import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor"; import { errorDetails } from "./ops/monitor";
@@ -12,7 +13,7 @@ import type { FetchDiscord } from "./auth/discord";
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes"; import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
import type { DiscordSharingConfig } from "./sharing/config"; import type { DiscordSharingConfig } from "./sharing/config";
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) { export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
const app = new Hono<AuthEnv>(); const app = new Hono<AuthEnv>();
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
@@ -32,6 +33,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503); if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });
app.route("/api/discord/interactions", createDiscordRoutes(db, operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }, now ?? Date.now, liveSharing.changed));
app.route("/api/sharing/live", liveSharing.routes); app.route("/api/sharing/live", liveSharing.routes);
app.route("/api/auth", auth.routes); app.route("/api/auth", auth.routes);
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now)); app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));

View File

@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
} }
} }
function publicUser(user: typeof users.$inferSelect): PublicUser { export function publicUser(user: typeof users.$inferSelect): PublicUser {
return { return {
id: user.id, id: user.id,
discordId: user.discordId, discordId: user.discordId,

View File

@@ -101,3 +101,15 @@ export const liveDiscordCards = sqliteTable('live_discord_cards', {
retryAt: integer('retry_at'), retryAt: integer('retry_at'),
updatedAt: integer('updated_at').notNull(), updatedAt: integer('updated_at').notNull(),
}); });
// Short-lived UI state and replay receipts; no Discord interaction tokens are persisted.
export const discordControls = sqliteTable('discord_controls', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
action: text('action').notNull(), payload: text('payload', { mode: 'json' }).$type<import('../discord/types').ControlData>().notNull(),
expiresAt: integer('expires_at').notNull(), used: integer('used', { mode: 'boolean' }).notNull().default(false),
}, t => [index('discord_controls_expiry_idx').on(t.expiresAt)]);
export const discordReceipts = sqliteTable('discord_receipts', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
response: text('response', { mode: 'json' }).$type<import('../discord/types').Reply>().notNull(),
expiresAt: integer('expires_at').notNull(),
}, t => [index('discord_receipts_expiry_idx').on(t.expiresAt)]);

26
src/discord/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Discord interface
Signed HTTP interactions enter `/api/discord/interactions`. The raw request body
is verified with the application's Ed25519 public key and a five-minute timestamp
window. `DISCORD_PUBLIC_KEY` enables the endpoint; `DISCORD_CLIENT_ID` identifies
the application. Commands are global with contexts `[0, 1]` (server and bot DM).
`HabitService` remains the shared domain implementation. Verified Discord user IDs
resolve the same account as website OAuth; new accounts start in UTC. Server
responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
expire after 15 minutes, and mutation controls are consumed after successful use.
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
`daily.ts` implements the initial `/today` workflow. `types.ts`, `ui.ts`, and
`state.ts` provide the shared feature building blocks. Feature tasks extend command
registration and route dispatch while keeping their behavior in separate modules.
Tests use real Ed25519 signatures and disposable migrated SQLite databases. The
foundation tests cover server/DM privacy, replay after restart, stale progress,
cross-account controls, and matching website state. Command registration and live
Discord validation will be completed during integration.
`/help` lists the current command definitions automatically, explains check-in
controls and privacy, and links to the website. It works before account setup and
does not create an account or synchronize habits. Re-run the registration script
with `--apply` after deployment to expose newly added commands in Discord.

5
src/discord/commands.ts Normal file
View File

@@ -0,0 +1,5 @@
// Global commands are required for bot DMs; guild-only registrations cannot run there.
export const commands = [
{ name: 'help', description: 'Show commands and how to use Minabot' },
{ name: 'today', description: 'View and complete today’s habits' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

50
src/discord/daily.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ApiError } from '../habits/service';
import { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
export function today(c: Context, data: ControlData = {}): Reply {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
const page = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
}
export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
const d = c.service.day(data.habitId!, date);
const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) };
const controls = [];
if (d.due && !d.future) {
if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1)));
if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'tasks') {
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10);
controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` }))));
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
}
}
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today')));
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
}
export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'today') return today(c, data);
if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] });
if (action === 'daily.detail') return detail(c, data);
if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]);
if (action === 'daily.save' || action === 'daily.task') {
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
const d = c.service.day(data.habitId!, data.date!);
if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
if (!task) throw new ApiError(404, 'Task not found.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
}
}

33
src/discord/help.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture } from './test-fixture';
import { commands } from './commands';
import { help } from './help';
import { discordControls, discordReceipts, users } from '../db/schema';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
for (const guild of [false, true]) test(`/help works in ${guild ? 'servers privately' : 'bot DMs'} without creating an account`, async () => {
f = discordFixture();
const before = f.db.select().from(users).all();
const response = await f.run({ data: { name: 'help' } }, guild, '555555555555555555');
expect(response.type).toBe(4);
expect(response.data.flags).toBe(guild ? 64 : undefined);
expect(response.data.allowed_mentions).toEqual({ parse: [] });
for (const command of commands) expect(response.data.content).toContain(`**/${command.name}** — ${command.description}`);
expect(response.data.components[0].components[0].url).toBe(`${f.origin}/`);
expect(f.db.select().from(users).all()).toEqual(before);
expect(f.db.select().from(discordControls).all()).toEqual([]);
expect(f.db.select().from(discordReceipts).all()).toEqual([]);
expect(f.changed).toEqual([]);
});
test('help stays within Discord message limits as command definitions grow', () => {
const response = help(true, 'https://example.com');
expect('content' in response.data! && response.data.content!.length).toBeLessThanOrEqual(2000);
expect(commands.find(c => c.name === 'help')?.contexts).toEqual([0, 1]);
});
test('unknown commands point users to help', async () => {
f = discordFixture();
expect((await f.run({ data: { name: 'unknown' } }, true)).data.content).toContain('/help');
});

22
src/discord/help.ts Normal file
View File

@@ -0,0 +1,22 @@
import { commands } from './commands';
import type { Reply } from './types';
/** Use registration metadata so newly integrated commands appear automatically. */
export function help(guild: boolean, origin: string): Reply {
return { type: 4, data: {
content: [
'**Minabot help**',
'Manage the same habits here and on the website. Commands work in servers and in DMs with the bot.',
'',
...commands.map(command => `**/${command.name}** — ${command.description}`),
'',
'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.',
'If a control expires or progress changes elsewhere, run the command again to get fresh controls.',
'',
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',
].join('\n'),
...(guild ? { flags: 64 } : {}),
allowed_mentions: { parse: [] },
components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Open website', url: `${origin}/` }] }],
} };
}

View File

@@ -0,0 +1,18 @@
import { expect, test } from 'bun:test';
import { registerCommands } from './registration';
const command = { name: 'today', description: 'Today', type: 1, contexts: [0, 1], integration_types: [0] };
test('registers global commands individually without removing other application commands', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
expect(await registerCommands('111111111111111111', 'fake-token', [command], async (url, init) => { calls.push({ url, init }); return Response.json({}); })).toEqual(['today']);
expect(calls[0]!.url).toBe('https://discord.com/api/v10/applications/111111111111111111/commands');
expect(calls[0]!.init!.method).toBe('POST');
expect(JSON.parse(calls[0]!.init!.body as string).contexts).toEqual([0, 1]);
});
test('rejects missing DM support before sending any request', async () => {
let called = false;
await expect(registerCommands('111111111111111111', 'fake-token', [{ ...command, contexts: [0] }], async () => { called = true; return Response.json({}); })).rejects.toThrow('bot DMs');
expect(called).toBe(false);
});
test('stops on rate limits and does not expose response bodies', async () => {
await expect(registerCommands('111111111111111111', 'fake-token', [command], async () => new Response('private provider data', { status: 429, headers: { 'Retry-After': '5' } }))).rejects.toThrow('HTTP 429');
});

View File

@@ -0,0 +1,26 @@
export type CommandDefinition = { name: string; description: string; type?: number; contexts: number[]; integration_types: number[]; options?: unknown[] };
type Transport = (url: string, init?: RequestInit) => Promise<Response>;
/** Upsert only Minabot's commands. Never bulk-overwrite unrelated application commands. */
export async function registerCommands(applicationId: string, token: string, definitions: CommandDefinition[], request: Transport = fetch) {
if (!/^\d{17,20}$/.test(applicationId) || !token.trim()) throw new Error('Set DISCORD_CLIENT_ID and DISCORD_BOT_TOKEN.');
const names = new Set<string>();
for (const command of definitions) {
if (names.has(command.name)) throw new Error(`Duplicate command: ${command.name}`);
names.add(command.name);
if (!command.contexts.includes(0) || !command.contexts.includes(1)) throw new Error(`${command.name} must support servers and bot DMs.`);
}
const registered: string[] = [];
for (const command of definitions) {
const response = await request(`https://discord.com/api/v10/applications/${applicationId}/commands`, {
method: 'POST', headers: { Authorization: `Bot ${token.trim().replace(/^Bot\s+/i, '')}`, 'Content-Type': 'application/json' },
body: JSON.stringify(command), redirect: 'error', signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
// Provider payloads may contain sensitive data; report status only.
const retry = response.status === 429 ? ` Retry after ${response.headers.get('Retry-After') ?? 'a few'} seconds.` : '';
throw new Error(`Registration of /${command.name} failed (HTTP ${response.status}).${retry} Already registered: ${registered.join(', ') || 'none'}. Rerunning safely upserts commands.`);
}
registered.push(command.name);
}
return registered;
}

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
describe('signed Discord interactions', () => {
test('registers commands globally for servers and bot DMs', () => {
for (const c of commands) { expect(c.contexts).toEqual([0, 1]); expect(c.integration_types).toEqual([0]); }
});
test('rejects forged and old signatures and answers Discord PING', async () => {
f = discordFixture();
expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Ed25519': '0'.repeat(128) })).status).toBe(401);
expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Timestamp': '1000000000' })).status).toBe(401);
expect(await f.run({ type: 1 })).toEqual({ type: 1 });
expect((await f.requestDiscord(f.payload({ application_id: 'wrong' }))).status).toBe(400);
});
for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201);
const today = await f.run({ data: { name: 'today' } }, guild);
expect(today.data.flags).toBe(guild ? 64 : undefined);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild);
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
f.restart(); await f.requestDiscord(click);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
expect(f.changed).toEqual(['alice']);
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild);
expect(failed.data.content).toContain('Progress changed');
});
test('owner-bound buttons and habit IDs prevent cross-account access', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
const a = await f.run({ data: { name: 'today' } });
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob);
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const b = await f.run({ data: { name: 'today' } }, false, f.bob);
expect(b.data.content).not.toContain('Private');
});
test('old daily buttons cannot correct history accidentally', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const t = await f.run({ data: { name: 'today' } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } });
f.setDiscordTime('2026-09-05T12:00:00Z');
expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
});

65
src/discord/routes.ts Normal file
View File

@@ -0,0 +1,65 @@
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 { message } from './ui';
import { help } from './help';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string };
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
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));
const reply = db.transaction((): Reply => {
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 receipt.response;
const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const controls = new ControlStore(db, userId, now()); controls.prune();
const service = new HabitService(db, user, now()); service.sync();
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, changed: () => changed(userId) };
let result: Reply;
try {
if (i.type === 4) result = { type: 8, data: { choices: [] } };
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? '');
result = dailyAction(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;
});
return c.json(reply);
});
return app;
}

29
src/discord/state.ts Normal file
View File

@@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto';
import { eq, lt } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import { discordControls, discordReceipts } from '../db/schema';
import { ApiError } from '../habits/service';
import type { ControlData } from './types';
export class ControlStore {
constructor(readonly db: AppDatabase, readonly userId: string, readonly now: number) {}
create(action: string, payload: ControlData = {}, lifetime = 15 * 60000) {
const id = randomBytes(18).toString('base64url');
this.db.insert(discordControls).values({ id, userId: this.userId, action, payload, expiresAt: this.now + lifetime }).run();
return `mina:${id}`;
}
read(customId: string) {
const row = this.db.select().from(discordControls).where(eq(discordControls.id, customId.replace(/^mina:/, ''))).get();
if (!customId.startsWith('mina:') || !row || row.userId !== this.userId || row.expiresAt <= this.now || row.used)
throw new ApiError(409, 'This control expired or belongs to another user. Run the command again.');
return row;
}
consume(customId: string) {
const row = this.read(customId);
this.db.update(discordControls).set({ used: true }).where(eq(discordControls.id, row.id)).run();
}
prune() {
this.db.delete(discordControls).where(lt(discordControls.expiresAt, this.now)).run();
this.db.delete(discordReceipts).where(lt(discordReceipts.expiresAt, this.now)).run();
}
}

View File

@@ -0,0 +1,35 @@
import { generateKeyPairSync, sign } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from '../habits/test-fixture';
import { users } from '../db/schema';
import { createDiscordRoutes } from './routes';
import type { Interaction } from './types';
export function discordFixture() {
const f = fixture();
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
let time = Date.parse('2026-09-04T12:00:00Z'), sequence = 10000000000000000n;
const alice = '111111111111111111', bob = '222222222222222222', applicationId = '333333333333333333';
f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run();
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
const changed: string[] = [];
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id));
function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
const profile = { id: user, username: user === alice ? 'alice' : 'bob' };
return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
}
async function request(i: Interaction, headers: Record<string, string> = {}) {
const body = JSON.stringify(i), timestamp = String(time / 1000);
return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body });
}
async function run(data: Partial<Interaction>, guild = false, user = alice): Promise<any> { return (await request(payload(data, guild, user))).json(); }
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
return item.custom_id;
}
export function submit(custom_id: string, values: Record<string, string>) {
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
}

23
src/discord/types.ts Normal file
View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
import type { AppDatabase } from '../auth';
import type { HabitService } from '../habits/service';
import type { ControlStore } from './state';
const option: z.ZodType<Option> = z.lazy(() => z.object({ name: z.string(), type: z.number(), value: z.union([z.string(), z.number(), z.boolean()]).optional(), focused: z.boolean().optional(), options: z.array(option).optional() }));
export type Option = { name: string; type: number; value?: string | number | boolean; focused?: boolean; options?: Option[] };
const profile = z.object({ id: z.string().regex(/^\d{17,20}$/), username: z.string().max(100), global_name: z.string().max(100).nullish(), avatar: z.string().max(100).nullish() });
const component: z.ZodType<ComponentInput> = z.lazy(() => z.object({ type: z.number(), custom_id: z.string().optional(), value: z.string().optional(), values: z.array(z.string()).optional(), components: z.array(component).optional() }));
type ComponentInput = { type: number; custom_id?: string; value?: string; values?: string[]; components?: ComponentInput[] };
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; changed: () => void };
export function fields(i: Interaction) {
const result: Record<string, string> = {};
const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); });
walk(i.data?.components ?? []); return result;
}
export const options = (i: Interaction) => Object.fromEntries((i.data?.options ?? []).map(o => [o.name, o.value]));

21
src/discord/ui.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Context, ControlData, DiscordComponent, Reply } from './types';
export const text = (s: string) => s.replace(/([\\`*_~|>])/g, '\\$1').replace(/@/g, '@\u200b');
export const row = (...components: DiscordComponent[]): DiscordComponent => ({ type: 1, components });
export const button = (c: Context, label: string, action: string, payload: ControlData = {}, style = 2): DiscordComponent => ({ type: 2, label, style, custom_id: c.controls.create(action, payload) });
export function message(c: Context, content: string, components: DiscordComponent[] = [], update = true): Reply {
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
return { type: edit ? 7 : 4, data: { content: content.slice(0, 2000), components, allowed_mentions: { parse: [] }, ...(!edit && c.interaction.guild_id ? { flags: 64 } : {}) } };
}
export type Field = { id: string; label: string; value?: string; placeholder?: string; required?: boolean; max?: number };
export function modal(c: Context, title: string, action: string, data: ControlData, fields: Field[]): Reply {
return { type: 9, data: { custom_id: c.controls.create(action, data), title: title.slice(0, 45), components: fields.map(f => row({ type: 4, custom_id: f.id, label: f.label.slice(0, 45), style: 1, required: f.required ?? true, max_length: f.max ?? 200, ...(f.value !== undefined ? { value: f.value } : {}), ...(f.placeholder ? { placeholder: f.placeholder.slice(0, 100) } : {}) })) } };
}
export function select(c: Context, placeholder: string, action: string, data: ControlData, items: { id: string; name: string; description?: string }[]) {
return row({ type: 3, custom_id: c.controls.create(action, data), placeholder, options: items.slice(0, 25).map(i => ({ label: i.name.slice(0, 100) || 'Untitled', value: i.id, ...(i.description ? { description: i.description.slice(0, 100) } : {}) })) });
}
export function pages(c: Context, action: string, data: ControlData, total: number, size = 10) {
const page = data.page ?? 0, buttons: DiscordComponent[] = [];
if (page > 0) buttons.push(button(c, 'Previous', action, { ...data, page: page - 1 }));
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
return buttons.length ? [row(...buttons)] : [];
}

View File

@@ -20,7 +20,7 @@ if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined; const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop()); if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) }); const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing()); if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());