feat: add opt-in daily Discord reminders with quiet hours
This commit is contained in:
86
src/reminders/worker.ts
Normal file
86
src/reminders/worker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../auth";
|
||||
import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
|
||||
import { HabitService } from "../habits/service";
|
||||
import { localDate } from "../habits/calendar";
|
||||
import { inQuietHours } from "./contracts";
|
||||
import type { DiscordFetch } from "../sharing/routes";
|
||||
|
||||
export type ReminderConfig = { token: string; origin: 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);
|
||||
return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
|
||||
}
|
||||
export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, request: DiscordFetch = fetch, now: () => number = Date.now) {
|
||||
if (!config.token) return;
|
||||
const cooldown = db.select().from(reminderWorkerState).where(eq(reminderWorkerState.id, "discord")).get();
|
||||
if (cooldown && cooldown.blockedUntil > now()) return;
|
||||
const headers = { Authorization: `Bot ${config.token}`, "Content-Type": "application/json" };
|
||||
const rows = db.select({ prefs: reminderSettings, user: users }).from(reminderSettings).innerJoin(users, eq(users.id, reminderSettings.userId)).where(eq(reminderSettings.enabled, true)).all();
|
||||
// Retain recent delivery history and deduplication records without unbounded growth.
|
||||
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
|
||||
for (const { user } of rows) {
|
||||
function due() {
|
||||
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
|
||||
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
|
||||
if (!currentUser || !prefs?.enabled) return null;
|
||||
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
|
||||
if (clock < prefs.time || inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
|
||||
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
|
||||
service.sync();
|
||||
// Avoid sending an artificial "today" after a backwards timezone change.
|
||||
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining } : null;
|
||||
}
|
||||
const initial = due(); if (!initial) continue;
|
||||
const id = db.transaction(tx => {
|
||||
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get();
|
||||
if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null;
|
||||
const id = previous?.id ?? crypto.randomUUID();
|
||||
if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
|
||||
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run();
|
||||
return id;
|
||||
}, { behavior: "immediate" });
|
||||
if (!id) continue;
|
||||
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
|
||||
const defer = async (response?: Response) => {
|
||||
const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null;
|
||||
const seconds = Math.min(86400, Math.max(60, Number(body?.retry_after) || 60));
|
||||
update("deferred", now() + seconds * 1000);
|
||||
if (response?.status === 429) db.insert(reminderWorkerState).values({ id: "discord", blockedUntil: now() + seconds * 1000 })
|
||||
.onConflictDoUpdate({ target: reminderWorkerState.id, set: { blockedUntil: now() + seconds * 1000 } }).run();
|
||||
};
|
||||
let channel: Response;
|
||||
try { channel = await request("https://discord.com/api/v10/users/@me/channels", {
|
||||
method: "POST", headers, body: JSON.stringify({ recipient_id: user.discordId }), redirect: "error", signal: AbortSignal.timeout(10000),
|
||||
}); } catch { await defer(); continue; }
|
||||
if (channel.status === 429) { await defer(channel); return; }
|
||||
if (channel.status >= 500) { await defer(channel); continue; }
|
||||
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
|
||||
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
|
||||
// A user may opt out or finish a habit while Discord opens the DM channel.
|
||||
const latest = due();
|
||||
if (!latest || latest.date !== initial.date) { update("skipped"); continue; }
|
||||
let response: Response;
|
||||
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
|
||||
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
|
||||
body: JSON.stringify({ content: `A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
|
||||
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(id).digest("hex").slice(0, 24), enforce_nonce: true }),
|
||||
}); } catch { update("uncertain"); continue; }
|
||||
if (response.status === 429) { await defer(response); return; }
|
||||
update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");
|
||||
}
|
||||
}
|
||||
export function startReminders(db: AppDatabase, config: ReminderConfig) {
|
||||
let running = false;
|
||||
const tick = async () => {
|
||||
if (running) return; running = true;
|
||||
try { await sendDueReminders(db, config); }
|
||||
catch { console.error(JSON.stringify({ event: "reminder_worker_failed", timestamp: new Date().toISOString() })); }
|
||||
finally { running = false; }
|
||||
};
|
||||
const timer = setInterval(() => void tick(), 60000); timer.unref();
|
||||
return { stop: () => clearInterval(timer) };
|
||||
}
|
||||
Reference in New Issue
Block a user