Files
araharah-executor 141d6d2a88 fix(07-06): root-cause the /cari menu defect — register-commands.mjs sent the wrong Bot API field name
- the Bot API parameter is 'scope' (verified against
  core.telegram.org/bots/api/methods/setmycommands); the script's
  'bot_command_scope' was an unknown field Telegram silently ignores,
  so every call hit the default scope and the two clear calls wiped the
  default set entirely — the ok:true lines proved nothing about scope
- registration re-run: default (agnostic/en/ms) + all_private_chats
  (agnostic/ms) read back help, privacy, cari via getMyCommands with
  the correct 'scope' param; group scopes verified cleared
- docs/KNOWN-LIMITATIONS.md: the 'Telegram-side defect' attribution
  corrected to the actual script bug; residual client menu-caching
  caveat + type-the-command workaround kept
- bot/README.md command row updated to the real scope set
2026-10-25 05:29:38 +08:00

268 lines
15 KiB
Markdown

<!-- generated-by: gsd-doc-writer -->
# @araharah/bot
The arah² Telegram bot — **arah2bot**. A [grammY](https://grammy.dev) bot running on a single Cloudflare Worker that receives Telegram updates, keeps TTL-only chat sessions in Workers KV, gates the Mini App's session handshake, and serves the Mini App's static assets from the same origin.
Part of the [arah²](../README.md) monorepo. For the design system see [`theme/`](../theme/), for the Mini App see [`web/`](../web/), and for cross-package architecture see [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md).
---
## What it does
Three jobs, all inside one Worker fetch handler (`src/webhook.ts`):
- **Chat commands** — `/start` (`/mula`), `/help`, `/privacy` (`/privasi`) reply with plain text in English or Bahasa Melayu.
- **Session gate** — validates a Mini App's verbatim `Telegram.WebApp.initData` string by HMAC and mints an opaque, 15-minute session token.
- **Location relay** — when the Mini App can't get a fix, the bot sends a `request_location` reply keyboard into the user's own chat; the shared pin lands in KV and the app polls for it.
Privacy is structural rather than promised: every write to KV carries an explicit `expirationTtl`, and the persisted record is exactly the four claim fields plus exactly the three location fields — nothing else can be serialized. A test (`PG1`) greps the storage sources to hold that line.
```mermaid
graph TD
TG["Telegram Bot API"]
W["Cloudflare Worker — araharah-bot"]
BOT["grammY bot — commands + location keyboard"]
SEC["initData HMAC gate"]
KV[("KV chat_sessions — session TTL 900s, pin TTL 300s")]
APP["Mini App — web/dist via ASSETS binding"]
TG -->|"POST /telegram/webhook + secret header"| W
W --> BOT
W --> SEC
SEC -->|"mints opaque token"| KV
BOT -->|"request_location keyboard"| TG
TG -->|"shared pin message"| BOT
BOT -->|"writePin loc:chat_id"| KV
APP -->|"POST /api/session — initData"| W
APP -->|"GET /api/location — Bearer"| W
APP -->|"POST /api/location-fallback — Bearer"| W
W --> KV
classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class TG telegram
class W,BOT,SEC worker
class KV storage
class APP web
```
---
## Installation
`@araharah/bot` is private and consumed through the workspace, not installed on its own. From the repo root:
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
Then create the local secrets file (gitignored — never commit it):
```bash
cp bot/.dev.vars.example bot/.dev.vars
```
`bot/.dev.vars` holds three values:
| Variable | Purpose |
|---|---|
| `BOT_TOKEN` | Telegram Bot API token from [@BotFather](https://t.me/BotFather). |
| `WEBHOOK_SECRET` | Secret token Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`. **Must be non-empty** — the webhook fails closed with `500` otherwise. |
| `WEBHOOK_URL` | Full origin for the Worker; `set-webhook.mjs` appends `/telegram/webhook` when only an origin is set. |
<!-- VERIFY: Wrangler authenticates to your Cloudflare account on first `wrangler dev` / `wrangler deploy` — confirm which login flow your team uses (OAuth vs. `CLOUDFLARE_API_TOKEN`). -->
---
## Quick start
1. **Build the workspace** — `web/dist` is served through the Worker's assets binding, so the Mini App must exist first.
```bash
npm run build
```
2. **Run the Worker locally** on port `8787` with local KV emulation.
```bash
npm run dev
```
3. **Check the surface.**
```bash
curl -i http://localhost:8787/api/location
# → 401 {"error":"unauthorized"} (no bearer token)
curl -i -X GET http://localhost:8787/api/session
# → 405 Method not allowed (route exists, method is wrong)
curl -i http://localhost:8787/nope
# → 404 Not found
```
4. **Register the Telegram-side wiring** after deploying: the webhook, the command menu, and the menu button.
```bash
npm run deploy # wrangler deploy + set-webhook.mjs
npm run bot:commands
node scripts/set-menu-button.mjs # points the menu button at the Mini App origin
```
---
## HTTP surface
The `fetch` handler in `src/webhook.ts` routes exactly four paths and returns `404` for everything else. Every log line and every rejection body is **category only** — never a payload, a user id, a chat id, or a timestamp.
| Method | Path | Behaviour |
|---|---|---|
| `POST` | `/telegram/webhook` | grammY's `cloudflare-mod` webhook adapter. Fails closed with `500` when `WEBHOOK_SECRET` is missing or empty — grammY's own secret check silently accepts everything when its secret is `undefined`. Otherwise a constant-time compare of `X-Telegram-Bot-Api-Secret-Token` runs **before** any bot processing or KV touch. The bot is instantiated per request because the token only arrives with `env`. |
| `POST` | `/api/session` | The security gate. The client posts its verbatim initData string, capped at 4096 characters (a lying or absent `Content-Length` is caught by a post-read length check, both reject `413` **before** parsing). HMAC-validated and fresh → a one-time opaque token; rejection precedence is `malformed` → `tampered` → `stale`, each a `401` carrying only the category. Minting rotates any prior token for that chat. |
| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Authorization: Bearer <token>`. The KV key derives from the **claim's** `chat_id`, never from a client-supplied id, so one user's token can never read another's pin. Honest `{"pin":null}` when nothing is stored; `401` for a missing or expired token; `405` for a non-GET. |
| `POST` | `/api/location-fallback` | The "ask via chat" path. The worker asks the bot to send the `request_location` reply keyboard into the chat named by the resolved claim. The request body is never read — neither chat id nor locale is accepted from the client. A rejected Telegram send is a category-only `502`. |
```bash
# Mint a session from a Mini App initData string
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
# → {"token":"<opaque uuid>"}
# Poll the relay with that token held in memory
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
# → {"pin":null}
```
---
## Commands
| Command | Description |
|---|---|
| `/start`, `/mula` | One-sentence introduction to arah² and its privacy posture. |
| `/help` | Lists the commands that exist in this phase. |
| `/privacy`, `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and what is never stored (location, routes, trip history, profile). |
| *shared pin message* | Not a command — a `message` handler records `ctx.message.location` into KV and confirms. |
Replies are plain text with link previews disabled. There are no inline buttons, callback routes, or reply menus in the command tree — the location reply keyboard in `handlers/location-fallback.ts` is the one deliberate exception, and a test (`CS4`) holds that boundary.
---
## Package scripts
Run from the repo root (`npm run bot:<script>`) or inside `bot/`.
| Script | Description |
|---|---|
| `npm test` | Runs `test/runner.mjs`, a hand-rolled zero-dependency harness. |
| `npm run typecheck` | `tsc --noEmit` against `src/` with Workers types. |
| `npm run dev` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `wrangler deploy` followed by `scripts/set-webhook.mjs`. |
| `npm run commands` | `scripts/register-commands.mjs` — one-time `setMyCommands` arming every scope a stale set could shadow: default (en) · default+ms · all_private_chats (en) · all_private_chats+ms, plus clearing the group scopes (all calls send `scope: { type: … }` — the Bot API field name; a previous wrong name, `bot_command_scope`, was silently ignored by Telegram and caused the /cari-menu incident, fixed 2026-10-25). |
`scripts/set-menu-button.mjs` is run directly (`node scripts/set-menu-button.mjs`) rather than through a package script; it points the bot's menu button at a `web_app` URL derived from `WEBHOOK_URL`'s origin.
The command-menu descriptions are **derived from `src/i18n/strings.ts`**'s `helpMenu` lines, not written inline in the script, so Telegram's registered menu and the `/help` reply cannot drift apart. A missing `/help` or `/privacy` line in `helpMenu` throws rather than silently registering a blank description.
---
## Configuration
The typed binding contract is `Env` in `src/config.ts`:
```ts
export interface Env {
BOT_TOKEN: string; // wrangler secret — from @BotFather
WEBHOOK_SECRET: string; // wrangler secret — must be non-empty; the route fails closed otherwise
chat_sessions: KVNamespace; // TTL-only namespace
}
```
Non-secret settings live in `wrangler.jsonc`:
| Key | Value | Note |
|---|---|---|
| `name` | `araharah-bot` | Worker name. |
| `main` | `src/webhook.ts` | Worker entry point. |
| `compatibility_date` | `2026-10-22` | |
| `workers_dev` | `true` | Serves on the `*.workers.dev` origin — the `WEBHOOK_URL` target for this phase. |
| `assets.directory` | `../web/dist` | Served through the `ASSETS` binding. |
| `assets.not_found_handling` | `none` | Unmatched paths fall through to the Worker, which answers `404`. |
| `run_worker_first` | `["/api/*", "/telegram/*"]` | A **glob array**, not a boolean — the Worker owns these prefixes; everything else is served as a static asset. |
| `kv_namespaces` | binding `chat_sessions` | |
The KV namespace id is committed in `wrangler.jsonc`; it is an identifier, not a secret.
<!-- VERIFY: The deployed `workers.dev` origin that `WEBHOOK_URL` should carry is account-specific and not recorded in the repository — take it from `wrangler deploy` output. -->
---
## Module map
| Path | Responsibility |
|---|---|
| `src/webhook.ts` | Worker entry and the only fetch handler: four routes, the 4 KB initData cap, bearer-token resolution. |
| `src/config.ts` | The `Env` contract — nothing else declares bindings. |
| `src/bot/bot.ts` | `createBot(env)` — the grammY factory; registers every command handler. Per-request instantiation, because Workers have no startup hook and the token arrives with `env`. |
| `src/bot/commands/{start,help,privacy}.ts` | One command surface each, with pure `*ReplyText(locale)` builders so tests don't need a bot instance. |
| `src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard; records a shared pin on the `message` handler. |
| `src/security/initdata.ts` | Pure HMAC validation of initData. Depends only on the runtime's global `crypto.subtle`, so it runs unchanged in the Worker and in bare-node tests. |
| `src/kv/sessions.ts` | Mint / rotate / resolve session claims. `SESSION_TTL_SECONDS = 900`. |
| `src/kv/location-pin.ts` | Write / read / clear the chat-scoped pin. `PIN_TTL_SECONDS = 300`. |
| `src/i18n/strings.ts` | Every user-facing string, in `en` and `ms`. Nothing inline anywhere else. |
| `scripts/` | One-time Telegram registration: webhook, command menu, menu button. |
| `test/` | Zero-dependency suites plus the runner and registry. |
### KV key scheme
| Key | Value | TTL |
|---|---|---|
| `session:<token>` | JSON `SessionClaim` — `user_id`, `chat_id`, `issued_at`, `language_code` | 900 s |
| `chat:<chat_id>` | The chat's current token (per-chat pointer) | 900 s |
| `loc:<chat_id>` | JSON `LocationPin` — `latitude`, `longitude`, `horizontal_accuracy` | 300 s |
Session TTL is deliberately tied to the 900 s initData freshness window: a session cannot outlive the proof that minted it. Minting rotates — the previous `session:<token>` is deleted before the new record is written, then a best-effort post-mint reconciliation re-reads the pointer and sweeps a racing mint's record. Under KV's lack of transactions, a record orphaned by a lost race is invisible to both sweeps and simply dies with its TTL; nothing persists longer.
### initData validation
`validateInitData(raw, botToken, opts?)` parses the Telegram-style querystring manually — values are decoded once and `+` stays literal, so `URLSearchParams` is deliberately not used. The data-check string excludes **only** `hash` (the `signature` field participates; excluding it broke real webviews), sorts by field name, and joins with newlines. The key is derived with `HMAC-SHA256(key="WebAppData", message=botToken)` and compared in constant time. `auth_date` older than `maxAgeSeconds` (default 900) is `stale`; `nowSeconds` is injectable, and the suite covers the boundary at 899 s (ok) and 901 s (stale) — the 900 s boundary itself is not asserted by any case.
---
## Testing
The suite is a hand-rolled harness — no test framework dependency. `test/harness.mjs` exports a `test(name, fn)` registry; `test/runner.mjs` auto-discovers every `*.test.mjs` in `test/`, imports them, and prints `PASS`/`FAIL` per case with a summary line. A suite that fails to load is recorded as one failing case rather than crashing the run. Exit code is `0` only when every case passed.
```bash
npm run bot:test # from the repo root
npm test # from bot/
```
| Suite | Cases | Covers |
|---|---|---|
| `test/initdata.test.mjs` | 12 | HMAC vectors (RFC-4231 case 1), field ordering, the `signature` erratum, freshness boundaries, malformed shapes. |
| `test/sessions.test.mjs` | 7 | Exact claim key-set, TTL on every `put`, rotation, interleaved-mint behaviour, claim round-trip. |
| `test/location-pin.test.mjs` | 14 | TTL-carrying key, out-of-range coordinate rejection, malformed-JSON reads, plus the full `/api/location` and `/api/location-fallback` route surface including own-chat-only access and locale honouring. |
| `test/webhook.test.mjs` | 6 | Valid initData → token, the 4 KB cap (declared, lying, and unreadable bodies), `405` on `GET /api/session`, fail-closed `500` on an unarmed `WEBHOOK_SECRET`. |
| `test/commands.test.mjs` | 9 | Locale key-set parity, `pickLocale` fallback, privacy copy honesty, the text-only surface gate, `helpMenu`/menu derivation, and the exact command stack. |
| `test/privacy.test.mjs` | 2 | Grep gates: every `.put(` in a storage source carries `expirationTtl` on the same line; storage modules export nothing persistence-shaped. |
Typecheck separately:
```bash
npm run bot:typecheck
```
---
## Notes
- **Secrets never appear in code or logs.** `BOT_TOKEN` and `WEBHOOK_SECRET` are wrangler secrets locally and in production; `bot/.dev.vars` is gitignored. The scripts read it but only ever print Telegram's `ok` / `error_code` / `description`.
- **Logging is category-only by construction.** `"[initdata] rejected: tampered"`, `"[location] rejected: unauthorized"` — never the payload, the chat id, or a timestamp.
- **`not_found_handling: "none"` matters.** With the default, unmatched paths would be served as a 404 *asset* and the Worker's own `404` branch would be unreachable.
- **`run_worker_first` is a glob array.** Passing a boolean silently breaks the route split.
- **`requestLocationFallback` has exactly one caller** (`handleLocationFallback` in `src/webhook.ts`). A second caller would be a design change, not a refactor.