feat: polish Discord daily check-ins and add reminder buttons

This commit is contained in:
syntaxbullet
2026-09-08 08:20:13 +02:00
parent 2671fe0f34
commit 8b9bd75f24
7 changed files with 160 additions and 13 deletions

View File

@@ -48,7 +48,7 @@ const app = createApi(
let reminderRunning = false; let reminderRunning = false;
if (sharingPreview) setInterval(async () => { if (sharingPreview) setInterval(async () => {
if (reminderRunning) return; reminderRunning = true; 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; } finally { reminderRunning = false; }
}, 1000).unref(); }, 1000).unref();
const server = Bun.serve({ const server = Bun.serve({

View File

@@ -24,3 +24,12 @@ Discord validation will be completed during integration.
controls and privacy, and links to the website. It works before account setup and 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 does not create an account or synchronize habits. Re-run the registration script
with `--apply` after deployment to expose newly added commands in Discord. 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.

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

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

View File

@@ -1,24 +1,35 @@
import { ZodError } from 'zod';
import { ApiError } from '../habits/service'; import { 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 { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types'; import type { Context, ControlData, Reply } from './types';
import { fields } from './types'; import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui'; 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 { 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.'); 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 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.'}`, 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 { export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today; 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.'); 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 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 = []; const controls = [];
if (d.due && !d.future) { 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 === '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') { if (d.method === 'tasks') {
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10); 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}` })))); 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'))); 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 === '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.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.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 (action === 'daily.save' || action === 'daily.task') {
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.'); 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!); 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') { if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]); const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
if (!task) throw new ApiError(404, 'Task not found.'); 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); 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);
}
}

View File

@@ -17,7 +17,7 @@ if (process.env.NODE_ENV !== "production") {
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop()); 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()); 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) }); const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });

View File

@@ -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)]); 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(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]!).allowed_mentions.parse).toEqual([]);
expect(JSON.parse(sent[0]!).components).toBeUndefined();
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1); await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
f.setTime("2026-09-05T18:00:00Z"); f.setTime("2026-09-05T18:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true }); 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 () => { test("opt-out during channel creation cancels the message", async () => {
const { f } = await setup(); let posts = 0; const { f } = await setup(); let posts = 0;
try { try {
await sendDueReminders(f.db, config, async url => { await sendDueReminders(f.db, { ...config, commandsAvailable: true }, async url => {
if (!url.endsWith("/users/@me/channels")) posts++; if (!url.endsWith("/users/@me/channels")) posts++;
f.db.update(reminderSettings).set({ enabled: false }).run(); f.db.update(reminderSettings).set({ enabled: false }).run();
return Response.json({ id: "223456789012345678" }); return Response.json({ id: "223456789012345678" });

View File

@@ -1,3 +1,4 @@
import { reminderCheckIn } from "../discord/daily";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { and, eq, inArray, lt } from "drizzle-orm"; import { and, eq, inArray, lt } from "drizzle-orm";
import type { AppDatabase } from "../auth"; import type { AppDatabase } from "../auth";
@@ -7,7 +8,7 @@ import { localDate } from "../habits/calendar";
import { entriesForDate, inQuietHours, timesForDate } from "./contracts"; import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
import type { DiscordFetch } from "../sharing/routes"; 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) { 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); 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}`; 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. // Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null; 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; 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 initial = due(); if (!initial) continue;
const claims = db.transaction(tx => { 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`, { try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000), 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}/`, 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 }), allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }),
}); } catch { update("uncertain"); continue; } }); } catch { update("uncertain"); continue; }
if (response.status === 429) { await defer(response); return; } if (response.status === 429) { await defer(response); return; }