205 Commits

Author SHA1 Message Date
bafcc5284a feat(bot): trim time menu to Now + Custom only 2026-05-09 18:15:13 +08:00
9e180b65a2 feat(bot): Custom day & time goes straight to year/month/day picker
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.
2026-05-09 18:10:44 +08:00
45fcc11e7b feat(bot): menu-driven year/month/day picker for exact dates
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.
2026-05-09 18:06:11 +08:00
f5666a9d2c feat(bot): more day options + free-text date input
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.
2026-05-09 18:01:11 +08:00
689891dd87 fix(bot): render custom day/hour/minute pickers as plain text
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.
2026-05-09 17:57:45 +08:00
e9f3fd6e29 fix(db): cascade reminder_runs on reminder delete
Deleting a reminder that had already fired failed with FK violation
'reminder_runs_reminder_id_reminders_id_fk'. Add ON DELETE CASCADE so
the run history is removed alongside its reminder.

reminder_run_targets cascades on run_id (already), so the chain is:
reminder → reminder_runs → reminder_run_targets, all removed in one go.
2026-05-09 17:54:20 +08:00
92deaf1032 fix(scheduler): flip one-off reminders to 'ended' after firing
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.
2026-05-09 17:52:36 +08:00
6a221fe043 fix(bot): render Review screen as plain text to avoid Markdown parsing errors
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
2026-05-09 17:49:00 +08:00
a5bbf3a25d feat(bot): redesign reminder time picker (menu-driven)
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.
2026-05-09 17:45:08 +08:00
2129403f39 feat(bot): wire reminder wizard + list/detail callbacks
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>
2026-05-09 17:37:11 +08:00
1578f1f948 feat(bot): add reminder menu views (list, detail, wizard steps) 2026-05-09 17:31:55 +08:00
afd5fcb73b feat(bot): add wizard state for reminder creation 2026-05-09 17:30:17 +08:00
01eb5752ee feat(scheduler): add fire-reminder handler + job registration
Also fix rrule default-import workaround so the shared package loads
correctly under NodeNext ESM resolution (rrule@2.8.1 has no exports field).
2026-05-09 17:29:21 +08:00
2ed436ef0e feat(bot): add Telegram media ingest into /data/media 2026-05-09 17:23:59 +08:00
d9a5f5a5e2 feat(bot): extend sender with image/video/document support 2026-05-09 17:23:06 +08:00
1aef3e969c feat(reminders): add time-parsing + CRUD helpers 2026-05-09 17:22:00 +08:00
113adc7edf feat(scheduler): add pg-boss client + lifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:19:01 +08:00
bf3586fe7b docs(plan): add plan 2 — reminder scheduling & sending
End-state of plan 2: operator can schedule one-off reminders via the
Telegram menu wizard, attach text + optional media (photo/video/doc),
and the bot fires them on time to a chosen group. Failed sends retry
with backoff. Run history captured in the DB.

Out of scope (deferred to follow-ups):
- Recurring reminders (RRULE)
- Multi-group / multi-part messages beyond text+1 media
- Run history view in menu
- Web dashboard (plan 3)

9 tasks covering pg-boss client, reminder CRUD helpers, sender refactor
(media), Telegram media ingest, fire-reminder handler, wizard state,
menu views, callback wiring, and end-to-end verification.
2026-05-09 17:15:57 +08:00
f613d83b40 docs: add top-level README 2026-05-09 17:10:48 +08:00
9062ba7e7f fix(bot): drop removed groups during sync
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`.
2026-05-09 17:08:11 +08:00
83d9bf6e9b fix(bot): upgrade Baileys 6.17.16 → 7.0.0-rc10 for protocol compatibility
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.
2026-05-09 17:01:47 +08:00
43882d5a1b feat(bot): refresh groups list — manual button + auto event listener
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
2026-05-09 16:54:55 +08:00
5259f88776 fix(bot): chunk participant pre-key fetches to survive broken JIDs
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.
2026-05-09 16:52:48 +08:00
2fdcdb6202 fix(bot): explicit assertSessions before group send
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.
2026-05-09 16:50:51 +08:00
99cece16c0 fix(bot): pre-fetch group metadata + retry sender on libsignal race
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.
2026-05-09 16:48:42 +08:00
3c4eedff03 feat(bot): tap-to-send test message from groups menu
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.
2026-05-09 16:46:22 +08:00
7b0c8c47e2 feat(bot): BotFather-style menu navigation
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)
2026-05-09 16:42:44 +08:00
56fd71a6a0 feat(bot): inline keyboards + Telegram slash menu
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.
2026-05-09 16:35:28 +08:00
ee1113280d fix(bot): clean up stale pairing state on /pair retry
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
2026-05-09 16:32:23 +08:00
1e3173424a fix(bot): pin Baileys to latest WA Web version + handle smart quotes
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.
2026-05-09 16:28:01 +08:00
33e1fcd2c4 docs: add manual end-to-end pairing test runbook 2026-05-09 16:24:18 +08:00
a77df43ae4 feat(bot): add /pair /unpair /accounts /groups commands 2026-05-09 16:23:22 +08:00
f8bd20184f feat(bot): add group sync upsert 2026-05-09 16:21:01 +08:00
c2ee793ae6 feat(bot): add session manager with state machine + reconnect 2026-05-09 16:20:20 +08:00
fc05a8b459 feat(bot): add Baileys session wrapper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:18:11 +08:00
dd1eb711df feat(bot): add QR PNG renderer 2026-05-09 16:16:09 +08:00
20f24270d9 feat(bot): add telegram bot with whitelist, /start, /help, audit 2026-05-09 16:15:17 +08:00
3f3b090caa feat(bot): add audit log writer 2026-05-09 16:12:53 +08:00
4a790b9a60 feat(bot): scaffold env, logger, db, health, shutdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:10:37 +08:00
8167872415 chore: add bot Dockerfile and bot service in dev compose 2026-05-09 16:07:46 +08:00
bf22c177a4 fix(env): quote SEED_OPERATOR_NAME so bash sourcing tolerates parens 2026-05-09 16:06:52 +08:00
3c3b5165b8 feat(scripts): add db.sh wrapper and stubs for plans 2/4 2026-05-09 15:43:01 +08:00
cd50aa1415 chore: fix tools pnpm install + populate .env.development
Replace corepack-prepared pnpm with `npm install -g pnpm@9.12.0` so the
binary lives in /usr/local/bin (readable by any UID) instead of root's
corepack cache. Avoids re-downloading pnpm on every container restart
when running as a non-root host user.

Also populate .env.development with real dev credentials (Postgres at
192.168.0.210/wabot, dev Telegram bot, operator Telegram ID 818380985).
2026-05-09 15:29:56 +08:00
fa4970a76c feat(db): add drizzle schema for all tables + initial migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:19:36 +08:00
09a9f5f348 feat(shared): add rrule, media-path, timezone helpers 2026-05-09 15:16:46 +08:00
8396e80334 chore: bootstrap pnpm install via tools container 2026-05-09 15:14:18 +08:00
f1831b8a56 chore: add gen_auth_secret + bootstrap env files 2026-05-09 15:12:09 +08:00
30168ad793 feat(scripts): add dev.sh with exec/pnpm/shell subcommands 2026-05-09 15:11:02 +08:00
c49c0a75dc chore: add tools container + base/dev compose 2026-05-09 15:10:06 +08:00
a553b538a5 chore: initialize pnpm workspace + Turbo config 2026-05-09 15:09:08 +08:00