diff --git a/docs/superpowers/plans/2026-05-09-reminder-scheduling.md b/docs/superpowers/plans/2026-05-09-reminder-scheduling.md new file mode 100644 index 0000000..bd9ebc0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-reminder-scheduling.md @@ -0,0 +1,1628 @@ +# Reminder Scheduling & Sending — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** End-state — operator can create one-off scheduled reminders via the Telegram menu, attach a text body and optional media (photo/video/document), and the bot fires them on time to the chosen group. Failed sends retry with backoff. Run history is browsable in the menu. + +**Architecture:** Reuse the existing `bot` service. Add `pg-boss` (Postgres-native job queue) for delayed scheduling. Extend `sender.ts` to send media. Extend the Telegram menu with a Reminders branch (list, new, detail). Media uploaded via Telegram is downloaded once, hashed, and stored on the shared volume at `/data/media/{yyyy/mm}/{uuid}.{ext}` with a `media_files` row. + +**Tech stack additions:** `pg-boss` (Postgres job queue), `node-fetch` already pulled by Baileys (or use built-in `fetch`). + +**Out of scope for plan 2** (deferred to plan 2.5 or plan 3): +- Recurring reminders (RRULE engine integration). Schema is already there; the wizard and pg-boss `repeatEvery` wiring will land in a follow-up. +- Multi-group targets per reminder (DB supports it; menu only allows one for now). +- Multi-part messages beyond text + one media (DB supports `position`-ordered parts; UI will only build single text or text+media for now). +- Web dashboard (plan 3). + +**Spec reference:** `docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md` §11 (reminder execution flow), §12 (error handling). + +--- + +## File structure produced by this plan + +``` +apps/bot/src/ +├── scheduler/ +│ ├── pgboss-client.ts init + graceful shutdown +│ ├── reminder-jobs.ts register handler, schedule/upsert/cancel +│ └── fire-reminder.ts job handler — load, send, record +├── reminders/ +│ ├── crud.ts create/get/list/delete reminder helpers +│ └── time-parsing.ts parseDueAt for quick-picks and free text +├── media/ +│ └── ingest.ts download Telegram media → /data/media + DB row +├── whatsapp/ +│ └── sender.ts (extended) sendTextToGroup, sendMediaToGroup +└── telegram/ + ├── menus.ts (extended) reminders list, detail, wizard views + ├── callbacks.ts (extended) reminder callbacks + ├── commands/ + │ └── reminders.ts /reminders command + └── state.ts (extended) wizard state +``` + +--- + +## Task 1: Add pg-boss dependency, create client + lifecycle + +**Files:** +- Modify: `apps/bot/package.json` — add `"pg-boss": "^10.1.5"` +- Create: `apps/bot/src/scheduler/pgboss-client.ts` +- Modify: `apps/bot/src/index.ts` — start/stop pg-boss + +- [ ] **Step 1: Add pg-boss to apps/bot/package.json deps and run install** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm add pg-boss --filter @cmbot/bot +``` + +Expected: pg-boss 10.x added under apps/bot/package.json deps. pnpm-lock.yaml updated. + +- [ ] **Step 2: Create `apps/bot/src/scheduler/pgboss-client.ts`** + +```typescript +import PgBoss from "pg-boss"; +import { env } from "../env.js"; +import { logger } from "../logger.js"; + +let boss: PgBoss | null = null; + +export async function startBoss(): Promise { + if (boss) return boss; + const instance = new PgBoss({ + connectionString: env.DATABASE_URL, + schema: "pgboss", + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + }); + instance.on("error", (err) => logger.error({ err }, "pg-boss: error")); + await instance.start(); + boss = instance; + logger.info("pg-boss started"); + return instance; +} + +export async function stopBoss(): Promise { + if (!boss) return; + await boss.stop({ graceful: true, timeout: 5000 }); + boss = null; + logger.info("pg-boss stopped"); +} + +export function getBoss(): PgBoss { + if (!boss) throw new Error("pg-boss not started"); + return boss; +} +``` + +- [ ] **Step 3: Modify `apps/bot/src/index.ts` to start/stop pg-boss** + +Replace contents: + +```typescript +import { logger } from "./logger.js"; +import { pool } from "./db.js"; +import { startHealthServer, setSessionCountsProvider } from "./health.js"; +import { createTelegramBot } from "./telegram/bot.js"; +import { sessionManager } from "./whatsapp/session-manager.js"; +import { startBoss, stopBoss } from "./scheduler/pgboss-client.js"; +import { registerReminderJobs } from "./scheduler/reminder-jobs.js"; + +async function main(): Promise { + logger.info("bot starting"); + const health = startHealthServer(); + setSessionCountsProvider(() => sessionManager.getCounts()); + + const boss = await startBoss(); + await registerReminderJobs(boss); + + const tg = createTelegramBot(); + void tg.start({ + onStart: (info) => logger.info({ username: info.username }, "telegram polling started"), + drop_pending_updates: true, + }); + + await sessionManager.resumeFromDb(); + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "shutting down"); + await tg.stop(); + await sessionManager.stopAll(); + await stopBoss(); + health.close(); + await pool.end(); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + logger.info("bot ready"); +} + +main().catch((err) => { + logger.fatal({ err }, "bot failed to start"); + process.exit(1); +}); +``` + +NOTE: `registerReminderJobs` doesn't exist yet — Task 5 creates it. Either put a temporary stub at the import site for this commit, or commit Task 1 + Task 5 together. The simpler option: add a placeholder file `apps/bot/src/scheduler/reminder-jobs.ts` exporting a no-op `registerReminderJobs(_boss)` that Task 5 will fill in. + +- [ ] **Step 4: Create placeholder `apps/bot/src/scheduler/reminder-jobs.ts`** + +```typescript +import type PgBoss from "pg-boss"; +import { logger } from "../logger.js"; + +// Wired up properly in Task 5. Placeholder so index.ts can import. +export async function registerReminderJobs(_boss: PgBoss): Promise { + logger.debug("registerReminderJobs: placeholder (task 5 will fill in)"); +} +``` + +- [ ] **Step 5: Typecheck and restart** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +NO_SUDO=1 scripts/dev.sh restart-bot +``` + +Verify in logs: `pg-boss started`, no errors. The pgboss schema gets created on first run; verify with: + +```bash +NO_SUDO=1 scripts/dev.sh exec sh -c 'cd packages/db && pnpm exec tsx -e " +(async () => { + const { Pool } = await import(\"pg\"); + const p = new Pool({ connectionString: process.env.DATABASE_URL }); + const r = await p.query(\"SELECT count(*) FROM information_schema.tables WHERE table_schema=\\\$1\", [\"pgboss\"]); + console.log(\"pgboss tables:\", r.rows[0].count); + await p.end(); +})(); +"' +``` + +Expected: 5+ pgboss tables (job, schedule, archive, version, …). + +- [ ] **Step 6: Commit** + +```bash +git add apps/bot pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(scheduler): add pg-boss client + lifecycle" +``` + +--- + +## Task 2: Reminder CRUD helpers + +**Files:** +- Create: `apps/bot/src/reminders/crud.ts` +- Create: `apps/bot/src/reminders/time-parsing.ts` +- Create: `apps/bot/src/reminders/time-parsing.test.ts` + +- [ ] **Step 1: Create `apps/bot/src/reminders/time-parsing.ts`** + +```typescript +import { DateTime } from "luxon"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; + +export type Quick = "now" | "in_1h" | "in_3h" | "tomorrow_9am" | "next_mon_9am"; + +export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): Date { + const now = DateTime.now().setZone(timezone); + switch (quick) { + case "now": + return now.plus({ seconds: 30 }).toJSDate(); + case "in_1h": + return now.plus({ hours: 1 }).toJSDate(); + case "in_3h": + return now.plus({ hours: 3 }).toJSDate(); + case "tomorrow_9am": + return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate(); + case "next_mon_9am": { + const dow = now.weekday; // 1 = Mon + const daysUntilMon = ((1 - dow + 7) % 7) || 7; + return now.plus({ days: daysUntilMon }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate(); + } + } +} + +export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string }; + +const FORMATS = [ + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd HH:mm:ss", + "yyyy/MM/dd HH:mm", + "dd/MM/yyyy HH:mm", + "dd-MM-yyyy HH:mm", +]; + +export function parseFreeText(input: string, timezone: string = DEFAULT_TIMEZONE): ParseResult { + const trimmed = input.trim(); + for (const fmt of FORMATS) { + const dt = DateTime.fromFormat(trimmed, fmt, { zone: timezone }); + if (dt.isValid) { + const jsDate = dt.toJSDate(); + if (jsDate.getTime() <= Date.now()) { + return { ok: false, reason: "Time is in the past" }; + } + return { ok: true, date: jsDate }; + } + } + return { ok: false, reason: "Couldn't parse — try YYYY-MM-DD HH:MM (e.g. 2026-05-15 09:00)" }; +} +``` + +- [ ] **Step 2: Create `apps/bot/src/reminders/time-parsing.test.ts`** + +```typescript +import { describe, it, expect } from "vitest"; +import { quickToDate, parseFreeText } from "./time-parsing.js"; + +describe("quickToDate", () => { + it("in_1h returns ~1h ahead", () => { + const d = quickToDate("in_1h"); + const diffMs = d.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(55 * 60 * 1000); + expect(diffMs).toBeLessThan(65 * 60 * 1000); + }); + it("now returns ~30s ahead", () => { + const d = quickToDate("now"); + expect(d.getTime() - Date.now()).toBeGreaterThan(20 * 1000); + }); +}); + +describe("parseFreeText", () => { + it("accepts YYYY-MM-DD HH:MM", () => { + const r = parseFreeText("2099-12-31 23:59"); + expect(r.ok).toBe(true); + }); + it("rejects past times", () => { + const r = parseFreeText("2020-01-01 00:00"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/past/i); + }); + it("rejects garbage", () => { + const r = parseFreeText("not a date"); + expect(r.ok).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run tests (fail → pass cycle)** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot test +``` + +After step 1 the test should pass since the implementation is in step 1. Confirm: 18 tests pass total (3 new + 15 existing). + +- [ ] **Step 4: Create `apps/bot/src/reminders/crud.ts`** + +```typescript +import { eq, desc } from "drizzle-orm"; +import { + reminders, + reminderMessages, + reminderTargets, + type Reminder, +} from "@cmbot/db"; +import { db } from "../db.js"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; + +export type CreateReminderInput = { + accountId: string; + groupId: string; + name: string; + scheduledAt: Date; + text?: string | null; + mediaId?: string | null; + caption?: string | null; + createdBy: string; + timezone?: string; +}; + +export type ReminderWithDetails = Reminder & { + targets: { groupId: string }[]; + messages: { id: string; position: number; kind: string; textContent: string | null; mediaId: string | null }[]; +}; + +export async function createReminder(input: CreateReminderInput): Promise { + return await db.transaction(async (tx) => { + const [rem] = await tx + .insert(reminders) + .values({ + accountId: input.accountId, + name: input.name, + scheduleKind: "one_off", + scheduledAt: input.scheduledAt, + timezone: input.timezone ?? DEFAULT_TIMEZONE, + status: "active", + createdBy: input.createdBy, + }) + .returning({ id: reminders.id }); + + await tx.insert(reminderTargets).values({ + reminderId: rem!.id, + groupId: input.groupId, + position: 0, + }); + + let position = 0; + if (input.text && !input.mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: position++, + kind: "text", + textContent: input.text, + mediaId: null, + }); + } else if (input.mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: position++, + kind: "media", + textContent: input.caption ?? input.text ?? null, + mediaId: input.mediaId, + }); + } + + return rem!.id; + }); +} + +export async function getReminderWithDetails(id: string): Promise { + const rem = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, id), + }); + if (!rem) return null; + const targets = await db.query.reminderTargets.findMany({ + where: (t, { eq }) => eq(t.reminderId, id), + }); + const messages = await db.query.reminderMessages.findMany({ + where: (m, { eq }) => eq(m.reminderId, id), + orderBy: (m, { asc }) => [asc(m.position)], + }); + return { ...rem, targets, messages }; +} + +export async function listRemindersForOperator( + operatorId: string, + limit = 50, +): Promise<(Reminder & { accountLabel: string; groupCount: number })[]> { + // Hand-rolled SQL for the join — drizzle's relational query doesn't easily + // express "all reminders for accounts owned by this operator". + const rows = await db.execute(/* sql */ ` + SELECT + r.*, + wa.label as account_label, + (SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count + FROM reminders r + JOIN whatsapp_accounts wa ON wa.id = r.account_id + WHERE wa.operator_id = '${operatorId}' + ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC + LIMIT ${limit} + `); + return rows.rows as never; +} + +export async function deleteReminder(id: string): Promise { + await db.delete(reminders).where(eq(reminders.id, id)); +} +``` + +NOTE: the raw SQL string in `listRemindersForOperator` does NOT escape `operatorId`. Since `operatorId` is a UUID we control (looked up server-side), this is safe in this codebase. If you ever feed user-input into it, switch to parameterized SQL using drizzle's `sql\`\`` template tag. + +- [ ] **Step 5: Typecheck and commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +git add apps/bot/src/reminders +git -c commit.gpgsign=false commit -m "feat(reminders): add time-parsing + CRUD helpers" +``` + +--- + +## Task 3: Sender refactor — add media support + +**Files:** +- Modify: `apps/bot/src/whatsapp/sender.ts` + +- [ ] **Step 1: Replace `apps/bot/src/whatsapp/sender.ts`** with the extended version + +```typescript +import { readFile, stat } from "node:fs/promises"; +import type { WASocket, AnyMessageContent } from "@whiskeysockets/baileys"; +import pino from "pino"; + +const logger = pino({ name: "sender" }); + +type SocketWithAssertSessions = WASocket & { + assertSessions?: (jids: string[], force: boolean) => Promise; +}; + +const CHUNK_SIZE = 5; + +async function chunked(items: T[], size: number): Promise { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +async function ensureSessionsForGroup( + socket: WASocket, + groupJid: string, +): Promise<{ ok: number; failed: number; total: number }> { + const metadata = await socket.groupMetadata(groupJid); + const participantJids = metadata.participants.map((p) => p.id); + const internal = socket as SocketWithAssertSessions; + if (typeof internal.assertSessions !== "function") { + return { ok: 0, failed: 0, total: participantJids.length }; + } + let ok = 0; + let failed = 0; + for (const chunk of await chunked(participantJids, CHUNK_SIZE)) { + try { + await internal.assertSessions(chunk, true); + ok += chunk.length; + } catch (err) { + failed += chunk.length; + logger.warn({ groupJid, err: (err as Error).message }, "assertSessions chunk failed"); + } + } + return { ok, failed, total: participantJids.length }; +} + +async function sendWithRetry( + socket: WASocket, + groupJid: string, + content: AnyMessageContent, +): Promise<{ messageId: string | undefined }> { + await ensureSessionsForGroup(socket, groupJid); + try { + const result = await socket.sendMessage(groupJid, content); + return { messageId: result?.key?.id ?? undefined }; + } catch (err) { + const message = (err as Error)?.message ?? ""; + if (message.includes("No sessions")) { + await new Promise((r) => setTimeout(r, 2000)); + await ensureSessionsForGroup(socket, groupJid); + const result = await socket.sendMessage(groupJid, content); + return { messageId: result?.key?.id ?? undefined }; + } + throw err; + } +} + +export async function sendTextToGroup( + socket: WASocket, + groupJid: string, + text: string, +): Promise<{ messageId: string | undefined }> { + return sendWithRetry(socket, groupJid, { text }); +} + +export type MediaKind = "image" | "video" | "document"; + +export async function sendMediaToGroup( + socket: WASocket, + groupJid: string, + kind: MediaKind, + filePath: string, + options: { caption?: string; mimeType?: string; filename?: string } = {}, +): Promise<{ messageId: string | undefined }> { + // Validate the file exists and read into a buffer. For very large files + // (>50MB) Baileys also accepts a stream, but for our reminder use case + // files are typically <30MB which fits comfortably in memory. + await stat(filePath); + const buffer = await readFile(filePath); + + const content: AnyMessageContent = + kind === "image" + ? { image: buffer, caption: options.caption, mimetype: options.mimeType } + : kind === "video" + ? { video: buffer, caption: options.caption, mimetype: options.mimeType } + : { + document: buffer, + caption: options.caption, + fileName: options.filename ?? "file", + mimetype: options.mimeType ?? "application/octet-stream", + }; + + return sendWithRetry(socket, groupJid, content); +} +``` + +- [ ] **Step 2: Typecheck and commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +git add apps/bot/src/whatsapp/sender.ts +git -c commit.gpgsign=false commit -m "feat(bot): extend sender with image/video/document support" +``` + +--- + +## Task 4: Media ingest from Telegram + +**Files:** +- Create: `apps/bot/src/media/ingest.ts` + +- [ ] **Step 1: Create `apps/bot/src/media/ingest.ts`** + +```typescript +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { mediaFiles } from "@cmbot/db"; +import { newMediaPath, absoluteMediaPath } from "@cmbot/shared"; +import { db } from "../db.js"; +import { env } from "../env.js"; +import { logger } from "../logger.js"; + +export type IngestInput = { + operatorId: string; + filenameOriginal: string; + mimeType: string; + buffer: Buffer; +}; + +export type IngestResult = { + mediaId: string; + storagePath: string; +}; + +export async function ingestMediaBuffer(input: IngestInput): Promise { + const sha256 = createHash("sha256").update(input.buffer).digest("hex"); + const storagePath = newMediaPath(input.filenameOriginal); + const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, input.buffer); + + const [row] = await db + .insert(mediaFiles) + .values({ + operatorId: input.operatorId, + filenameOriginal: input.filenameOriginal, + mimeType: input.mimeType, + sizeBytes: input.buffer.byteLength, + sha256, + storagePath, + }) + .returning({ id: mediaFiles.id }); + + logger.info( + { mediaId: row!.id, sizeBytes: input.buffer.byteLength, sha256 }, + "media: ingested", + ); + + return { mediaId: row!.id, storagePath }; +} + +/** + * Download a Telegram file by file_id and ingest it. Returns the new media row. + */ +export async function ingestTelegramFile( + operatorId: string, + apiBase: string, + botToken: string, + fileId: string, + defaultFilename: string, + mimeType: string, +): Promise { + // 1. getFile — Telegram returns a file_path + const getFileUrl = `${apiBase}/bot${botToken}/getFile?file_id=${encodeURIComponent(fileId)}`; + const getFileRes = await fetch(getFileUrl); + if (!getFileRes.ok) { + throw new Error(`Telegram getFile failed: ${getFileRes.status} ${getFileRes.statusText}`); + } + const getFileJson = (await getFileRes.json()) as { + ok: boolean; + result?: { file_path?: string }; + }; + if (!getFileJson.ok || !getFileJson.result?.file_path) { + throw new Error("Telegram getFile: missing file_path in response"); + } + // 2. Download bytes + const downloadUrl = `${apiBase}/file/bot${botToken}/${getFileJson.result.file_path}`; + const dl = await fetch(downloadUrl); + if (!dl.ok) { + throw new Error(`Telegram file download failed: ${dl.status} ${dl.statusText}`); + } + const buffer = Buffer.from(await dl.arrayBuffer()); + + // The Telegram-side filename can be missing; fall back to defaultFilename. + const filename = getFileJson.result.file_path.split("/").pop() ?? defaultFilename; + return ingestMediaBuffer({ + operatorId, + filenameOriginal: filename, + mimeType, + buffer, + }); +} +``` + +- [ ] **Step 2: Typecheck and commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +git add apps/bot/src/media +git -c commit.gpgsign=false commit -m "feat(bot): add Telegram media ingest into /data/media" +``` + +--- + +## Task 5: Fire-reminder handler + reminder-jobs registration + +**Files:** +- Modify: `apps/bot/src/scheduler/reminder-jobs.ts` (replace placeholder) +- Create: `apps/bot/src/scheduler/fire-reminder.ts` + +- [ ] **Step 1: Create `apps/bot/src/scheduler/fire-reminder.ts`** + +```typescript +import { reminderRuns, reminderRunTargets, whatsappGroups, mediaFiles } from "@cmbot/db"; +import { db } from "../db.js"; +import { logger } from "../logger.js"; +import { sessionManager } from "../whatsapp/session-manager.js"; +import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js"; +import { absoluteMediaPath } from "@cmbot/shared"; +import { env } from "../env.js"; +import { writeAuditLog } from "../audit.js"; +import { getReminderWithDetails } from "../reminders/crud.js"; +import { eq } from "drizzle-orm"; + +export type FireReminderPayload = { reminderId: string }; + +export async function fireReminder(payload: FireReminderPayload): Promise { + const reminder = await getReminderWithDetails(payload.reminderId); + if (!reminder) { + logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found"); + return; + } + if (reminder.status !== "active") { + logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)"); + return; + } + + const [run] = await db + .insert(reminderRuns) + .values({ reminderId: reminder.id, status: "pending" }) + .returning({ id: reminderRuns.id }); + const runId = run!.id; + + const session = sessionManager.getSession(reminder.accountId); + if (!session) { + logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected"); + for (const target of reminder.targets) { + await db.insert(reminderRunTargets).values({ + runId, + groupId: target.groupId, + status: "skipped", + error: "account not connected", + }); + } + await db + .update(reminderRuns) + .set({ status: "skipped", errorSummary: "account not connected" }) + .where(eq(reminderRuns.id, runId)); + return; + } + + let allSent = true; + let anySent = false; + for (const target of reminder.targets) { + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, target.groupId), + }); + if (!group) { + await db.insert(reminderRunTargets).values({ + runId, + groupId: target.groupId, + status: "skipped", + error: "group missing from db", + }); + allSent = false; + continue; + } + const start = Date.now(); + try { + let lastMessageId: string | undefined; + for (const part of reminder.messages) { + if (part.kind === "text" && part.textContent) { + const r = await sendTextToGroup(session.socket, group.waGroupJid, part.textContent); + lastMessageId = r.messageId; + } else if (part.mediaId) { + const media = await db.query.mediaFiles.findFirst({ + where: (m, { eq }) => eq(m.id, part.mediaId!), + }); + if (!media) throw new Error(`media row missing: ${part.mediaId}`); + const filePath = absoluteMediaPath(media.storagePath, env.MEDIA_DIR); + const kind: "image" | "video" | "document" = + part.kind === "image" ? "image" : part.kind === "video" ? "video" : "document"; + const r = await sendMediaToGroup(session.socket, group.waGroupJid, kind, filePath, { + caption: part.textContent ?? undefined, + mimeType: media.mimeType, + filename: media.filenameOriginal, + }); + lastMessageId = r.messageId; + } + // 1.5s jitter between message parts to stay under WA's rate limit + await new Promise((r) => setTimeout(r, 1500)); + } + await db.insert(reminderRunTargets).values({ + runId, + groupId: target.groupId, + status: "sent", + waMessageId: lastMessageId ?? null, + latencyMs: Date.now() - start, + }); + anySent = true; + } catch (err) { + logger.error({ err, reminderId: reminder.id, groupId: target.groupId }, "fire-reminder: send failed"); + await db.insert(reminderRunTargets).values({ + runId, + groupId: target.groupId, + status: "failed", + error: (err as Error).message, + }); + allSent = false; + } + } + + const status = allSent ? "success" : anySent ? "partial" : "failed"; + await db + .update(reminderRuns) + .set({ status }) + .where(eq(reminderRuns.id, runId)); + + await writeAuditLog(db, { + operatorId: reminder.createdBy, + source: "system", + action: "reminder.fired", + targetType: "reminder", + targetId: reminder.id, + payload: { runId, status }, + }); + + logger.info({ reminderId: reminder.id, runId, status }, "fire-reminder: done"); +} +``` + +- [ ] **Step 2: Replace `apps/bot/src/scheduler/reminder-jobs.ts`** with the real implementation + +```typescript +import type PgBoss from "pg-boss"; +import { logger } from "../logger.js"; +import { fireReminder, type FireReminderPayload } from "./fire-reminder.js"; + +export const REMINDER_FIRE_QUEUE = "reminder.fire"; + +export async function registerReminderJobs(boss: PgBoss): Promise { + await boss.createQueue(REMINDER_FIRE_QUEUE); + await boss.work(REMINDER_FIRE_QUEUE, async ([job]) => { + if (!job) return; + logger.debug({ jobId: job.id, payload: job.data }, "reminder.fire: handling"); + await fireReminder(job.data); + }); + logger.info("reminder.fire: handler registered"); +} + +export async function scheduleReminderFire( + boss: PgBoss, + reminderId: string, + scheduledAt: Date, +): Promise { + const id = await boss.send( + REMINDER_FIRE_QUEUE, + { reminderId }, + { + startAfter: scheduledAt, + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + // Use the reminderId as a singleton key so re-scheduling cancels the old job + singletonKey: `reminder:${reminderId}`, + }, + ); + logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled"); + return id; +} + +export async function cancelReminderFire(boss: PgBoss, reminderId: string): Promise { + // pg-boss cancels by job id; we don't store the id, so we cancel by singleton key. + // Since we use singletonKey, scheduling a new job with the same key auto-cancels + // the old one. For explicit cancel-on-delete, schedule a far-future no-op or + // use boss.deleteSingleton — but pg-boss 10.x exposes only specific cancel APIs. + // For now we just log; the job will fire and find the reminder gone (handled by + // fire-reminder's "reminder not found" branch). + logger.info({ reminderId }, "reminder.fire: cancel requested (will fizzle on fire)"); +} +``` + +NOTE: `cancelReminderFire` is a soft-cancel — the job still fires, but `fireReminder` exits early when the reminder row is gone. Hard cancel via pg-boss requires storing the `jobId` per reminder; out of scope for plan 2 (added in plan 2.5 if it becomes a real problem). + +- [ ] **Step 3: Typecheck, restart bot, commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +NO_SUDO=1 scripts/dev.sh restart-bot +git add apps/bot/src/scheduler +git -c commit.gpgsign=false commit -m "feat(scheduler): add fire-reminder handler + job registration" +``` + +Verify in logs: `reminder.fire: handler registered`, no errors. + +--- + +## Task 6: Wizard state for reminder creation + +**Files:** +- Modify: `apps/bot/src/telegram/state.ts` + +The wizard needs per-user state spanning multiple messages. State machine: +`idle → picked_account → picked_group → composing → set_time → confirming → done`. + +Stored as a single object keyed by Telegram user ID. + +- [ ] **Step 1: Append wizard helpers to `apps/bot/src/telegram/state.ts`** + +```typescript +// Reminder creation wizard state. +export type WizardStep = + | "pick_account" + | "pick_group" + | "compose" + | "set_time" + | "confirm"; + +export type WizardState = { + step: WizardStep; + accountId?: string; + groupId?: string; + text?: string | null; + mediaId?: string | null; + caption?: string | null; + scheduledAt?: Date; + expiresAt: number; +}; + +const wizardState = new Map(); +const WIZARD_TTL_MS = 30 * 60 * 1000; + +export function getWizard(userId: number): WizardState | null { + const w = wizardState.get(userId); + if (!w) return null; + if (Date.now() >= w.expiresAt) { + wizardState.delete(userId); + return null; + } + return w; +} + +export function startWizard(userId: number): WizardState { + const w: WizardState = { step: "pick_account", expiresAt: Date.now() + WIZARD_TTL_MS }; + wizardState.set(userId, w); + return w; +} + +export function updateWizard(userId: number, patch: Partial): WizardState | null { + const w = getWizard(userId); + if (!w) return null; + const next = { ...w, ...patch, expiresAt: Date.now() + WIZARD_TTL_MS }; + wizardState.set(userId, next); + return next; +} + +export function clearWizard(userId: number): void { + wizardState.delete(userId); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/bot/src/telegram/state.ts +git -c commit.gpgsign=false commit -m "feat(bot): add wizard state for reminder creation" +``` + +--- + +## Task 7: Reminder menu views (list, detail, wizard) + +**Files:** +- Modify: `apps/bot/src/telegram/menus.ts` + +Append the following render functions and update `mainMenu()` to include a 📅 Reminders button. Show the full diff carefully — many additions, one modification. + +- [ ] **Step 1: Update `mainMenu()` in `apps/bot/src/telegram/menus.ts`** to add the Reminders button + +Find: +```typescript +export function mainMenu(): MenuView { + const keyboard = new InlineKeyboard() + .text("📒 Accounts", "m:accounts") + .text("📡 Pair New", "m:pair") + .row() + .text("❓ Help", "m:help"); +``` + +Replace with: +```typescript +export function mainMenu(): MenuView { + const keyboard = new InlineKeyboard() + .text("📒 Accounts", "m:accounts") + .text("📅 Reminders", "m:reminders") + .row() + .text("📡 Pair New", "m:pair") + .text("❓ Help", "m:help"); +``` + +- [ ] **Step 2: Append reminder views at the end of `menus.ts`** + +```typescript +import { listRemindersForOperator, getReminderWithDetails } from "../reminders/crud.js"; +import { DateTime } from "luxon"; + +export async function remindersMenu(operatorId: string, operatorTimezone: string): Promise { + const list = await listRemindersForOperator(operatorId, 30); + const keyboard = new InlineKeyboard(); + for (const r of list) { + const when = r.scheduledAt + ? DateTime.fromJSDate(r.scheduledAt).setZone(operatorTimezone).toFormat("dd MMM HH:mm") + : "—"; + const label = `${r.status === "active" ? "🟢" : r.status === "ended" ? "⚪" : "⏸"} ${r.name} · ${when}`; + keyboard.text(label.slice(0, 60), `rm:${r.id}`).row(); + } + keyboard.text("➕ New Reminder", "rm:new").row().text("⬅ Main Menu", "m:main"); + if (list.length === 0) { + return { + text: "📅 *Reminders*\n\nYou haven't created any reminders yet.", + keyboard, + }; + } + return { + text: `📅 *Reminders* (${list.length})\n\nTap one to view, or *➕ New* to schedule a fresh one.`, + keyboard, + }; +} + +export async function reminderDetailMenu( + reminderId: string, + operatorTimezone: string, +): Promise { + const rem = await getReminderWithDetails(reminderId); + if (!rem) return null; + const when = rem.scheduledAt + ? DateTime.fromJSDate(rem.scheduledAt).setZone(operatorTimezone).toFormat("yyyy-MM-dd HH:mm") + : "—"; + const messagePreview = rem.messages + .map((m) => (m.kind === "text" ? m.textContent : `[${m.kind}]`)) + .filter(Boolean) + .join("\n"); + + const keyboard = new InlineKeyboard() + .text("🗑 Delete", `rm_del:${reminderId}`) + .row() + .text("⬅ Reminders", "m:reminders") + .text("⬅ Main Menu", "m:main"); + + return { + text: + `📅 *${rem.name}*\n\n` + + `When: \`${when}\` (${rem.timezone})\n` + + `Status: \`${rem.status}\`\n` + + `Targets: ${rem.targets.length}\n\n` + + `*Body:*\n${messagePreview || "(empty)"}`, + keyboard, + }; +} + +export function reminderPickAccountMenu( + accounts: { id: string; label: string; phoneNumber: string | null }[], +): MenuView { + const keyboard = new InlineKeyboard(); + for (const a of accounts) { + const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : ""; + keyboard.text(`📒 ${a.label}${phone}`, `rm_acc:${a.id}`).row(); + } + keyboard.text("⬅ Cancel", "m:reminders"); + return { + text: "➕ *New Reminder — Step 1 / 4*\n\nWhich WhatsApp account should send it?", + keyboard, + }; +} + +export function reminderPickGroupMenu( + groups: { id: string; name: string }[], +): MenuView { + const keyboard = new InlineKeyboard(); + for (const g of groups.slice(0, 30)) { + const name = g.name.length > 32 ? `${g.name.slice(0, 31)}…` : g.name; + keyboard.text(`👥 ${name}`, `rm_grp:${g.id}`).row(); + } + keyboard.text("⬅ Cancel", "m:reminders"); + return { + text: "➕ *New Reminder — Step 2 / 4*\n\nWhich group?", + keyboard, + }; +} + +export function reminderComposeMenu(): MenuView { + const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:reminders"); + return { + text: + "➕ *New Reminder — Step 3 / 4*\n\n" + + "Send the message body now — text, photo, video, or document.\n\n" + + "Reply to *this* message with what you want sent. " + + "If you send media with a caption, the caption is included.", + keyboard, + }; +} + +export function reminderTimeMenu(): MenuView { + const keyboard = new InlineKeyboard() + .text("🕐 In 1 hour", "rm_t:in_1h") + .text("🕒 In 3 hours", "rm_t:in_3h") + .row() + .text("🌅 Tomorrow 9 AM", "rm_t:tomorrow_9am") + .text("📅 Next Mon 9 AM", "rm_t:next_mon_9am") + .row() + .text("⌨️ Custom date/time", "rm_t:custom") + .row() + .text("⬅ Cancel", "m:reminders"); + return { + text: + "➕ *New Reminder — Step 4 / 4*\n\n" + + "When should it fire? Pick a quick option or type a date/time.", + keyboard, + }; +} + +export function reminderConfirmMenu(summary: { + accountLabel: string; + groupName: string; + body: string; + whenLocal: string; +}): MenuView { + const keyboard = new InlineKeyboard() + .text("✅ Schedule", "rm_save") + .text("⬅ Cancel", "m:reminders"); + return { + text: + "*Review*\n\n" + + `Account: ${summary.accountLabel}\n` + + `Group: ${summary.groupName}\n` + + `When: ${summary.whenLocal}\n\n` + + `*Body:*\n${summary.body}`, + keyboard, + }; +} +``` + +- [ ] **Step 3: Typecheck and commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +git add apps/bot/src/telegram/menus.ts +git -c commit.gpgsign=false commit -m "feat(bot): add reminder menu views (list, detail, wizard steps)" +``` + +--- + +## Task 8: Wire reminder callbacks into bot.ts and message:text handler + +**Files:** +- Modify: `apps/bot/src/telegram/callbacks.ts` +- Modify: `apps/bot/src/telegram/bot.ts` +- Create: `apps/bot/src/telegram/commands/reminders.ts` + +This is the biggest task. The reminder wizard touches several of the existing patterns. Read carefully. + +- [ ] **Step 1: Append callback handlers to `apps/bot/src/telegram/callbacks.ts`** + +```typescript +import { + remindersMenu, + reminderDetailMenu, + reminderPickAccountMenu, + reminderPickGroupMenu, + reminderComposeMenu, + reminderTimeMenu, + reminderConfirmMenu, +} from "./menus.js"; +import { + startWizard, + getWizard, + updateWizard, + clearWizard, +} from "./state.js"; +import { createReminder, deleteReminder, getReminderWithDetails } from "../reminders/crud.js"; +import { quickToDate, parseFreeText, type Quick } from "../reminders/time-parsing.js"; +import { ingestTelegramFile } from "../media/ingest.js"; +import { scheduleReminderFire, cancelReminderFire } from "../scheduler/reminder-jobs.js"; +import { getBoss } from "../scheduler/pgboss-client.js"; +import { env } from "../env.js"; +import { DateTime } from "luxon"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; + +export async function showRemindersMenu(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await remindersMenu(op.id, op.defaultTimezone ?? DEFAULT_TIMEZONE); + await showMenu(ctx, view); +} + +export async function showReminderDetail(ctx: Context, reminderId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await reminderDetailMenu(reminderId, op.defaultTimezone ?? DEFAULT_TIMEZONE); + if (!view) { + await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true }); + return; + } + await showMenu(ctx, view); +} + +export async function deleteReminderCallback(ctx: Context, reminderId: string): Promise { + const op = await findOperator(ctx); + if (!op) { + await ctx.answerCallbackQuery(); + return; + } + const rem = await getReminderWithDetails(reminderId); + if (!rem) { + await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true }); + return; + } + await deleteReminder(reminderId); + await cancelReminderFire(getBoss(), reminderId); + await writeAuditLog(db, { + operatorId: op.id, + source: "telegram", + action: "reminder.deleted", + targetType: "reminder", + targetId: reminderId, + payload: { name: rem.name }, + }); + await ctx.answerCallbackQuery({ text: "Deleted." }); + await showRemindersMenu(ctx); +} + +export async function startReminderWizard(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const userId = ctx.from?.id; + if (!userId) return; + startWizard(userId); + + const accounts = await db.query.whatsappAccounts.findMany({ + where: (a, { eq }) => eq(a.operatorId, op.id), + orderBy: (a, { asc }) => [asc(a.label)], + }); + if (accounts.length === 0) { + await showMenu(ctx, { + text: "You need to pair an account before scheduling a reminder.", + keyboard: new InlineKeyboard().text("⬅ Reminders", "m:reminders"), + }); + return; + } + await showMenu(ctx, reminderPickAccountMenu(accounts)); +} + +export async function wizardPickAccount(ctx: Context, accountId: string): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + updateWizard(userId, { step: "pick_group", accountId }); + const groups = await db.query.whatsappGroups.findMany({ + where: (g, { eq }) => eq(g.accountId, accountId), + orderBy: (g, { asc }) => [asc(g.name)], + }); + await showMenu(ctx, reminderPickGroupMenu(groups)); +} + +export async function wizardPickGroup(ctx: Context, groupId: string): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + updateWizard(userId, { step: "compose", groupId }); + await showMenu(ctx, reminderComposeMenu()); +} + +export async function wizardSetTimeQuick(ctx: Context, quick: Quick): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + const w = getWizard(userId); + if (!w) { + await ctx.reply("Wizard expired. Tap /menu to start again."); + return; + } + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const date = quickToDate(quick, tz); + updateWizard(userId, { step: "confirm", scheduledAt: date }); + await showWizardConfirm(ctx); +} + +export async function wizardSetTimeCustomPrompt(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + updateWizard(userId, { step: "set_time" }); + await showMenu(ctx, { + text: "⌨️ Reply with date/time as `YYYY-MM-DD HH:MM`, e.g. `2026-05-15 09:00`.", + keyboard: new InlineKeyboard().text("⬅ Cancel", "m:reminders"), + }); +} + +export async function showWizardConfirm(ctx: Context): Promise { + const userId = ctx.from?.id; + if (!userId) return; + const w = getWizard(userId); + if (!w || !w.accountId || !w.groupId || !w.scheduledAt) { + await ctx.reply("Wizard incomplete. Tap /menu and try again."); + return; + } + const op = await findOperator(ctx); + if (!op) return; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, w.accountId!), eq(a.operatorId, op.id)), + }); + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, w.groupId!), + }); + if (!account || !group) { + await ctx.reply("Account or group missing. Tap /menu and try again."); + return; + } + const tz = op.defaultTimezone ?? DEFAULT_TIMEZONE; + const whenLocal = DateTime.fromJSDate(w.scheduledAt).setZone(tz).toFormat("yyyy-MM-dd HH:mm"); + const body = w.text || (w.mediaId ? `[${w.mediaId.startsWith("img") ? "image" : "media"}]${w.caption ? ` ${w.caption}` : ""}` : "(empty)"); + await showMenu(ctx, reminderConfirmMenu({ + accountLabel: account.label, + groupName: group.name, + body, + whenLocal: `${whenLocal} (${tz})`, + })); +} + +export async function wizardSave(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + const w = getWizard(userId); + if (!w || !w.accountId || !w.groupId || !w.scheduledAt) { + await ctx.reply("Wizard incomplete. Tap /menu and try again."); + return; + } + const op = await findOperator(ctx); + if (!op) return; + const reminderId = await createReminder({ + accountId: w.accountId, + groupId: w.groupId, + name: w.text?.slice(0, 50) ?? "Reminder", + scheduledAt: w.scheduledAt, + text: w.text ?? null, + mediaId: w.mediaId ?? null, + caption: w.caption ?? null, + createdBy: op.id, + timezone: op.defaultTimezone ?? DEFAULT_TIMEZONE, + }); + await scheduleReminderFire(getBoss(), reminderId, w.scheduledAt); + await writeAuditLog(db, { + operatorId: op.id, + source: "telegram", + action: "reminder.created", + targetType: "reminder", + targetId: reminderId, + payload: { scheduledAt: w.scheduledAt.toISOString() }, + }); + clearWizard(userId); + await ctx.reply( + `✅ Scheduled. Tap /menu → 📅 Reminders to view.`, + ); +} +``` + +- [ ] **Step 2: Create `apps/bot/src/telegram/commands/reminders.ts`** + +```typescript +import type { Context } from "grammy"; +import { showRemindersMenu } from "../callbacks.js"; + +export async function handleReminders(ctx: Context): Promise { + await showRemindersMenu(ctx); +} +``` + +- [ ] **Step 3: Wire callbacks + commands in `apps/bot/src/telegram/bot.ts`** + +Add new imports: +```typescript +import { handleReminders } from "./commands/reminders.js"; +import { + showRemindersMenu, + showReminderDetail, + deleteReminderCallback, + startReminderWizard, + wizardPickAccount, + wizardPickGroup, + wizardSetTimeQuick, + wizardSetTimeCustomPrompt, + wizardSave, + showWizardConfirm, +} from "./callbacks.js"; +import { getWizard, updateWizard, clearWizard } from "./state.js"; +import { ingestTelegramFile } from "../media/ingest.js"; +import { parseFreeText, type Quick } from "../reminders/time-parsing.js"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; +``` + +Add new commands and callbacks (place after the existing ones): +```typescript +bot.command("reminders", handleReminders); + +bot.callbackQuery("m:reminders", showRemindersMenu); +bot.callbackQuery("rm:new", startReminderWizard); +bot.callbackQuery(/^rm_acc:(.+)$/, async (ctx) => { + await wizardPickAccount(ctx, ctx.match[1]!); +}); +bot.callbackQuery(/^rm_grp:(.+)$/, async (ctx) => { + await wizardPickGroup(ctx, ctx.match[1]!); +}); +bot.callbackQuery(/^rm_t:(.+)$/, async (ctx) => { + const quick = ctx.match[1]!; + if (quick === "custom") { + await wizardSetTimeCustomPrompt(ctx); + } else { + await wizardSetTimeQuick(ctx, quick as Quick); + } +}); +bot.callbackQuery("rm_save", wizardSave); +bot.callbackQuery(/^rm_del:(.+)$/, async (ctx) => { + await deleteReminderCallback(ctx, ctx.match[1]!); +}); +bot.callbackQuery(/^rm:(.+)$/, async (ctx) => { + await showReminderDetail(ctx, ctx.match[1]!); +}); +``` + +- [ ] **Step 4: Update the message:text handler in `bot.ts`** + +The handler needs three new branches: +1. Wizard step `compose` + plain text → set `text`, advance to `set_time`, show time menu. +2. Wizard step `set_time` (custom path) → parse date, advance to `confirm`, show confirm. + +Find the existing `bot.on("message:text", ...)` block and add wizard handling before the "Tap /menu" fallback. The full updated handler: + +```typescript +bot.on("message:text", async (ctx) => { + const text = ctx.message?.text ?? ""; + if (text.startsWith("/")) return; + const tgId = ctx.from?.id; + if (tgId === undefined) return; + + // Pending "Pair New" label + if (consumePendingPairLabel(tgId)) { + const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, ""); + if (!label) { + await ctx.reply("That label is empty. Tap /menu and try again."); + return; + } + await executePairFlow(ctx, label); + return; + } + + // Pending "Send Test" message body + const pendingGroupId = consumePendingSendToGroup(tgId); + if (pendingGroupId) { + const body = text.trim(); + if (!body) { + await ctx.reply("Empty message. Tap /menu and try again."); + return; + } + await executeSendTest(ctx, pendingGroupId, body); + return; + } + + // Reminder wizard + const w = getWizard(tgId); + if (w) { + if (w.step === "compose") { + updateWizard(tgId, { text: text.trim() }); + // Advance to time step + const { reminderTimeMenu } = await import("./menus.js"); + const view = reminderTimeMenu(); + await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); + return; + } + if (w.step === "set_time") { + const op = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, tgId), + }); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const parsed = parseFreeText(text, tz); + if (!parsed.ok) { + await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`); + return; + } + updateWizard(tgId, { step: "confirm", scheduledAt: parsed.date }); + await showWizardConfirm(ctx); + return; + } + } + + await ctx.reply("Tap /menu to see what I can do."); +}); +``` + +- [ ] **Step 5: Add a media handler for the wizard's compose step** + +Below the `message:text` handler in `bot.ts`, add: + +```typescript +bot.on(["message:photo", "message:video", "message:document"], async (ctx) => { + const tgId = ctx.from?.id; + if (tgId === undefined) return; + const w = getWizard(tgId); + if (!w || w.step !== "compose") return; + const op = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, tgId), + }); + if (!op) return; + // Identify the file + const photo = ctx.message?.photo; + const video = ctx.message?.video; + const doc = ctx.message?.document; + let fileId: string | null = null; + let mimeType = "application/octet-stream"; + let filename = "media"; + let kind: "image" | "video" | "document" = "document"; + if (photo && photo.length > 0) { + fileId = photo[photo.length - 1]!.file_id; + mimeType = "image/jpeg"; + filename = "photo.jpg"; + kind = "image"; + } else if (video) { + fileId = video.file_id; + mimeType = video.mime_type ?? "video/mp4"; + filename = video.file_name ?? "video.mp4"; + kind = "video"; + } else if (doc) { + fileId = doc.file_id; + mimeType = doc.mime_type ?? "application/octet-stream"; + filename = doc.file_name ?? "document"; + kind = "document"; + } + if (!fileId) return; + + await ctx.reply("📥 Downloading…"); + try { + const result = await ingestTelegramFile( + op.id, + "https://api.telegram.org", + env.TELEGRAM_BOT_TOKEN, + fileId, + filename, + mimeType, + ); + const caption = ctx.message?.caption ?? null; + updateWizard(tgId, { mediaId: result.mediaId, caption, text: caption }); + // Advance to time step + const { reminderTimeMenu } = await import("./menus.js"); + const view = reminderTimeMenu(); + await ctx.reply(`✅ ${kind} stored. Now pick a time.`, { + reply_markup: view.keyboard, + parse_mode: "Markdown", + }); + } catch (err) { + logger.error({ err }, "wizard media ingest failed"); + await ctx.reply(`❌ Couldn't download/store the file: ${(err as Error).message}`); + } +}); +``` + +NOTE: this requires `db` and `logger` to be imported in `bot.ts` if they aren't already. Check the existing imports and add what's missing. + +- [ ] **Step 6: Update setMyCommands** to include `/reminders` + +Find the `setMyCommands` block and add a `reminders` entry between `accounts` and `pair`: +```typescript +{ command: "reminders", description: "List and schedule reminders" }, +``` + +- [ ] **Step 7: Typecheck, restart, commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +NO_SUDO=1 scripts/dev.sh restart-bot +git add apps/bot +git -c commit.gpgsign=false commit -m "feat(bot): wire reminder wizard + list/detail callbacks" +``` + +--- + +## Task 9: VERIFY — end-to-end one-off reminder + +This is a manual verification step. + +- [ ] **Step 1: Schedule a near-future text reminder** + +In Telegram: +1. `/menu` → 📅 Reminders → ➕ New Reminder +2. Pick your paired account. +3. Pick a small test group. +4. Reply with a short text, e.g. `Test reminder from bot`. +5. Tap 🕐 In 1 hour — wait, that's too long. Instead pick "Custom" and reply with a date/time ~1 minute in the future, e.g. `2026-05-09 16:30` (replacing with current local time + 1 min). +6. ✅ Schedule. + +- [ ] **Step 2: Watch logs** + +```bash +docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml logs -f bot +``` + +After ~1 minute you should see: +``` +reminder.fire: scheduled +... (delay) ... +reminder.fire: handling +fire-reminder: done status=success +``` + +And a message arriving in your test WhatsApp group. + +- [ ] **Step 3: Verify in DB** + +```bash +NO_SUDO=1 scripts/dev.sh exec sh -c 'cd packages/db && pnpm exec tsx -e " +(async () => { + const { Pool } = await import(\"pg\"); + const p = new Pool({ connectionString: process.env.DATABASE_URL }); + console.log(\"reminders:\", (await p.query(\"SELECT id, name, status, scheduled_at FROM reminders\")).rows); + console.log(\"runs:\", (await p.query(\"SELECT reminder_id, status, fired_at FROM reminder_runs ORDER BY fired_at DESC LIMIT 5\")).rows); + console.log(\"run_targets:\", (await p.query(\"SELECT run_id, status, latency_ms, error FROM reminder_run_targets ORDER BY run_id DESC LIMIT 5\")).rows); + await p.end(); +})(); +"' +``` + +Expected: reminder row exists, one reminder_runs row with `status='success'`, one reminder_run_targets row with `status='sent'` and a non-null `latency_ms`. + +- [ ] **Step 4: Try a media reminder** + +Repeat steps 1-6 but in step 4 send a small photo with a caption instead of typing text. Verify the photo arrives in the WhatsApp group. + +- [ ] **Step 5: Try a delete** + +Schedule a 5-minute-out reminder, then immediately go to /menu → 📅 Reminders → tap it → 🗑 Delete. The pg-boss job will still fire but `fireReminder` will skip with "reminder not found" (soft cancel). + +- [ ] **Step 6: Sign-off (no commit)** + +If all the above works, the headline reminder feature is solid. If anything fails, capture the error and we'll patch. + +--- + +## Plan 2 done — what's working + +After all 9 tasks: + +- pg-boss running against the same Postgres, with retry-with-backoff job semantics. +- Reminder CRUD via Telegram menu wizard: pick account → pick group → compose text/media → pick time (quick or custom) → confirm. +- Media upload from Telegram (photo, video, document) — downloaded once, hashed, stored on the shared volume, referenced by reminder. +- Fire-reminder handler sends each message part in order, throttled with 1.5s jitter, records per-target results. +- Run history visible (via DB queries; UI improvements can come later). +- Soft cancel on delete — fired job skips when reminder is gone. + +## Deferred / next plan + +- **Recurring reminders (RRULE)** — schema is ready; needs wizard quick-picks (every day at 9am, every Monday, etc.) and pg-boss `repeatEvery` wiring. +- **Multi-group targets** per reminder. +- **Multi-part messages** (text + image + caption stack). +- **Run history view in menu** — currently you have to query Postgres directly to see past sends. +- **Web dashboard (plan 3)** — replaces the Telegram wizard with a richer UI for media upload and bulk scheduling.