yiekheng c7a6f5f1b0 feat: humanise cron in list summaries; magic-byte detect HEIC; sidebar brand link tests
Three threads from the recent UX iteration:

1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
   ----------------------------------------------------------------
   `describeRecurrence` for a kind=cron spec used to emit
   "Cron: <expr>" verbatim, which is unreadable on the list row's
   recurrence line.

   New pure helper `describeCronRule(rule)` parses the cron shapes
   the recurrence picker produces and renders them as natural
   sentences:

      "0 9 * * *"             → "Every day at 09:00"
      "0 9 * * 1-5"           → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
      "0 9 * * 1,3,5"         → "Every week on Mon, Wed, Fri at 09:00"
      "0 9 1,15 * *"          → "Every month on days 1, 15 at 09:00"
      "0 9 13 5 *"            → "Every year in May on day 13 at 09:00"
      "30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"

   Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
   descriptions with " · " for compactness in the list density.

   Long DOM lists (>6 days) collapse with a "+N more" tail to keep
   the line short; same convention the picker's per-row preview uses.

   Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
   expression — better than swallowing entirely.

2. HEIC/AVIF magic-byte sniffing at upload
   ----------------------------------------------------------------
   The mime-only check we shipped earlier missed iOS Safari's
   habit of uploading HEIC photos with Content-Type: image/jpeg.
   The file then made it to the bot, where Sharp's HEIF decoder
   plugin is missing, the thumbnail extraction failed, and the
   message went out without a working preview — read by the user
   as "image still not send".

   New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
   the upload and looks for the ISOBMFF "ftyp" marker followed by
   one of the brands Sharp can't decode (HEIF: heic / heix / hevc
   / heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
   case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
   mp4 are not flagged.

   `uploadMediaAction` now runs the sniff against the buffered
   bytes before persisting, returning the same "Images are not
   supported, please re-upload images" error as the mime path.

3. Sidebar brand link → dashboard tests
   ----------------------------------------------------------------
   Asserts the desktop <aside> contains an <a href="/" aria-label=
   "Go to dashboard"> at the top, scoped via a new extractSidebar
   helper so it can't accidentally match the mobile-header brand
   link (which uses aria-label="Go home"). A second test confirms
   the two aria-labels stay distinct.

22 web test files / 232 passing (was 212):
  - +12 cron-description cases in lib/recurrence.test.ts
  - +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
  - +2 sidebar-brand-link cases in app-shell.test.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:59:13 +08:00
2026-05-09 17:10:48 +08:00

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: /menu opens 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.update event, 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 scheduler
  • apps/web/ — Next.js dashboard (plan 3)
  • packages/db/ — Drizzle schema and migrations
  • packages/shared/ — cross-app helpers (rrule, media paths, timezones)
  • docs/superpowers/specs/ — design specs and manual test runbooks
  • docs/superpowers/plans/ — implementation plans
  • docker/ — Dockerfiles (tools.Dockerfile, bot.Dockerfile, web.Dockerfile placeholder)
  • 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.

Description
No description provided
Readme 1.5 MiB
Languages
TypeScript 97.8%
Shell 1.2%
Dockerfile 0.5%
CSS 0.5%