yiekheng a789b61e1f fix(bot): triple-fire reminder bug — force pg-boss policy + close TOCTOU dedupe
Repro: fire a reminder, message lands 2-3 times in WhatsApp (logs
showed three 'fire-reminder: done' entries within 1.5 s for the same
reminderId).

Two interlocking root causes:

  1. The queue was created at 'standard' policy (pre-dating the
     stately rollout). pg-boss's createQueue is idempotent and DOES
     NOT update the policy on an existing queue row, so re-deploying
     the code that requested policy=stately silently kept the
     standard policy. Standard accepts duplicate enqueues with the
     same singletonKey — three reminder.fire jobs for the same
     reminderId could all land at once.

  2. The handler-level recent-run dedupe was TOCTOU. The check ran
     OUTSIDE the per-account mutex, so three concurrent invocations
     all read 'no recent run', then queued up on the mutex one at a
     time and each INSERTed a fresh run + sent the message.

Fixes:

  - registerReminderJobs now forces the queue policy via direct SQL
    (UPDATE pgboss.queue SET policy = 'stately' WHERE name = ...
    AND policy <> 'stately') on every boot. Idempotent + survives
    pre-existing standard-policy rows.
  - fireReminderInner re-checks for a recent run AFTER the mutex is
    held but BEFORE the INSERT. By that point any concurrent winner
    has already inserted, so the duplicate sees the row and bails
    cleanly.

New test in fire-reminder.test.ts (the TOCTOU repro): outer check
returns no recent run, inner check returns a freshly-inserted one,
asserts the mutex was acquired but the second findFirst was hit
(i.e. we got past the outer check and the inner check stopped us).

Verified live: pgboss.queue.policy is now 'stately' for reminder.fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:43:41 +08:00

cm WhatsApp Reminder Bot

Self-hosted WhatsApp reminder bot. Pair multiple WhatsApp accounts via a browser-based PWA, schedule recurring reminders to groups, and watch the run history all from a phone home-screen icon.

Status

Plans 1, 2, and 3 complete. The web app at wabot.04080616.xyz is the primary control surface; the Telegram bot has been removed.

What's working today:

  • Self-hosted Next.js 16 PWA — installable on a phone home screen. Mobile-first single-row header with a slide-out drawer; desktop sidebar.
  • Live QR pairing — server-side Baileys session feeds the QR payload directly into the browser via Server-Sent Events. Scan, see " Connected" within seconds, auto-redirect.
  • Multi-account, multi-group reminders — 5-step wizard (Account → Message → When → Groups → Review) plus per-section edit pages so you don't have to walk the wizard end-to-end to fix one field. Active recurrence picker covers Daily / Weekly / Monthly / Yearly with multi-rule support and per-rule fire-time pickers; the rendered description reads as plain English ("Every week on Mon, Wed, Fri at 09:00") not raw cron.
  • Multi-message stacks — a reminder can carry multiple ordered parts (text + media), fired in sequence with a 1.5 s gap. Media files swap at any time from the Edit Message page.
  • Smart media handling — per-kind WhatsApp size caps (5 MB image, 16 MB video/audio, 100 MB document). HEIC photos and .mov videos fall back to the document delivery path so they reach the recipient as a downloadable file instead of failing silently.
  • Swipe-to-act rows — on mobile, swipe a reminder or activity row left for Delete or right for Pause/Restart/Archive. iOS-Mail style.
  • Activity tab — last 200 runs with status filters (Success / Partial / Failed / Skipped) plus an Archived tab. Archive a noisy run to keep the main list readable; restore later. Hard-delete always available. Run history survives a reminder deletion.
  • Auto-reconnect on transient drops; restart-survival via Baileys session persistence. Pair once, the device stays linked across container restarts.
  • All actions audited. Reminder run history queryable from the UI; per-run target results (sent / failed / skipped) preserved even when the underlying group is removed.

Test count: 249 web + 31 shared + 26 bot = 306 passing.

Host requirements

Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived tools sidecar.

Architecture in one paragraph

Two app containers and one external dependency. bot (Node.js) holds the live Baileys WhatsApp sessions, the pg-boss scheduler, and a Postgres LISTEN bot.command consumer. web (Next.js 16 App Router

  • React 19) is stateless UI: Server Components for reads, Server Actions for mutations, an SSE endpoint for live updates, @serwist/next for the PWA shell. tools is a long-running Node 22 + pnpm sidecar used for installs / tests / typechecks / migrations so the host doesn't need a Node toolchain. Postgres lives external at 192.168.0.210 in a wabot database. All cross-service communication goes through Postgres (LISTEN/NOTIFY for events, table writes for state).

Full design spec: docs/superpowers/specs/2026-05-09-web-app-design.md

Quick start (dev)

Prerequisites: Docker, the wabot database + waBot role on 192.168.0.210 (with a pg_hba.conf line permitting 192.168.0.0/24).

# 1. Configure env
cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
scripts/gen_auth_secret.sh --write

# 2. Bring up the stack, install deps
NO_SUDO=1 scripts/dev.sh up
NO_SUDO=1 scripts/dev.sh pnpm install

# 3. Apply migrations and seed your operator row
NO_SUDO=1 scripts/db.sh migrate
NO_SUDO=1 scripts/db.sh seed

# 4. Open the web app
#    Local:  http://localhost:9000
#    LAN:    http://<host-ip>:9000   (e.g. http://192.168.0.253:9000)
#    Public: https://wabot.04080616.xyz   (whatever your reverse proxy serves)

Pair an account: /accounts → "New Account" → enter a label → "Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".

PWA install: phone Chrome → menu → "Install App" / "Add to Home Screen". Launches fullscreen.

NO_SUDO=1 is the right setting if your user is in the docker group (the default for this repo). Drop it if you need sudo docker.

Manual test runbook

End-to-end checks that unit tests can't cover (live Baileys, WhatsApp delivery, swipe gestures): docs/superpowers/specs/manual-test-web.md.

Layout

  • apps/bot/ — Baileys WhatsApp + pg-boss scheduler + LISTEN/NOTIFY command consumer
  • apps/web/ — Next.js 16 App Router PWA
  • packages/db/ — Drizzle schema and migrations
  • packages/shared/ — cross-app helpers (rrule, media paths, timezones, WhatsApp media classifier)
  • docs/superpowers/specs/ — design specs and manual test runbooks
  • docs/superpowers/plans/ — implementation plans
  • docker/ — Dockerfiles (tools.Dockerfile, bot.Dockerfile, web.Dockerfile)
  • scripts/dev.sh, db.sh, gen_auth_secret.sh

Scripts

All pnpm/tsx/drizzle-kit invocations run inside the tools container, so no host Node is needed.

Script Purpose
scripts/dev.sh up|down|logs|status|build|exec|pnpm|shell|restart-bot Stack lifecycle and tools-container shell
scripts/db.sh migrate|generate|studio|seed|reset Drizzle migration helper
scripts/gen_auth_secret.sh [--write] Generate AUTH_SECRET (host-only, no Node needed)

Set NO_SUDO=1 if your user is in the docker group (recommended).

Deferred

  • Standalone media library browser (currently media is uploaded per-reminder).
  • E2E browser tests (Playwright) on the swipe and pairing flows.
  • Auth (passkeys / email-password) — bring back if URL exposure becomes a concern. Today the app trusts whatever's in front of the reverse proxy.
  • Multi-operator — schema supports operator_id on every row, but the seed runs as a single operator and there's no /signup or invite flow yet.
Description
No description provided
Readme 1.5 MiB
Languages
TypeScript 97.8%
Shell 1.2%
Dockerfile 0.5%
CSS 0.5%