--- 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: /?dest= (D-11, RESEARCH Pitfall 1)" - "alternates callback surface: scoped alt: 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= 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=` from the configured `MINI_APP_URL`, plus 2–4 alternates as `alt:` 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=`), 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: `), 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** (``): `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=` 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.