feat: add Discord reminder and timezone settings

This commit is contained in:
syntaxbullet
2026-09-08 08:23:27 +02:00
parent 3487488f7b
commit 5dbec04b04
12 changed files with 324 additions and 28 deletions

View File

@@ -33,7 +33,7 @@ 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));
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));

View File

@@ -33,3 +33,29 @@ 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.

View File

@@ -3,6 +3,8 @@ 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: '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] }));

View File

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

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

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

View File

@@ -11,10 +11,11 @@ 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 { message } from './ui';
import { help } from './help';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string };
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
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;
@@ -45,17 +46,18 @@ 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) };
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 === '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 = historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
result = 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;

View File

@@ -4,7 +4,7 @@ import { fixture } from '../habits/test-fixture';
import { users } from '../db/schema';
import { createDiscordRoutes } from './routes';
import type { Interaction } from './types';
export function discordFixture() {
export function discordFixture(remindersAvailable = false) {
const f = fixture();
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
@@ -13,7 +13,7 @@ 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));
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id));
function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
const profile = { id: user, username: user === alice ? 'alice' : 'bob' };
return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
@@ -23,7 +23,7 @@ export function discordFixture() {
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)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);

View File

@@ -13,8 +13,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 };
export function fields(i: Interaction) {
const result: Record<string, string> = {};
const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); });

View File

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

View File

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

View File

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

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

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