# @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. | --- ## 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 `. 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":""} # Poll the relay with that token held in memory curl -H "Authorization: Bearer " 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: