feat(02-03): strings module + text-only command surface with Malay aliases (D-01..D-05)

- src/i18n/strings.ts: single copy source of truth, en + ms identical
  key-sets, clinical/honest start + privacy copy, pickLocale fallback rule
- /start /mula, /help, /privacy /privasi handlers: plain text replies with
  link previews disabled, zero menu/button surface (D-02, grep-gated)
- createBot(env) factory registers exactly the phase command stack
- scripts/register-commands.mjs: one-time setMyCommands, two scopes
  (default + language_code ms), descriptions derived from helpMenu —
  no inline copy, no future-command stubs (D-03/D-05)
- test/commands.test.mjs: CS1-CS6 gates — locale parity, pickLocale
  matrix, PRIV-01 copy gate vs never-facts + TTL wording, D-02 structural
  grep, D-03 menu gap-freedom, D-04 alias completeness (27 passing)
This commit is contained in:
araharah-executor
2026-10-22 11:48:50 +08:00
parent 632ae70ad2
commit b4393fbcf3
7 changed files with 441 additions and 0 deletions
+26
View File
@@ -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;
}
+30
View File
@@ -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<void> {
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);
}
+33
View File
@@ -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<void> {
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);
}
+32
View File
@@ -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<void> {
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);
}
+66
View File
@@ -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<Locale, BotStrings> = {
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";
}