diff --git a/bot/scripts/register-commands.mjs b/bot/scripts/register-commands.mjs new file mode 100644 index 0000000..92b03ec --- /dev/null +++ b/bot/scripts/register-commands.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/* + * register-commands.mjs — one-time setMyCommands registration (D-03, D-04, D-17). + * ------------------------------------------------------------------------------- + * Registers the bot's persistent command menu in two scopes: + * 1. default scope — English /help and /privacy descriptions + * 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 + * exactly two fetch calls below, one per scope. + * + * D-05 (single copy source): the descriptions are not inline here — they + * are derived from src/i18n/strings.ts `helpMenu` lines ("/cmd — text"), + * the same text /help replies with, so reply and registered menu cannot + * drift apart. + * + * Secrets stay secret (T-02-04): BOT_TOKEN is read from bot/.dev.vars + * (never committed, D-15) and is never echoed — only the API response's + * ok/result fields are printed. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { strings } from "../src/i18n/strings.ts"; + +/* ————— configuration (D-15: local secrets from gitignored .dev.vars) ————— */ + +function readDevVars() { + const devVarsPath = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", ".dev.vars"); + let raw = ""; + try { + raw = readFileSync(devVarsPath, "utf8"); + } catch { + return {}; + } + const vars = {}; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return vars; +} + +const token = process.env.BOT_TOKEN ?? readDevVars().BOT_TOKEN; +if (!token) { + console.error( + "BOT_TOKEN missing — copy bot/.dev.vars.example to bot/.dev.vars (gitignored) and paste your @BotFather token.", + ); + process.exit(1); +} + +/* ————— menu derived from the strings module (D-03/D-05, gap-free) ————— */ + +/** 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) => { + const marker = `/${command} — `; + const line = lines.find((l) => l.startsWith(marker)); + if (line === undefined) { + throw new Error(`helpMenu for "${locale}" is missing its /${command} line (D-03 copy drift)`); + } + return { command, description: line.slice(marker.length).trim() }; + }); +} + +/* ————— exactly two API calls: default scope, then the ms scope (D-03/D-04) ————— */ + +const API = `https://api.telegram.org/bot${token}/setMyCommands`; +const JSON_HEADERS = { "content-type": "application/json" }; + +const defaultPayload = { commands: menuFor("en"), bot_command_scope: { type: "default" } }; +const msPayload = { commands: menuFor("ms"), bot_command_scope: { type: "default" }, language_code: "ms" }; + +const defaultRes = await fetch(API, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(defaultPayload) }); +const defaultBody = await defaultRes.json(); +console.log(`default scope -> ok:${defaultBody.ok} result:${JSON.stringify(defaultBody.result ?? null)}`); + +const msRes = await fetch(API, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(msPayload) }); +const msBody = await msRes.json(); +console.log(`ms scope -> ok:${msBody.ok} result:${JSON.stringify(msBody.result ?? null)}`); + +if (!defaultBody.ok || !msBody.ok) { + const firstError = !defaultBody.ok ? defaultBody : msBody; + console.error(`setMyCommands failed: ${firstError.error_code ?? "?"} ${firstError.description ?? "unknown error"}`); + process.exit(1); +} diff --git a/bot/src/bot/bot.ts b/bot/src/bot/bot.ts new file mode 100644 index 0000000..ab878f4 --- /dev/null +++ b/bot/src/bot/bot.ts @@ -0,0 +1,26 @@ +/* + * bot.ts — the grammY bot factory (D-02, D-05, D-11). + * ---------------------------------------------------- + * `createBot(env)` instantiates the Bot with the env-bound token and + * registers the command-stack handlers (aliases included). It runs inside + * the Worker's fetch handler (Workers have no server startup hook, and the + * token only arrives with `env`). + * + * D-02 (structural, pinned by test/commands.test.mjs): handlers send plain + * text replies only — no reply menus, no inline button payloads, no + * callback routes, no menu augmentation per request. The persistent menu + * is registered once by scripts/register-commands.mjs (D-03). + */ +import { Bot } from "grammy"; +import type { Env } from "../config.ts"; +import { registerHelp } from "./commands/help.ts"; +import { registerPrivacy } from "./commands/privacy.ts"; +import { registerStart } from "./commands/start.ts"; + +export function createBot(env: Env): Bot { + const bot = new Bot(env.BOT_TOKEN); + registerStart(bot); // /start + /mula (D-04) + registerHelp(bot); // /help + registerPrivacy(bot); // /privacy + /privasi (D-04) + return bot; +} diff --git a/bot/src/bot/commands/help.ts b/bot/src/bot/commands/help.ts new file mode 100644 index 0000000..a8ecd1f --- /dev/null +++ b/bot/src/bot/commands/help.ts @@ -0,0 +1,30 @@ +/* + * help.ts — the /help handler (D-03, D-04, D-05). + * ------------------------------------------------ + * No Malay alias in this phase (D-04 names /mula and /privasi only — + * later-flow aliases belong to their own phases). + * + * The reply is the helpMenu text, whose "cmd — description" lines are the + * same descriptions the setMyCommands registration script lists in the + * command menu — menu and reply cannot drift apart (D-03, gap-free). + * + * Plain text-only reply, D-02-pinned; copy lives only in strings.ts (D-05). + */ +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; + +/** Pure builder — the exact text the /help surface replies with. */ +export function helpReplyText(locale: Locale): string { + return strings[locale].helpMenu; +} + +async function handleHelp(ctx: Context): Promise { + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(helpReplyText(locale), { link_preview_options: { is_disabled: true } }); +} + +/** Register the command stack entry: /help. */ +export function registerHelp(bot: Bot): void { + bot.command("help", handleHelp); +} diff --git a/bot/src/bot/commands/privacy.ts b/bot/src/bot/commands/privacy.ts new file mode 100644 index 0000000..8ed4cb3 --- /dev/null +++ b/bot/src/bot/commands/privacy.ts @@ -0,0 +1,33 @@ +/* + * privacy.ts — the /privacy and /privasi handlers (PRIV-01, D-02, D-04, D-05). + * ----------------------------------------------------------------------------- + * This is the visible half of the privacy promise (PRIV-01): what is stored + * and what is never stored, said as plainly as the storage code does it. + * The stored-facts text must keep matching src/kv/sessions.ts's D-08 claim + * fields and TTL — the copy gate in test/commands.test.mjs holds that line. + * + * Aliases share one handler on the same command stack (D-04): + * `/privacy` and `/privasi` are grammatically the same command. + * + * Plain text-only reply, D-02-pinned; copy lives only in strings.ts (D-05). + */ +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; + +/** Pure builder — stored-facts block, then the never-facts block. */ +export function privacyReplyText(locale: Locale): string { + const s = strings[locale]; + return `${s.privacyStored}\n\n${s.privacyNotStored}`; +} + +async function handlePrivacy(ctx: Context): Promise { + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(privacyReplyText(locale), { link_preview_options: { is_disabled: true } }); +} + +/** Register the command stack entry: /privacy and its Malay alias /privasi. */ +export function registerPrivacy(bot: Bot): void { + bot.command("privacy", handlePrivacy); + bot.command("privasi", handlePrivacy); +} diff --git a/bot/src/bot/commands/start.ts b/bot/src/bot/commands/start.ts new file mode 100644 index 0000000..3cda6c0 --- /dev/null +++ b/bot/src/bot/commands/start.ts @@ -0,0 +1,32 @@ +/* + * start.ts — the /start and /mula handlers (D-01, D-02, D-04, D-05). + * ------------------------------------------------------------------- + * Aliases share one handler on the same command stack (D-04): + * `/start` and `/mula` are grammatically the same command. + * + * D-02 (structural, pinned by test/commands.test.mjs): the reply is plain + * text with link previews disabled — no reply menus, no inline button + * payloads, no callback routes anywhere in this tree. Telegram commands + * are the interface until the Mini App launch path lands (Phase 4). + * + * All copy comes from src/i18n/strings.ts (D-05) — nothing inline here. + */ +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; + +/** Pure builder — the exact text the /start surface replies with. */ +export function startReplyText(locale: Locale): string { + return strings[locale].startIntro; +} + +async function handleStart(ctx: Context): Promise { + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(startReplyText(locale), { link_preview_options: { is_disabled: true } }); +} + +/** Register the command stack entry: /start and its Malay alias /mula. */ +export function registerStart(bot: Bot): void { + bot.command("start", handleStart); + bot.command("mula", handleStart); +} diff --git a/bot/src/i18n/strings.ts b/bot/src/i18n/strings.ts new file mode 100644 index 0000000..ff9790d --- /dev/null +++ b/bot/src/i18n/strings.ts @@ -0,0 +1,66 @@ +/* + * strings.ts — the copy's tokens.css (D-05). + * ------------------------------------------- + * Every user-facing string the bot sends lives here and NOWHERE else — + * handlers import from this module, the setMyCommands registration script + * derives its menu descriptions from `helpMenu`, and there is no inline + * copy anywhere in the command tree. + * + * Locales: English default ("en") + Bahasa Melayu ("ms"). Tone is clinical + * + honest (D-01): one sentence for what araharah is, plain statements of + * what is stored and what is not — no emoji, no marketing flourishes. + * + * Claim-surface honesty (D-17 / PRIV-01): `privacyStored` names the exact + * D-08 claim fields written by src/kv/sessions.ts (user id, chat id, + * language, ~15-minute TTL) and nothing more; `privacyNotStored` names the + * exact never-facts. Any matching change to the storage layer must be + * reflected here and the copy gate in test/commands.test.mjs will keep + * the two in step. + */ + +export interface BotStrings { + /** D-01: what araharah is, in one sentence. */ + startIntro: string; + /** PRIV-01: exactly what is stored — must mirror the D-08 claim fields and the TTL. */ + privacyStored: string; + /** PRIV-01: exactly what is NOT stored — the never-facts. */ + privacyNotStored: string; + /** D-03: lists only commands that exist in this phase — derived lines + * ("cmd — description") feed the setMyCommands registration script. */ + helpMenu: string; +} + +export type Locale = "en" | "ms"; + +export const strings: Record = { + en: { + startIntro: + "araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.", + privacyStored: + "What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.", + privacyNotStored: + "What araharah never keeps: your location, your routes, your trip history, any profile about you.", + helpMenu: + "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores", + }, + ms: { + startIntro: + "araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.", + privacyStored: + "Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.", + privacyNotStored: + "Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", + helpMenu: + "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan", + }, +}; + +/** + * D-05 fallback rule: a Telegram `language_code` that starts with the + * Malay tag selects "ms"; every other value (including null/undefined and + * unknown languages) falls back to English. + */ +export function pickLocale(telegramLanguageCode?: string | null): Locale { + const code = telegramLanguageCode ?? ""; + return code === "ms" || code.startsWith("ms-") ? "ms" : "en"; +} diff --git a/bot/test/commands.test.mjs b/bot/test/commands.test.mjs new file mode 100644 index 0000000..248c42a --- /dev/null +++ b/bot/test/commands.test.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/* + * commands.test.mjs — command-surface gates (ONBD-01, PRIV-01, D-02, D-03, D-04, D-05). + * ------------------------------------------------------------------------------------- + * Automated half of the command surface: + * CS1 strings integrity — en/ms key-sets identical (D-05) + * CS2 pickLocale fallback rule — ms-tagged → "ms", everything else → "en" + * CS3 PRIV-01 copy gate — privacy copy states the never-facts + * (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) + * CS6 D-04 alias completeness — command stack registers exactly + * /start /mula /help /privacy /privasi (help unprefixed-alias only) + * + * Source-introspection style (privacy.test.mjs precedent): structural + * claims are pinned by reading the shipped source, not by behavior mocks. + */ +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, 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("/")), +); + +/* ————— CS1 — en/ms key-set identity (D-05) ————— */ + +test("CS1 strings module: en and ms locales carry identical key-sets", () => { + const enKeys = Object.keys(strings.en).sort(); + const msKeys = Object.keys(strings.ms).sort(); + assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)"); + assert.ok(enKeys.length >= 4, "expected the full BotStrings key-set (start, privacy x2, help)"); +}); + +/* ————— CS2 — pickLocale fallback rule (D-05) ————— */ + +test("CS2 pickLocale: ms-tagged codes pick ms; everything else falls back to en", () => { + assert.equal(pickLocale("ms"), "ms"); + assert.equal(pickLocale("ms-MY"), "ms"); + assert.equal(pickLocale("en"), "en"); + assert.equal(pickLocale("en-GB"), "en"); + assert.equal(pickLocale("id"), "en", "unknown/neighbor locale falls back to en"); + assert.equal(pickLocale("zh-hans"), "en"); + assert.equal(pickLocale(null), "en"); + assert.equal(pickLocale(undefined), "en"); + assert.equal(pickLocale(""), "en"); +}); + +/* ————— CS3 — PRIV-01 copy gate ————— */ + +test("CS3 PRIV-01: privacy copy states the never-facts in both locales", () => { + assert.ok(/location/i.test(strings.en.privacyNotStored), "en never-facts must name location"); + assert.ok(/routes/i.test(strings.en.privacyNotStored), "en never-facts must name routes"); + assert.ok(/history/i.test(strings.en.privacyNotStored), "en never-facts must name trip history"); + assert.ok(/profil/i.test(strings.en.privacyNotStored), "en never-facts must name profile"); + + assert.ok(/lokasi/i.test(strings.ms.privacyNotStored), "ms never-facts must name lokasi"); + assert.ok(/laluan/i.test(strings.ms.privacyNotStored), "ms never-facts must name laluan"); + assert.ok(/sejarah/i.test(strings.ms.privacyNotStored), "ms never-facts must name sejarah"); + assert.ok(/profil/i.test(strings.ms.privacyNotStored), "ms never-facts must name profil"); +}); + +test("CS3 PRIV-01: stored-facts copy carries the TTL-minutes wording in both locales", () => { + assert.ok(/15 minutes/.test(strings.en.privacyStored), "en stored-facts must carry the ~15-minutes TTL wording"); + assert.ok(/15 minit/.test(strings.ms.privacyStored), "ms stored-facts must carry the ~15-minit TTL wording"); +}); + +/* ————— CS4 — D-02 structural gate ————— */ + +test("CS4 D-02: the command tree source has no reply-menu or inline-button surface", () => { + for (const file of commandSources) { + const source = readFileSync(file, "utf8"); + assert.doesNotMatch( + source, + /keyboard/i, + `${path.basename(file)} — a menu/button surface token appeared (D-02: text-only replies)`, + ); + } +}); + +test("CS4 D-02: all replies go out with link previews disabled and nothing else attached", () => { + for (const file of commandSources) { + const source = readFileSync(file, "utf8"); + const replyCalls = source.match(/ctx\.reply\(/g) ?? []; + if (replyCalls.length > 0) { + assert.match(source, /link_preview_options:\s*\{\s*is_disabled:\s*true\s*\}/, `${file} must disable link previews`); + } + } +}); + +/* ————— CS5 — D-03 menu gap-freedom ————— */ + +/** Parse the `cmd — text` lines out of a helpMenu string. */ +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("CS5 D-03: helpMenu lists exactly /help and /privacy — 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)`, + ); + } +}); + +test("CS5 D-03: register-commands derives both scopes from helpMenu — two fetch calls, ms scope marked", () => { + const source = readFileSync( + path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "scripts", "register-commands.mjs"), + "utf8", + ); + const fetchCalls = source.match(/\bawait fetch\(/g) ?? []; + assert.equal(fetchCalls.length, 2, "register-commands must issue exactly two setMyCommands fetch calls (default + ms)"); + + assert.ok(source.includes('language_code: "ms"'), "the ms scope payload must set language_code 'ms'"); + assert.ok(source.includes("menuFor(\"en\")"), "default scope derives from the en helpMenu"); + assert.ok(source.includes("menuFor(\"ms\")"), "ms scope derives from the ms helpMenu"); + + assert.doesNotMatch( + source, + /description:\s*["'`]/, + "registration script must not inline copy — descriptions derive from strings.ts (D-05)", + ); + assert.doesNotMatch( + source, + /cari|pergi_ke/, + "no future-command stubs may be registered before their phases exist (D-03)", + ); +}); + +/* ————— CS6 — D-04 alias completeness on the command stack ————— */ + +test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /privasi", () => { + const registered = new Set(); + for (const file of commandSources) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(/bot\.command\("([^"]+)"/g)) { + registered.add(match[1]); + } + } + assert.deepEqual( + [...registered].sort(), + ["help", "mula", "privasi", "privacy", "start"].sort(), + "the command stack must be exactly the phase surface (D-04; no drift, no extras)", + ); +});