diff --git a/bot/scripts/register-commands.mjs b/bot/scripts/register-commands.mjs index 92b03ec..e8c4b71 100644 --- a/bot/scripts/register-commands.mjs +++ b/bot/scripts/register-commands.mjs @@ -7,7 +7,8 @@ * 2. default + language_code "ms" — Malay descriptions for ms users * * D-03: the menu lists ONLY commands that exist in this phase (/help, - * /privacy) — no stub entries reserved for future commands. There are + * /privacy, /cari) — no stub entries reserved for future commands (the + * Phase-5 navigation command stays reserved and unregistered). There are * exactly two fetch calls below, one per scope. * * D-05 (single copy source): the descriptions are not inline here — they @@ -58,7 +59,7 @@ if (!token) { /** Extract the `/cmd — description` entries from a locale's helpMenu. */ function menuFor(locale) { const lines = strings[locale].helpMenu.split("\n"); - return ["help", "privacy"].map((command) => { + return ["help", "privacy", "cari"].map((command) => { const marker = `/${command} — `; const line = lines.find((l) => l.startsWith(marker)); if (line === undefined) { diff --git a/bot/src/bot/bot.ts b/bot/src/bot/bot.ts index 0a8aec0..8eceed1 100644 --- a/bot/src/bot/bot.ts +++ b/bot/src/bot/bot.ts @@ -21,10 +21,10 @@ * 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 + * (handlers/search-alternate.ts) — a single memoized index. The venue + * card's inline-button surface is the SECOND documented exception to the + * D-02 plain-text rule (the first being the GEO-02 reply markup 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. */ diff --git a/bot/src/bot/handlers/location-fallback.ts b/bot/src/bot/handlers/location-fallback.ts index c10e59f..706b2ab 100644 --- a/bot/src/bot/handlers/location-fallback.ts +++ b/bot/src/bot/handlers/location-fallback.ts @@ -58,11 +58,13 @@ export async function recordSharedPin( /** Register the GEO-02 message handler. Filters on ctx.message?.location * — Telegram-authenticated updates only (T-03-03), so a fabricated pin - * can never enter KV. */ + * can never enter KV. A message WITHOUT a location is passed straight + * through (next) — this handler matches EVERY message, so swallowing + * would starve all downstream message-stage handlers (e.g. /cari). */ export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void { - bot.on("message", async (ctx: Context) => { + bot.on("message", async (ctx, next) => { const location = ctx.message?.location; - if (!location) return; + if (!location) return next(); const chatId = ctx.chat?.id; if (chatId === undefined) return; await recordSharedPin(kv, chatId, location); diff --git a/bot/test/cari.test.mjs b/bot/test/cari.test.mjs new file mode 100644 index 0000000..54ab294 --- /dev/null +++ b/bot/test/cari.test.mjs @@ -0,0 +1,430 @@ +#!/usr/bin/env node +/* + * cari.test.mjs — the /cari payload-shape gates (D-01, D-02, D-10, D-11, D-13, D-15). + * ------------------------------------------------------------------------------------ + * Two gate styles, per the repo's established conventions: + * + * Style 1 — fake-transport payload capture (webhook.test.mjs's seam): + * createBot(env, { baseFetch }) is driven with synthetic Telegram + * updates; the fake fetch records every outgoing Bot API request + * (method, url, JSON body). Assertions run on the RECORDED request + * bodies — exactly what Telegram would receive. The live API is never + * touched (bot.init()'s getMe also lands in the fake). + * + * Style 2 — source-introspection structural gates (privacy.test.mjs / + * commands.test.mjs precedent): read the shipped source, strip + * comments, assert on code text only. + * + * Cases: + * CR1 the venue payload is structurally correct (lat/lng finite, title = + * canonical name, curated address, reply_markup.inline_keyboard) + * CR2 the OPEN button's URL carries the plain id and the CONFIGURED + * origin — /?dest=, never a query-derived origin + * CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate) + * CR4 the alternates ride on the SAME message — exactly one sendVenue, + * 2-4 alternates as alt: callback buttons + * CR5 the callback_data carries no user data — alt: only, <= 64 + * bytes, no space/query/coordinates + * CR6 a no-match query sends NO venue and NO keyboard — the honest + * searchNoMatch refusal (D-13: never a fabricated pin) + * CR7 an over-long query is rejected before any search (V5, T-04-21) + * CR8 an empty query produces no venue card — the usage line + * CR9 every callback path answers the callback query (30-second rule, + * T-04-27) — the alt: matcher AND the catch-all + * CR10 the query text never reaches a log line (D-15) — every console.log + * in cari.ts is a category-only string literal + * CR11 the copy exists in both locales (refusals differ; the OPEN label + * and the OSM attribution are identical by design) + * CR12 the helpMenu carries the /cari line in both locales + * CR13 the registration script derives the /cari scope from helpMenu — + * "cari" in the command list, still exactly two fetch calls + * CR14 an alternate tap swaps to a NEW venue card for the tapped entry + * (no in-place edits — T-04-28) + * CR15 a hostile callback id is dropped, not echoed (T-04-23) + */ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const { strings } = await import("../src/i18n/strings.ts"); +const { createBot } = await import("../src/bot/bot.ts"); +const { createGazetteerSource } = await import("../../search/src/index.ts"); + +const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); + +/** The comment-strip helper (web/test/no-storage.test.mjs precedent) — + * source-introspection gates must never assert on prose. */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); +} + +/* ————— fake-transport harness (webhook.test.mjs's makeKV + baseFetch seam) ————— */ + +const TEST_TOKEN = "12345:TESTTOKEN"; +const ORIGIN = "https://araharah-bot.example.workers.dev"; +const BOT_USERNAME = "araharah_test_bot"; + +function makeKV() { + const map = new Map(); + return { + async get(key) { + return map.has(key) ? map.get(key) : null; + }, + async put(key, value) { + map.set(key, value); + }, + async delete(key) { + map.delete(key); + }, + map, + }; +} + +/** Records every outgoing Bot API call; answers getMe with a synthetic bot + * user and everything else with ok:true. NEVER touches the network. */ +function makeTransport() { + const calls = []; + const fetchFn = async (url, init) => { + const u = String(url); + const matched = u.match(/bot[^/]+\/([A-Za-z]+)$/); + const method = matched ? matched[1] : u; + let body = null; + if (init?.body !== undefined) { + try { + body = JSON.parse(init.body); + } catch { + body = init.body; + } + } + calls.push({ method, url: u, body }); + const result = + method === "getMe" + ? { id: 12345, is_bot: true, first_name: "araharah", username: BOT_USERNAME } + : true; + return new Response(JSON.stringify({ ok: true, result }), { + headers: { "content-type": "application/json" }, + }); + }; + return { calls, fetchFn }; +} + +async function makeBot() { + const { calls, fetchFn } = makeTransport(); + const bot = createBot( + { + BOT_TOKEN: TEST_TOKEN, + WEBHOOK_SECRET: "wk-test-secret", + chat_sessions: makeKV(), + MINI_APP_URL: ORIGIN, + }, + { baseFetch: fetchFn }, + ); + await bot.init(); // the getMe lands in the fake transport, not the network + return { bot, calls }; +} + +function messageUpdate(text) { + const command = text.split(" ")[0] ?? text; + return { + update_id: Math.floor(Math.random() * 1e9), + message: { + message_id: 1, + from: { id: 42, is_bot: false, first_name: "Zul", language_code: "en" }, + chat: { id: 42, type: "private" }, + date: Math.floor(Date.now() / 1000), + text, + // Real Telegram command messages always carry this entity — grammY's + // command filter matches on it, so the synthetic update must too. + entities: [{ offset: 0, length: command.length, type: "bot_command" }], + }, + }; +} + +function callbackUpdate(data) { + return { + update_id: Math.floor(Math.random() * 1e9), + callback_query: { + id: `cq${Math.floor(Math.random() * 1e9)}`, + from: { id: 42, is_bot: false, first_name: "Zul", language_code: "en" }, + message: { + message_id: 1, + chat: { id: 42, type: "private" }, + date: Math.floor(Date.now() / 1000), + text: "/cari KLCC", + }, + data, + }, + }; +} + +/* ————— recorded-call views (getMe is harness noise) ————— */ + +function apiCalls(calls) { + return calls.filter((c) => c.method !== "getMe"); +} +function venueCalls(calls) { + return apiCalls(calls).filter((c) => c.method === "sendVenue"); +} +function messageCalls(calls) { + return apiCalls(calls).filter((c) => c.method === "sendMessage"); +} + +const source = createGazetteerSource(); + +/* ————— CR1 — the venue payload is structurally correct ————— */ + +test("CR1 the venue payload is structurally correct", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate("/cari KLCC")); + const venues = venueCalls(calls); + assert.equal(venues.length, 1, "exactly one sendVenue (D-02: the card IS the confirmation)"); + const body = venues[0].body; + assert.equal(body.chat_id, 42); + assert.ok(Number.isFinite(body.latitude), "latitude is a finite number"); + assert.ok(Number.isFinite(body.longitude), "longitude is a finite number"); + const entry = source.byId("suria-klcc"); + assert.equal(body.latitude, entry.lat, "the pin is the entry's own coordinate"); + assert.equal(body.longitude, entry.lng, ""); + assert.ok(typeof body.title === "string" && body.title.length > 0, "title non-empty"); + assert.equal(body.title, entry.name, "title is the entry's canonical name"); + assert.ok(typeof body.address === "string" && body.address.length > 0, "curated address non-empty"); + assert.equal(body.address, entry.address, "address is the CURATED string verbatim (never normalized)"); + assert.ok(body.reply_markup?.inline_keyboard, "the card carries an inline keyboard (D-10)"); +}); + +/* ————— CR2 — the OPEN button's URL carries the plain id and the configured origin ————— */ + +test("CR2 the OPEN button's URL carries the plain id and the configured origin", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate("/cari KLCC")); + const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; + const open = keyboard[0].find((btn) => btn.web_app); + assert.ok(open, "row 0 carries the OPEN web_app button"); + assert.equal(open.text, strings.en.searchOpenButton); + assert.equal( + open.web_app.url, + `${ORIGIN}/?dest=suria-klcc`, + "the URL is exactly /?dest=", + ); + assert.ok( + open.web_app.url.startsWith(`${ORIGIN}/`), + "the origin part equals the configured MINI_APP_URL — never a query-derived value", + ); + const dest = new URL(open.web_app.url).searchParams.get("dest"); + assert.match(dest, /^[A-Za-z0-9_-]{1,64}$/, "the dest value is a slug (start_param-safe charset)"); + assert.ok(!open.web_app.url.includes("KLCC"), "the user's query text never reaches the URL"); +}); + +/* ————— CR3 — NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate) ————— */ + +test("CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate)", () => { + const banned = [/createHmac/, /crypto\.subtle/, /sign\(/, /hmac/i, /signature/i, /sha256/i, /secret/i]; + for (const rel of ["bot/commands/cari.ts", "bot/handlers/search-alternate.ts"]) { + const stripped = stripComments(readFileSync(path.join(BOT_SRC, ...rel.split("/")), "utf8")); + for (const pattern of banned) { + assert.doesNotMatch( + stripped, + pattern, + `${rel} — a crypto-shaped token appeared in code: ${pattern} (the deep-link payload is a PUBLIC place id — D-11)`, + ); + } + } +}); + +/* ————— CR4 — the alternates ride on the SAME message ————— */ + +test("CR4 the alternates ride on the SAME message", async () => { + const { bot, calls } = await makeBot(); + // "kuala" resolves 5 hits — 1 best + 4 alternates, exercising the cap. + await bot.handleUpdate(messageUpdate("/cari kuala")); + assert.equal(venueCalls(calls).length, 1, "exactly ONE venue message for the whole search (D-02)"); + assert.equal(apiCalls(calls).length, 1, "exactly ONE outgoing Bot API message in total"); + const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; + const alternates = keyboard.flat().filter((btn) => btn.callback_data !== undefined); + assert.ok(alternates.length >= 2 && alternates.length <= 4, "2-4 alternates ride on the card"); + for (const btn of alternates) { + assert.match(btn.callback_data, /^alt:[a-z0-9-]{1,64}$/, "every alternate is an alt: callback"); + assert.ok(btn.text.length > 0, "each alternate's label is the entry's own name"); + } +}); + +/* ————— CR5 — the callback_data carries no user data ————— */ + +test("CR5 the callback_data carries no user data", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate("/cari KLCC")); + const keyboard = venueCalls(calls)[0].body.reply_markup.inline_keyboard; + const allData = keyboard.flat().map((btn) => btn.callback_data).filter((d) => d !== undefined); + assert.ok(allData.length >= 1, "the keyboard carries callback buttons to inspect"); + for (const data of allData) { + assert.match(data, /^alt:[a-z0-9-]+$/, `callback_data is exactly alt:: ${data}`); + assert.ok(!data.includes(" "), "no space in callback_data"); + assert.ok(!/\d+\.\d+/.test(data.slice(4)), "no coordinate in callback_data"); + assert.ok(Buffer.byteLength(data, "utf8") <= 64, `callback_data within Telegram's 64-byte limit: ${data}`); + } +}); + +/* ————— CR6 — a no-match query sends NO venue and NO keyboard ————— */ + +test("CR6 a no-match query sends NO venue and NO keyboard", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate("/cari Kuching")); + assert.equal(venueCalls(calls).length, 0, "no sendVenue for an out-of-area query (D-13)"); + const messages = messageCalls(calls); + assert.equal(messages.length, 1, "the honest refusal is the only reply"); + const body = messages[0].body; + assert.ok(body.reply_markup === undefined, "no reply_markup of any kind on the refusal"); + assert.equal(body.text, strings.en.searchNoMatch, "the refusal names the v1 area, not the query"); + assert.ok(!body.text.includes("Kuching"), "the refusal copy must not echo the query text"); +}); + +/* ————— CR7 — an over-long query is rejected before any search ————— */ + +test("CR7 an over-long query is rejected before any search", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate(`/cari ${"x".repeat(200)}`)); + assert.equal(venueCalls(calls).length, 0, "no venue card for an over-cap query (V5)"); + const messages = messageCalls(calls); + assert.equal(messages.length, 1); + assert.equal(messages[0].body.text, strings.en.searchQueryTooLong, "the over-cap refusal copy"); + assert.ok(messages[0].body.reply_markup === undefined, "no keyboard on the over-cap refusal"); +}); + +/* ————— CR8 — an empty query produces no venue card ————— */ + +test("CR8 an empty query produces no venue card", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(messageUpdate("/cari")); + assert.equal(venueCalls(calls).length, 0, "no venue card without a query"); + const messages = messageCalls(calls); + assert.equal(messages.length, 1); + assert.equal(messages[0].body.text, strings.en.searchUsage, "the usage copy"); + assert.ok(messages[0].body.reply_markup === undefined, "no keyboard on the usage reply"); +}); + +/* ————— CR9 — every callback path answers the callback query ————— */ + +test("CR9 every callback path answers the callback query", async () => { + const src = readFileSync(path.join(BOT_SRC, "bot", "handlers", "search-alternate.ts"), "utf8"); + const answers = src.match(/answerCallbackQuery/g) ?? []; + assert.ok(answers.length >= 2, "the alt: matcher AND the catch-all both answer (30-second rule)"); + assert.match( + src, + /bot\.on\("callback_query:data"/, + "the catch-all exists — Telegram never shows the 60-second spinner", + ); + assert.match(src, /bot\.callbackQuery\(\s*\/\^alt:/, "the matcher is scoped to the alt: prefix"); + // Behavioural half: an UNKNOWN callback data still gets answered. + const { bot, calls } = await makeBot(); + await bot.handleUpdate(callbackUpdate("something-else-entirely")); + const answered = apiCalls(calls).some((c) => c.method === "answerCallbackQuery"); + assert.ok(answered, "an unknown callback query is answered, not dropped on the floor"); + assert.equal(venueCalls(calls).length, 0, "and it never fabricates a card"); +}); + +/* ————— CR10 — the query text never reaches a log line (D-15) ————— */ + +test("CR10 the query text never reaches a log line (D-15)", () => { + const stripped = stripComments(readFileSync(path.join(BOT_SRC, "bot", "commands", "cari.ts"), "utf8")); + const logs = stripped.match(/console\.log\([^;]*\)/g) ?? []; + assert.ok(logs.length >= 1, "cari.ts carries its rejection categories"); + for (const log of logs) { + assert.match( + log, + /^console\.log\("\[search\] rejected: [a-z-]+"\)$/, + `log must be a category-only string literal — no variable, no payload: ${log}`, + ); + } +}); + +/* ————— CR11 — the copy exists in both locales ————— */ + +test("CR11 the copy exists in both locales", () => { + for (const field of ["searchNoMatch", "searchUsage", "searchQueryTooLong"]) { + assert.ok(strings.en[field]?.length > 0, `en ${field} non-empty`); + assert.ok(strings.ms[field]?.length > 0, `ms ${field} non-empty`); + assert.notEqual(strings.en[field], strings.ms[field], `${field} is not an untranslated copy`); + } + assert.equal( + strings.en.searchOpenButton, + strings.ms.searchOpenButton, + "the OPEN label is identical in both locales by design (a product affordance)", + ); + assert.equal(strings.en.searchOpenButton, "OPEN IN ARAHARAH"); + assert.equal( + strings.en.osmAttribution, + strings.ms.osmAttribution, + "the OSM attribution is identical in both locales (legal text)", + ); + assert.equal(strings.en.osmAttribution, "© OpenStreetMap contributors"); +}); + +/* ————— CR12 — the helpMenu carries the /cari line in both locales ————— */ + +function menuEntries(helpMenu) { + return helpMenu + .split("\n") + .filter((line) => line.startsWith("/")) + .map((line) => { + const em = line.indexOf(" — "); + assert.ok(em !== -1, `helpMenu line lacks its " — " description marker: ${line}`); + return { command: line.slice(1, em), description: line.slice(em + 3).trim() }; + }); +} + +test("CR12 the helpMenu carries the /cari line in both locales", () => { + const en = menuEntries(strings.en.helpMenu).find((e) => e.command === "cari"); + const ms = menuEntries(strings.ms.helpMenu).find((e) => e.command === "cari"); + assert.ok(en, "en helpMenu has a /cari entry"); + assert.ok(ms, "ms helpMenu has a /cari entry"); + assert.ok(en.description.length > 0 && ms.description.length > 0, "both descriptions non-empty"); + assert.notEqual(en.description, ms.description, "the ms description is in the ms locale's own language"); +}); + +/* ————— CR13 — the registration script derives the /cari scope from helpMenu ————— */ + +test("CR13 the registration script derives the /cari scope from helpMenu", () => { + const src = readFileSync( + path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "scripts", "register-commands.mjs"), + "utf8", + ); + assert.ok(src.includes('["help", "privacy", "cari"]'), "the hardcoded command list gained cari"); + const fetchCalls = src.match(/\bawait fetch\(/g) ?? []; + assert.equal(fetchCalls.length, 2, "still exactly two fetch calls (default + ms scopes)"); +}); + +/* ————— CR14 — the alternates swap produces a new venue card ————— */ + +test("CR14 the alternates swap produces a new venue card for the tapped entry", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(callbackUpdate("alt:aquaria-klcc")); + const venues = venueCalls(calls); + assert.equal(venues.length, 1, "the tap produces exactly one new venue card"); + const entry = source.byId("aquaria-klcc"); + assert.ok(entry, "the tapped id resolves through the source"); + assert.equal(venues[0].body.title, entry.name, "the swapped card is for the TAPPED entry"); + assert.ok( + apiCalls(calls).some((c) => c.method === "answerCallbackQuery"), + "the swap path answers the callback first", + ); + const handlerSrc = readFileSync(path.join(BOT_SRC, "bot", "handlers", "search-alternate.ts"), "utf8"); + assert.ok( + !handlerSrc.includes("editMessageText"), + "the swap sends a NEW card — in-place edits never touch a venue message (T-04-28)", + ); +}); + +/* ————— CR15 — a hostile callback id is dropped, not echoed ————— */ + +test("CR15 a hostile callback id is dropped, not echoed", async () => { + const { bot, calls } = await makeBot(); + await bot.handleUpdate(callbackUpdate("alt:../../etc/passwd")); + assert.equal(venueCalls(calls).length, 0, "a traversal-shaped id produces NO venue card"); + assert.equal(messageCalls(calls).length, 0, "nothing is echoed into the chat either"); + const answered = apiCalls(calls).some((c) => c.method === "answerCallbackQuery"); + assert.ok(answered, "even a hostile callback is answered (30-second rule) before being dropped"); +}); diff --git a/bot/test/commands.test.mjs b/bot/test/commands.test.mjs index 248c42a..8eda957 100644 --- a/bot/test/commands.test.mjs +++ b/bot/test/commands.test.mjs @@ -9,11 +9,24 @@ * (location / routes / history) and the stored TTL wording * CS4 D-02 structural gate — command tree source contains no reply-menu * or inline-button surface (grep: zero "keyboard"-shaped tokens) - * CS5 D-03 menu gap-freedom — helpMenu lists exactly /help and /privacy, - * and the registration script derives both scopes from it (no - * inline description literals, no future-command stubs) + * CARVE (the SECOND documented exception, after the GEO-02 reply + * keyboard in handlers/location-fallback.ts): commands/cari.ts ships + * the D-02/D-10 venue card whose INLINE keyboard (InlineKeyboard + + * reply_markup with inline_keyboard: the OPEN IN ARAHARAH web_app + * button + alternates) is a command's output — hiding it from this + * gate would be the more dangerous choice, so the gate scans cari.ts + * for the REPLY-keyboard shape specifically and still bans it there. + * A reply keyboard remains a handlers/ surface only. + * CS4b link previews — any command source calling ctx.reply( disables + * link previews (a query echoed into a refusal must not become a + * link preview) + * CS5 D-03 menu gap-freedom — helpMenu lists exactly /help, /privacy, + * and /cari, and the registration script derives all three scopes + * from it (no inline description literals; /pergi_ke stays a banned + * future stub — reserved for Phase 5, D-01) * CS6 D-04 alias completeness — command stack registers exactly - * /start /mula /help /privacy /privasi (help unprefixed-alias only) + * /start /mula /help /privacy /privasi /cari /search (help + * unprefixed-alias only) * * Source-introspection style (privacy.test.mjs precedent): structural * claims are pinned by reading the shipped source, not by behavior mocks. @@ -27,9 +40,13 @@ import { test } from "./harness.mjs"; const { strings, pickLocale } = await import("../src/i18n/strings.ts"); const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src", "bot"); -const commandSources = ["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"].map((rel) => - path.join(BOT_SRC, ...rel.split("/")), -); +const commandSources = [ + "bot.ts", + "commands/start.ts", + "commands/help.ts", + "commands/privacy.ts", + "commands/cari.ts", +].map((rel) => path.join(BOT_SRC, ...rel.split("/"))); /* ————— CS1 — en/ms key-set identity (D-05) ————— */ @@ -75,9 +92,25 @@ test("CS3 PRIV-01: stored-facts copy carries the TTL-minutes wording in both loc /* ————— CS4 — D-02 structural gate ————— */ -test("CS4 D-02: the command tree source has no reply-menu or inline-button surface", () => { +test("CS4 D-02: the command tree has no reply-menu surface — the /cari inline-keyboard carve", () => { for (const file of commandSources) { const source = readFileSync(file, "utf8"); + if (file.endsWith("commands/cari.ts")) { + // The documented carve (second exception after the GEO-02 reply + // keyboard): the venue card's INLINE keyboard is allowed, but a + // REPLY keyboard must never appear in a command source. + assert.doesNotMatch( + source, + /reply_markup:\s*\{\s*keyboard/, + "cari.ts — a reply-keyboard shape appeared (reply keyboards live in handlers/, not commands/)", + ); + assert.doesNotMatch( + source, + /ReplyKeyboardMarkup/, + "cari.ts — ReplyKeyboardMarkup appeared (the venue card carries an INLINE keyboard only)", + ); + continue; + } assert.doesNotMatch( source, /keyboard/i, @@ -110,13 +143,13 @@ function menuEntries(helpMenu) { }); } -test("CS5 D-03: helpMenu lists exactly /help and /privacy — no future-command stubs", () => { +test("CS5 D-03: helpMenu lists exactly /help, /privacy, and /cari — no future-command stubs", () => { for (const locale of ["en", "ms"]) { const entries = menuEntries(strings[locale].helpMenu); assert.deepEqual( entries.map((e) => e.command), - ["help", "privacy"], - `${locale} menu must contain exactly the two live commands (D-03)`, + ["help", "privacy", "cari"], + `${locale} menu must contain exactly the live commands, in helpMenu order (D-03; /pergi_ke stays reserved for Phase 5, D-01)`, ); } }); @@ -140,14 +173,14 @@ test("CS5 D-03: register-commands derives both scopes from helpMenu — two fetc ); assert.doesNotMatch( source, - /cari|pergi_ke/, - "no future-command stubs may be registered before their phases exist (D-03)", + /pergi_ke/, + "no future-command stubs may be registered before their phases exist (/pergi_ke stays reserved for Phase 5, D-01; /cari is a live command since 04-03)", ); }); /* ————— CS6 — D-04 alias completeness on the command stack ————— */ -test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi", () => { +test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi /cari /search", () => { const registered = new Set(); for (const file of commandSources) { const source = readFileSync(file, "utf8"); @@ -157,7 +190,7 @@ test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /pri } assert.deepEqual( [...registered].sort(), - ["help", "mula", "privasi", "privacy", "start"].sort(), + ["cari", "help", "mula", "privasi", "privacy", "search", "start"].sort(), "the command stack must be exactly the phase surface (D-04; no drift, no extras)", ); });