yiekheng 68d3de5ee2 feat(reminders): user-supplied name; auto-derived as fallback
Reminders pick up a real, user-controlled name instead of being
auto-named from the first message body. Auto-derive stays as the
fallback so empty inputs still produce something useful.

Resolution policy (single source of truth in lib/reminder-name.ts)
------------------------------------------------------------------
1. User-supplied name, trimmed, clamped to 60 chars.
2. First text-bearing message part — text body or media caption,
   trimmed, clamped to 60.
3. Literal "Reminder" (only if every part is media-without-caption
   and no name was given).

Wizard
------
- New "Name" input above the message stack on step 2 (Compose).
  Optional (label says so), maxLength 60, placeholder gives an
  example. Blank flows through the URL as an absent param.
- The name parameter passes through every subsequent step
  (when, groups, review) via the existing URL-state pattern.
- Review step gains a "Name" row at the very top showing what the
  resolver will produce. If the user left it blank, the row shows
  the auto-derived value plus a muted "(auto from message)" tag so
  they know what's happening.

Edit forms
----------
- `EditMessageForm` gains the same Name input at the top —
  consistent with the wizard's compose step.
- `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the
  current `name` and forward it unchanged on save. Otherwise saving
  any of those sections would re-auto-derive the name from the
  message body, silently overriding what the operator typed.

Server action
-------------
- Both `createReminderAction` and `updateReminderAction` accept an
  optional `name` field on the schema. The body collapses through
  the new `resolveReminderName` helper, replacing the inline
  `firstLabel ?? "Reminder"` slice.

Tests (+17 new in lib/reminder-name.test.ts)
--------------------------------------------
- User priority: user name wins over message body even when both
  are present; trimming.
- Auto-derive: first text part, first non-empty after skipping
  empties, media caption when present, trims around the value.
- Fallback: null/undefined/empty stack, every-part-empty, every
  part media-without-caption.
- Clamping: user-supplied long names truncate at 60; auto-derived
  long names truncate at 60; short names pass through.
- The 60-char ceiling matches what the wizard's <Input maxLength>
  enforces and what the DB column allows.

Existing tests updated to pass the new required prop (`initialName`
on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR
fixtures, plus a couple in no-render-warnings.test.tsx).

Total: 298 web + 31 shared + 26 bot = 355 passing (was 338).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:22 +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%