From a73a38830ae701be15f5e267e9b874e1bcd8ba80 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 01:13:13 +0800 Subject: [PATCH] feat(04-03): the /cari command + venue card + OPEN deep link (D-01/D-02/D-10/D-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cari.ts: /cari + /search twin, 128-char pre-search cap (T-04-21), honest no-match refusal (D-13), single sendVenue card with inline OPEN + alternates - deep-link carrier corrected per RESEARCH Pitfall 1: web_app button URL carries ?dest= — WebAppInfo has only a url field; no crypto - origin from configured MINI_APP_URL only (never user input); blank origin omits the OPEN button instead of guessing (T-04-30) - strings.ts: searchUsage/searchNoMatch/searchQueryTooLong/searchOpenButton/ osmAttribution in en+ms; helpMenu gains the /cari line in both locales - category-only logging law (D-15): four literal rejection categories, query never logged; success path logs nothing - config: MINI_APP_URL in Env + wrangler vars (public, not secret) + .dev.vars.example + docs/CONFIGURATION.md row and sections - bot deps gain @araharah/search (workspace link) --- bot/.dev.vars.example | 3 + bot/package.json | 3 +- bot/src/bot/commands/cari.ts | 124 +++++++++++++++++++++++++++++++++++ bot/src/config.ts | 5 ++ bot/src/i18n/strings.ts | 28 +++++++- bot/wrangler.jsonc | 5 ++ docs/CONFIGURATION.md | 17 ++++- package-lock.json | 1 + 8 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 bot/src/bot/commands/cari.ts diff --git a/bot/.dev.vars.example b/bot/.dev.vars.example index dfff161..97856eb 100644 --- a/bot/.dev.vars.example +++ b/bot/.dev.vars.example @@ -3,3 +3,6 @@ BOT_TOKEN= WEBHOOK_SECRET= # Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set. WEBHOOK_URL= +# Public Mini App origin (not a secret) — lives in bot/wrangler.jsonc "vars" in production. +# The /cari venue card's OPEN button deep link is built from it: /?dest=. +MINI_APP_URL= diff --git a/bot/package.json b/bot/package.json index fc060f3..03037fd 100644 --- a/bot/package.json +++ b/bot/package.json @@ -10,7 +10,8 @@ "commands": "node scripts/register-commands.mjs" }, "dependencies": { - "grammy": "^1.46.0" + "grammy": "^1.46.0", + "@araharah/search": "*" }, "devDependencies": { "wrangler": "^4.151.3", diff --git a/bot/src/bot/commands/cari.ts b/bot/src/bot/commands/cari.ts new file mode 100644 index 0000000..8339a7d --- /dev/null +++ b/bot/src/bot/commands/cari.ts @@ -0,0 +1,124 @@ +/* + * cari.ts — the /cari command handler: search → venue card (D-01, D-02, D-10, D-11). + * -------------------------------------------------------------------------------- + * Search entry is the /cari command in chat, with its bilingual twin /search + * sharing the same handler (D-01; the Phase-2 D-04 alias precedent paired + * /start↔/mula and /privacy↔/privasi). /pergi_ke stays RESERVED for Phase 5 + * and is deliberately NOT registered here — the future-stub ban (CS5b) keeps + * it that way. + * + * D-02 shape: ONE message per search — a native venue card (Bot API + * sendVenue) carrying an inline keyboard: row 0 is the OPEN IN ARAHARAH + * web_app button, the following row carries 2–4 alternates as callback + * buttons. This keyboard is the SECOND deliberate exception to the Phase-2 + * "plain text only" command-surface rule (the first being the GEO-02 reply + * keyboard in handlers/location-fallback.ts); CS4's carve documents it. + * The card IS the confirmation (sendVenue has no `text` parameter — its + * `title` is the entry's canonical name and `address` the curated address, + * verbatim, never the normalized form). + * + * DEEP-LINK MECHANISM (D-11, carrier corrected per RESEARCH Pitfall 1): a + * `web_app` inline button's WebAppInfo carries EXACTLY ONE field — `url` + * (@grammyjs/types markup.d.ts) — so `start_param` cannot ride an inline + * button. The gazetteer id therefore travels in the URL's OWN query string + * (`?dest=`), the webview reads it from location.search, and + * `initData.start_param` remains the secondary path (the documented + * direct-link / home-screen route). The payload is a PLAIN public place + * id — no crypto, no signing (D-11): a hostile id can only send the user + * to a place they asked about. + * + * The OPEN button's origin comes ONLY from the configured MINI_APP_URL env + * var — never from user input (an unvalidated origin would be an open + * redirect, RESEARCH §Security V4). When the origin is blank the OPEN + * button is omitted (never guessed) and the card still confirms the + * destination. + * + * PRIVACY (D-15): the query is user payload. Every log line here is a + * category-only literal — the raw query, results, ids, chat ids, and + * timestamps NEVER reach a log. Refusal copy names the area, never the + * query. + * + * All copy comes from src/i18n/strings.ts — nothing inline here. + */ +import { InlineKeyboard } from "grammy"; +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; +import type { GazetteerEntry, SearchSource } from "@araharah/search/src/index.ts"; + +/** V5: the query is untrusted input — bounded before ANY processing + * (a megabyte argument must never reach normalization/scoring, T-04-21). */ +export const MAX_QUERY_CHARS = 128; + +/** The Mini App deep link for a gazetteer entry (D-11). + * Carrier corrected per RESEARCH Pitfall 1: a `web_app` inline button's + * WebAppInfo carries ONLY a `url` — there is no `start_param` field, and + * Telegram populates start_param only from an attachment-menu deep link. + * The id therefore travels in the URL's own query string and the webview + * reads it from location.search. start_param remains the secondary path + * (the documented direct-link mechanism, used by the home-screen shortcut). + * No crypto: the payload is a public place id (D-11). */ +export function miniAppUrlFor(origin: string, id: string): string { + return `${origin.replace(/\/+$/, "")}/?dest=${encodeURIComponent(id)}`; +} + +/** Pure builder — the empty-query usage line. */ +export function searchUsageText(locale: Locale): string { + return strings[locale].searchUsage; +} + +/** Pure builder — the honest out-of-area refusal (D-13). */ +export function searchNoMatchText(locale: Locale): string { + return strings[locale].searchNoMatch; +} + +async function handleCari(ctx: Context, source: SearchSource, origin: string): Promise { + const locale = pickLocale(ctx.from?.language_code); + const raw = (typeof ctx.match === "string" ? ctx.match : "").trim(); + + if (raw.length === 0) { + console.log("[search] rejected: empty-query"); + await ctx.reply(searchUsageText(locale), { link_preview_options: { is_disabled: true } }); + return; + } + + if (raw.length > MAX_QUERY_CHARS) { + console.log("[search] rejected: query-too-long"); + await ctx.reply(strings[locale].searchQueryTooLong, { link_preview_options: { is_disabled: true } }); + return; + } + + const hits = await source.search({ text: raw, limit: 5 }); + if (hits.length === 0) { + console.log("[search] rejected: no-match"); + await ctx.reply(searchNoMatchText(locale), { link_preview_options: { is_disabled: true } }); + return; + } + + // D-02: the best result ships immediately; 2–4 alternates ride on the + // SAME message. Exactly ONE sendVenue — no second message round. + const entry: GazetteerEntry = hits[0]!.entry; + const alternates: GazetteerEntry[] = hits.slice(1).map((hit) => hit.entry); + + const keyboard = new InlineKeyboard(); + if (origin.trim() !== "") { + keyboard.webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id)).row(); + } else { + console.log("[search] rejected: mini-app-origin-missing"); + } + for (const alternate of alternates.slice(0, 4)) { + keyboard.text(alternate.name, `alt:${alternate.id}`); + } + + await ctx.replyWithVenue(entry.lat, entry.lng, entry.name, entry.address ?? "", { + reply_markup: keyboard, + }); + // No log line on the success path — every log line is a privacy surface (D-15). +} + +/** Register the search command stack: /cari and its bilingual twin /search. + * /pergi_ke is reserved for Phase 5 (D-01) and must never be registered here. */ +export function registerCari(bot: Bot, source: SearchSource, origin: string): void { + bot.command("cari", (ctx) => handleCari(ctx, source, origin)); + bot.command("search", (ctx) => handleCari(ctx, source, origin)); +} diff --git a/bot/src/config.ts b/bot/src/config.ts index 9bbe25d..baa8f18 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -9,4 +9,9 @@ export interface Env { // accept-everything. WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02) + // The deployed Mini App ORIGIN (public, not a secret) — used to build the + // OPEN button's deep link. NOT derived from user input: the deep-link + // origin is a configured value (RESEARCH §Security V4 — an unvalidated + // origin would be an open redirect). + MINI_APP_URL: string; } diff --git a/bot/src/i18n/strings.ts b/bot/src/i18n/strings.ts index 1f19434..c33bb9f 100644 --- a/bot/src/i18n/strings.ts +++ b/bot/src/i18n/strings.ts @@ -35,6 +35,20 @@ export interface BotStrings { /** GEO-02: confirmation after the user's shared pin has been recorded * in the TTL-only location-pin KV (GEO-03). */ pinReceived: string; + /** SRCH-03/D-01: the empty-query usage line for /cari. */ + searchUsage: string; + /** D-13: the honest out-of-area refusal — names the v1 area, promises + * no pin. Never echoes the query text (D-15). */ + searchNoMatch: string; + /** V5: the over-cap refusal for an over-long /cari argument. */ + searchQueryTooLong: string; + /** D-10: the OPEN label on the venue card's web_app button — a product + * affordance, identical in both locales by design. */ + searchOpenButton: string; + /** The OSM data attribution for search-driven surfaces — identical in + * both locales (legal attribution, not prose; same string as + * web/src/i18n/strings.ts). */ + osmAttribution: string; } export type Locale = "en" | "ms"; @@ -48,10 +62,15 @@ export const strings: Record = { privacyNotStored: "What araharah never keeps: your location, your routes, your trip history, any profile about you.", helpMenu: - "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores", + "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores\n/cari — Search for a destination in the KL metro", awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.", shareLocationButton: "Share my location", pinReceived: "Got it — your starting point is set.", + searchUsage: "/cari — search for a destination in the KL metro.", + searchNoMatch: "Not in the v1 KL area. araharah only knows Kuala Lumpur and the Klang Valley for now.", + searchQueryTooLong: "That query is too long. Send just the place name.", + searchOpenButton: "OPEN IN ARAHARAH", + osmAttribution: "© OpenStreetMap contributors", }, ms: { startIntro: @@ -61,10 +80,15 @@ export const strings: Record = { privacyNotStored: "Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", helpMenu: - "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan", + "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL", awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.", shareLocationButton: "Kongsi lokasi saya", pinReceived: "Baik — titik mula anda sudah ditetapkan.", + searchUsage: "/cari — cari destinasi dalam kawasan KL.", + searchNoMatch: "Bukan dalam kawasan KL v1. araharah hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini.", + searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.", + searchOpenButton: "OPEN IN ARAHARAH", + osmAttribution: "© OpenStreetMap contributors", }, }; diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index 9952933..4d803d7 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -15,5 +15,10 @@ "not_found_handling": "none" }, "run_worker_first": ["/api/*", "/telegram/*"], + // Public vars (NOT secrets): the deployed Mini App ORIGIN used to build + // the /cari venue card's OPEN deep link (/?dest=). + // It must equal the Mini App URL registered in @BotFather (Bot API 10.2 + // enforces the origin). Never derived from user input. + "vars": { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" }, "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e2e1603..9486077 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -24,13 +24,14 @@ Configuration surfaces, by kind: ## Environment variables -Three variables reach the Worker (`bot/src/config.ts` declares them as a typed `Env` interface); -a fourth (`WEBHOOK_URL`) is consumed only by the one-time registration scripts, never by the Worker. +Four variables reach the Worker (`bot/src/config.ts` declares them as a typed `Env` interface); +a fifth (`WEBHOOK_URL`) is consumed only by the one-time registration scripts, never by the Worker. | Variable | Required | Default | Description | | --- | --- | --- | --- | | `BOT_TOKEN` | **Required** | none | Telegram Bot API token from @BotFather. Used by `createBot()` in `bot/src/bot/bot.ts` to construct the grammY `Bot`, and by `validateInitData()` in `bot/src/security/initdata.ts` as the HMAC key for Mini App `initData` verification. | | `WEBHOOK_SECRET` | **Required** | none | Value Telegram echoes in the `X-Telegram-Bot-Api-Secret-Token` header on every update. `/telegram/webhook` constant-time-compares it **before** any bot processing or KV access. If missing or empty at request time the route fails closed with `500 Server misconfigured` — grammY's secret check would otherwise accept every request. | +| `MINI_APP_URL` | **Required for the deep link** | none | Public origin of the deployed Mini App (e.g. `https://araharah-bot..workers.dev`) — NOT a secret, so it lives in `bot/wrangler.jsonc`'s `vars`, not in a secret. The `/cari` venue card's OPEN button is built from it: `/?dest=`. It must equal the Mini App URL registered in @BotFather (Bot API 10.2 enforces the origin). The Worker never derives it from user input — an unvalidated origin would be an open redirect. | | `WEBHOOK_URL` | **Required for the one-time scripts** | none | Full origin + path of the deployed Worker, used by `bot/scripts/set-webhook.mjs` and `bot/scripts/set-menu-button.mjs`. If the value points at the workers.dev root (no path), `set-webhook.mjs` appends `/telegram/webhook` automatically. The Worker never reads this variable. | | `chat_sessions` | **Required (binding)** | n/a | Not an env var but a Cloudflare KV namespace binding. Wired in `bot/wrangler.jsonc` under `kv_namespaces`; every `kv.put()` in `bot/src/kv/sessions.ts` and `bot/src/kv/location-pin.ts` writes through it with an explicit `expirationTtl`. | @@ -42,6 +43,9 @@ BOT_TOKEN= WEBHOOK_SECRET= # Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set. WEBHOOK_URL= +# Public Mini App origin (not a secret) — lives in bot/wrangler.jsonc "vars" in production. +# The /cari venue card's OPEN button deep link is built from it: /?dest=. +MINI_APP_URL= ``` Script-level precedence: every script in `bot/scripts/` resolves a variable as @@ -120,6 +124,9 @@ print a missing-variable list to stderr and `process.exit(1)` before any network - `KV namespace id` — `bot/wrangler.jsonc` already carries a concrete id for `chat_sessions`; the file's leading comment notes it was filled in after `npx wrangler kv namespace create chat_sessions`. - `WEBHOOK_URL` path segment — auto-derived by `set-webhook.mjs` when the configured origin has no path. +- `MINI_APP_URL` — a non-secret configuration value declared in the Worker `Env` and shipped via + `bot/wrangler.jsonc`'s `vars` block (public origin, not a secret). When it is blank the `/cari` + OPEN button is omitted rather than guessed; the venue card and its alternates still send. - KV TTLs — constants in source, not configuration: `SESSION_TTL_SECONDS = 900` (`bot/src/kv/sessions.ts`) and `PIN_TTL_SECONDS = 300` (`bot/src/kv/location-pin.ts`). Both are pinned by a grep gate in the test suite so no write path can persist without a TTL. @@ -141,6 +148,9 @@ print a missing-variable list to stderr and `process.exit(1)` before any network "not_found_handling": "none" }, "run_worker_first": ["/api/*", "/telegram/*"], + // Public vars (NOT secrets): the deployed Mini App ORIGIN used to build + // the /cari venue card's OPEN deep link (/?dest=). + "vars": { "MINI_APP_URL": "https://araharah-bot..workers.dev" }, "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } ``` @@ -154,6 +164,9 @@ Key notes: - `not_found_handling: "none"` — unmatched paths are not served the SPA `index.html` fallback. - `workers_dev: true` serves on the `..workers.dev` URL, which is the `WEBHOOK_URL` target. +- `vars: { "MINI_APP_URL": ... }` — a **public** origin shipped as a plain var (never a secret): + the deployed Mini App origin the `/cari` venue card's OPEN button deep link is built from. It + must equal the Mini App URL registered in @BotFather; see the env-var table above. diff --git a/package-lock.json b/package-lock.json index 8fae74e..598d62f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "bot": { "name": "@araharah/bot", "dependencies": { + "@araharah/search": "*", "grammy": "^1.46.0" }, "devDependencies": {