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>
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
.movvideos 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/nextfor the PWA shell.toolsis 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 at192.168.0.210in awabotdatabase. All cross-service communication goes through Postgres (LISTEN/NOTIFYfor 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 consumerapps/web/— Next.js 16 App Router PWApackages/db/— Drizzle schema and migrationspackages/shared/— cross-app helpers (rrule, media paths, timezones, WhatsApp media classifier)docs/superpowers/specs/— design specs and manual test runbooksdocs/superpowers/plans/— implementation plansdocker/— 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_idon every row, but the seed runs as a single operator and there's no /signup or invite flow yet.