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
|
* attaches a reply-markup location request). Also: requestLocationFallback
|
||||||
* has exactly ONE caller — handleLocationFallback in src/webhook.ts. If a
|
* has exactly ONE caller — handleLocationFallback in src/webhook.ts. If a
|
||||||
* second caller ever appears, that is a design change, not a refactor.
|
* 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 { Bot } from "grammy";
|
||||||
import type { Env } from "../config.ts";
|
import type { Env } from "../config.ts";
|
||||||
|
import { createGazetteerSource } from "@araharah/search/src/index.ts";
|
||||||
import { registerLocationFallback } from "./handlers/location-fallback.ts";
|
import { registerLocationFallback } from "./handlers/location-fallback.ts";
|
||||||
|
import { registerSearchAlternate } from "./handlers/search-alternate.ts";
|
||||||
import { registerHelp } from "./commands/help.ts";
|
import { registerHelp } from "./commands/help.ts";
|
||||||
import { registerPrivacy } from "./commands/privacy.ts";
|
import { registerPrivacy } from "./commands/privacy.ts";
|
||||||
import { registerStart } from "./commands/start.ts";
|
import { registerStart } from "./commands/start.ts";
|
||||||
|
import { registerCari } from "./commands/cari.ts";
|
||||||
|
|
||||||
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
||||||
const bot = opts.baseFetch
|
const bot = opts.baseFetch
|
||||||
@@ -33,5 +46,9 @@ export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bo
|
|||||||
registerHelp(bot); // /help
|
registerHelp(bot); // /help
|
||||||
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
||||||
registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback
|
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;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
* D-02 shape: ONE message per search — a native venue card (Bot API
|
* 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
|
* sendVenue) carrying an inline keyboard: row 0 is the OPEN IN ARAHARAH
|
||||||
* web_app button, the following row carries 2–4 alternates as callback
|
* web_app button, the following row carries 2–4 alternates as callback
|
||||||
* buttons. This keyboard is the SECOND deliberate exception to the Phase-2
|
* buttons. The card is built by the ONE shared builder sendVenueCard
|
||||||
* "plain text only" command-surface rule (the first being the GEO-02 reply
|
* (handlers/search-alternate.ts) so the first card and a swapped card are
|
||||||
* keyboard in handlers/location-fallback.ts); CS4's carve documents it.
|
* structurally identical; that keyboard is the SECOND deliberate exception
|
||||||
* The card IS the confirmation (sendVenue has no `text` parameter — its
|
* to the Phase-2 "plain text only" command-surface rule (the first being
|
||||||
* `title` is the entry's canonical name and `address` the curated address,
|
* the GEO-02 reply keyboard in handlers/location-fallback.ts); CS4's carve
|
||||||
* verbatim, never the normalized form).
|
* 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
|
* DEEP-LINK MECHANISM (D-11, carrier corrected per RESEARCH Pitfall 1): a
|
||||||
* `web_app` inline button's WebAppInfo carries EXACTLY ONE field — `url`
|
* `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.
|
* All copy comes from src/i18n/strings.ts — nothing inline here.
|
||||||
*/
|
*/
|
||||||
import { InlineKeyboard } from "grammy";
|
|
||||||
import type { Bot, Context } from "grammy";
|
import type { Bot, Context } from "grammy";
|
||||||
import { pickLocale, strings } from "../../i18n/strings.ts";
|
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||||
import type { Locale } 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
|
/** V5: the query is untrusted input — bounded before ANY processing
|
||||||
* (a megabyte argument must never reach normalization/scoring, T-04-21). */
|
* (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
|
// D-02: the best result ships immediately; 2–4 alternates ride on the
|
||||||
// SAME message. Exactly ONE sendVenue — no second message round.
|
// SAME message. Exactly ONE sendVenue — no second message round. The
|
||||||
const entry: GazetteerEntry = hits[0]!.entry;
|
// keyboard is built inside the ONE shared card builder so the first card
|
||||||
const alternates: GazetteerEntry[] = hits.slice(1).map((hit) => hit.entry);
|
// and a swapped card are structurally identical (D-02, task 2).
|
||||||
|
const entry = hits[0]!.entry;
|
||||||
const keyboard = new InlineKeyboard();
|
const alternates = hits.slice(1).map((hit) => hit.entry);
|
||||||
if (origin.trim() !== "") {
|
await sendVenueCard(ctx, entry, alternates, origin, locale);
|
||||||
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).
|
// 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