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=
# 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: <MINI_APP_URL>/?dest=<id>.
MINI_APP_URL=
+2 -1
View File
@@ -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",
+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.
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;
}
+26 -2
View File
@@ -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<Locale, BotStrings> = {
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 <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: {
startIntro:
@@ -61,10 +80,15 @@ export const strings: Record<Locale, BotStrings> = {
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 <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"
},
"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" }]
}