Reminders can now deliver a stack of message parts in send order. The
DB and bot's fire-reminder loop already supported this — only the UI
and the server action's input shape were single-message. This change
makes the whole flow stack-aware end-to-end.
What's new
----------
A reminder is now a list of MessagePart objects:
{ kind: "text", textContent: "Hi", mediaId: null }
{ kind: "media", textContent: "cap", mediaId: uuid }
{ kind: "media", textContent: null, mediaId: uuid }
The bot fires them in order with ~1.5 s spacing (already the case in
fire-reminder.ts).
Cap of 10 parts per reminder. Anything more clutters the URL beyond
the 2KB practical budget for the wizard's encoded `messages=…` param.
Where this shows up
-------------------
1. `<MessageStack>` — new shared component (apps/web/src/components/
message-stack.tsx). Each block is either a text Textarea or a
media block (file picker + preview + caption Input). Per-block
move-up / move-down / delete buttons. "+ Add text" / "+ Add file"
buttons at the bottom. Reused by both the wizard's compose step
AND the per-section Edit Message page.
2. Edit Message page — was a single Textarea + read-only attachment
indicator with a "Replacing it isn't supported" note. Now uses
MessageStack and lets the operator add/remove/reorder parts AND
swap the file on a media block, fixing
the asked-for "should let user change media files too" gap.
3. Wizard — Compose / When / Groups / Review pass a single
`messages=<urlencoded JSON>` param instead of three separate
text/mediaId/caption fields. The Review step renders one row per
part, with file names resolved from the DB so users see "menu.pdf"
not an opaque uuid. Every step accepts the legacy fields too and
folds them into the new shape on entry, so older bookmarked URLs
keep working.
4. Server actions (createReminder / updateReminder) accept either:
- The new `messages: MessagePart[]` field, OR
- The legacy `text` / `mediaId` / `caption` triple,
and resolve to a flat parts list before doing anything else. Both
actions then write one row per part into `reminder_messages` with
a sequential `position` column, replacing the old "always 1 row"
logic in updateReminderAction.
5. The reminder name (visible in lists, detail header, etc.) is
sourced from the first part with a non-empty text body — falling
back to the literal "Reminder" if every part is media-without-
caption. Capped at 50 chars to fit the existing column.
Wire-format helpers
-------------------
New `lib/reminder-messages.ts`:
- `MessagePart` interface (the canonical shape)
- `isValidMessagePart` — reject empty texts and orphan-mediaId rows
- `encodeMessages` / `decodeMessages` — URI-encoded JSON, drops
invalid entries, returns null when nothing valid is left
- `legacyMessageToParts` — synthesise a one-element stack from the
old text/mediaId/caption fields (used by step pages on entry)
Tests (15 + 5 = 20 new; 146 total, was 132 + adjustment)
--------------------------------------------------------
- `lib/reminder-messages.test.ts`: round-trip a non-trivial stack;
survive URL-unsafe characters in text (\\n, & = % #); reject
null / empty / garbage; drop invalid entries; legacy-fallback paths.
- `edit-message-form.test.tsx`: rewrites for the new prop shape
(initialMessages instead of initialText/initialMediaId/initialCaption);
asserts the form renders one block per initial part and that media
filename appears in the SSR markup.
- `no-render-warnings.test.tsx`: same prop-shape update for the two
EditMessageForm hydration / button-nesting guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cm WhatsApp Reminder Bot
Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram-delivered QR codes and sends scheduled reminders to groups.
Status
Plan 1 complete. Foundation, DB schema, and Telegram-driven WhatsApp pairing are working end-to-end. Reminder scheduling, the web dashboard, and production deploy are upcoming plans (docs/superpowers/plans/).
What's working today:
- Single-operator Telegram bot with a whitelist + audit log of every command.
- BotFather-style menu navigation:
/menuopens a single message that edits in place as you navigate. - Pair a new WhatsApp account with
/menu→ 📡 Pair New → reply with a label. QR is delivered to Telegram and refreshed in place as it expires. - Browse paired accounts with 📒 Accounts. Tap an account → see groups, send a test text message, or unpair.
- Group sync runs at pairing and on every Baileys
groups.upsert/groups.updateevent, plus a manual 🔄 Refresh button. Removed groups are pruned automatically. - Auto-reconnect on transient drops; restart-survival via Baileys
useMultiFileAuthState(no QR rescan needed across container restarts as long as WhatsApp hasn't logged the device out).
Host requirements
Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived tools service.
Architecture in one paragraph
Two app containers and one external dependency. bot (Node.js) holds the live Baileys WhatsApp sessions, the grammy Telegram bot, and (in plan 2) a pg-boss scheduler. web (Next.js, plan 3) is stateless UI + API. 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-03-whatsapp-bot-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), and a Telegram bot token from @BotFather.
# 1. Configure env
cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, TELEGRAM_BOT_TOKEN, your TG user ID
scripts/gen_auth_secret.sh --write
# 2. Bring up the tools container, 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. Watch the bot service
NO_SUDO=1 scripts/dev.sh logs bot
In Telegram, message your dev bot /menu, tap 📡 Pair New, reply with a label, scan the QR.
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.
Layout
apps/bot/— Node service: Baileys WhatsApp + grammy Telegram + (later) pg-boss schedulerapps/web/— Next.js dashboard (plan 3)packages/db/— Drizzle schema and migrationspackages/shared/— cross-app helpers (rrule, media paths, timezones)docs/superpowers/specs/— design specs and manual test runbooksdocs/superpowers/plans/— implementation plansdocker/— Dockerfiles (tools.Dockerfile,bot.Dockerfile,web.Dockerfileplaceholder)scripts/—dev.sh,db.sh,gen_auth_secret.sh, plus stubs for plans 2/4
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) |
scripts/publish.sh |
Push to Gitea registry — implemented in plan 4 |
scripts/link-account.sh |
CLI pairing without Telegram — implemented in plan 2 |
Set NO_SUDO=1 if your user is in the docker group (recommended).
Next plan
docs/superpowers/plans/<next-date>-reminder-scheduling.md — pg-boss, reminder CRUD via Telegram, fire-reminder handler, sender (text/image/video), retry policy, run history.