Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
"Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
Groups) is itself a clickable card that deep-links into the wizard
step in edit mode (editReminderId URL param). No standalone Edit
button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
wizard URL params, and forwards to /reminders/new. The wizard
threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
reminder and the (possibly changed) target account, replaces targets
+ messages wholesale, re-arms the pg-boss job (singleton key picks
up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
zone (not now+1h). Same-minute clicks bump silently to next minute
via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
when ?filter=failed and there are reminders in other states.
Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
weekday picker (weekly), day-of-month input (monthly), end picker
(Never / After N occurrences / On date), live human-readable
summary preview.
QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
per-account session listener on Re-pair so listeners can't
accumulate. The UI countdown was flicking every ~5 s because each
Re-pair attached an extra listener — every Baileys QR event then
triggered a fresh DB write + NOTIFY.
Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
validateScheduledAt (incl. the "click-too-fast" same-minute grace)
and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
404 on missing row, 200 with image/png + no-store + correct PNG
bytes, and verifies the where-clause queries by accountId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor the /accounts page into a thin data-fetching shell plus a
pure presentational AccountsListView. The view has no DB or server-
action dependencies (the deleteFormAction is passed in), which makes
it directly unit-testable.
Tests use react-dom/server's renderToStaticMarkup — no jsdom or DOM
testing-library needed. next/link and the radix Dialog are mocked to
plain wrappers so the markup is deterministic.
Coverage:
- one cell per account, each with one main account-card and one
delete-card
- main card links to /accounts/[id]
- account label appears in main card, delete card description, and
the destructive confirm dialog
- delete card is a <button> with the right aria-label
- delete dialog form has a hidden accountId input matching the row
- phone number renders when paired; "Not paired yet" when not
- header CTA renders an Add Account link
- empty state replaces the grid and still offers Add Account
vitest config: include src/**/*.test.{ts,tsx} and switch esbuild jsx
to "automatic" so test files don't need a React import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the corner trash icon with a separate destructive-themed card
stacked beneath each account card. Whole card is the confirm-dialog
trigger.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
Monthly / Yearly). Build the RRULE client-side and thread it through
the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
using the existing rrule helpers in @cmbot/shared. One-off behavior
unchanged.
- Add reminders.last_fired_at column to track last fire.
Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
silently truncating it; QR never reached the web → "QR hang"). PNG
now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
{type: session.qr, accountId, ts}. Web fetches the bytes from a new
read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
fresh one — fixes Re-pair where session.start was a silent no-op and
Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.
Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.
Account list page
- Cards are the link target. A small floating Delete trigger (top-right
trash icon) opens the destructive confirm dialog without blocking
navigation on the rest of the card.
Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
proves strict Zod .datetime() rejected luxon's offset ISO and the
{ offset: true } option accepts both forms.
Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix "Invalid datetime" error: createReminderAction's Zod schema rejected
offset-suffixed ISO strings (luxon's `toISO()` produces +08:00 form).
Switched to `.datetime({ offset: true })`.
- Replace the single datetime-local input with separate native date + time
inputs (proper UI pickers on both desktop and mobile). Default value is
now computed server-side ("now + 1h") and passed in as a prop, so first
render is fully populated and there's no SSR/client hydration mismatch
from `Date.now()` inside the client component. Removed the quick-pick
shortcuts.
- Reorder wizard steps: Account → Compose → When → Groups → Review.
Groups is now the last and optional step (Continue button reads
"Skip groups" when empty); the action accepts an empty array and
inserts no reminder_targets in that case.
- Account list: card is the link target. Removed inline Pair / Open /
Delete quick-action buttons; lifecycle actions stay on the detail page.
- Account detail: removed the "Sync Groups Now" card. The bot already
auto-syncs on `groups.upsert` / `groups.update` events. The Groups card
itself is now a clickable link instead of carrying an inline View
button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface state-aware quick actions directly on each card so the user
doesn't have to drill into the detail page just to delete or re-pair an
account. Re-pair shows when status != connected; Delete (with
destructive confirm dialog) is always available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the account lifecycle to match how operators actually want to
work the system:
- Add Account → creates a row with status='unpaired'. No QR yet; the
operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
and opens the live QR flow. Works for first-time pair AND for re-pair
of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
session files; sets status='unpaired' but KEEPS the row (and its
reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
reminders, run history.
Schema:
- whatsapp_groups.account_id and reminders.account_id now have
ON DELETE CASCADE so deleting an account fans out cleanly.
UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).
Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
ships ESM that some bundlers can't resolve via default OR named
import. Use createRequire to bridge — works under both NodeNext
(bot runtime) and Turbopack (web SSR).
Two related fixes:
1. Phone (and any LAN client) couldn't reach the web container because
the dev compose mapped 127.0.0.1:WEB_PORT instead of binding all
interfaces. Drop the loopback prefix.
2. Turbopack and NodeNext disagree on extension handling: bot's tsc
needs `.js` extensions in source imports; Turbopack's transpilePackages
path can't resolve those `.js` requests back to `.ts` source. Switch
to consuming the workspace packages via their compiled dist instead:
- packages/db + packages/shared point `main`/`exports` at ./dist/*
- drop transpilePackages from next.config.ts; web picks up the
compiled `.js` files directly
- dev compose command for web builds shared+db before running
`next dev` so dist is fresh when Turbopack starts
- put the `.js` extensions back in packages/db source so NodeNext
compilers (bot's tsc, packages/db's own tsc) are happy
- Local dev WEB_PORT now 9000 to match the planned dev/staging domain
test.04080616.xyz. Production deployment uses port 8100 mapped to
rexwa.04080616.xyz (configured in plan 4).
- apps/bot/tsconfig.json: exclude src/**/*.test.ts from the production
build (vitest types only present at dev time, mirrors the same fix we
made earlier for packages/shared).
Three small build-time fixes surfaced when the Docker images first ran
their full production build (previously only dev mode via tsx):
- packages/shared: exclude *.test.ts from tsc (vitest types not needed
for shipped output), add @types/node dep so node:crypto resolves
- packages/db: add @types/node dep for the same reason
- apps/web: pin Next.js Turbopack root to the workspace root via
next.config.ts so the bundler doesn't fail to detect the monorepo
layout from inside the Docker image
Two related fixes for abandoned pairings:
- After /pair starts a Baileys session, arm a 5-minute timer. If the
operator doesn't scan in time the bot stops the session, deletes the
pending account row + session files, and pings them in Telegram.
- On bot startup, sweep any 'pending' account rows older than 1 hour —
catches the case where the bot was restarted mid-pair, leaving a
stale row no in-memory state could clean up.
The preset day list (Today/Tomorrow/+1 week/etc.) was redundant with the
top-level time-quick options (Now / Tomorrow 9 AM / Next Mon 9 AM) and
added an extra step for the operator's actual use case (specific dates).
Tapping "Custom day & time" now opens the year picker directly. Back from
the year picker returns to the time menu (Now / Tomorrow / etc.) instead
of looping into itself.
Replace the typed-date input with a fully button-driven calendar:
Year (current + next 4) → Month (12 buttons, past months disabled)
→ Day (calendar grid for that month, past days disabled)
→ Hour → Minute (existing screens, computed day-offset)
Past months/days render as inert "·" cells with a no-op callback so
operator taps don't error. Year picker covers up to 4 years out — well
beyond the typical reminder horizon.
Replaces the "📝 Specific date…" typed input with "📅 Pick exact date…"
which never asks for keyboard text.
Day picker was limited to ≤1 month. Two enhancements after live testing:
- Add +2 months and +3 months presets
- Add a "📝 Specific date…" option that prompts the operator to type
YYYY-MM-DD; the bot validates, computes the day-offset, and continues
straight to the hour picker (rest of the wizard unchanged)
Lets the operator schedule reminders at arbitrary future dates without
expanding the preset list to absurd lengths.
The day picker text included `(timezone: Asia/Kuala_Lumpur)` and the `_`
in the IANA name triggered Markdown's italic delimiter — Telegram's parser
then couldn't find the closing `_` and rejected the message with 400
'can't parse entities at byte offset 62'.
Drop Markdown formatting for all three custom-time picker views (day,
hour, minute) since they include system-generated content (timezones,
day labels, dates) that may contain underscores or other markdown chars.
A fired one-off reminder was staying active forever in the DB and showing
🟢 in the Reminders list. Update reminders.status to 'ended' once a one-off
has fired (regardless of run outcome — one-off is done after one attempt).
Recurring reminders stay 'active' — they have more occurrences pending.
The reminder confirm screen was failing with 'can't parse entities' (400)
because the body string included `[media...]` which Telegram's legacy
Markdown mode tries to interpret as a link `[text](url)` and rejects when
the closing `(url)` isn't present. Same risk for any user-typed body
containing `*`, `_`, backticks, or `[`.
Two fixes:
- Add optional parseMode field to MenuView; showMenu honors it
- reminderConfirmMenu and reminderDetailMenu render as plain text
(parseMode: undefined) since both include user-supplied content
- Replace `[media...]` brackets with `(media...)` parens in the wizard
body preview so the placeholder itself can't trigger link parsing
Time picker UX changes after live testing:
- Add "🕐 Now" quick option (fires within 30s)
- Remove "🕐 In 1 hour" / "🕒 In 3 hours" — Now + Tomorrow 9 AM cover the
practical fast-path
- Replace free-text custom date input with a 3-step menu picker:
Day (Today, Tomorrow, +2d, +3d, +4d, +5d, +1w, +2w, +1m)
→ Hour (24-hour grid, daytime first)
→ Minute (5-min increments)
- Validate the chosen day+hour+minute against "now" and reject if past
Drops parseFreeText path entirely; the wizard's set_time step is gone.
Appends all 9 reminder handler exports to callbacks.ts, creates
commands/reminders.ts, registers the /reminders command, all
callback queries (literal matches before regex catch-alls), wizard
branches in message:text, a media ingest handler, and updates
setMyCommands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously syncGroupsForAccount only upserted, so groups removed from
WhatsApp (deleted, bot was kicked, etc.) lingered in the DB.
Now compute the diff: any whatsapp_groups row for this account whose
wa_group_jid is not in the live fetch result is deleted. Skip the delete
sweep when the fetch returns empty — that's more likely transient than
a genuine "every group gone" signal, and we don't want to nuke valid
data on a hiccup.
Return shape gains a `removed` count alongside `synced`.
The 6.17.x line was returning 406 not-acceptable from WhatsApp's pre-key
endpoint when distributing sender keys to per-device JIDs (e.g.
40471728529510:18@s.whatsapp.net). This blocked every group send
regardless of group size.
Baileys 7.0.0-rc series tracks WhatsApp's current protocol. API is
drop-in compatible — typecheck clean, no source changes needed.
Re-pair required: 6.x signal session files are not portable to 7.x.
Group sync previously only ran once at pairing time, so groups created in
WhatsApp afterwards never showed up.
Two complementary fixes:
- 🔄 Refresh button in the groups list view triggers
syncGroupsForAccount() on demand and re-renders the menu
- session.ts now subscribes to Baileys 'groups.upsert' and 'groups.update'
events and re-syncs (debounced 1.5s) so new groups appear without
manual action
WhatsApp's pre-key endpoint returns 406 not-acceptable if ANY single JID
in the batch is in a broken state (deleted account, deactivated, etc.).
With Baileys' default behavior of asking for the whole participant list at
once, one stale member poisons the whole group send.
Chunk participant JIDs into batches of 5 and tolerate per-chunk failures.
The send fan-out then works for the participants whose sessions did land,
which covers the vast majority of real-world groups.
Also adds explicit pino logging so we can see which chunks failed during
diagnosis.