docs(04-03): complete chat-search-surface plan — /cari venue card + alternates + corrected deep-link carrier

This commit is contained in:
araharah-executor
2026-10-24 01:23:41 +08:00
parent 5dcef0e2bb
commit 3c790e26de
@@ -0,0 +1,245 @@
---
phase: 04-destination-search-kl-gazetteer
plan: 03
subsystem: bot
tags: [cari, sendvenue, inline-keyboard, callback-query, deep-link, web-app-button, telegram, grammY, i18n, privacy]
requires:
- phase: 04-01 (shared search module)
provides: "@araharah/search — createGazetteerSource(), SearchSource/GazetteerEntry contracts, byId() honest null"
- phase: 04-02 (corpus + bake-off close)
provides: "closed green corpus gate the /cari surface ships against (lever None)"
- phase: 02 (bot skeleton)
provides: "createBot factory + baseFetch seam, strings module + CS gates, register-commands.mjs, category-only logging law"
provides:
- "/cari + /search command surface — one sendVenue venue card per search (D-01/D-02/D-10/SRCH-03)"
- "OPEN IN ARAHARAH web_app button with the corrected deep-link carrier: <MINI_APP_URL>/?dest=<plain slug id> (D-11, RESEARCH Pitfall 1)"
- "alternates callback surface: scoped alt:<id> matcher + always-answer catch-all; one-tap card swap via NEW sendVenue (D-02)"
- "sendVenueCard — the ONE shared card builder (first card and swapped card structurally identical)"
- "MINI_APP_URL env contract (public origin in wrangler vars) + docs/CONFIGURATION.md row"
- "search copy in en+ms (usage / honest refusal / over-cap / OPEN label / OSM attribution) + /cari helpMenu lines"
- "CR1-CR15 payload-shape gate suite (fake-transport + source-introspection) and the four amended CS gates"
affects:
- "plan 04-04 (webview reads ?dest= from location.search; device gate validates the same origin)"
- "phase 5 (origin/destination resolvers plug into the same SearchSource; /pergi_ke reserved there)"
tech-stack:
added: []
patterns:
- "fake-transport payload capture: createBot(env, { baseFetch }) + synthetic Telegram updates (bot_command entity required) — asserts run on recorded request bodies, never the live API"
- "structural gates count RAW source: comment prose must not carry gate-banned tokens (editMessageText / keyboard / pergi_ke literals)"
- "carrier-swap seam: miniAppUrlFor(origin, id) is the one place the deep link is built"
key-files:
created:
- bot/src/bot/commands/cari.ts
- bot/src/bot/handlers/search-alternate.ts
- bot/test/cari.test.mjs
modified:
- bot/src/i18n/strings.ts
- bot/src/bot/bot.ts
- bot/src/bot/handlers/location-fallback.ts
- bot/src/config.ts
- bot/wrangler.jsonc
- bot/.dev.vars.example
- bot/package.json
- package-lock.json
- bot/scripts/register-commands.mjs
- bot/test/commands.test.mjs
- docs/CONFIGURATION.md
key-decisions:
- "Deep-link carrier corrected per RESEARCH Pitfall 1: WebAppInfo has exactly one field (url), so the OPEN button carries ?dest=<plain slug id> read from location.search; initData.start_param stays the secondary direct-link/home-screen path; no crypto (CR3 enforces)"
- "MINI_APP_URL set to the REAL deployed origin https://araharah-bot.araharah-bot.workers.dev (matches the deployed WEBHOOK_URL origin; public var, not a secret) — must equal the @BotFather Mini App URL, verified at 04-04's device gate"
- "Blank MINI_APP_URL omits the OPEN button (never guesses an origin); the card + alternates still send (T-04-30)"
- "CS4 carve documented as the SECOND deliberate exception (inline keyboard in commands/cari.ts output); reply keyboards remain banned in commands/"
- "CS5b future-stub ban narrowed to /pergi_ke only — /cari is live, the Phase-5 navigation command stays unregistered (D-01)"
requirements-completed: [SRCH-03, ONBD-02]
metrics:
duration: "~13 min"
completed: 2026-10-23
tasks: 3
filesCreated: 3
filesModified: 11
---
# Phase 4 Plan 3: Chat Search Surface (/cari + venue card + alternates) Summary
**One-liner:** `/cari` (+ `/search`) turns a query into ONE native sendVenue card — canonical name,
curated address, inline OPEN IN ARAHARAH `web_app` button carrying `?dest=<plain slug id>` from the
configured `MINI_APP_URL`, plus 2–4 alternates as `alt:<id>` callback buttons that swap the card in
one tap — with honest out-of-area refusal, a 128-char pre-search cap, category-only logging, and a
15-case payload gate suite proving the venue shape, the no-crypto deep link, and the always-answer rule.
## Performance
- **Duration:** ~13 min
- **Started:** 2026-10-23T17:10:48Z
- **Completed:** 2026-10-23T17:24:00Z
- **Tasks:** 3/3
- **Files modified:** 14 (3 created, 11 modified)
## Accomplishments
- The chat-side search surface is live end-to-end through the real `createBot` factory: search →
ONE venue message → OPEN button deep link → alternates swap, all gated by tests that capture the
exact request bodies Telegram would receive.
- The deep-link mechanism is the research-corrected carrier: `web_app` button URL carries the plain
gazetteer id (`?dest=<slug>`), because `WebAppInfo` has exactly one field (`url`) — `start_param`
cannot ride an inline button. D-11's locked intent (plain id, no crypto, one tap) is delivered
unchanged; CR3 structurally bans every crypto token from the deep-link path.
- Privacy law is enforced mechanically: every `console.log` in `cari.ts` must be a category-only
string literal (`[search] rejected: <category>`), the success path logs nothing, refusal copy
never echoes the query, and `callback_data` is a public place id only (≤ 64 bytes).
- The four `commands.test.mjs` landmines were defused in the same plan that landed `/cari` (CS4
carve, CS5 menu, CS5b narrowed stub ban, CS6 alias set) — the suite went RED on cue after task 2
and is green again at close.
## Task Commits
1. **task 1: the search copy + the /cari command + the venue card + the OPEN deep link** - `a73a388` (feat)
2. **task 2: the alternates callback handler + the bot factory wiring** - `13e5073` (feat)
3. **task 3: the four commands.test.mjs landmines + the new cari.test.mjs gate suite** - `5dcef0e` (test)
## Files Created/Modified
- `bot/src/bot/commands/cari.ts` (NEW) — the /cari + /search handler: query cap before any search,
SearchSource call, honest refusals, one `sendVenueCard` call; exports `MAX_QUERY_CHARS`,
`miniAppUrlFor`, the pure text builders, `registerCari`
- `bot/src/bot/handlers/search-alternate.ts` (NEW) — the ONE `sendVenueCard` builder, async
`alternatesFor`, the scoped `alt:` callback matcher (answer → validate → byId → swap), the
always-answer catch-all
- `bot/test/cari.test.mjs` (NEW) — CR1–CR15: fake-transport payload capture through the real
`createBot` + source-introspection gates with the `stripComments` helper
- `bot/src/i18n/strings.ts` — five new fields in en+ms + the `/cari` helpMenu line in both locales
- `bot/src/bot/bot.ts` — ONE `createGazetteerSource()` shared by both registrations; origin from
`env.MINI_APP_URL ?? ""`; header names the second D-02 exception
- `bot/src/bot/handlers/location-fallback.ts` — non-location messages now pass through (`next`)
- `bot/src/config.ts` — `MINI_APP_URL` in the Env contract
- `bot/wrangler.jsonc` — `"vars": { "MINI_APP_URL": ... }` (public origin, not a secret)
- `bot/.dev.vars.example` — commented MINI_APP_URL line
- `bot/package.json` + `package-lock.json` — `@araharah/search` workspace dependency
- `bot/scripts/register-commands.mjs` — menu list gains `cari` (two fetch calls unchanged)
- `bot/test/commands.test.mjs` — commandSources + cari.ts; CS4 carve; CS5 three-command menu; CS5b
narrowed to `/pergi_ke`; CS6 alias set gains cari+search; header documents the carve
- `docs/CONFIGURATION.md` — MINI_APP_URL env-table row + wrangler section + non-secret settings note
## Verification Results
- `npm test -w bot` — **65 passed, 0 failed** (PASS CR1…CR15 ×15, PASS CS×9, all prior suites green)
- `npm run bot:typecheck` — green
- `npm test -w web` — 33/33 (unaffected); `npm run search:test` — 57/57 (unaffected)
- `npx wrangler deploy --dry-run` — exit 0; `MINI_APP_URL` bound as an Environment Variable; the
bundle INLINES the gazetteer (`suria-klcc` marker present — completes 04-01's deferred L3 half)
- `grep -rl 'createHmac\|crypto\.subtle' cari.ts search-alternate.ts` → nothing (D-11)
- Non-category log lines in cari.ts → 0; `pergi_ke` registrations → 0 in both cari.ts and
register-commands.mjs (D-01 reservation)
## Decisions Made
- **Carrier correction implemented as specified** (`<mechanism_correction>`): `miniAppUrlFor(origin, id)`
is the single builder; the swap to another carrier is one line if the device test fails.
- **Real origin, not a placeholder:** `MINI_APP_URL` = `https://araharah-bot.araharah-bot.workers.dev`,
taken from the deployed `WEBHOOK_URL` origin (local gitignored `.dev.vars`; hostname only was read).
- **Alternates layout:** the plan's authoritative code snippet puts all alternates on one row after
the OPEN row (verified fine for 4 buttons in Telegram's inline keyboard shape).
- **`osmAttribution` consumer deferred with reason:** the venue card cannot carry text and the
refusal copy is pinned verbatim, so the attribution string is defined + CR11-pinned but not yet
rendered by a bot surface (see Known Stubs).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] The GEO-02 message handler swallowed every non-location message, starving /cari**
- **Found during:** task 3 (GREEN — the fake-transport suite drove `/cari KLCC` through the real
`createBot` and recorded zero API calls)
- **Issue:** `registerLocationFallback`'s `bot.on("message", …)` matches EVERY message and returned
without calling `next()`. Existing commands worked only because they were registered before it;
`/cari`, registered after, never fired. Same-shape synthetic updates without Telegram's
`bot_command` entity also don't match grammY's command filter (test-side learning, fixed in the
harness).
- **Fix:** the handler now takes grammY's `next` and passes non-location messages through
(`if (!location) return next();`), with the law documented in its doc comment.
- **Files modified:** bot/src/bot/handlers/location-fallback.ts
- **Verification:** CR1–CR8 (command path) green through the real factory; all LP*/WK* suites stay
green (the pin path is untouched).
- **Committed in:** `5dcef0e` (part of the task 3 commit)
**2. [Rule 1 - Bug] Gate-banned tokens appeared in comment prose (structural gates count raw source)**
- **Found during:** task 2 (editMessageText) and task 3 (GREEN — CS4, CS5b)
- **Issue:** `search-alternate.ts`'s header mentioned `editMessageText` (gate: zero occurrences);
`bot.ts`'s new header said "inline keyboard" (CS4's `/keyboard/i` scan); the
`register-commands.mjs` header named `pergi_ke` (CS5b's ban is a raw-source grep).
- **Fix:** reworded the three comments to carry the same meaning without the banned literal — the
gates' intent (no edit CALLS, no reply keyboards in commands/, no pergi_ke REGISTRATION) is intact.
- **Files modified:** bot/src/bot/handlers/search-alternate.ts (pre-commit, `13e5073`), bot/src/bot/bot.ts,
bot/scripts/register-commands.mjs (in `5dcef0e`)
- **Verification:** CS4/CS5b green; `grep -c editMessageText search-alternate.ts` → 0.
---
**Total deviations:** 2 auto-fixed (2 × Rule 1)
**Impact on plan:** Both fixes were required for the plan's own gates to hold; no scope creep. The
location-fallback pass-through is the one behavioral change outside the plan's file list and is
covered by the pre-existing LP suites.
## Issues Encountered
- grammY's `bot.handleUpdate` does NOT auto-initialize (it throws "Bot not initialized") — the
harness calls `await bot.init()` through the fake transport, which also proved `getMe` lands in
the fake (no network anywhere in the suite).
- grammY's command filter requires the `bot_command` entity on synthetic command messages; the
harness now emits it (real Telegram always does).
## Known Stubs
- `strings.*.osmAttribution` (bot/src/i18n/strings.ts) — defined and CR11-pinned in both locales but
not yet rendered by any bot surface: `sendVenue` has no text field and the refusal copy is pinned
verbatim, so no honest rendering site exists in v1's chat surface. The webview already carries the
identical string (web/src/i18n/strings.ts line 73) where OSM data is actually displayed. A future
chat surface that displays OSM-derived data (e.g. Phase 5 route summaries) consumes it from here —
the strings module is deliberately the single home for copy.
## Auth Gates
None — no secrets touched; the deployed origin was read from local gitignored `.dev.vars` (hostname
only), never echoed beyond the origin, and lives in `wrangler.jsonc` `vars` (public by design).
## Threat Flags
None — the built surface is exactly the plan's threat model (T-04-21…T-04-31), and every mitigation
landed with a gate: the 128-char cap (CR7), category-only logs (CR10), `alt:`-only validated
callback ids (CR5/CR15 + slug regex before byId), configured-origin-only deep links (CR2), the
no-crypto law (CR3), the honest refusal (CR6), the always-answer rule (CR9), and no in-place venue
edits (CR14).
## TDD Gate Compliance
NOTE — this plan realizes RED→GREEN at the PLAN level, not per-task: the plan itself sequences the
payload-shape gate suite in task 3 ("the four landmines in the SAME change"; "the suite goes RED the
moment cari.ts + the strings land"). RED evidence per task was the failing acceptance probes (task
1: missing file/strings/env greps; task 2: exactly the predicted CS4+CS5 failures, 48/2), and task
3's suite then pinned the implementation (65/65). The git log therefore shows
`feat → feat → test` rather than per-task `test → feat` pairs. No plan-level `type: tdd` frontmatter
exists (type: execute).
## User Setup Required
None new — `MINI_APP_URL` ships as a public `vars` entry in `bot/wrangler.jsonc` (already set to the
deployed origin). The device gate in plan 04-04 must confirm it equals the @BotFather Mini App URL
(Bot API 10.2 enforces the match). The deployed Worker needs a `wrangler deploy` to pick up the new
vars + handlers — deliberately NOT done here (04-04 owns deploy + device gate).
## Next Phase Readiness
- Plan 04-04 (web) can build the `?dest=` reader against a stable carrier: `?dest=<slug>` from
`location.search` (primary), `initData.start_param` (secondary), `/^[A-Za-z0-9_-]{1,64}$/`
validation, unknown id → honest "destination unavailable".
- The alternates/best-hit flow exercises `createGazetteerSource()` in the Worker bundle (verified by
the dry-run marker), so the webview and bot now share one dataset end-to-end.
- `/pergi_ke` remains unregistered, reserved for Phase 5; the SearchSource seam is ready for the
origin/destination resolvers.
## Self-Check: PASSED
- All 15 touched/created files exist on disk (verified individually above).
- All 3 task commits present in `git log` (`a73a388`, `13e5073`, `5dcef0e`); zero file deletions in
any task commit (`git diff --diff-filter=D` = 0 after each).
- `npm test -w bot` re-run at close: 65 passed, 0 failed; typecheck green; web 33/33; search 57/57.