feat: add private Discord progress and explicit shared delivery controls
This commit is contained in:
@@ -22,6 +22,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
});
|
||||
const auth = createAuth(db, config, request, now);
|
||||
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
|
||||
@@ -33,14 +34,14 @@ 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 }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed));
|
||||
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
|
||||
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));
|
||||
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
||||
app.route("/api/sharing", sharing);
|
||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||
app.onError((_error, c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
|
||||
@@ -59,3 +59,16 @@ the website and reminder worker. Direct route fixtures default it to false.
|
||||
No new environment variables, migrations, outbound requests, or remote command
|
||||
registration are needed by this module. Global command registration remains an
|
||||
integration step. Successful mutations invoke the supplied live-card dirty hook.
|
||||
`progress.ts` implements `/progress`: six-habit text pages, focused private
|
||||
previews, explicit destination confirmation for snapshots, and live-image
|
||||
start/status/pause/resume. API composition injects the same `sharing.send` and
|
||||
`live.start/pause/state` operations used by website routes. Keep these shared
|
||||
instances when reconciling dispatch; do not construct a second live worker.
|
||||
|
||||
Rendering and network actions return ephemeral type-5 acknowledgements and edit
|
||||
the original interaction response once. `DeferredDispatch.complete` and
|
||||
`schedule` are injectable for tests. Interaction tokens live only in memory,
|
||||
never receipts or controls. A process interruption after acknowledgement can
|
||||
leave the private response unfinished; restarting never replays the public send.
|
||||
Run `/progress` again to inspect status. Public snapshot and live deliveries
|
||||
retain their existing persistent quotas, nonce and uncertain-delivery guards.
|
||||
|
||||
@@ -5,6 +5,7 @@ export const commands = [
|
||||
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
|
||||
{ name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' },
|
||||
{ name: 'settings', description: 'Set your timezone and view delivery settings' },
|
||||
{ name: 'progress', description: 'Preview progress and manage public snapshot or live sharing' },
|
||||
{ name: 'today', description: 'View and complete today’s habits' },
|
||||
{ name: 'history', description: 'View and correct habit progress for a calendar date' },
|
||||
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));
|
||||
|
||||
114
src/discord/progress.test.ts
Normal file
114
src/discord/progress.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { createAuth } from '../auth';
|
||||
import { createSharingRoutes } from '../sharing/routes';
|
||||
import { createLiveDiscord } from '../sharing/live-discord';
|
||||
import { discordFixture, control } from './test-fixture';
|
||||
import type { DeferredMessage } from './progress';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
let stop = () => {};
|
||||
afterEach(() => { stop(); f?.close(); });
|
||||
function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean } = {}) {
|
||||
const jobs: (() => Promise<void>)[] = [], replies: DeferredMessage[] = [], sends: RequestInit[] = [];
|
||||
f = discordFixture((base, now) => {
|
||||
const config = { token: 'test-bot-token', channelId: '555555555555555555', allowedUserIds: settings.allowed, writesEnabled: settings.writes ?? true };
|
||||
const request = async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST' || init?.method === 'PATCH') { sends.push(init); if (settings.fail) throw new Error('uncertain'); return Response.json({ id: '777777777777777777' }); }
|
||||
return Response.json({ id: config.channelId, guild_id: '666666666666666666', type: 0, name: 'habit-sharing' });
|
||||
};
|
||||
const auth = createAuth(base.db, { origin: base.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, now);
|
||||
const sharing = createSharingRoutes(base.db, auth, config, now, request), live = createLiveDiscord(base.db, auth, config, now, request);
|
||||
stop = live.stop;
|
||||
return { progress: { sharing, live }, schedule: job => jobs.push(job), complete: async (_i, result) => { replies.push(result); } };
|
||||
});
|
||||
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
|
||||
}
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
|
||||
}
|
||||
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const panel = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(panel.data.content).toContain('Reading — 1/1');
|
||||
expect(panel.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const preview = await env.drain();
|
||||
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
|
||||
expect(preview.data.content).toContain('1/1');
|
||||
await click(preview, 'Choose public destination', guild);
|
||||
const confirmation = await env.drain();
|
||||
expect(confirmation.data.content).toContain('habit-sharing');
|
||||
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
|
||||
await f.requestDiscord(i); await env.drain();
|
||||
f.restart(); await f.requestDiscord(i); await env.drain();
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls', guild); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2);
|
||||
await click(panel, 'Live image controls', guild); const active = await env.drain();
|
||||
await click(active, 'Pause live image', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('paused');
|
||||
await click(panel, 'Live image controls', guild); const paused = await env.drain();
|
||||
await click(paused, 'Resume live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2); // Unchanged image keeps the original message.
|
||||
});
|
||||
test('pagination, cross-account controls, stale snapshot and uncertain delivery never resend', async () => {
|
||||
const env = setup({ fail: true });
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
expect(control(panel, 'Next')).toBeString();
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
|
||||
expect(stolen.data.content).toContain('belongs to another user');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirm = await env.drain();
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
|
||||
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
|
||||
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('could not confirm');
|
||||
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
});
|
||||
test('allowlist and disabled writes apply to command shares', async () => {
|
||||
const env = setup({ allowed: ['222222222222222222'] });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('approved community');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
|
||||
const env = setup({ writes: false });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly'); const denied = await env.drain();
|
||||
expect(denied.data.content).toContain('disabled in development');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
|
||||
const env = setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const first = await env.drain();
|
||||
await click(first, 'Share snapshot publicly'); await env.drain();
|
||||
await click(preview, 'Choose public destination'); const next = await env.drain();
|
||||
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
|
||||
expect(limited.data.content).toContain('Sharing limit reached');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
|
||||
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
});
|
||||
100
src/discord/progress.ts
Normal file
100
src/discord/progress.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { liveCardData, renderLiveCard } from '../sharing/live-card-image';
|
||||
import type { createSharingRoutes } from '../sharing/routes';
|
||||
import type { createLiveDiscord } from '../sharing/live-discord';
|
||||
import type { Context, ControlData, Message, Reply } from './types';
|
||||
import { button, message, pages, row, select, text } from './ui';
|
||||
|
||||
export type ProgressOperations = {
|
||||
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
|
||||
live: Pick<ReturnType<typeof createLiveDiscord>, 'state' | 'start' | 'pause'>;
|
||||
};
|
||||
export type DeferredMessage = { message: Message; image?: Uint8Array };
|
||||
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
function imageData(c: Context, data: ControlData) {
|
||||
const result = liveCardData(c.db, c.service.user.id, c.now);
|
||||
if (data.habitId) {
|
||||
const habits = c.service.list();
|
||||
const index = habits.findIndex(h => h.id === data.habitId);
|
||||
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
|
||||
result.habits = [result.habits[index]!];
|
||||
}
|
||||
return { ...result, avatarUrl: null };
|
||||
}
|
||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||
const original = await loadImage(await renderLiveCard(source, data.theme ?? 'light'));
|
||||
const canvas = createCanvas(1200, 675);
|
||||
canvas.getContext('2d').drawImage(original, 0, 0, 1200, 675);
|
||||
return new Uint8Array(await canvas.encode('png'));
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
||||
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
|
||||
c.defer(job);
|
||||
return { type: 5, data: { flags: 64 } };
|
||||
}
|
||||
export function progress(c: Context, data: ControlData = {}): Reply {
|
||||
const habits = c.service.list(), page = data.page ?? 0;
|
||||
const visible = habits.slice(page * 6, page * 6 + 6);
|
||||
const result = imageData(c, {});
|
||||
const state = c.progress?.live.state(c.service.user.id);
|
||||
return message(c, `**Progress · ${result.from} to ${result.to}**\n${text(c.service.user.timezone)} · Live image: ${state?.status ?? 'unavailable'}\n\n${visible.map(h => {
|
||||
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
|
||||
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`;
|
||||
}).join('\n') || 'No active habits. Create a habit on the website or with /habits.'}\n\nPreviews are private. A public share requires confirmation of the configured destination.`, [
|
||||
...(visible.length ? [select(c, 'Preview a habit', 'progress.preview', {}, visible.map(h => ({ id: h.id, name: h.name })))] : []),
|
||||
...pages(c, 'progress', data, habits.length, 6),
|
||||
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')),
|
||||
], false);
|
||||
}
|
||||
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'progress') return progress(c, data);
|
||||
if (!action.startsWith('progress.')) return;
|
||||
const ops = c.progress;
|
||||
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
|
||||
if (action === 'progress.preview') {
|
||||
const habitId = c.interaction.data?.values?.[0] ?? data.habitId;
|
||||
c.service.current(habitId!);
|
||||
const selection = { habitId, theme: data.theme ?? 'light' as const };
|
||||
return defer(c, async () => {
|
||||
const content = imageData(c, selection);
|
||||
const image = await render(c, selection, content);
|
||||
const confirmed = { ...selection, expected: fingerprint(content) };
|
||||
const stats = content.habits[0]!;
|
||||
return output(message(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }))], false), image);
|
||||
});
|
||||
}
|
||||
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
|
||||
const liveSnapshot = () => ({ state: ops.live.state(c.service.user.id), content: imageData(c, {}) });
|
||||
if (action === 'progress.confirm' || action === 'progress.live') return defer(c, async () => {
|
||||
const destination = await ops.sharing.destination(c.service.user);
|
||||
const state = ops.live.state(c.service.user.id);
|
||||
const selection = { ...data, expected: action === 'progress.confirm' ? data.expected : fingerprint(liveSnapshot()), deliveryId: crypto.randomUUID(), mode: destination.channelUrl };
|
||||
return output(message(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))], false));
|
||||
});
|
||||
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
|
||||
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
|
||||
if (fingerprint(current) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
|
||||
// Reserve the action in the same transaction as the interaction receipt, before outbound work.
|
||||
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
|
||||
c.controls.consume(c.interaction.data!.custom_id!);
|
||||
return defer(c, async () => {
|
||||
if (action !== 'progress.pause') {
|
||||
const destination = await ops.sharing.destination(c.service.user);
|
||||
if (destination.channelUrl !== data.mode) throw new ApiError(409, 'The sharing destination changed. Confirm the new destination first.');
|
||||
}
|
||||
if (fingerprint(action === 'progress.send' ? imageData(c, data) : liveSnapshot()) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
|
||||
if (action === 'progress.send') {
|
||||
const source = imageData(c, data);
|
||||
const image = await render(c, data, source);
|
||||
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
|
||||
}
|
||||
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
|
||||
return output(message(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))], false));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,26 @@ import { dailyAction, today } from './daily';
|
||||
import { manageHabits, manageTasks, managementAction } from './management';
|
||||
import { history, historyAction } from './history';
|
||||
import { preferences, preferencesAction } from './preferences';
|
||||
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
|
||||
import { message } from './ui';
|
||||
import { help } from './help';
|
||||
|
||||
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
|
||||
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
|
||||
export type DeferredDispatch = {
|
||||
progress?: ProgressOperations;
|
||||
complete?: (interaction: import('./types').Interaction, result: DeferredMessage) => Promise<void>;
|
||||
schedule?: (job: () => Promise<void>) => void;
|
||||
};
|
||||
/** Edit the deferred original exactly once. Never retry an uncertain network result. */
|
||||
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
|
||||
const body = new FormData();
|
||||
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: result.message.content?.slice(0, 1024) }] : [] };
|
||||
body.set('payload_json', JSON.stringify(payload));
|
||||
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
|
||||
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error('Deferred Discord response was rejected.');
|
||||
}
|
||||
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) {
|
||||
const app = new Hono();
|
||||
const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null;
|
||||
app.use('*', bodyLimit({ maxSize: 64 * 1024 }));
|
||||
@@ -37,6 +52,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400);
|
||||
// Help needs no account creation, habit synchronization, or saved UI state.
|
||||
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
|
||||
let pending: (() => Promise<DeferredMessage>) | undefined;
|
||||
const reply = db.transaction((): Reply => {
|
||||
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
@@ -46,18 +62,19 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
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, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId) };
|
||||
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
|
||||
let result: Reply;
|
||||
try {
|
||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
||||
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'progress') result = progress(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
|
||||
else if (i.type === 2 && ['settings', 'reminders'].includes(i.data?.name ?? '')) result = preferences(ctx, i.data!.name!);
|
||||
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
|
||||
else if ([3, 5].includes(i.type)) {
|
||||
const state = controls.read(i.data?.custom_id ?? '');
|
||||
result = preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
|
||||
result = progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} else result = message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||
@@ -66,6 +83,17 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
|
||||
return result;
|
||||
});
|
||||
if (pending) {
|
||||
const work = pending;
|
||||
const job = async () => {
|
||||
let result: DeferredMessage;
|
||||
try { result = await work(); }
|
||||
catch (error) { result = { message: { content: error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', components: [], allowed_mentions: { parse: [] } } }; }
|
||||
try { await (dispatch.complete ?? completeDeferred)(i, result); }
|
||||
catch { console.error('Discord deferred reply failed; it will not be retried.'); }
|
||||
};
|
||||
(dispatch.schedule ?? (job => { setTimeout(() => { void job(); }, 0); }))(job);
|
||||
}
|
||||
return c.json(reply);
|
||||
});
|
||||
return app;
|
||||
|
||||
@@ -2,9 +2,9 @@ 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 { createDiscordRoutes, type DeferredDispatch } from './routes';
|
||||
import type { Interaction } from './types';
|
||||
export function discordFixture(remindersAvailable = false) {
|
||||
export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>, now: () => number) => DeferredDispatch)) {
|
||||
const f = fixture();
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
|
||||
@@ -13,17 +13,19 @@ export function discordFixture(remindersAvailable = false) {
|
||||
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, remindersAvailable }, () => time, id => changed.push(id));
|
||||
const dispatch = typeof setup === "function" ? setup(f, () => time) : undefined;
|
||||
const remindersAvailable = setup === true;
|
||||
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch);
|
||||
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 };
|
||||
return { id: String(++sequence), application_id: applicationId, type: 2, token: 'test-interaction-token', ...(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, remindersAvailable }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
|
||||
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch); }, 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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeferredMessage, ProgressOperations } from './progress';
|
||||
import { z } from 'zod';
|
||||
import type { AppDatabase } from '../auth';
|
||||
import type { HabitService } from '../habits/service';
|
||||
@@ -14,7 +15,7 @@ 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; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
|
||||
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void };
|
||||
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void; progress?: ProgressOperations; defer?: (job: () => Promise<DeferredMessage>) => 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); });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from
|
||||
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: 400 | 404 | 409 | 422 | 429 | 502, message: string) { super(message); }
|
||||
constructor(public status: 400 | 403 | 404 | 409 | 422 | 429 | 502 | 503, message: string) { super(message); }
|
||||
}
|
||||
const missing = () => new ApiError(404, 'Not found');
|
||||
export type Habit = typeof habits.$inferSelect;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { liveDiscordCards, users } from "../db/schema";
|
||||
import { ApiError } from "../habits/service";
|
||||
import { endOfDay, localDate } from "../habits/calendar";
|
||||
import { liveCardData, renderLiveCard } from "./live-card-image";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
import type { DiscordFetch } from "./routes";
|
||||
|
||||
@@ -155,12 +156,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
|
||||
return c.body(new Uint8Array(image));
|
||||
});
|
||||
app.post('/', async c => {
|
||||
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
|
||||
async function start(user: PublicUser, theme: 'light' | 'dark') {
|
||||
if (config.writesEnabled === false) throw new ApiError(403, 'Discord channel writes are disabled in development. Preview and download are still available.');
|
||||
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
||||
const user = c.get('user');
|
||||
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
||||
const input = options.safeParse(await c.req.json().catch(() => null));
|
||||
if (!allowed(user.discordId)) throw new ApiError(403, 'Discord sharing is limited to approved community members.');
|
||||
const input = options.safeParse({ theme });
|
||||
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
|
||||
const existing = row(user.id);
|
||||
if (existing?.status === 'uncertain')
|
||||
@@ -171,23 +171,28 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
.onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
|
||||
}
|
||||
await flush(user.id);
|
||||
return c.json(state(user.id));
|
||||
});
|
||||
app.delete('/', async c => {
|
||||
const id = c.get('user').id;
|
||||
return state(user.id);
|
||||
}
|
||||
async function pause(id: string) {
|
||||
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
// Return only after any already-sent edit has finished.
|
||||
await running.get(id);
|
||||
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
return c.json(state(id));
|
||||
return state(id);
|
||||
}
|
||||
app.post('/', async c => {
|
||||
const input = options.safeParse(await c.req.json().catch(() => null));
|
||||
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
|
||||
return c.json(await start(c.get('user'), input.data.theme));
|
||||
});
|
||||
app.delete('/', async c => c.json(await pause(c.get('user').id)));
|
||||
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
||||
for (const current of db.select().from(liveDiscordCards).all()) {
|
||||
if (config.writesEnabled === false) break;
|
||||
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
|
||||
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
|
||||
}
|
||||
return { routes: app, changed, flush, stop };
|
||||
return { routes: app, changed, flush, stop, state, start, pause };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { discordDeliveries, sharingLimits } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { shareInput, type ShareData } from "./contracts";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
|
||||
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
@@ -98,14 +99,15 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const destination = await channel();
|
||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||
});
|
||||
app.post("/discord/send", async c => {
|
||||
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403);
|
||||
const userId = c.get("user").id;
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
||||
function assertSend(user: PublicUser) {
|
||||
if (config.writesEnabled === false) throw new ApiError(403, "Discord channel writes are disabled in development. Preview and download are still available.");
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
|
||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||
const image = form.get("image");
|
||||
}
|
||||
async function send(user: PublicUser, deliveryId: string, image: File) {
|
||||
assertSend(user);
|
||||
const userId = user.id;
|
||||
const id = z.string().uuid().safeParse(deliveryId);
|
||||
if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB.");
|
||||
const bytes = Buffer.from(await image.arrayBuffer());
|
||||
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending.");
|
||||
@@ -114,7 +116,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||
if (previous) {
|
||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
return ({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
}
|
||||
const timestamp = now();
|
||||
// Reserve both quotas and the delivery before any asynchronous outbound work.
|
||||
@@ -128,7 +130,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
|
||||
const quotas = [
|
||||
{ key: `channel:${config.channelId}`, maximum: 10 },
|
||||
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
|
||||
{ key: `channel:${config.channelId}:user:${user.discordId}`, maximum: 1 },
|
||||
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
|
||||
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
|
||||
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
|
||||
@@ -142,20 +144,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
}, { behavior: "immediate" });
|
||||
if (retryAfter === -1) {
|
||||
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
|
||||
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
return ({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
}
|
||||
if (retryAfter > 0) {
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
|
||||
throw Object.assign(new ApiError(429, `Sharing limit reached. Try again in ${retryAfter} seconds.`), { retryAfter });
|
||||
}
|
||||
const attachment = new FormData();
|
||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "mina-habits-progress.png", description: "Progress card shared from Mina Habits" }] }));
|
||||
attachment.set("files[0]", image, "mina-habits-progress.png");
|
||||
let response: Response;
|
||||
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
|
||||
catch { return c.json({ status: "uncertain" }); }
|
||||
catch { return ({ status: "uncertain" }); }
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500) return c.json({ status: "uncertain" });
|
||||
if (response.status >= 500) return ({ status: "uncertain" });
|
||||
db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
|
||||
cachedChannel = null;
|
||||
throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads).");
|
||||
@@ -163,7 +164,20 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const message = await response.json().catch(() => null) as { id?: string } | null;
|
||||
const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null;
|
||||
db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run();
|
||||
return c.json({ status: "sent", messageUrl: messageUrl ?? undefined });
|
||||
return ({ status: "sent", messageUrl: messageUrl ?? undefined });
|
||||
}
|
||||
app.post("/discord/send", async c => {
|
||||
assertSend(c.get("user"));
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
try { return c.json(await send(c.get("user"), String(form.get("deliveryId")), form.get("image") as File)); }
|
||||
catch (error) {
|
||||
if (error instanceof ApiError && 'retryAfter' in error) c.header("Retry-After", String(error.retryAfter));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return app;
|
||||
async function destination(user: PublicUser) {
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
|
||||
return channel();
|
||||
}
|
||||
return Object.assign(app, { send, destination });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user