Files
Arah2/bot/README.md
T
araharah-executor 91ad0c9d93 docs: generate + verify project documentation — canonical docs, per-package READMEs, verified fix pass
- New: docs/ARCHITECTURE.md, docs/GETTING-STARTED.md, docs/DEVELOPMENT.md,
  docs/TESTING.md, docs/CONFIGURATION.md (colored mermaid diagrams, quoted labels)
- Regenerated README.md (arah² / arah2bot naming, canonical clone URL)
- New per-package READMEs: theme/, bot/, web/
- Verified 13 docs against the live codebase (2 fix iterations + 2 residual
  corrections); corrected 56 stale claims across AGENTS/DESIGN/IDEAS/GSD_FIX
2026-10-23 20:56:40 +08:00

15 KiB

@araharah/bot

The arah² Telegram bot — arah2bot. A grammY 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² monorepo. For the design system see theme/, for the Mini App see web/, and for cross-package architecture see 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.

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:

git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install

Then create the local secrets file (gitignored — never commit it):

cp bot/.dev.vars.example bot/.dev.vars

bot/.dev.vars holds three values:

Variable Purpose
BOT_TOKEN Telegram Bot API token from @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.

Quick start

  1. Build the workspace — web/dist is served through the Worker's assets binding, so the Mini App must exist first.

    npm run build
    
  2. Run the Worker locally on port 8787 with local KV emulation.

    npm run dev
    
  3. Check the surface.

    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.

    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.
# 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 in the default scope, plus a language_code: "ms" variant of that same default scope (both calls send bot_command_scope: { type: "default" }).

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:

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.


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.

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:

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.