feat: scaffold plug-n-play telegram bot on cloudflare workers

grammY-based bot with a module plugin system loaded from the MODULES env
var. Three command visibility levels (public/protected/private) share a
unified command namespace with conflict detection at registry build.

- 4 initial modules (util, wordle, loldle, misc); util fully implemented,
  others are stubs proving the plugin system end-to-end
- util: /info (chat/thread/sender ids) + /help (pure renderer over the
  registry, HTML parse mode, escapes user-influenced strings)
- KVStore interface with CFKVStore and a per-module prefixing factory;
  getJSON/putJSON convenience helpers; other backends drop in via one file
- Webhook at POST /webhook with secret-token validation via grammY's
  webhookCallback; no admin HTTP surface
- Post-deploy register script (npm run deploy = wrangler deploy && node
  --env-file=.env.deploy scripts/register.js) for setWebhook and
  setMyCommands; --dry-run flag for preview
- 56 vitest unit tests across 7 suites covering registry, db wrapper,
  dispatcher, help renderer, validators, and HTML escaper
- biome for lint + format; phased implementation plan under plans/
This commit is contained in:
2026-04-11 09:49:06 +07:00
parent e76ad8c0ee
commit c4314f21df
51 changed files with 6928 additions and 1 deletions

View File

@@ -0,0 +1,101 @@
# Phase 02 — Webhook entrypoint
## Context Links
- Plan: [plan.md](plan.md)
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md)
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** fetch handler with URL routing (`/webhook`, `GET /` health, 404 otherwise), memoized `Bot` instance, grammY webhook secret-token validation wired through `webhookCallback`. Webhook + command-menu registration with Telegram is handled OUT OF BAND via a post-deploy node script (phase-07) — the Worker itself exposes no admin surface.
## Key Insights
- Use **`"cloudflare-mod"`** adapter (NOT `"cloudflare"` — that's the legacy service-worker variant).
- `webhookCallback(bot, "cloudflare-mod", { secretToken })` delegates `X-Telegram-Bot-Api-Secret-Token` validation to grammY — no manual header parsing.
- Bot instance must be memoized at module scope but lazily constructed (env not available at import time).
- No admin HTTP surface on the Worker — `setWebhook` + `setMyCommands` run from a local node script at deploy time, not via the Worker.
## Requirements
### Functional
- `POST /webhook` → delegate to `webhookCallback`. Wrong/missing secret → 401 (handled by grammY).
- `GET /` → 200 `"miti99bot ok"` (health check, unauthenticated).
- Anything else → 404.
### Non-functional
- Single `fetch` function, <80 LOC.
- No top-level await.
- No global state besides memoized Bot.
## Architecture
```
Request
fetch(req, env, ctx)
├── GET / → 200 "ok"
├── POST /webhook → webhookCallback(bot, "cloudflare-mod", {secretToken})(req)
└── * → 404
```
`getBot(env)` lazily constructs and memoizes the `Bot`, installs dispatcher middleware (from phase-04), and returns the instance.
## Related Code Files
### Create
- `src/index.js` (fetch handler + URL router)
- `src/bot.js` (memoized `getBot(env)` factory — wires grammY middleware from registry/dispatcher)
### Modify
- none
### Delete
- none
## Implementation Steps
1. Create `src/index.js`:
- Import `getBot` from `./bot.js`.
- Export default object with `async fetch(request, env, ctx)`.
- Parse `new URL(request.url)`, switch on `pathname`.
- For `POST /webhook`: `return webhookCallback(getBot(env), "cloudflare-mod", { secretToken: env.TELEGRAM_WEBHOOK_SECRET })(request)`.
- For `GET /`: return 200 `"miti99bot ok"`.
- Default: 404.
2. Create `src/bot.js`:
- Module-scope `let botInstance = null`.
- `export function getBot(env)`:
- If `botInstance` exists, return it.
- Construct `new Bot(env.TELEGRAM_BOT_TOKEN)`.
- `installDispatcher(bot, env)` — imported from `src/modules/dispatcher.js` (phase-04 — stub import now, real impl later).
- Assign + return.
- Temporary stub: if `installDispatcher` not yet implemented, create a placeholder function in `src/modules/dispatcher.js` that does nothing so this phase compiles.
3. Env validation: on first `getBot` call, throw if `TELEGRAM_BOT_TOKEN` / `TELEGRAM_WEBHOOK_SECRET` / `MODULES` missing. Fail fast is a feature.
4. `npm run lint` — fix any issues.
5. `wrangler dev` — hit `GET /` locally, confirm 200. Hit `POST /webhook` without secret header, confirm 401.
## Todo List
- [ ] `src/index.js` fetch handler + URL router
- [ ] `src/bot.js` memoized factory
- [ ] Placeholder `src/modules/dispatcher.js` exporting `installDispatcher(bot, env)` no-op
- [ ] Env var validation with clear error messages
- [ ] Manual smoke test via `wrangler dev`
## Success Criteria
- `GET /` returns 200 `"miti99bot ok"`.
- `POST /webhook` without header → 401 (via grammY).
- `POST /webhook` with correct `X-Telegram-Bot-Api-Secret-Token` header and a valid Telegram update JSON body → 200.
- Unknown path → 404.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Wrong adapter string breaks webhook | Low | High | Pin `"cloudflare-mod"`; test with `wrangler dev` + curl |
| Memoized Bot leaks state between deploys | Low | Low | Warm-restart resets module scope; documented behavior |
| Cold-start latency from first Bot() construction | Med | Low | Acceptable for bot use case |
## Security Considerations
- `TELEGRAM_WEBHOOK_SECRET` MUST be configured before enabling webhook in Telegram; grammY's `secretToken` option gives 401 on mismatch.
- Worker has NO admin HTTP surface — no attack surface beyond `/webhook` (secret-gated by grammY) and the public health check.
- Never log secrets, even on error paths.
## Next Steps
- Phase 03 creates the DB abstraction that modules will use in phase 04+.
- Phase 04 replaces the dispatcher stub with real middleware.