feat: add signed Discord command foundation and daily check-ins
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
|||||||
21
src/discord/README.md
Normal file
21
src/discord/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 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.
|
||||||
4
src/discord/commands.ts
Normal file
4
src/discord/commands.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Global commands are required for bot DMs; guild-only registrations cannot run there.
|
||||||
|
export const commands = [
|
||||||
|
{ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/discord/routes.ts
Normal file
62
src/discord/routes.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 /today to get started.', [], false);
|
||||||
|
} else result = message(ctx, 'Run /today to get started.', [], 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;
|
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());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user