feat: add Discord reminder and timezone settings
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user