From 13e50732619285bfc70dcba13cdd0011fbdebf26 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 01:15:21 +0800 Subject: [PATCH] feat(04-03): the alternates callback handler + the bot factory wiring (D-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search-alternate.ts: scoped alt: callback matcher that answers FIRST (30-second rule), validates the id against the slug charset BEFORE the lookup (T-04-23), resolves via source.byId() — honest null, never a fabricated card; catch-all callback_query:data answers everything else so Telegram never spins for 60 s (T-04-27) - sendVenueCard: the ONE card builder shared by /cari and the swap path — first card and swapped card structurally identical; a tap sends a NEW sendVenue (no in-place edits — they destroy the venue payload, T-04-28) - alternatesFor(entry, source): async — awaits search() on the entry's own name, excludes the entry, caps at 4 (D-02) - bot.ts: ONE createGazetteerSource() shared by both registrations (single memoized index); origin = env.MINI_APP_URL ?? ''; header names the second documented D-02 exception - cari.ts now calls the shared card builder - wrangler dry-run inlines the gazetteer (suria-klcc marker in the bundle) — completes the L3 bundle-marker half deferred from plan 04-01 --- bot/src/bot/bot.ts | 17 +++++ bot/src/bot/commands/cari.ts | 41 ++++------ bot/src/bot/handlers/search-alternate.ts | 97 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 bot/src/bot/handlers/search-alternate.ts diff --git a/bot/src/bot/bot.ts b/bot/src/bot/bot.ts index 76ae404..0a8aec0 100644 --- a/bot/src/bot/bot.ts +++ b/bot/src/bot/bot.ts @@ -17,13 +17,26 @@ * attaches a reply-markup location request). Also: requestLocationFallback * has exactly ONE caller — handleLocationFallback in src/webhook.ts. If a * second caller ever appears, that is a design change, not a refactor. + * + * SEARCH (D-01/D-02/D-10/D-11): createBot builds ONE gazetteer SearchSource + * (D-03, backend #1) and shares it between the /cari command surface + * (commands/cari.ts) and the alternates switcher + * (handlers/search-alternate.ts) — a single memoized index. The venue card + * + inline keyboard is the SECOND documented exception to the D-02 + * plain-text rule (the first being the GEO-02 reply keyboard above). The + * OPEN button's deep-link origin comes from MINI_APP_URL — a configured + * public value, never user input; a blank origin omits the button rather + * than guessing. */ import { Bot } from "grammy"; import type { Env } from "../config.ts"; +import { createGazetteerSource } from "@araharah/search/src/index.ts"; import { registerLocationFallback } from "./handlers/location-fallback.ts"; +import { registerSearchAlternate } from "./handlers/search-alternate.ts"; import { registerHelp } from "./commands/help.ts"; import { registerPrivacy } from "./commands/privacy.ts"; import { registerStart } from "./commands/start.ts"; +import { registerCari } from "./commands/cari.ts"; export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot { const bot = opts.baseFetch @@ -33,5 +46,9 @@ export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bo registerHelp(bot); // /help registerPrivacy(bot); // /privacy + /privasi (D-04) registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback + const source = createGazetteerSource(); // D-03: the in-Worker gazetteer (backend #1) + const origin = env.MINI_APP_URL ?? ""; // the public Mini App origin (D-11) + registerCari(bot, source, origin); // /cari + /search (D-01/D-02/D-10) + registerSearchAlternate(bot, source, origin); // the alternates switcher (D-02) return bot; } diff --git a/bot/src/bot/commands/cari.ts b/bot/src/bot/commands/cari.ts index 8339a7d..54549b4 100644 --- a/bot/src/bot/commands/cari.ts +++ b/bot/src/bot/commands/cari.ts @@ -10,12 +10,14 @@ * 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). + * buttons. The card is built by the ONE shared builder sendVenueCard + * (handlers/search-alternate.ts) so the first card and a swapped card are + * structurally identical; that 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` @@ -40,11 +42,11 @@ * * 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"; +import { sendVenueCard } from "../handlers/search-alternate.ts"; +import type { 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). */ @@ -96,23 +98,12 @@ async function handleCari(ctx: Context, source: SearchSource, origin: string): P } // 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, - }); + // SAME message. Exactly ONE sendVenue — no second message round. The + // keyboard is built inside the ONE shared card builder so the first card + // and a swapped card are structurally identical (D-02, task 2). + const entry = hits[0]!.entry; + const alternates = hits.slice(1).map((hit) => hit.entry); + await sendVenueCard(ctx, entry, alternates, origin, locale); // No log line on the success path — every log line is a privacy surface (D-15). } diff --git a/bot/src/bot/handlers/search-alternate.ts b/bot/src/bot/handlers/search-alternate.ts new file mode 100644 index 0000000..97f5280 --- /dev/null +++ b/bot/src/bot/handlers/search-alternate.ts @@ -0,0 +1,97 @@ +/* + * search-alternate.ts — the alternates callback handler + the ONE venue-card builder (D-02). + * ----------------------------------------------------------------------------------------- + * When the user taps an alternate on the /cari venue card, Telegram delivers a + * callback_query whose data is EXACTLY "alt:" (RESEARCH Pitfall 10) — + * only the public gazetteer id, never a name, a query, or a coordinate. The + * handler answers the callback FIRST (Telegram's 30-second window, T-04-27), + * validates the id against the slug charset BEFORE any lookup (T-04-23), and + * resolves it through the source's synchronous byId() — an honest null when + * unknown, never a fabricated card (D-13). + * + * SWAP SEMANTICS (T-04-28): tapping an alternate sends a NEW sendVenue for + * that entry (with that entry's own alternates). In-place message edits are + * never used on a venue message — an edit would replace the venue payload + * and destroy the card. The OLD card stays in the chat as history; that is + * Telegram-native and honest. One tap to switch, no second user round (D-02). + * + * sendVenueCard is the ONE card builder, shared by the /cari command handler + * and this callback handler, so the first card and the swapped card are + * structurally identical. The inline keyboard it builds is the SECOND + * deliberate exception to the Phase-2 "plain text only" command-surface rule + * (the first being the GEO-02 reply keyboard in location-fallback.ts). + * + * PRIVACY (D-15): callback payloads are public place ids; nothing here logs, + * and the card carries only public place data. + * + * 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 { miniAppUrlFor } from "../commands/cari.ts"; +import type { GazetteerEntry, SearchSource } from "@araharah/search/src/index.ts"; + +/** Entry ids are slugs — a hostile callback id is dropped before any lookup. */ +const CALLBACK_ID_PATTERN = /^[a-z0-9-]{1,64}$/; + +/** Alternates for a swapped card: the next best hits for the entry's own + * name, excluding the entry itself, capped at 4 (D-02). ASYNC because the + * SearchSource interface's search() returns a Promise (only byId() is + * synchronous). Empty result → the card carries the OPEN button alone. */ +export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise { + const hits = await source.search({ text: entry.name, limit: 5 }); + return hits.map((hit) => hit.entry).filter((candidate) => candidate.id !== entry.id).slice(0, 4); +} + +/** The ONE venue-card builder (D-02/D-10): a single sendVenue whose title is + * the entry's canonical name and address the curated string (verbatim, + * never normalized), carrying the OPEN IN ARAHARAH web_app button (row 0) + * and up to 4 alternates as callback buttons. When the configured Mini App + * origin is blank the OPEN button is OMITTED — never guessed (T-04-30) — + * and the card still confirms the destination (SRCH-03). */ +export async function sendVenueCard( + ctx: Context, + entry: GazetteerEntry, + alternates: readonly GazetteerEntry[], + origin: string, + locale: Locale, +): Promise { + 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, + }); +} + +/** Register the alternates surface: the scoped alt: matcher plus the + * catch-all that answers every other callback (grammY's documented + * recommendation — otherwise clients may spin for up to a minute). */ +export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): void { + bot.callbackQuery(/^alt:(.+)$/, async (ctx) => { + await ctx.answerCallbackQuery(); // must happen within Telegram's 30-second window + const match = ctx.match; + const id = Array.isArray(match) ? (match[1] ?? "") : ""; + if (!CALLBACK_ID_PATTERN.test(id)) return; // hostile id → answered, then dropped + const entry = source.byId(id); + if (entry === null) return; // honest null — never a fabricated card + const locale = pickLocale(ctx.from?.language_code); + await sendVenueCard(ctx, entry, await alternatesFor(entry, source), origin, locale); + }); + + // Always answer everything else so Telegram doesn't show a 60-second spinner. + // The alt: path above owns that prefix exclusively — never double-answer. + bot.on("callback_query:data", async (ctx) => { + const data = ctx.callbackQuery?.data ?? ""; + if (/^alt:/.test(data)) return; + await ctx.answerCallbackQuery(); + }); +}