next-themes hydration mismatch
- Removed the next-themes wrapper, ThemeProvider component, and the
Settings appearance card — there's no theme-toggle UI anywhere in
the app, so the library was just adding a pre-hydration `<script>`
that triggered React 19's "script tag while rendering" warning and
the `<html>` class swap caused the hydration mismatch.
- Sonner Toaster now uses a fixed `theme="light"` instead of useTheme.
- Layout drops `suppressHydrationWarning` on `<html>` since we no
longer mutate it on mount.
QR refs exhausted before the user could scan
- Pass `qrTimeout: 60_000` to makeWASocket so each QR (first AND
subsequent) lasts a full minute. Default was 60 s for the first and
20 s for each subsequent → ~6 refs × default = ~2.5 min before
Baileys gave up. With 60 s flat, the user has the full ~5 min
window matching pair-handler's PAIR_TIMEOUT_MS.
Pairing-timed-out screen
- "Try again" used to link to /accounts/new (creates a new account
instead of re-pairing the existing one). Link now points to the
existing /accounts/[id] detail page where the operator can hit
Re-pair.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The session-manager's auto-reconnect (5 s after a non-logged-out close)
was firing during initial pairing. Baileys closes the socket whenever it
exhausts its QR refs (or transient handshake errors); the auto-reconnect
then opened a brand-new socket → new QR pool → another close 5 s later.
The web saw a fresh QR every ~5 s and the user could never link, because
WhatsApp invalidates each QR as soon as Baileys cycles to the next.
Fix: only auto-reconnect for accounts that have been linked before
(`whatsapp_accounts.last_connected_at IS NOT NULL`). For brand-new
pairing attempts the pair-handler's 5-minute window is now the single
authority; on close we just stop the session and let the operator
retry. With auto-reconnect off, Baileys uses its default QR cadence:
60 s for the first QR, 20 s for each subsequent rotation, ~6 refs total
(~3 minutes of valid scanning) — plenty of time to scan.
Pair-handler now also surfaces ANY close as `session.timeout` to the
web (was only emitting on `loggedOut`). Without this the user would be
left staring at the last QR after Baileys gives up, with no way to know
pairing failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard
- Stat cards are now clickable: Accounts → /accounts, Active reminders →
/reminders?filter=active, Recent runs → /reminders.
- Recent activity rows link to the underlying reminder when it still
exists. Runs whose reminder has been deleted render with a "(deleted)"
marker and stay non-clickable.
- New "Clear history" action wipes all run rows the operator owns plus
any orphan rows (reminderId=NULL).
Run history persists after reminder delete
- reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so
deleting a reminder no longer cascade-erases its history.
- New reminder_runs.reminder_name column snapshots the name at fire
time so history rows stay readable even after the reminder is gone.
- Fire-reminder records the snapshot.
- Dashboard query LEFT JOINs and COALESCEs name from the live reminder,
the snapshot, or "(deleted reminder)" as last resort.
QR
- Drop the 25 s server-side throttle. With listener accumulation already
fixed (previous commit), the payload-equality dedupe is enough.
Symptom: after the first QR expired the throttle blocked the next
emit, and the QR never refreshed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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).
- 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).
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.
groupMetadata alone wasn't enough — Baileys won't establish individual
libsignal sessions lazily during sendMessage, so the first send to a
freshly-paired group fails per-participant. Cast to the internal
assertSessions(jids, force=true) and call it on every participant before
attempting to send.
First send to a group after pairing fails with libsignal SessionError
"No sessions" because Baileys hasn't yet established encryption sessions
with all participants. Force-fetch group metadata before sendMessage so
Baileys populates its participant map; if the first send still races,
retry once after a 1.5s delay.
Each entry in the groups list is now a button. Tapping shows a group detail
view with [📝 Send Test Text]. Operator replies with the message body and
the bot sends it to the selected WhatsApp group via the live Baileys session,
records the action in audit_log, and shows success/failure inline.
This is a small forerunner of the full reminder send pipeline that plan 2
will build out (with media, scheduling, retries). Useful right now to
validate the end-to-end Telegram-to-WhatsApp send path during pairing tests.
All flows are now reachable from /menu (alias for /start). Single message
edits in place via editMessageText for hierarchical navigation, every leaf
has ⬅ Back / ⬅ Main Menu buttons.
Menu hierarchy:
/menu → main menu
📒 Accounts → list (each account is a button)
📒 <Account> → detail (📂 Groups | 🗑 Unpair | ⬅ back)
📂 Groups → groups list (⬅ back to account, ⬅ main menu)
🗑 Unpair → confirm (✅ yes | ⬅ cancel) → done
📡 Pair New → prompt for label, operator replies as plain message
❓ Help → help text + ⬅ Main Menu
Implementation notes:
- New menus.ts module with pure render functions for each view
- New state.ts tracks pending "awaiting pair label" per Telegram user
- bot.on("message:text") consumes the pending label after Pair New
- /pair, /unpair, /groups commands still work for power users; they reuse
the same handlers behind the scenes (executePairFlow extracted from
handlePair so the menu and the command share one path)
UX improvements driven by live testing:
- setMyCommands populates Telegram's '/' picker with all commands and
descriptions, so the operator gets autocomplete instead of guessing
syntax.
- /start replies with an inline keyboard ([📒 Accounts] [📡 How to Pair]
[❓ Help]) — quick navigation without typing.
- /accounts emits one message per account with [📂 Groups] [🗑 Unpair]
inline buttons. Tapping triggers a callback (no typed labels needed).
- New callbacks module wires the buttons. Unpair shows a confirm/cancel
prompt before acting.
/pair still requires a typed label since the value is operator-defined
content rather than a selection from existing data.
When the operator misses a QR and retries /pair for the same label, the
previous pairing flow (Baileys session in memory + Telegram message id +
event listener) was still alive. Multiple listeners then raced to edit
the same QR message, surfacing as 400 'message is not modified' errors.
Fixes:
- Track one listener per account; new /pair tears down the previous one
- Stop the existing Baileys session and wipe its session dir so the new
attempt starts from a clean slate
- Skip duplicate QR pushes (Baileys can re-emit identical QR strings)
- Fall back to a fresh photo if editMessageMedia fails for any reason
Two pairing-flow fixes after live test:
- Connection Failure during pairing: Baileys announced a stale WhatsApp Web
version that the server rejected before the QR was emitted. Pull the
current version via fetchLatestBaileysVersion() at session start.
- Telegram mobile auto-converts straight quotes to curly quotes, so labels
like /pair "test 1" arrived as “test 1” and the curly quotes were never
stripped. Extend the quote-stripping regex on /pair, /unpair, /groups.