Compare commits
8 Commits
2671fe0f34
...
d088179c5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d088179c5e | ||
|
|
2c135398d7 | ||
|
|
a1ae65634b | ||
|
|
5dbec04b04 | ||
|
|
3487488f7b | ||
|
|
ab2aa74eba | ||
|
|
07a0f80fc0 | ||
|
|
8b9bd75f24 |
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
112
src/discord/daily.test.ts
Normal 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'));
|
||||
});
|
||||
@@ -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
199
src/discord/history.test.ts
Normal 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
64
src/discord/history.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
123
src/discord/management-tasks.test.ts
Normal file
123
src/discord/management-tasks.test.ts
Normal 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');
|
||||
});
|
||||
98
src/discord/management.test.ts
Normal file
98
src/discord/management.test.ts
Normal 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
187
src/discord/management.ts
Normal 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 }); }
|
||||
}
|
||||
134
src/discord/preferences.test.ts
Normal file
134
src/discord/preferences.test.ts
Normal 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
113
src/discord/preferences.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/discord/progress-image.test.ts
Normal file
34
src/discord/progress-image.test.ts
Normal 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');
|
||||
});
|
||||
66
src/discord/progress-image.ts
Normal file
66
src/discord/progress-image.ts
Normal 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'));
|
||||
}
|
||||
114
src/discord/progress.test.ts
Normal file
114
src/discord/progress.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { createAuth } from '../auth';
|
||||
import { createSharingRoutes } from '../sharing/routes';
|
||||
import { createLiveDiscord } from '../sharing/live-discord';
|
||||
import { discordFixture, control } from './test-fixture';
|
||||
import type { DeferredMessage } from './progress';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
let stop = () => {};
|
||||
afterEach(() => { stop(); f?.close(); });
|
||||
function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean } = {}) {
|
||||
const jobs: (() => Promise<void>)[] = [], replies: DeferredMessage[] = [], sends: RequestInit[] = [];
|
||||
f = discordFixture((base, now) => {
|
||||
const config = { token: 'test-bot-token', channelId: '555555555555555555', allowedUserIds: settings.allowed, writesEnabled: settings.writes ?? true };
|
||||
const request = async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST' || init?.method === 'PATCH') { sends.push(init); if (settings.fail) throw new Error('uncertain'); return Response.json({ id: '777777777777777777' }); }
|
||||
return Response.json({ id: config.channelId, guild_id: '666666666666666666', type: 0, name: 'habit-sharing' });
|
||||
};
|
||||
const auth = createAuth(base.db, { origin: base.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, now);
|
||||
const sharing = createSharingRoutes(base.db, auth, config, now, request), live = createLiveDiscord(base.db, auth, config, now, request);
|
||||
stop = live.stop;
|
||||
return { progress: { sharing, live }, schedule: job => jobs.push(job), complete: async (_i, result) => { replies.push(result); } };
|
||||
});
|
||||
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
|
||||
}
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
|
||||
}
|
||||
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const panel = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(panel.data.content).toContain('Reading — 1/1');
|
||||
expect(panel.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const preview = await env.drain();
|
||||
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
|
||||
expect(preview.data.content).toContain('1/1');
|
||||
await click(preview, 'Choose public destination', guild);
|
||||
const confirmation = await env.drain();
|
||||
expect(confirmation.data.content).toContain('habit-sharing');
|
||||
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
|
||||
await f.requestDiscord(i); await env.drain();
|
||||
f.restart(); await f.requestDiscord(i); await env.drain();
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls', guild); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2);
|
||||
await click(panel, 'Live image controls', guild); const active = await env.drain();
|
||||
await click(active, 'Pause live image', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('paused');
|
||||
await click(panel, 'Live image controls', guild); const paused = await env.drain();
|
||||
await click(paused, 'Resume live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2); // Unchanged image keeps the original message.
|
||||
});
|
||||
test('pagination, cross-account controls, stale snapshot and uncertain delivery never resend', async () => {
|
||||
const env = setup({ fail: true });
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
expect(control(panel, 'Next')).toBeString();
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
|
||||
expect(stolen.data.content).toContain('belongs to another user');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirm = await env.drain();
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
|
||||
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
|
||||
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('could not confirm');
|
||||
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
});
|
||||
test('allowlist and disabled writes apply to command shares', async () => {
|
||||
const env = setup({ allowed: ['222222222222222222'] });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('approved community');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
|
||||
const env = setup({ writes: false });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly'); const denied = await env.drain();
|
||||
expect(denied.data.content).toContain('disabled in development');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
|
||||
const env = setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const first = await env.drain();
|
||||
await click(first, 'Share snapshot publicly'); await env.drain();
|
||||
await click(preview, 'Choose public destination'); const next = await env.drain();
|
||||
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
|
||||
expect(limited.data.content).toContain('Sharing limit reached');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
|
||||
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
});
|
||||
97
src/discord/progress.ts
Normal file
97
src/discord/progress.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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
31
src/reminders/service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { liveDiscordCards, users } from "../db/schema";
|
||||
import { ApiError } from "../habits/service";
|
||||
import { endOfDay, localDate } from "../habits/calendar";
|
||||
import { liveCardData, renderLiveCard } from "./live-card-image";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
import type { DiscordFetch } from "./routes";
|
||||
|
||||
@@ -155,12 +156,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
|
||||
return c.body(new Uint8Array(image));
|
||||
});
|
||||
app.post('/', async c => {
|
||||
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
|
||||
async function start(user: PublicUser, theme: 'light' | 'dark') {
|
||||
if (config.writesEnabled === false) throw new ApiError(403, 'Discord channel writes are disabled in development. Preview and download are still available.');
|
||||
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
||||
const user = c.get('user');
|
||||
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
||||
const input = options.safeParse(await c.req.json().catch(() => null));
|
||||
if (!allowed(user.discordId)) throw new ApiError(403, 'Discord sharing is limited to approved community members.');
|
||||
const input = options.safeParse({ theme });
|
||||
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
|
||||
const existing = row(user.id);
|
||||
if (existing?.status === 'uncertain')
|
||||
@@ -171,23 +171,28 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
.onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
|
||||
}
|
||||
await flush(user.id);
|
||||
return c.json(state(user.id));
|
||||
});
|
||||
app.delete('/', async c => {
|
||||
const id = c.get('user').id;
|
||||
return state(user.id);
|
||||
}
|
||||
async function pause(id: string) {
|
||||
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
// Return only after any already-sent edit has finished.
|
||||
await running.get(id);
|
||||
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
return c.json(state(id));
|
||||
return state(id);
|
||||
}
|
||||
app.post('/', async c => {
|
||||
const input = options.safeParse(await c.req.json().catch(() => null));
|
||||
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
|
||||
return c.json(await start(c.get('user'), input.data.theme));
|
||||
});
|
||||
app.delete('/', async c => c.json(await pause(c.get('user').id)));
|
||||
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
||||
for (const current of db.select().from(liveDiscordCards).all()) {
|
||||
if (config.writesEnabled === false) break;
|
||||
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
|
||||
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
|
||||
}
|
||||
return { routes: app, changed, flush, stop };
|
||||
return { routes: app, changed, flush, stop, state, start, pause };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { discordDeliveries, sharingLimits } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { shareInput, type ShareData } from "./contracts";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
|
||||
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
@@ -98,14 +99,15 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const destination = await channel();
|
||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||
});
|
||||
app.post("/discord/send", async c => {
|
||||
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403);
|
||||
const userId = c.get("user").id;
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
||||
function assertSend(user: PublicUser) {
|
||||
if (config.writesEnabled === false) throw new ApiError(403, "Discord channel writes are disabled in development. Preview and download are still available.");
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
|
||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||
const image = form.get("image");
|
||||
}
|
||||
async function send(user: PublicUser, deliveryId: string, image: File) {
|
||||
assertSend(user);
|
||||
const userId = user.id;
|
||||
const id = z.string().uuid().safeParse(deliveryId);
|
||||
if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB.");
|
||||
const bytes = Buffer.from(await image.arrayBuffer());
|
||||
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending.");
|
||||
@@ -114,7 +116,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||
if (previous) {
|
||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
return ({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
}
|
||||
const timestamp = now();
|
||||
// Reserve both quotas and the delivery before any asynchronous outbound work.
|
||||
@@ -128,7 +130,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
|
||||
const quotas = [
|
||||
{ key: `channel:${config.channelId}`, maximum: 10 },
|
||||
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
|
||||
{ key: `channel:${config.channelId}:user:${user.discordId}`, maximum: 1 },
|
||||
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
|
||||
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
|
||||
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
|
||||
@@ -142,20 +144,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
}, { behavior: "immediate" });
|
||||
if (retryAfter === -1) {
|
||||
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
|
||||
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
return ({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
}
|
||||
if (retryAfter > 0) {
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
|
||||
throw Object.assign(new ApiError(429, `Sharing limit reached. Try again in ${retryAfter} seconds.`), { retryAfter });
|
||||
}
|
||||
const attachment = new FormData();
|
||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "mina-habits-progress.png", description: "Progress card shared from Mina Habits" }] }));
|
||||
attachment.set("files[0]", image, "mina-habits-progress.png");
|
||||
let response: Response;
|
||||
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
|
||||
catch { return c.json({ status: "uncertain" }); }
|
||||
catch { return ({ status: "uncertain" }); }
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500) return c.json({ status: "uncertain" });
|
||||
if (response.status >= 500) return ({ status: "uncertain" });
|
||||
db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
|
||||
cachedChannel = null;
|
||||
throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads).");
|
||||
@@ -163,7 +164,20 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
const message = await response.json().catch(() => null) as { id?: string } | null;
|
||||
const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null;
|
||||
db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run();
|
||||
return c.json({ status: "sent", messageUrl: messageUrl ?? undefined });
|
||||
});
|
||||
return app;
|
||||
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;
|
||||
}
|
||||
});
|
||||
async function destination(user: PublicUser) {
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
|
||||
return channel();
|
||||
}
|
||||
return Object.assign(app, { send, destination });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user