#!/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, /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 * 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", "cari"].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() }; }); } /* ————— scope set (device-gate fix 2026-10-24): Telegram resolves a chat's * command menu from the NARROWEST scope that has ANY commands set — a stale * all_private_chats set shadows the default scope entirely (the /cari-missing- * from-menu incident). The script therefore arms every scope it may shadow: * default (en) · default+ms · all_private_chats (en) · all_private_chats+ms * and CLEARS the group scopes (leftover phase-2 experiments) so no junk set * shadows anything elsewhere. * * GATE-BUG FIX (07-06 canary, 2026-10-25): the Bot API parameter is `scope` * (https://core.telegram.org/bots/api/methods/setmycommands) — this script * previously sent `bot_command_scope`, an unknown field Telegram SILENTLY * ignores, so every call actually hit the default scope and the two "clear" * calls wiped the default set entirely (the true root cause of the /cari * menu defect; the setMyCommands ok:true lines proved nothing about scope). * ————— */ const API = `https://api.telegram.org/bot${token}/setMyCommands`; const JSON_HEADERS = { "content-type": "application/json" }; const CALLS = [ { label: "default (en)", body: { commands: menuFor("en"), scope: { type: "default" } } }, { label: "default (ms)", body: { commands: menuFor("ms"), scope: { type: "default" }, language_code: "ms" } }, { label: "all_private_chats (en)", body: { commands: menuFor("en"), scope: { type: "all_private_chats" } } }, { label: "all_private_chats (ms)", body: { commands: menuFor("ms"), scope: { type: "all_private_chats" }, language_code: "ms" } }, { label: "clear all_group_chats", body: { commands: [], scope: { type: "all_group_chats" } } }, { label: "clear all_chat_administrators", body: { commands: [], scope: { type: "all_chat_administrators" } } }, ]; let failed = null; for (const call of CALLS) { const res = await fetch(API, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(call.body) }); const body = await res.json(); console.log(`${call.label.padEnd(30)} -> ok:${body.ok} ${body.ok ? "" : JSON.stringify(body)}`); if (!body.ok && failed === null) failed = body; } if (!failed) { console.log("all command scopes armed"); } else { const firstError = failed; console.error(`setMyCommands failed: ${firstError.error_code ?? "?"} ${firstError.description ?? "unknown error"}`); process.exit(1); }