Compare commits
3 Commits
8e846fa11b
...
2671fe0f34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2671fe0f34 | ||
|
|
0a34142b3e | ||
|
|
4f38bf1431 |
@@ -25,3 +25,6 @@ BACKUP_RETAIN=7
|
||||
BACKUP_REPLICA_DIR=
|
||||
# Optional public HTTPS health URL for the independent monitor.
|
||||
MONITOR_URL=
|
||||
# Discord Developer Portal > General Information > Public Key.
|
||||
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
|
||||
DISCORD_PUBLIC_KEY=
|
||||
|
||||
20
drizzle/0012_discord_commands.sql
Normal file
20
drizzle/0012_discord_commands.sql
Normal 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`);
|
||||
1217
drizzle/meta/0012_snapshot.json
Normal file
1217
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1788845281801,
|
||||
"tag": "0011_multiple_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1788847978168,
|
||||
"tag": "0012_discord_commands",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
10
scripts/register-discord-commands.ts
Normal file
10
scripts/register-discord-commands.ts
Normal 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.');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
|
||||
import { createLiveDiscord } from "./sharing/live-discord";
|
||||
import { createReminderRoutes } from "./reminders/routes";
|
||||
import { errorDetails } from "./ops/monitor";
|
||||
@@ -12,7 +13,7 @@ import type { FetchDiscord } from "./auth/discord";
|
||||
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||
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>();
|
||||
app.use("*", async (c, next) => {
|
||||
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);
|
||||
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/auth", auth.routes);
|
||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||
|
||||
@@ -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 {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
|
||||
@@ -101,3 +101,15 @@ export const liveDiscordCards = sqliteTable('live_discord_cards', {
|
||||
retryAt: integer('retry_at'),
|
||||
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
26
src/discord/README.md
Normal 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
5
src/discord/commands.ts
Normal 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
50
src/discord/daily.ts
Normal 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
33
src/discord/help.test.ts
Normal 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
22
src/discord/help.ts
Normal 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}/` }] }],
|
||||
} };
|
||||
}
|
||||
18
src/discord/registration.test.ts
Normal file
18
src/discord/registration.test.ts
Normal 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');
|
||||
});
|
||||
26
src/discord/registration.ts
Normal file
26
src/discord/registration.ts
Normal 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;
|
||||
}
|
||||
47
src/discord/routes.test.ts
Normal file
47
src/discord/routes.test.ts
Normal 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
65
src/discord/routes.ts
Normal 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
29
src/discord/state.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/discord/test-fixture.ts
Normal file
35
src/discord/test-fixture.ts
Normal 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
23
src/discord/types.ts
Normal 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
21
src/discord/ui.ts
Normal 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)] : [];
|
||||
}
|
||||
@@ -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;
|
||||
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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user