docs(04): plan revision 2 — 04-03 contract wording fixes (inline-card sequencing, async alternatesFor)
This commit is contained in:
@@ -250,7 +250,7 @@ export function registerSearchAlternate(bot: Bot, source: SearchSource, origin:
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(id)) return; // hostile id → answer, then drop
|
||||
const entry = source.byId(id);
|
||||
if (entry === null) return; // honest null — never a fabricated card
|
||||
await sendVenueCard(ctx, entry, alternatesFor(entry, source), origin, locale);
|
||||
await sendVenueCard(ctx, entry, await alternatesFor(entry, source), origin, locale);
|
||||
});
|
||||
|
||||
// Always answer everything else so Telegram doesn't show a 60-second spinner.
|
||||
@@ -266,7 +266,7 @@ export function registerSearchAlternate(bot: Bot, source: SearchSource, origin:
|
||||
|
||||
**`answerCallbackQuery` on EVERY path** — the 30-second rule (RESEARCH §Security: "`answerCallbackQuery` not called — Denial of Service (UX spinner)"). The catch-all `bot.on("callback_query:data", ...)` is grammY's documented recommendation ("Always answer all other callback queries … Otherwise, some clients may display a loading animation for up to a minute").
|
||||
|
||||
**The alternates for a swapped card.** `alternatesFor(entry, source)` asks the source for the entry's own neighbourhood — `source.search({ text: entry.name, limit: 5 })` — and filters out the entry itself, capping at 4. If that yields nothing, the card carries the OPEN button alone.
|
||||
**The alternates for a swapped card.** `alternatesFor(entry, source)` is ASYNC — `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise<GazetteerEntry[]>` — because the `SearchSource` interface's `search()` returns `Promise<SearchHit[]>` (only `byId()` is synchronous). It awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, filters out the entry itself, capping at 4. The callback caller is already async, so it awaits the result. If that yields nothing, the card carries the OPEN button alone.
|
||||
</alternates_contract>
|
||||
|
||||
<strings_contract>
|
||||
@@ -429,6 +429,8 @@ The handler's flow:
|
||||
5. `if (hits.length === 0)` → reply `searchNoMatchText(locale)` with link previews disabled; log `[search] rejected: no-match`; RETURN (no venue card, no keyboard — D-13's honest refusal).
|
||||
6. Otherwise: build the alternates from `hits.slice(1)` (each `hits[i].entry`), then `await sendVenueCard(ctx, hits[0].entry, alternates, origin, locale)` — exactly ONE message (D-02). NO log line on the success path (there is nothing category-worthy to say, and every log line is a privacy surface).
|
||||
|
||||
**Task sequencing note:** at THIS task the card is a single inline `replyWithVenue` in `cari.ts` (this task's acceptance gate counts exactly one `replyWithVenue` here, and `npm run bot:typecheck` must pass without `search-alternate.ts` existing); task 2 extracts it into `sendVenueCard` (search-alternate.ts) and `cari.ts` imports it.
|
||||
|
||||
The OPEN button is `new InlineKeyboard().webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id))`.
|
||||
|
||||
When `origin` is empty or blank: skip the OPEN button, send the card with the alternates only, and log `[search] rejected: mini-app-origin-missing`. The card still confirms the destination (SRCH-03 holds).
|
||||
@@ -472,7 +474,7 @@ When `origin` is empty or blank: skip the OPEN button, send the card with the al
|
||||
Exports:
|
||||
- `export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): void`
|
||||
- `export async function sendVenueCard(ctx: Context, entry: GazetteerEntry, alternates: readonly GazetteerEntry[], origin: string, locale: Locale): Promise<void>` — the ONE venue-card builder, shared by the `/cari` handler and the callback handler so the first card and the swapped card are structurally identical.
|
||||
- `export function alternatesFor(entry: GazetteerEntry, source: SearchSource): GazetteerEntry[]` — the entry's own neighbourhood, filtered to exclude the entry itself, capped at 4.
|
||||
- `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise<GazetteerEntry[]>` — ASYNC because the `SearchSource` interface's `search()` returns `Promise<SearchHit[]>`: it awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, excludes the entry itself, capped at 4.
|
||||
|
||||
The card builder:
|
||||
|
||||
@@ -502,7 +504,7 @@ export async function sendVenueCard(
|
||||
- `await ctx.answerCallbackQuery();` FIRST (the 30-second rule).
|
||||
- The id is validated against `/^[a-z0-9-]{1,64}$/` BEFORE any lookup — a hostile id is dropped silently (after the answer).
|
||||
- `const entry = source.byId(id);` → `null` means RETURN (honest null — never a fabricated card).
|
||||
- The new card's alternates: `alternatesFor(entry, source)`.
|
||||
- The new card's alternates: `await alternatesFor(entry, source)` (async — the awaited result feeds `sendVenueCard`).
|
||||
- The locale comes from `pickLocale(ctx.from?.language_code)` — the same rule the other handlers use.
|
||||
|
||||
**The catch-all:** `bot.on("callback_query:data", async (ctx) => { const data = ctx.callbackQuery?.data ?? ""; if (/^alt:/.test(data)) return; await ctx.answerCallbackQuery(); })` — the specific matcher owns the `alt:` path exclusively, so the catch-all answers everything else (grammY's documented recommendation) and never double-answers.
|
||||
|
||||
Reference in New Issue
Block a user