Observed: reminder fired twice within ~2s. The bot logs showed two
distinct pg-boss jobIds for the same reminder enqueued at the same
scheduledAt — both ran fire-reminder, both sent the message.
Root cause: pg-boss's `singletonKey` only deduplicates on queues with
a 'singleton' / 'stately' / 'short' policy. Our queue was created
without specifying a policy, defaulting to 'standard', which IGNORES
the singletonKey. Two sends with the same key produced two jobs.
Fix lives at two layers:
* Layer 1 — queue policy. createQueue(REMINDER_FIRE_QUEUE) now
passes `{ policy: 'stately' }`. With this, future fresh deploys
fold a duplicate send (same singletonKey) into the existing
'created' job rather than producing a second one. This doesn't
retroactively change an existing queue's policy (pg-boss doesn't
support that), but new queues are correct from creation.
* Layer 2 — defense-in-depth check inside fireReminder. Before
acquiring the per-account mutex, query reminderRuns for any row
with the same reminderId fired in the last 30s. If found, log
+ bail. This guards against:
- Existing queues stuck on policy='standard'.
- Race windows even within 'stately' policy.
- The operator double-clicking Save in the wizard.
- A jittery pg_notify('bot.command') replay.
Resume jobs (payload.runId set) skip this check — they're meant
to attach to an existing run.
Tests:
* New "BAILS OUT when a fresh fire collides with a recent run" case
in fire-reminder.test.ts.
* beforeEach now resets findExistingRunMock too, since both the
resume and dedupe paths share that mock.
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.