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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user