feat(04-03): the /cari command + venue card + OPEN deep link (D-01/D-02/D-10/D-11)

- 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=<plain slug id> — 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)
This commit is contained in:
araharah-executor
2026-10-24 01:13:13 +08:00
parent 3aec9542a3
commit a73a38830a
8 changed files with 181 additions and 5 deletions
+3
View File
@@ -3,3 +3,6 @@ BOT_TOKEN=
WEBHOOK_SECRET= WEBHOOK_SECRET=
# Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set. # Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set.
WEBHOOK_URL= 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: <MINI_APP_URL>/?dest=<id>.
MINI_APP_URL=
+2 -1
View File
@@ -10,7 +10,8 @@
"commands": "node scripts/register-commands.mjs" "commands": "node scripts/register-commands.mjs"
}, },
"dependencies": { "dependencies": {
"grammy": "^1.46.0" "grammy": "^1.46.0",
"@araharah/search": "*"
}, },
"devDependencies": { "devDependencies": {
"wrangler": "^4.151.3", "wrangler": "^4.151.3",
+124
View File
@@ -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=<id>`), 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<void> {
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));
}
+5
View File
@@ -9,4 +9,9 @@ export interface Env {
// accept-everything. // accept-everything.
WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token 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) 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;
} }
+26 -2
View File
@@ -35,6 +35,20 @@ export interface BotStrings {
/** GEO-02: confirmation after the user's shared pin has been recorded /** GEO-02: confirmation after the user's shared pin has been recorded
* in the TTL-only location-pin KV (GEO-03). */ * in the TTL-only location-pin KV (GEO-03). */
pinReceived: string; 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"; export type Locale = "en" | "ms";
@@ -48,10 +62,15 @@ export const strings: Record<Locale, BotStrings> = {
privacyNotStored: privacyNotStored:
"What araharah never keeps: your location, your routes, your trip history, any profile about you.", "What araharah never keeps: your location, your routes, your trip history, any profile about you.",
helpMenu: 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.", awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.",
shareLocationButton: "Share my location", shareLocationButton: "Share my location",
pinReceived: "Got it — your starting point is set.", pinReceived: "Got it — your starting point is set.",
searchUsage: "/cari <place> — 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: { ms: {
startIntro: startIntro:
@@ -61,10 +80,15 @@ export const strings: Record<Locale, BotStrings> = {
privacyNotStored: privacyNotStored:
"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", "Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
helpMenu: 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.", awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.",
shareLocationButton: "Kongsi lokasi saya", shareLocationButton: "Kongsi lokasi saya",
pinReceived: "Baik — titik mula anda sudah ditetapkan.", pinReceived: "Baik — titik mula anda sudah ditetapkan.",
searchUsage: "/cari <tempat> — 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",
}, },
}; };
+5
View File
@@ -15,5 +15,10 @@
"not_found_handling": "none" "not_found_handling": "none"
}, },
"run_worker_first": ["/api/*", "/telegram/*"], "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 (<MINI_APP_URL>/?dest=<id>).
// 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" }] "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
} }
+15 -2
View File
@@ -24,13 +24,14 @@ Configuration surfaces, by kind:
## Environment variables ## Environment variables
Three variables reach the Worker (`bot/src/config.ts` declares them as a typed `Env` interface); Four 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. a fifth (`WEBHOOK_URL`) is consumed only by the one-time registration scripts, never by the Worker.
| Variable | Required | Default | Description | | 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. | | `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. | | `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.<account>.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: `<MINI_APP_URL>/?dest=<gazetteer id>`. 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. | | `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`. | | `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= WEBHOOK_SECRET=
# Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set. # Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set.
WEBHOOK_URL= 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: <MINI_APP_URL>/?dest=<id>.
MINI_APP_URL=
``` ```
Script-level precedence: every script in `bot/scripts/` resolves a variable as 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 - `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`. 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. - `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` - 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 (`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. 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" "not_found_handling": "none"
}, },
"run_worker_first": ["/api/*", "/telegram/*"], "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 (<MINI_APP_URL>/?dest=<id>).
"vars": { "MINI_APP_URL": "https://araharah-bot.<account>.workers.dev" },
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] "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. - `not_found_handling: "none"` — unmatched paths are not served the SPA `index.html` fallback.
- `workers_dev: true` serves on the `<name>.<account>.workers.dev` URL, which is the `WEBHOOK_URL` - `workers_dev: true` serves on the `<name>.<account>.workers.dev` URL, which is the `WEBHOOK_URL`
target. 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.
<!-- VERIFY: the Cloudflare account subdomain backing `araharah-bot.<account>.workers.dev` is not recorded in the repository. --> <!-- VERIFY: the Cloudflare account subdomain backing `araharah-bot.<account>.workers.dev` is not recorded in the repository. -->
+1
View File
@@ -15,6 +15,7 @@
"bot": { "bot": {
"name": "@araharah/bot", "name": "@araharah/bot",
"dependencies": { "dependencies": {
"@araharah/search": "*",
"grammy": "^1.46.0" "grammy": "^1.46.0"
}, },
"devDependencies": { "devDependencies": {