import { reminderCheckIn } from "../discord/daily"; import { createHash } from "node:crypto"; import { and, eq, inArray, 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 { entriesForDate, inQuietHours, timesForDate } from "./contracts"; import type { DiscordFetch } from "../sharing/routes"; export type ReminderConfig = { token: string; origin: string; commandsAvailable?: boolean }; function localClock(timestamp: number, timezone: string) { const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp); return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`; } 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 (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock); if (!times.length) 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, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null; } const initial = due(); if (!initial) continue; const claims = db.transaction(tx => { // A crashed/aborted send must not block later times indefinitely. tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run(); const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).all(); // Legacy deliveries cover the whole day. An in-flight batch owns this user's turn. const legacy = previous.find(row => row.slot === "*"); if ((legacy && legacy.status !== "deferred") || previous.some(row => row.status === "pending")) return []; const claims: { id: string; slot: string }[] = []; for (const slot of legacy ? ["*"] : initial.times) { const prior = previous.find(row => row.slot === slot); if (prior && (prior.status !== "deferred" || (prior.retryAt ?? 0) > now())) continue; const id = prior?.id ?? crypto.randomUUID(); if (prior) 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, slot, status: "pending", updatedAt: now() }).run(); claims.push({ id, slot }); } return claims; }, { behavior: "immediate" }); if (!claims.length) continue; const ids = claims.map(claim => claim.id); const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(inArray(reminderDeliveries.id, ids)).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 || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; } const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))]; // Bound the label prefix so the count and link always fit Discord's message limit. const labels = names.join("; "); const prefix = labels ? `Reminder: ${labels.length > 1400 ? labels.slice(0, 1399) + "…" : labels}\n` : ""; 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: `${prefix}A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`, ...(config.commandsAvailable ? { components: reminderCheckIn(db, user.id, latest.timezone, now()) } : {}), allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }), }); } catch { update("uncertain"); continue; } if (response.status === 429) { await defer(response); return; } 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) }; }