feat: add private Discord progress and explicit shared delivery controls

This commit is contained in:
syntaxbullet
2026-09-08 08:23:55 +02:00
parent 5dbec04b04
commit a1ae65634b
11 changed files with 318 additions and 39 deletions

View File

@@ -22,6 +22,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
}); });
const auth = createAuth(db, config, request, now); const auth = createAuth(db, config, request, now);
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest); 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) => { app.use("*", async (c, next) => {
await 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")) 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); 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 }), 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/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));
app.route("/api/account", createAccountRoutes(db, auth, 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.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now)); 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.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => { app.onError((_error, c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");

View File

@@ -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 No new environment variables, migrations, outbound requests, or remote command
registration are needed by this module. Global command registration remains an registration are needed by this module. Global command registration remains an
integration step. Successful mutations invoke the supplied live-card dirty hook. 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.

View File

@@ -5,6 +5,7 @@ export const commands = [
{ name: 'habits', description: 'Create, edit, archive and restore your habits' }, { name: 'habits', description: 'Create, edit, archive and restore your habits' },
{ name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' }, { name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' },
{ name: 'settings', description: 'Set your timezone and view delivery settings' }, { 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: 'today', description: 'View and complete today’s habits' },
{ name: 'history', description: 'View and correct habit progress for a calendar date' }, { name: 'history', description: 'View and correct habit progress for a calendar date' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

View 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
View 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));
});
}
}

View File

@@ -12,11 +12,26 @@ import { dailyAction, today } from './daily';
import { manageHabits, manageTasks, managementAction } from './management'; import { manageHabits, manageTasks, managementAction } from './management';
import { history, historyAction } from './history'; import { history, historyAction } from './history';
import { preferences, preferencesAction } from './preferences'; import { preferences, preferencesAction } from './preferences';
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
import { message } from './ui'; import { message } from './ui';
import { help } from './help'; import { help } from './help';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean }; 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 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; 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.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); 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. // 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)); 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 reply = db.transaction((): Reply => {
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID(); 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 user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const controls = new ControlStore(db, userId, now()); controls.prune(); const controls = new ControlStore(db, userId, now()); controls.prune();
const service = new HabitService(db, user, now()); service.sync(); 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; let result: Reply;
try { try {
if (i.type === 4) result = { type: 8, data: { choices: [] } }; 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 === 'tasks') result = manageTasks(ctx);
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(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 && 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 && ['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 (i.type === 2 && i.data?.name === 'history') result = history(ctx);
else if ([3, 5].includes(i.type)) { else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? ''); 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); } else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw 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(); db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result; 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 c.json(reply);
}); });
return app; return app;

View File

@@ -2,9 +2,9 @@ import { generateKeyPairSync, sign } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { fixture } from '../habits/test-fixture'; import { fixture } from '../habits/test-fixture';
import { users } from '../db/schema'; import { users } from '../db/schema';
import { createDiscordRoutes } from './routes'; import { createDiscordRoutes, type DeferredDispatch } from './routes';
import type { Interaction } from './types'; 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 f = fixture();
const { publicKey, privateKey } = generateKeyPairSync('ed25519'); const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex'); 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: alice }).where(eq(users.id, 'alice')).run();
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run(); f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
const changed: string[] = []; 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 { function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
const profile = { id: user, username: user === alice ? 'alice' : 'bob' }; 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> = {}) { async function request(i: Interaction, headers: Record<string, string> = {}) {
const body = JSON.stringify(i), timestamp = String(time / 1000); 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 }); 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(); } 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 { 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); const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);

View File

@@ -1,3 +1,4 @@
import type { DeferredMessage, ProgressOperations } from './progress';
import { z } from 'zod'; import { z } from 'zod';
import type { AppDatabase } from '../auth'; import type { AppDatabase } from '../auth';
import type { HabitService } from '../habits/service'; 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 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 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 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) { export function fields(i: Interaction) {
const result: Record<string, string> = {}; 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); }); 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); });

View File

@@ -6,7 +6,7 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts'; import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
export class ApiError extends Error { 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'); const missing = () => new ApiError(404, 'Not found');
export type Habit = typeof habits.$inferSelect; export type Habit = typeof habits.$inferSelect;

View File

@@ -8,6 +8,7 @@ import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service"; import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar"; import { endOfDay, localDate } from "../habits/calendar";
import { liveCardData, renderLiveCard } from "./live-card-image"; import { liveCardData, renderLiveCard } from "./live-card-image";
import type { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config"; import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes"; 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"'); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image)); return c.body(new Uint8Array(image));
}); });
app.post('/', async c => { async function start(user: PublicUser, theme: 'light' | 'dark') {
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403); 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.'); if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
const user = c.get('user'); if (!allowed(user.discordId)) throw new ApiError(403, 'Discord sharing is limited to approved community members.');
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403); const input = options.safeParse({ theme });
const input = options.safeParse(await c.req.json().catch(() => null));
if (!input.success) throw new ApiError(422, 'Choose a card theme.'); if (!input.success) throw new ApiError(422, 'Choose a card theme.');
const existing = row(user.id); const existing = row(user.id);
if (existing?.status === 'uncertain') 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(); .onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
} }
await flush(user.id); await flush(user.id);
return c.json(state(user.id)); return state(user.id);
}); }
app.delete('/', async c => { async function pause(id: string) {
const id = c.get('user').id;
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' }); if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id); clearTimeout(timers.get(id)); timers.delete(id);
// Return only after any already-sent edit has finished. // Return only after any already-sent edit has finished.
await running.get(id); await running.get(id);
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' }); if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id); 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. // Recover only known message edits. Never risk duplicating an interrupted first post.
for (const current of db.select().from(liveDiscordCards).all()) { for (const current of db.select().from(liveDiscordCards).all()) {
if (config.writesEnabled === false) break; 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.' }); 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())); 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 };
} }

View File

@@ -8,6 +8,7 @@ import { discordDeliveries, sharingLimits } from "../db/schema";
import { HabitService, ApiError } from "../habits/service"; import { HabitService, ApiError } from "../habits/service";
import { shade } from "../habits/calendar"; import { shade } from "../habits/calendar";
import { shareInput, type ShareData } from "./contracts"; import { shareInput, type ShareData } from "./contracts";
import type { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config"; import type { DiscordSharingConfig } from "./config";
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>; 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(); const destination = await channel();
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl }); return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
}); });
app.post("/discord/send", async c => { function assertSend(user: PublicUser) {
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403); if (config.writesEnabled === false) throw new ApiError(403, "Discord channel writes are disabled in development. Preview and download are still available.");
const userId = c.get("user").id; if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server."); 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")); async function send(user: PublicUser, deliveryId: string, image: File) {
const image = form.get("image"); 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."); 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()); 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."); 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(); const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
if (previous) { if (previous) {
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again."); 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(); const timestamp = now();
// Reserve both quotas and the delivery before any asynchronous outbound work. // 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(); tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
const quotas = [ const quotas = [
{ key: `channel:${config.channelId}`, maximum: 10 }, { 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() })); ].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); 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))); 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" }); }, { behavior: "immediate" });
if (retryAfter === -1) { if (retryAfter === -1) {
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!; 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) { if (retryAfter > 0) {
c.header("Retry-After", String(retryAfter)); throw Object.assign(new ApiError(429, `Sharing limit reached. Try again in ${retryAfter} seconds.`), { retryAfter });
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
} }
const attachment = new FormData(); 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("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"); attachment.set("files[0]", image, "mina-habits-progress.png");
let response: Response; let response: Response;
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); } 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.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(); db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
cachedChannel = null; 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)."); 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 message = await response.json().catch(() => null) as { id?: string } | null;
const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : 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(); 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 });
} }