158 Commits

Author SHA1 Message Date
e45bcb581a fix(web,build): consume packages/db + shared via dist; bind web to LAN
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
2026-05-10 00:18:56 +08:00
3d470069d3 feat(web): create reminder + media upload server actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:48:35 +08:00
6916f5a0ed feat(web): delete reminder server action wired to detail page 2026-05-09 23:46:23 +08:00
83a19d4800 feat(web): send-test server action wired into group detail 2026-05-09 23:44:22 +08:00
68b46f8d71 feat(web): pair / unpair / sync server actions + live QR page 2026-05-09 23:42:16 +08:00
de21edd905 feat(web): settings page with operator info + theme toggle 2026-05-09 23:37:56 +08:00
8fd5468e3a feat(web): reminders list + detail pages with run history 2026-05-09 23:36:18 +08:00
6b1a9191ab feat(web): groups list + group detail pages with trigram search 2026-05-09 23:32:00 +08:00
7708dd671c feat(web): dashboard + accounts list + account detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:27:24 +08:00
8771e65c8c feat(web): edge middleware deny /api except events + health 2026-05-09 23:15:52 +08:00
1fe674c70e feat(web): SSE endpoint + useEvents hook 2026-05-09 23:11:25 +08:00
63d41c4389 feat(web): app shell with responsive nav + theme provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:09:33 +08:00
c9960aae24 chore: switch web to port 9000 (test.04080616.xyz) + exclude test files from bot tsc build
- 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).
2026-05-09 23:07:29 +08:00
17f9ee179f feat(db,web): pg_trgm + indexes + Postgres-backed cache and rate-limit
- Add cacheEntries and rateLimitBuckets tables to schema
- Generate migration 0002_left_jimmy_woo.sql with pg_trgm extension and all indexes
- Implement cache.ts (get/set/delete/getOrSet/sweep) backed by Postgres
- Implement rate-limit.ts (sliding-window UPSERT) backed by Postgres
- Implement search.ts (trigramMatch / trigramRank helpers)
- Add vitest 2.1.9 + vitest.config.ts; 7 unit tests pass (4 cache + 3 rate-limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:03:10 +08:00
499bcf22ed fix(build): production tsc + Next.js workspace root resolution
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
2026-05-09 22:54:51 +08:00
2f7313b9ac feat(web): db client, operator helper, IPC notify, logger 2026-05-09 22:48:00 +08:00
7238369503 feat(web): shadcn/ui init + base components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:46:16 +08:00
161ffec84c feat(web): scaffold Next.js 16 app with Tailwind 4 + Geist 2026-05-09 22:40:03 +08:00
21e8e5b582 feat(bot): remove Telegram code; switch to IPC consumer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:37:49 +08:00
af21bc5599 feat(bot): add IPC handlers for pair / unpair / sync / send-test / schedule 2026-05-09 22:34:01 +08:00
abcf19b71a feat(bot): add IPC notify helper + command consumer skeleton 2026-05-09 22:31:40 +08:00
97099bf28a feat(bot): clean up stale pair flows + 5-min pair timeout
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.
2026-05-09 21:59:48 +08:00
5a775e076b feat(bot): year picker shows current + next 10 years (3 columns) 2026-05-09 18:15:45 +08:00
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
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
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