feat(04-03): the alternates callback handler + the bot factory wiring (D-02)

- 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
This commit is contained in:
araharah-executor
2026-10-24 01:15:21 +08:00
parent a73a38830a
commit 13e5073261
3 changed files with 130 additions and 25 deletions
+17
View File
@@ -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;
}
+16 -25
View File
@@ -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).
}
+97
View File
@@ -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:<entryId>" (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<GazetteerEntry[]> {
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<void> {
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();
});
}