Compare commits

..

8 Commits

29 changed files with 1609 additions and 76 deletions

View File

@@ -48,7 +48,7 @@ const app = createApi(
let reminderRunning = false;
if (sharingPreview) setInterval(async () => {
if (reminderRunning) return; reminderRunning = true;
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
finally { reminderRunning = false; }
}, 1000).unref();
const server = Bun.serve({

View File

@@ -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 }, 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");

View File

@@ -11,16 +11,99 @@ responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
expire after 15 minutes, and mutation controls are consumed after successful use.
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
`daily.ts` implements the initial `/today` workflow. `types.ts`, `ui.ts`, and
`daily.ts` implements the `/today` workflow. `types.ts`, `ui.ts`, and
`state.ts` provide the shared feature building blocks. Feature tasks extend command
registration and route dispatch while keeping their behavior in separate modules.
Tests use real Ed25519 signatures and disposable migrated SQLite databases. The
foundation tests cover server/DM privacy, replay after restart, stale progress,
cross-account controls, and matching website state. Command registration and live
Discord validation will be completed during integration.
cross-account controls, and matching website state. All feature modules are now
composed in the shared API. End-to-end user interaction in Discord remains a
manual acceptance check after deployment and registration.
`/help` lists the current command definitions automatically, explains check-in
controls and privacy, and links to the website. It works before account setup and
does not create an account or synchronize habits. Re-run the registration script
with `--apply` after deployment to expose newly added commands in Discord.
Reminder delivery accepts `commandsAvailable`; production enables it when
`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set.
The existing bot token still controls delivery. Configured reminders include an
owner-bound `today` control with `mode: 'reminder'`: it opens a separate private
flow, leaving the reminder unchanged, and expires at the sending account's next
local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules
are unchanged. The dashboard preview explicitly disables buttons because it has
no signed interaction endpoint configured. Global command registration and the
Discord interaction endpoint must be configured separately before production use.
## Preferences
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
paginate ten named schedules at a time; entries with matching name and schedule
are edited together as multiple comma-separated 24-hour times. Forms accept
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
all schedules. Removing the last schedule disables delivery and stores an empty
list; the shared contract permits empty lists only while disabled. Invalid forms
provide a prefilled retry button. UTC accounts receive a timezone setup hint.
`ReminderService` is shared with the website routes. Every save advances its
`updatedAt` monotonically; Discord controls compare the saved preference snapshot
and the user's timezone/update version before editing. `HabitService.setTimezone`
also advances the user timestamp monotonically, including repeated writes in the
same millisecond. This rejects old Discord forms after edits from either interface,
including change-and-revert sequences. Website PUT retains its existing request
contract. Delivery receipts do not invalidate preference controls.
Integration: retain the `preferences` command dispatch and `preferencesAction`
component dispatch in `routes.ts`, the two definitions in `commands.ts`, and the
preference fields in `ControlData`. `CommandConfig.remindersAvailable` supplies
availability; `createApi` derives it from the existing sharing bot token, matching
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.
`progress-image.ts` renders the focused 30-day snapshot at 1200×860 with bundled
Mina Sans / Instrument Serif, shared theme colors, and existing habit shades.
The 30 cells retain fixed readable sizes; symbols are Canvas paths. Text summaries
and buttons accompany the PNG. To produce review images without sending messages:
`PROGRESS_IMAGE_REVIEW_DIR=/tmp/minabot-progress-review bun test src/discord/progress-image.test.ts`.
## Integrated commands
All commands use global registration with server and bot-DM contexts. `/help`
reads this same registration metadata, so its command list stays in sync.
| Command | Function |
| --- | --- |
| `/help` | Command guide and website link |
| `/today` | Daily manual/count/task check-ins |
| `/habits` | Create, edit, schedule, archive, restore, permanently delete |
| `/tasks` | Add, rename, schedule, delete routine tasks |
| `/history` | Inspect and correct dated progress, including archived habits |
| `/reminders` | Named schedules, multiple times, quiet hours, enable/disable |
| `/settings` | Timezone and reminder delivery status |
| `/progress` | Private graphics, confirmed snapshot sharing, live-image controls |
After the deployment controller installs a release, register its definitions:
```sh
cd /opt/minabot
sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply
```
Registration upserts individual global commands and preserves unrelated commands.
The public key and interactions URL are one-time setup; adding commands requires
rerunning registration after their handlers have deployed.

View File

@@ -1,5 +1,11 @@
// Global commands are required for bot DMs; guild-only registrations cannot run there.
export const commands = [
{ name: 'help', description: 'Show commands and how to use Minabot' },
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
{ 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] }));

112
src/discord/daily.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { discordFixture, control, submit } from './test-fixture';
import { discordControls, reminderSettings } from '../db/schema';
import { defaultReminder } from '../reminders/contracts';
import { sendDueReminders } from '../reminders/worker';
import { reminderCheckIn } from './daily';
import { ControlStore } from './state';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
async function open(habitId: string, guild = false) {
const t = await f.run({ data: { name: 'today' } }, guild);
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
}
for (const guild of [false, true]) {
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
d = await f.run(click(control(d, '+1')), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
let m = await f.run(click(control(d, 'Enter amount')), guild);
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
m = await f.run(click(control(invalid, 'Try again')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
expect(d.data.content).toContain('12/10');
m = await f.run(click(control(d, 'Enter amount')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
expect(d.data.content).toContain('Incomplete');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
expect(f.changed).toHaveLength(3);
});
test(`manual and paginated task completion reverse and reflect website edits (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const manual = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
let d = await open(manual.id, guild);
d = await f.run(click(control(d, 'Mark complete')), guild);
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(true);
d = await f.run(click(control(d, 'Mark incomplete')), guild);
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(false);
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
d = await open(h.id, guild);
d = await f.run(click(control(d, 'Next')), guild);
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
// Website task changes invalidate an already-open Discord selection.
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(stale.data.content).toContain('Progress changed');
d = await f.run(click(control(stale, 'Refresh')), guild);
expect(d.data.content).toContain('11/11 tasks · Complete');
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await open(manual.id, guild)).data.content).toContain('Complete');
});
}
test('habit pagination clamps after website archives', async () => {
f = discordFixture(); const habits = [];
for (let n = 0; n < 11; n++) habits.push(await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201));
const t = await f.run({ data: { name: 'today' } });
const next = control(t, 'Next');
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const refreshed = await f.run(click(next));
expect(refreshed.data.content).toContain('Habit 0');
});
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const d = await open(h.id);
f.setDiscordTime('2026-09-04T22:00:00Z');
const expired = await f.run(click(control(d, 'Mark complete')));
expect(expired.data.content).toContain('day changed');
const fresh = await f.run(click(control(expired, 'Today')));
expect(fresh.data.content).toContain('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T18:00:00Z');
await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
const sent: any[] = []; const now = Date.parse('2026-09-04T18:00:00Z');
const send = () => sendDueReminders(f.db, { token: 'fake', origin: f.origin, commandsAvailable: true }, async (url, init) => {
if (url.endsWith('/users/@me/channels')) return Response.json({ id: '223456789012345678' });
sent.push(JSON.parse(String(init?.body))); return Response.json({ id: '323456789012345678' });
}, () => now);
await Promise.all([send(), send()]); await send(); expect(sent).toHaveLength(1);
const id = control({ data: sent[0] }, 'Check in');
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
for (const guild of [false, true]) {
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
expect(flow.data.content).toContain('Walk');
}
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
});
test('reminder expiry follows DST boundary', () => {
f = discordFixture();
const now = Date.parse('2026-10-25T00:30:00Z');
const components = reminderCheckIn(f.db, 'alice', 'Europe/Belgrade', now);
const id = control({ data: { components } }, 'Check in');
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
});

View File

@@ -1,24 +1,35 @@
import { ZodError } from 'zod';
import { ApiError } from '../habits/service';
import { endOfDay, localDate } from '../habits/calendar';
import { ControlStore } from './state';
import type { AppDatabase } from '../auth';
import { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
const date = localDate(now, timezone);
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
}
export function today(c: Context, data: ControlData = {}): Reply {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
const page = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10);
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
}
export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
const d = c.service.day(data.habitId!, date);
const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) };
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
const state: ControlData = { ...data, expected: snapshot(d) };
const controls = [];
if (d.due && !d.future) {
if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1)));
if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'count') controls.push(row(...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), ...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'tasks') {
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10);
controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` }))));
@@ -26,17 +37,18 @@ export function detail(c: Context, data: ControlData): Reply {
}
}
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today')));
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
}
export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined {
function action(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'today') return today(c, data);
if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] });
if (action === 'daily.select') return detail(c, { ...data, page: 0, mode: undefined, habitId: c.interaction.data?.values?.[0] });
if (action === 'daily.detail') return detail(c, data);
if (action === 'daily.amount' && data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]);
if (action === 'daily.save' || action === 'daily.task') {
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
const d = c.service.day(data.habitId!, data.date!);
if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
if (!task) throw new ApiError(404, 'Task not found.');
@@ -48,3 +60,14 @@ export function dailyAction(c: Context, action: string, data: ControlData): Repl
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
}
}
export function dailyAction(c: Context, name: string, data: ControlData): Reply | undefined {
try { return action(c, name, data); }
catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
const invalid = error instanceof ZodError;
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
[row(recover, button(c, 'Today', 'today'))], false);
}
}

199
src/discord/history.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { control, discordFixture, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const items = (reply: any): any[] => reply.data.components.flatMap((r: any) => r.components ?? []);
async function click(reply: any, label: string, guild = false, values?: string[], user?: string) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild, user);
}
async function open(habitId: string, date: string, guild = false) {
const list = await f.run({ data: { name: 'history' } }, guild);
const form = await click(list, 'Choose a habit', guild, [habitId]);
expect(form.type).toBe(9);
return f.run(submit(form.data.custom_id, { date }), guild);
}
async function changeDate(reply: any, date: string, guild = false) {
const form = await click(reply, 'Change date', guild);
return f.run(submit(form.data.custom_id, { date }), guild);
}
function readOnly(reply: any) {
expect(reply.data.content).toContain('Read-only');
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
}
describe('/history', () => {
test('is global and available in both interaction contexts', () => {
expect(commands.find(c => c.name === 'history')).toMatchObject({ type: 1, contexts: [0, 1], integration_types: [0] });
});
for (const guild of [false, true]) test(`corrects archived count/manual/task history from ${guild ? 'server' : 'DM'} using dated website requirements`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 5, unit: 'pages' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 20, unit: 'chapters' });
f.setDiscordTime('2026-09-06T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
f.setDiscordTime('2026-09-07T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'tasks' });
const task = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'Original task' }, 201);
f.setDiscordTime('2026-09-08T12:00:00Z');
await f.json(`/habits/${h.id}/tasks/${task.id}`, 'PATCH', { name: 'New task', schedule: { type: 'weekdays', days: [1] } });
f.setDiscordTime('2026-09-09T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const list = await f.run({ data: { name: 'history' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
expect(items(list)[0].options[0].description).toContain('Archived');
let d = await open(h.id, '2026-09-04', guild);
expect(d.data.content).toContain('0/5 pages');
const amount = await click(d, 'Enter amount', guild);
d = await f.run(submit(amount.data.custom_id, { amount: '4' }), guild);
expect(d.data.content).toContain('4/5 pages');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('0/20 chapters');
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Checkbox');
d = await click(d, 'Mark complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true);
d = await click(d, 'Mark incomplete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Original task');
expect(d.data.content).not.toContain('New task');
d = await click(d, 'Choose a task to change its completion', guild, [task.id]);
expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true);
d = await click(d, 'Next date', guild);
readOnly(d);
d = await click(d, 'Next date', guild);
readOnly(d);
expect(d.data.content).toContain('archived on this date');
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
});
test('validates actual calendar dates and navigates read-only days and date boundaries', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201);
let d = await open(h.id, '2026-02-30');
expect(d.data.content).toContain('real calendar date');
for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) {
d = await changeDate(d, date);
expect(d.data.content).toContain('real calendar date');
}
d = await changeDate(d, '2024-02-29');
readOnly(d);
expect(d.data.content).toContain('not tracked yet');
d = await changeDate(d, '2026-09-05');
expect(d.data.content).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
f.setDiscordTime('2026-09-06T12:00:00Z');
d = await open(h.id, '2026-09-05');
readOnly(d);
expect(d.data.content).toContain('Friday');
d = await changeDate(d, '1970-01-01');
expect(items(d).some(i => i.label === 'Previous date')).toBe(false);
d = await changeDate(d, '9998-12-31');
expect(items(d).some(i => i.label === 'Next date')).toBe(false);
expect(f.changed).toEqual([]);
});
test('paginates habits and dated tasks, preserving history through task pagination and refresh', async () => {
f = discordFixture();
for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const h = await f.json('/habits', 'POST', { name: 'Checklist', method: 'tasks', tasks: Array.from({ length: 23 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
let list = await f.run({ data: { name: 'history' } });
expect(items(list)[0].options).toHaveLength(10);
list = await click(list, 'Next');
expect(items(list)[0].options).toHaveLength(3);
expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2');
const form = await click(list, 'Choose a habit', false, [h.id]);
let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }));
d = await click(d, 'Next');
expect(items(d)[0].options[0].label).toBe('○ Task 10');
expect(d.data.components.length).toBeLessThanOrEqual(5);
d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]);
expect(d.data.content).toContain('✓ Task 10');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('✓ Task 10');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true);
});
for (const guild of [false, true]) test(`rejects cross-account controls and forged selections in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201);
await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b');
const a = await f.run({ data: { name: 'history' } }, guild);
const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob);
expect(rejected.data.content).toContain('belongs to another user');
expect(rejected.data.flags).toBe(guild ? 64 : undefined);
const b = await f.run({ data: { name: 'history' } }, guild, f.bob);
expect(JSON.stringify(b)).not.toContain('Secret');
expect((await click(b, 'Choose a habit', guild, [h.id], f.bob)).data.content).toBe('Not found');
const d = await open(h.id, '2026-09-04', guild);
expect((await click(d, 'Mark complete', guild, undefined, f.bob)).data.content).toContain('belongs to another user');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
expect(f.changed).toEqual([]);
});
test('keeps stale controls, mutation consumption, replay receipts and website edits consistent', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, '2026-09-04');
const oldAmount = await click(d, 'Enter amount');
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 });
expect((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))).data.content).toContain('Progress changed');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('8/10');
const interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } });
const saved = await (await f.requestDiscord(interaction)).json();
f.restart();
expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved);
expect((await click(d, '+1')).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(9);
expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2);
expect(f.changed).toEqual(['alice']);
d = await open(h.id, '2026-09-04');
f.setDiscordTime('2026-09-05T12:16:00Z');
expect((await click(d, '+1')).data.content).toContain('expired');
});
test('uses account-local dates at midnight and retains historical record timezone', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201);
f.setDiscordTime('2026-09-04T22:05:00Z');
const list = await f.run({ data: { name: 'history' } });
const form = await click(list, 'Choose a habit', false, [h.id]);
expect(form.data.components[0].components[0].value).toBe('2026-09-05');
await f.json('/me', 'PATCH', { timezone: 'America/New_York' });
const d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Europe/Belgrade');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).timezone).toBe('Europe/Belgrade');
});
test('keeps earlier schedule requirements editable after a schedule change', async () => {
f = discordFixture();
const empty = await f.run({ data: { name: 'history' } });
expect(empty.data.content).toContain('No habits yet');
expect(items(empty)).toEqual([]);
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
let d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Every day');
d = await click(d, 'Mark complete');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true);
d = await click(d, 'Next date');
readOnly(d);
expect(d.data.content).toContain('Monday');
d = await changeDate(d, '2026-09-07');
expect(d.data.content).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false);
});
});

64
src/discord/history.ts Normal file
View File

@@ -0,0 +1,64 @@
import { addDays } from '../habits/calendar';
import { dateSchema, type Schedule } from '../habits/contracts';
import { dailyAction, detail } from './daily';
import { fields, type Context, type ControlData, type Message, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function scheduleLabel(schedule: Schedule): string {
if (schedule.type === 'daily') return 'Every day';
if (schedule.type === 'weekdays') return schedule.days.map(day => weekdays[day]).join(', ');
if (schedule.type === 'interval') return `Every ${schedule.every} days from ${schedule.anchor}`;
return `Every ${schedule.every} weeks on ${weekdays[schedule.weekday]} from ${schedule.anchor}`;
}
export function history(c: Context, data: ControlData = {}): Reply {
const all = c.service.list(true);
const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1));
const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false };
const visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**History** · ${text(c.service.user.timezone)}\nChoose a habit, then enter a calendar date. Archived habits are included.${all.length ? `\nPage ${page + 1} of ${Math.ceil(all.length / 10)}` : '\nNo habits yet. Use /habits to create one.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'history.select', state, visible.map(h => ({ id: h.id, name: h.name, description: `${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}` })))] : []), ...pages(c, 'history', state, all.length)]);
}
function dateForm(c: Context, data: ControlData): Reply {
c.service.owned(data.habitId!);
return modal(c, 'Choose history date', 'history.date', data, [{ id: 'date', label: 'Calendar date (YYYY-MM-DD)', value: data.date ?? c.service.today, max: 10 }]);
}
// Keep the dated editor and all mutation checks in daily.ts. Its opaque payloads
// preserve mode/today, so subsequent saves, forms and task pages return here.
function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
if (![4, 7].includes(reply.type)) return reply;
const d = c.service.day(data.habitId!, data.date!);
const state: ControlData = { habitId: data.habitId, date: data.date, mode: 'history', today: false, page: 0 };
const navigation = [
...(data.date! > '1970-01-01' ? [button(c, 'Previous date', 'history.detail', { ...state, date: addDays(data.date!, -1) })] : []),
...(data.date! < '9998-12-31' ? [button(c, 'Next date', 'history.detail', { ...state, date: addDays(data.date!, 1) })] : []),
button(c, 'Change date', 'history.form', state),
button(c, 'Choose habit', 'history', { date: data.date }),
];
const explanation = !d.requirements ? 'Read-only · This habit was not tracked yet.'
: d.requirements.archived ? 'Read-only · This habit was archived on this date.'
: !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : '';
const body = reply.data as Message;
const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : '';
return { ...reply, data: { ...body, content: `${requirements}${text(d.timezone)}\n${explanation ? explanation + '\n' : ''}${body.content ?? ''}`.slice(0, 2000), components: [...(body.components ?? []), row(...navigation)] } };
}
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'history') return history(c, data);
if (action === 'history.select') return dateForm(c, { ...data, habitId: c.interaction.data?.values?.[0], page: 0 });
if (action === 'history.form') return dateForm(c, data);
if (action === 'history.date') {
const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim());
if (!parsed.success) return message(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
data = { ...data, date: parsed.data, page: 0 };
action = 'history.detail';
}
if (action === 'history.detail' || (data.mode === 'history' && action.startsWith('daily.'))) {
const state = { ...data, date: dateSchema.parse(data.date), mode: 'history', today: false };
const reply = action === 'history.detail' ? detail(c, state) : dailyAction(c, action, state);
return reply && withNavigation(c, state, reply);
}
}

View File

@@ -0,0 +1,123 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
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 ? { values } : {}) } }, guild);
}
async function list(id: string, guild = false) {
const home = await f.run({ data: { name: 'tasks' } }, guild);
expect(home.data.flags).toBe(guild ? 64 : undefined);
return click(home, 'Choose a task habit', guild, [id]);
}
async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); }
async function setup() { return f.json('/habits', 'POST', { name: 'Morning', method: 'tasks' }, 201); }
for (const guild of [false, true]) test(`task add, rename, schedule and delete match website state in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture(); const habit = await setup();
const add = await click(await list(habit.id, guild), 'Add task', guild);
let detail = await f.run(submit(add.data.custom_id, { name: 'Stretch' }), guild);
let tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
expect(tasks).toHaveLength(1); expect(tasks[0].name).toBe('Stretch'); expect(tasks[0].schedule).toEqual({ type: 'daily' });
const taskId = tasks[0].id;
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${taskId}`, 'PUT', { done: true });
const rename = await click(await task(habit.id, taskId, guild), 'Rename task', guild);
detail = await f.run(submit(rename.data.custom_id, { name: 'Mobility' }), guild);
tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
expect(tasks[0].name).toBe('Mobility'); expect(tasks[0].id).toBe(taskId);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
const menu = await click(detail, 'Change task schedule', guild);
const form = await click(menu, 'Recurrence', guild, ['weekly']);
detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 });
const confirm = await click(detail, 'Delete task', guild);
expect(confirm.data.content).toContain('completion total may change');
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
await click(confirm, 'Confirm task deletion', guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).due).toBe(false);
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
});
test('task scheduling supports daily, selected weekdays, day intervals and overlap warnings', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Plan' }] }, 201);
const id = h.tasks[0].id;
let detail = await task(h.id, id);
let form = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['interval']);
detail = await f.run(submit(form.data.custom_id, { every: '3', anchor: '2026-09-01' }));
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' });
let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
detail = await click(menu, 'Select weekdays', false, ['2']);
expect(detail.data.content).toContain('never coincides');
menu = await click(detail, 'Change task schedule');
detail = await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
form = await click(detail, 'Rename task');
expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan');
});
test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Old task' }] }, 201);
const id = h.tasks[0].id;
const rename = await click(await task(h.id, id), 'Rename task');
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' });
expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed');
f.setDiscordTime('2026-09-05T12:00:00Z');
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0);
const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b');
const add = await click(await list(h.id), 'Add task');
expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another');
const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob);
expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another');
const aliceMenu = await f.run({ data: { name: 'tasks' } });
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
});
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
f = discordFixture(); const h = await setup();
const add = await click(await list(h.id), 'Add task');
const payload = f.payload(submit(add.data.custom_id, { name: 'Once' }));
await f.requestDiscord(payload); f.restart(); await f.requestDiscord(payload);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(1);
expect(f.changed).toHaveLength(1);
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201);
const first = await list(h.id);
expect(first.data.components[0].components[0].options).toHaveLength(10);
const next = await click(first, 'Next');
expect(next.data.components[0].components[0].options).toHaveLength(2);
expect(control(next, 'Previous')).toBeTruthy();
});
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
const h = await setup();
let add = await click(await list(h.id), 'Add task');
f.setDiscordTime('2026-09-04T22:01:00Z');
expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed');
add = await click(await list(h.id), 'Add task');
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' });
expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed');
const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
const home = await f.run({ data: { name: 'habits' } });
const detail = await click(home, 'Choose a habit to manage', false, [c.id]);
const form = await click(detail, 'Edit details');
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 });
expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed');
});
test('a complete checklist routine can be created entirely in Discord', async () => {
f = discordFixture();
const empty = await f.run({ data: { name: 'tasks' } }, true);
const methods = await click(empty, 'Create habit', true);
const form = await click(methods, 'Tracking method', true, ['tasks']);
const habit = await f.run(submit(form.data.custom_id, { name: 'Evening', color: '#abcdef' }), true);
const tasks = await click(habit, 'Manage tasks', true);
const add = await click(tasks, 'Add task', true);
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true);
expect(task.data.content).toContain('Prepare tomorrow');
const website = (await f.json('/habits')).habits[0];
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
const settings = await f.json(`/habits/${website.id}/calendar-settings`);
expect(settings.mainColor).toBe('#abcdef');
const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
const taskMenu = await list(website.id);
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]);
expect(blocked.data.content).toContain('available options');
});

View File

@@ -0,0 +1,98 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
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 ? { values } : {}) } }, guild);
}
async function open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
async function create(method: string, guild = false) {
const list = await f.run({ data: { name: 'habits' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
const methodMenu = await click(list, 'Create habit', guild);
const form = await click(methodMenu, 'Tracking method', guild, [method]);
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
expect(result.data.content).toContain('Read');
return { result, habit: (await f.json('/habits')).habits[0] };
}
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture(); const { result, habit } = await create('count', guild);
expect(habit.method).toBe('count'); expect(habit.target).toBe(10);
let detail = await click(result, 'Enable carryover', guild);
expect((await f.json('/habits')).habits[0].carryPartialProgress).toBe(true);
const edit = await click(detail, 'Edit details', guild);
detail = await f.run(submit(edit.data.custom_id, { name: 'Books', target: '20', unit: 'pages', color: '#654321' }), guild);
expect((await f.json('/habits')).habits[0].name).toBe('Books');
const confirmation = await click(detail, 'Archive habit', guild);
detail = await click(confirmation, 'Confirm', guild);
expect((await f.json('/habits')).habits).toEqual([]);
detail = await click(await click(detail, 'Restore habit', guild), 'Confirm', guild);
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
expect(detail.data.content).toContain('No habits');
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
expect(f.changed.length).toBe(7);
});
test('all recurrences use validated friendly controls', async () => {
f = discordFixture(); const { habit } = await create('manual');
for (const [kind, values, expected] of [
['interval', { every: '3', anchor: '2026-09-01' }, { type: 'interval', every: 3, anchor: '2026-09-01' }],
['weekly', { every: '2', anchor: '2026-09-01', weekday: 'Friday' }, { type: 'weekly', every: 2, anchor: '2026-09-01', weekday: 5 }],
] as const) {
const menu = await click(await open(habit.id), 'Change schedule');
const form = await click(menu, 'Recurrence', false, [kind]);
await f.run(submit(form.data.custom_id, { ...values }));
expect((await f.json('/habits')).habits[0].schedule).toEqual(expected);
}
let menu = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['weekdays']);
await click(menu, 'Select weekdays', false, ['1', '3', '5']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'weekdays', days: [1, 3, 5] });
menu = await click(await open(habit.id), 'Change schedule');
await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
});
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
f = discordFixture(); const { habit } = await create('manual');
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
f.setDiscordTime('2026-09-05T12:00:00Z');
const menu = await click(await open(habit.id), 'Change method');
const confirmation = await click(menu, 'New tracking method', false, ['count']);
expect(confirmation.data.content).toContain('resets today');
const form = await click(confirmation, 'Continue conversion');
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
expect(past.method).toBe('manual'); expect(past.value).toBe(1);
const today = await f.json(`/habits/${habit.id}/days/2026-09-05`);
expect(today.method).toBe('count'); expect(today.value).toBe(0);
const next = await click(await click(await open(habit.id), 'Change method'), 'New tracking method', false, ['tasks']);
await click(next, 'Continue conversion');
expect((await f.json('/habits')).habits[0].tasks).toEqual([]);
});
test('stale, replayed, and cross-account management controls cannot write', async () => {
f = discordFixture(); const { result, habit } = await create('manual');
const form = await click(result, 'Edit details');
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
const fresh = await click(await open(habit.id), 'Edit details');
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
await f.requestDiscord(payload); const changes = f.changed.length;
f.restart(); await f.requestDiscord(payload);
expect(f.changed.length).toBe(changes);
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
expect((await f.json('/habits')).habits[0].name).toBe('Final');
});
test('pagination keeps Discord component and option limits', async () => {
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const page = await f.run({ data: { name: 'habits' } });
expect(page.data.components[0].components[0].options.length).toBe(10);
const next = await click(page, 'Next');
expect(next.data.components[0].components[0].options.length).toBe(2);
expect(control(next, 'Previous')).toBeTruthy();
});

187
src/discord/management.ts Normal file
View File

@@ -0,0 +1,187 @@
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { fields, type Context, type ControlData, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const prefix = 'manage:';
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const recurrence = [{ id: 'daily', name: 'Every day' }, { id: 'weekdays', name: 'Selected weekdays' }, { id: 'interval', name: 'Every N days' }, { id: 'weekly', name: 'Every N weeks' }];
function stamp(c: Context, id: string) {
const h = c.service.current(id), day = c.service.day(id, c.service.today);
return JSON.stringify([h.revisionId, c.service.settings(id), c.service.today, c.service.user.timezone, day.value, day.tasks.map(t => [t.taskId, t.done])]);
}
function snapshot(c: Context, id: string): ControlData { return { habitId: id, expected: stamp(c, id) }; }
function check(c: Context, d: ControlData) {
if (!d.habitId || d.expected !== stamp(c, d.habitId)) throw new ApiError(409, 'This habit changed. Run /habits or /tasks again before saving.');
return c.service.current(d.habitId);
}
function consume(c: Context) { c.controls.consume(c.interaction.data!.custom_id!); c.changed(); }
function chosen(c: Context, allowed: string[]) {
const value = c.interaction.data?.values?.[0];
if (!value || !allowed.includes(value)) throw new ApiError(422, 'Choose one of the available options.');
return value;
}
export function scheduleLabel(s: Schedule): string {
if (s.type === 'daily') return 'Every day';
if (s.type === 'weekdays') return s.days.map(d => weekdays[d]).join(', ');
if (s.type === 'interval') return `Every ${s.every} days from ${s.anchor}`;
return `Every ${s.every} weeks on ${weekdays[s.weekday]} from ${s.anchor}`;
}
export function manageHabits(c: Context, d: ControlData = {}): Reply {
const archived = d.mode === 'archived';
const habits = c.service.list(true).filter(h => h.archived === archived);
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
const state = { mode: archived ? 'archived' : 'active', page };
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
...pages(c, prefix + 'list', state, habits.length),
]);
}
function detail(c: Context, id: string): Reply {
const h = c.service.current(id), d = snapshot(c, id);
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
]);
}
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
return modal(c, creating ? 'Create habit' : 'Edit habit', prefix + (creating ? 'save-create' : 'save-details'), { ...d, method }, [
{ id: 'name', label: 'Habit name', value: h?.name },
...(method === 'count' ? [{ id: 'target', label: 'Target (1–10000)', value: h?.method === 'count' ? String(h.target) : '1' }, { id: 'unit', label: 'Unit (pages, steps, minutes…)', max: 80, value: h?.method === 'count' ? h.unit : 'times' }] : []),
{ id: 'color', label: 'Color (#RRGGBB)', value: h ? c.service.settings(h.id).mainColor : '#196127', max: 7 },
]);
}
function schedulePicker(c: Context, d: ControlData): Reply {
check(c, d);
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
check(c, d);
if (d.taskId) c.service.changeTask(d.habitId!, d.taskId, taskPatch.parse({ schedule }));
else c.service.edit(d.habitId!, habitPatch.parse({ schedule }));
consume(c); return d.taskId ? taskDetail(c, d) : detail(c, d.habitId!);
}
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
if (!action.startsWith(prefix)) return;
const a = action.slice(prefix.length);
const taskReply = taskAction(c, a, d);
if (taskReply) return taskReply;
if (a === 'list') return manageHabits(c, d);
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
if (a === 'detail') return detail(c, d.habitId!);
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
if (a === 'save-create') {
const f = fields(c.interaction);
const h = c.service.create(habitInput.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, h.id);
}
if (a === 'edit') return detailsForm(c, d);
if (a === 'save-details') {
check(c, d); const f = fields(c.interaction);
c.service.edit(d.habitId!, habitPatch.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, d.habitId!);
}
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
if (a === 'method-confirm') {
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'convert') {
check(c, d);
if (d.method === 'count') return detailsForm(c, d);
c.service.edit(d.habitId!, habitPatch.parse({ method: d.method })); consume(c); return detail(c, d.habitId!);
}
if (a === 'confirm') {
const h = check(c, d);
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
if (a === 'schedule') return schedulePicker(c, d);
if (a === 'schedule-kind') {
const h = check(c, d), kind = chosen(c, recurrence.map(r => r.id));
if (kind === 'daily') return saveSchedule(c, d, { type: 'daily' });
if (kind === 'weekdays') {
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
return message(c, 'Select all days on which this repeats.', [menu]);
}
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
return modal(c, kind === 'weekly' ? 'Repeat every N weeks' : 'Repeat every N days', prefix + 'save-interval', { ...d, mode: kind }, [
{ id: 'every', label: kind === 'weekly' ? 'Number of weeks (1–520)' : 'Number of days (1–3650)', value: 'every' in s ? String(s.every) : '1' },
{ id: 'anchor', label: 'Anchor date (YYYY-MM-DD)', value: 'anchor' in s ? s.anchor : c.service.today, max: 10 },
...(kind === 'weekly' ? [{ id: 'weekday', label: 'Weekday (Monday, Tuesday…)', value: s.type === 'weekly' ? weekdays[s.weekday] : 'Monday' }] : []),
]);
}
if (a === 'save-weekdays') return saveSchedule(c, d, scheduleSchema.parse({ type: 'weekdays', days: (c.interaction.data?.values ?? []).map(Number) }));
if (a === 'save-interval') {
const f = fields(c.interaction), weekday = weekdays.findIndex(day => day.toLowerCase() === f.weekday?.trim().toLowerCase());
return saveSchedule(c, d, scheduleSchema.parse({ type: d.mode, every: Number(f.every), anchor: f.anchor, ...(d.mode === 'weekly' ? { weekday } : {}) }));
}
throw new ApiError(422, 'Unknown management action. Run /habits again.');
}
export function manageTasks(c: Context, d: ControlData = {}): Reply {
const habits = c.service.list().filter(h => h.method === 'tasks');
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
...pages(c, prefix + 'tasks-home', { page }, habits.length),
]);
}
function taskList(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!);
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
const state = { ...snapshot(c, h.id), page };
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
...pages(c, prefix + 'tasks-list', state, tasks.length),
]);
}
function taskDetail(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!), task = c.service.tasks(h.id).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found. Run /tasks again.');
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const state = { ...snapshot(c, h.id), taskId: task.id };
const warning = h.warnings.find(w => w.taskId === task.id);
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
]);
}
function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
if (a === 'tasks-home') return manageTasks(c, d);
if (a === 'tasks-open') return taskList(c, { habitId: chosen(c, c.service.list().filter(h => h.method === 'tasks').map(h => h.id)) });
if (a === 'tasks-list') return taskList(c, d);
if (a === 'task-open') { check(c, d); return taskDetail(c, { ...d, taskId: chosen(c, c.service.tasks(d.habitId!).map(t => t.id)) }); }
if (a === 'task-detail') return taskDetail(c, d);
if (a === 'task-add' || a === 'task-rename') {
check(c, d);
const task = a === 'task-rename' ? c.service.tasks(d.habitId!).find(t => t.id === d.taskId) : undefined;
if (a === 'task-rename' && !task) throw new ApiError(404, 'Task not found.');
return modal(c, task ? 'Rename task' : 'Add task (daily recurrence)', prefix + 'task-save', { ...d, taskId: task?.id }, [{ id: 'name', label: 'Task name', value: task?.name }]);
}
if (a === 'task-save') {
check(c, d);
const input = { name: fields(c.interaction).name };
const task = c.service.changeTask(d.habitId!, d.taskId, d.taskId ? taskPatch.parse(input) : taskInput.parse(input))!;
consume(c); return taskDetail(c, { ...d, taskId: task.id });
}
if (a === 'task-delete') {
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found.');
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
}
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
}

View File

@@ -0,0 +1,134 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { reminderDeliveries, users } from '../db/schema';
import { discordFixture, control, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' };
async function choose(list: any, guild = false, index = '0') {
return f.run({ ...click(list, 'Choose a reminder'), data: { custom_id: control(list, 'Choose a reminder'), values: [index] } }, guild);
}
test('settings and reminders are global commands in both contexts', () => {
for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]);
});
for (const guild of [false, true]) test(`full named reminder management shares website state in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture(true);
await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' });
let list = await f.run({ data: { name: 'reminders' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
const form = await f.run(click(list, 'Add reminder'), guild);
const interaction = f.payload(submit(form.data.custom_id, draft), guild);
list = await (await f.requestDiscord(interaction)).json();
f.restart(); await f.requestDiscord(interaction);
let web = await f.json('/reminders');
expect(web.reminders).toEqual(['09:00', '18:30'].map(time => ({ type: 'weekly', name: draft.name, time, days: [1, 3, 5] })));
expect(f.changed).toEqual(['alice']);
list = await f.run(click(list, 'Enable delivery'), guild);
expect((await f.json('/reminders')).enabled).toBe(true);
let detail = await choose(list, guild);
const edit = await f.run(click(detail, 'Edit reminder'), guild);
list = await f.run(submit(edit.data.custom_id, { name: 'Birthday', times: '12:00, 14:00', days: '', date: '2026-09-30' }), guild);
web = await f.json('/reminders');
expect(web.reminders).toEqual(['12:00', '14:00'].map(time => ({ type: 'date', name: 'Birthday', time, date: '2026-09-30' })));
const quiet = await f.run(click(list, 'Quiet hours'), guild);
list = await f.run(submit(quiet.data.custom_id, { start: '23:00', end: '07:00' }), guild);
expect((await f.json('/reminders')).quietStart).toBe('23:00');
detail = await choose(list, guild);
const confirmation = await f.run(click(detail, 'Remove reminder'), guild);
list = await f.run(click(confirmation, 'Confirm removal'), guild);
expect((await f.json('/reminders')).reminders).toEqual([]);
expect((await f.json('/reminders')).enabled).toBe(false);
expect(list.data.content).toContain('No reminders');
const settings = await f.run({ data: { name: 'settings' } }, guild);
const tz = await f.run(click(settings, 'Change timezone'), guild);
await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild);
expect((await f.json('/me')).timezone).toBe('America/New_York');
expect((await f.json('/today')).timezone).toBe('America/New_York');
expect(f.changed.length).toBe(6);
});
test('bad input can reopen a prefilled form without consuming the valid control', async () => {
f = discordFixture();
const list = await f.run({ data: { name: 'reminders' } });
let form = await f.run(click(list, 'Add reminder'));
for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) {
const failed = await f.run(submit(form.data.custom_id, invalid));
expect(failed.type).toBe(4);
form = await f.run(click(failed, 'Try again'));
expect(form.type).toBe(9);
expect(form.data.components[0].components[0].value).toBe(draft.name);
}
const saved = await f.run(submit(form.data.custom_id, draft));
expect(saved.data.content).toContain(draft.name);
const enabled = await f.run(click(saved, 'Enable delivery'));
expect(enabled.data.content).toContain('not configured');
expect((await f.json('/reminders')).enabled).toBe(false);
const settings = await f.run({ data: { name: 'settings' } });
const timezone = await f.run(click(settings, 'Change timezone'));
const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' }));
expect(invalid.data.content).toContain('Use a timezone');
const retry = await f.run(click(invalid, 'Try again'));
await f.run(submit(retry.data.custom_id, { timezone: 'UTC' }));
expect((await f.json('/me')).timezone).toBe('UTC');
});
test('website edits and timezone changes invalidate old controls even within one millisecond', async () => {
f = discordFixture();
const list = await f.run({ data: { name: 'reminders' } });
const form = await f.run(click(list, 'Add reminder'));
const before = await f.json('/reminders');
const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' };
await f.json('/reminders', 'PUT', prefs);
const blocked = await f.run(submit(form.data.custom_id, draft));
expect(blocked.data.content).toContain('Settings changed');
const newer = await f.run({ data: { name: 'reminders' } });
const newerForm = await f.run(click(newer, 'Add reminder'));
await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' });
await f.json('/reminders', 'PUT', prefs);
expect((await f.run(submit(newerForm.data.custom_id, draft))).data.content).toContain('Settings changed');
const settings = await f.run({ data: { name: 'settings' } });
const tz = await f.run(click(settings, 'Change timezone'));
await f.json('/me', 'PATCH', { timezone: 'UTC' });
await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' });
expect((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))).data.content).toContain('Settings changed');
expect((await f.json('/me')).timezone).toBe('Europe/Belgrade');
});
test('old forms cannot cross accounts; new Discord accounts discover UTC setup', async () => {
f = discordFixture();
const list = await f.run({ data: { name: 'reminders' } });
const form = await f.run(click(list, 'Add reminder'));
const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob);
expect(blocked.data.flags).toBe(64);
expect(blocked.data.content).toContain('belongs to another user');
expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1);
const id = '555555555555555555';
const settings = await f.run({ data: { name: 'settings' } }, false, id);
expect(settings.data.content).toContain('New accounts start in UTC');
expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC');
});
test('paginated list, 20-slot limit, delivery status and quiet-hour validation', async () => {
f = discordFixture(true);
const reminders = Array.from({ length: 20 }, (_, i) => ({ type: 'weekly', name: `Reminder ${i + 1}`, time: '12:00', days: [1] }));
await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' });
f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run();
const list = await f.run({ data: { name: 'reminders' } });
expect(list.data.content).toContain('Delivery unconfirmed');
const next = await f.run(click(list, 'Next'));
expect(next.data.content).toContain('Reminder 20');
const form = await f.run(click(next, 'Add reminder'));
const fail = await f.run(submit(form.data.custom_id, draft));
expect(fail.data.content).toContain('up to 20');
expect((await f.json('/reminders')).reminders).toHaveLength(20);
const quiet = await f.run(click(list, 'Quiet hours'));
const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' }));
const retry = await f.run(click(bad, 'Try again'));
const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' }));
expect(done.data.content).toContain('(off)');
const expired = await f.run({ data: { name: 'settings' } });
f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await f.run(click(expired, 'Change timezone'))).data.content).toContain('expired');
});

113
src/discord/preferences.ts Normal file
View File

@@ -0,0 +1,113 @@
import { eq } from 'drizzle-orm';
import { users } from '../db/schema';
import { timezoneInput } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { ReminderService } from '../reminders/service';
import type { ReminderEntry } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const deliveryLabels: Record<string, string> = {
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed',
};
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now);
const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!;
const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); };
const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) });
function current(c: Context, data: ControlData, timezoneOnly = false) {
if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version()))
throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.');
}
function groups(c: Context) {
const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = [];
service(c).read().reminders.forEach((entry, index) => {
const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]);
let group = result.find(g => key(g.entry) === key(entry));
if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); }
group.indexes.push(index); group.times.push(entry.time);
});
return result;
}
function group(c: Context, data: ControlData) {
const found = groups(c)[data.index ?? -1];
if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.');
return found;
}
const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', ');
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
...pages(c, 'prefs.list', { page }, all.length),
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page })),
]);
}
function edit(c: Context, data: ControlData): Reply {
const g = data.index === undefined ? undefined : group(c, data), draft = data.draft;
return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [
{ id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 },
{ id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 },
{ id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 },
{ id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 },
]);
}
function parseReminder(input: Record<string, string>): ReminderEntry[] {
const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? '';
if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).');
if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length)
throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.');
if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.');
if (date) return times.map(time => ({ type: 'date', name, time, date }));
const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3)));
if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.');
return times.map(time => ({ type: 'weekly', name, time, days }));
}
function save(c: Context, data: ControlData, changes: Record<string, unknown>) {
const prefs = service(c).read();
service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion);
c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'reminders');
}
export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (!action.startsWith('prefs.')) return;
if (action === 'prefs.list') return preferences(c, 'reminders', data);
if (action === 'prefs.settings') return preferences(c, 'settings');
current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave');
if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]);
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
if (action === 'prefs.edit') return edit(c, data);
if (action === 'prefs.select') {
const selected = c.interaction.data?.values?.[0] ?? '';
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
const state = { ...data, index: Number(selected) }, g = group(c, state);
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
}
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
try {
if (action === 'prefs.timezoneSave') {
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.');
c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'settings');
}
if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); }
if (action === 'prefs.toggle') return save(c, data, { enabled: data.done });
if (action === 'prefs.save' || action === 'prefs.removeSave') {
const removed = data.index === undefined ? [] : group(c, data).indexes;
const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i));
const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])];
return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) });
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 422) throw error;
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
}
}

View File

@@ -0,0 +1,34 @@
import { expect, test } from 'bun:test';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { renderProgressImage } from './progress-image';
import { addDays } from '../habits/calendar';
import type { LiveImageData } from '../sharing/live-card-image';
import { themePalette } from '../lib/theme';
const sample: LiveImageData = {
name: 'Mina', avatarUrl: null, from: '2026-08-06', to: '2026-09-04',
habits: [{ name: 'Read a little, every day', color: '#285d49', completed: 7, scheduled: 23,
days: Array.from({ length: 30 }, (_, i) => ({ date: addDays('2026-08-06', i), color: ['#eeeeee', '#a9beb5', '#285d49'][i % 3]!, due: i % 4 !== 0, ratio: i % 4 === 0 ? null : [0, .5, 1][i % 3]! })) }],
};
for (const theme of ['light', 'dark'] as const) test(`focused 30-day ${theme} PNG fits upload limits, has all cells and deterministic pixels`, async () => {
const png = await renderProgressImage(sample, theme);
const bytes = Buffer.from(png);
expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
expect(bytes.readUInt32BE(16)).toBe(1200); expect(bytes.readUInt32BE(20)).toBe(860);
expect(bytes.length).toBeLessThan(4 * 1024 * 1024);
expect(await renderProgressImage(sample, theme)).toEqual(png);
const image = await loadImage(png), canvas = createCanvas(1200, 860), ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const rgb = (hex: string) => [1,3,5].map(i => parseInt(hex.slice(i, i + 2), 16));
for (let i = 0; i < 30; i++) {
const pixel = [...ctx.getImageData(70 + (i % 10) * 108, 360 + Math.floor(i / 10) * 130, 1, 1).data];
expect(pixel[3]).toBe(255);
expect(pixel.slice(0, 3)).not.toEqual(rgb(themePalette[theme].paper));
}
// Opt-in artifact generation only; normal tests never write output files.
if (process.env.PROGRESS_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.PROGRESS_IMAGE_REVIEW_DIR}/progress-${theme}.png`, png);
});
test('long unicode names render safely and invalid ranges are rejected', async () => {
const long = { ...sample, name: '🌸'.repeat(100), habits: [{ ...sample.habits[0]!, name: '読む 📚 '.repeat(100) }] };
expect((await renderProgressImage(long)).length).toBeLessThan(4 * 1024 * 1024);
expect(renderProgressImage({ ...sample, habits: [] })).rejects.toThrow('Choose one habit');
});

View File

@@ -0,0 +1,66 @@
import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image';
import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme';
import { themedHabitShade, habitProgressColor } from '../lib/habit-palette';
import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') {
const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
const ctx = canvas.getContext('2d'), palette = themePalette[theme];
const rect = (x: number, y: number, width: number, height: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill();
};
const text = (value: string, x: number, y: number, size: number, width: number, color: string = palette.ink, serif = false) => {
ctx.font = `400 ${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
const chars = Array.from(value); let fitted = value;
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = `${chars.join('')}…`; }
ctx.fillText(fitted, x, y);
};
const color = (ratio: number | null, due: boolean, serverShade?: string) => !due ? palette.avatar : !ratio ? palette.empty : serverShade ? themedHabitShade(serverShade, habit.color, theme) : habitProgressColor(habit.color, ratio, theme);
const contrast = (hex: string) => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
return .299 * r! + .587 * g! + .114 * b! > 150 ? '#202a26' : '#ffffff';
};
rect(0, 0, 1200, 860, palette.paper);
rect(0, 0, 1200, 8, habit.color);
text(data.name, 64, 80, 28, 720);
text('Mina Habits.', 940, 82, 40, 200, palette.ink, true);
text(habit.name, 64, 175, 64, 1060, palette.ink, true);
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 64, 228, 26, 720, palette.muted);
text(`${habit.completed}/${habit.scheduled} days complete`, 800, 228, 26, 350);
rect(64, 261, 1072, 1, palette.rule);
habit.days.forEach((day, index) => {
const x = 64 + (index % 10) * 108, y = 325 + Math.floor(index / 10) * 130;
const label = new Date(`${day.date}T12:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
text(label, x, y, 20, 96, palette.muted);
rect(x, y + 15, 96, 70, color(day.ratio, day.due, day.color), 10);
// Draw symbols as paths: bundled text fonts need not contain checkmark glyphs.
const cx = x + 48, cy = y + 50;
ctx.strokeStyle = contrast(color(day.ratio, day.due, day.color));
ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath();
if (!day.due) { ctx.moveTo(cx - 7, cy); ctx.lineTo(cx + 7, cy); ctx.stroke(); }
else if ((day.ratio ?? 0) >= 1) { ctx.moveTo(cx - 9, cy); ctx.lineTo(cx - 2, cy + 7); ctx.lineTo(cx + 11, cy - 8); ctx.stroke(); }
else { ctx.arc(cx, cy, day.ratio ? 4 : 8, 0, Math.PI * 2); if (day.ratio) ctx.fill(); else ctx.stroke(); }
});
rect(64, 716, 1072, 1, palette.rule);
const legend = [
{ label: 'Not scheduled', ratio: null, due: false },
{ label: 'No progress', ratio: 0, due: true },
{ label: 'Partial', ratio: 0.5, due: true },
{ label: 'Complete', ratio: 1, due: true },
];
legend.forEach((item, index) => {
const x = 64 + index * 276;
rect(x, 750, 24, 24, color(item.ratio, item.due), 5);
text(item.label, x + 36, 772, 21, 230, palette.muted);
});
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
return new Uint8Array(await canvas.encode('png'));
}

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

97
src/discord/progress.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createHash } from 'node:crypto';
import { renderProgressImage } from './progress-image';
import { ApiError } from '../habits/service';
import { liveCardData } 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)) {
return renderProgressImage(source, data.theme ?? 'light');
}
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

@@ -9,11 +9,29 @@ import { ApiError, HabitService } from '../habits/service';
import { ControlStore } from './state';
import { interactionSchema, type Context, type Reply } from './types';
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 };
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
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 }));
@@ -34,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();
@@ -43,14 +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, 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 = dailyAction(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;
@@ -59,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;

View File

@@ -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() {
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() {
f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run();
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
const changed: string[] = [];
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id));
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 }, () => 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);

View File

@@ -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';
@@ -13,8 +14,8 @@ export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; changed: () => void };
export 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; 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); });

View File

@@ -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;
@@ -325,7 +325,7 @@ export class HabitService {
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
}
setTimezone(timezone: string) {
this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run();
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();
return { ...this.user, timezone };
}
}

View File

@@ -17,7 +17,7 @@ if (process.env.NODE_ENV !== "production") {
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });

View File

@@ -6,7 +6,8 @@ export const reminderEntry = z.discriminatedUnion("type", [
z.object({ type: z.literal("date"), name: z.string().trim().max(80).optional(), time: clock, date: z.iso.date() }).strict(),
]);
export type ReminderEntry = z.infer<typeof reminderEntry>;
export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).min(1).max(20).optional(), quietStart: clock, quietEnd: clock }).strict()
export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).max(20).optional(), quietStart: clock, quietEnd: clock }).strict()
.refine(value => !value.enabled || value.reminders === undefined || value.reminders.length > 0, "Add a reminder before enabling delivery")
.refine(value => value.reminders !== undefined || value.time !== undefined, "Add a reminder time");
export function entries(prefs: { reminders?: ReminderEntry[] | null; time?: string }): ReminderEntry[] {
return prefs.reminders ?? [{ type: "weekly", time: prefs.time ?? "20:00", days: allWeekdays }];

View File

@@ -1,28 +1,15 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { desc, eq } from "drizzle-orm";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { reminderDeliveries, reminderSettings } from "../db/schema";
import { defaultReminder, entries, reminderInput } from "./contracts";
import { ReminderService } from "./service";
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth);
app.get("/", c => {
const userId = c.get("user").id;
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, userId)).get() ?? defaultReminder;
const last = db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, userId)).orderBy(desc(reminderDeliveries.updatedAt)).get();
return c.json({ enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
});
app.get("/", c => c.json(new ReminderService(db, c.get("user").id, available, now()).read()));
app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 8192 }), async c => {
const parsed = reminderInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: "Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders)." }, 422);
if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422);
const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: now() };
db.insert(reminderSettings).values({ userId: c.get("user").id, ...row })
.onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
return c.json(parsed.data);
const input = await c.req.json().catch(() => null);
return c.json(new ReminderService(db, c.get("user").id, available, now()).save(input));
});
return app;
}

31
src/reminders/service.ts Normal file
View File

@@ -0,0 +1,31 @@
import { desc, eq } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import { reminderDeliveries, reminderSettings } from '../db/schema';
import { ApiError } from '../habits/service';
import { defaultReminder, entries, reminderInput } from './contracts';
/** Shared website/Discord persistence. The monotonic revision also catches A → B → A edits. */
export class ReminderService {
constructor(readonly db: AppDatabase, readonly userId: string, readonly available: boolean, readonly now: number) {}
read() {
const prefs = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? defaultReminder;
const last = this.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, this.userId)).orderBy(desc(reminderDeliveries.updatedAt)).get();
return { enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available: this.available,
lastDelivery: last ? { date: last.date, status: last.status === 'pending' ? 'uncertain' : last.status } : null };
}
version() {
return JSON.stringify(this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? null);
}
save(input: unknown, expected?: string) {
return this.db.transaction(() => {
if (expected !== undefined && expected !== this.version()) throw new ApiError(409, 'Reminders changed since this form was opened. Run /reminders to refresh.');
const parsed = reminderInput.safeParse(input);
if (!parsed.success) throw new ApiError(422, 'Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders).');
if (parsed.data.enabled && !this.available) throw new ApiError(422, 'Discord reminders are not configured on this server. You can save schedules while disabled.');
const previous = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get();
const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: Math.max(this.now, (previous?.updatedAt ?? 0) + 1) };
this.db.insert(reminderSettings).values({ userId: this.userId, ...row }).onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
return parsed.data;
});
}
}

View File

@@ -28,6 +28,7 @@ test("reminders follow local time, send only unfinished habits, and deduplicate
await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
expect(JSON.parse(sent[0]!).components).toBeUndefined();
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
f.setTime("2026-09-05T18:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
@@ -53,7 +54,7 @@ test("quiet hours defer until their end and DST overlap sends once", async () =>
test("opt-out during channel creation cancels the message", async () => {
const { f } = await setup(); let posts = 0;
try {
await sendDueReminders(f.db, config, async url => {
await sendDueReminders(f.db, { ...config, commandsAvailable: true }, async url => {
if (!url.endsWith("/users/@me/channels")) posts++;
f.db.update(reminderSettings).set({ enabled: false }).run();
return Response.json({ id: "223456789012345678" });

View File

@@ -1,3 +1,4 @@
import { reminderCheckIn } from "../discord/daily";
import { createHash } from "node:crypto";
import { and, eq, inArray, lt } from "drizzle-orm";
import type { AppDatabase } from "../auth";
@@ -7,7 +8,7 @@ import { localDate } from "../habits/calendar";
import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
import type { DiscordFetch } from "../sharing/routes";
export type ReminderConfig = { token: string; origin: string };
export type ReminderConfig = { token: string; origin: string; commandsAvailable?: boolean };
function localClock(timestamp: number, timezone: string) {
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp);
return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
@@ -34,7 +35,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
// Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
return remaining ? { date: service.today, remaining, times, entries: entriesForDate(prefs, service.today) } : null;
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
}
const initial = due(); if (!initial) continue;
const claims = db.transaction(tx => {
@@ -84,6 +85,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
body: JSON.stringify({ content: `${prefix}A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
...(config.commandsAvailable ? { components: reminderCheckIn(db, user.id, latest.timezone, now()) } : {}),
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }),
}); } catch { update("uncertain"); continue; }
if (response.status === 429) { await defer(response); return; }

View File

@@ -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 };
}

View File

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