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,125 @@
# Phase 01 — Scaffold project
## Context Links
- Plan: [plan.md](plan.md)
- Reports: [wrangler + secrets](../reports/researcher-260411-0853-wrangler-config-secrets.md)
## Overview
- **Priority:** P1 (blocker for everything)
- **Status:** pending
- **Description:** bootstrap repo structure, package.json, wrangler.toml template, biome, vitest, .gitignore, .dev.vars.example. No runtime code yet.
## Key Insights
- `nodejs_compat` flag NOT needed — grammY + our code uses Web APIs only. Keeps bundle small.
- `MODULES` must be declared as a comma-separated string in `[vars]` (wrangler does not accept top-level TOML arrays in `[vars]`).
- `.dev.vars` is a dotenv-format file for local secrets — gitignore it. Commit `.dev.vars.example`.
- biome handles lint + format in one binary. Single config file (`biome.json`). No eslint/prettier.
## Requirements
### Functional
- `npm install` produces a working dev environment.
- `npm run dev` starts `wrangler dev` on localhost.
- `npm run lint` / `npm run format` work via biome.
- `npm test` runs vitest (zero tests initially — exit 0).
- `wrangler deploy` pushes to CF (will fail without real KV ID — expected).
### Non-functional
- No TypeScript. `.js` + JSDoc.
- Zero extra dev deps beyond: `wrangler`, `grammy`, `@biomejs/biome`, `vitest`.
## Architecture
```
miti99bot/
├── src/
│ └── (empty — filled by later phases)
├── scripts/
│ └── (empty — register.js added in phase-07)
├── tests/
│ └── (empty)
├── package.json
├── wrangler.toml
├── biome.json
├── vitest.config.js
├── .dev.vars.example
├── .env.deploy.example
├── .gitignore # add: node_modules, .dev.vars, .env.deploy, .wrangler, dist
├── README.md # (already exists — updated in phase-09)
└── LICENSE # (already exists)
```
## Related Code Files
### Create
- `package.json`
- `wrangler.toml`
- `biome.json`
- `vitest.config.js`
- `.dev.vars.example`
- `.env.deploy.example`
### Modify
- `.gitignore` (add `node_modules/`, `.dev.vars`, `.env.deploy`, `.wrangler/`, `dist/`, `coverage/`)
### Delete
- none
## Implementation Steps
1. `npm init -y`, then edit `package.json`:
- `"type": "module"`
- scripts:
- `dev``wrangler dev`
- `deploy``wrangler deploy && npm run register`
- `register``node --env-file=.env.deploy scripts/register.js` (auto-runs `setWebhook` + `setMyCommands` — see phase-07)
- `lint``biome check src tests scripts`
- `format``biome format --write src tests scripts`
- `test``vitest run`
2. `npm install grammy`
3. `npm install -D wrangler @biomejs/biome vitest`
4. Pin versions by checking `npm view <pkg> version` and recording exact versions.
5. Create `wrangler.toml` from template in research report — leave KV IDs as `REPLACE_ME`.
6. Create `biome.json` with defaults + 2-space indent, double quotes, semicolons.
7. Create `vitest.config.js` with `environment: "node"` (pure logic tests only, no workerd pool).
8. Create `.dev.vars.example` (local dev secrets used by `wrangler dev`):
```
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
```
9. Create `.env.deploy.example` (consumed by `scripts/register.js` in phase-07; loaded via `node --env-file`):
```
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
WORKER_URL=https://<worker-subdomain>.workers.dev
```
10. Append `.gitignore` entries.
10. `npm run lint` + `npm test` + `wrangler --version` — all succeed.
## Todo List
- [ ] `npm init`, set `type: module`, scripts
- [ ] Install runtime + dev deps
- [ ] `wrangler.toml` template
- [ ] `biome.json`
- [ ] `vitest.config.js`
- [ ] `.dev.vars.example`
- [ ] Update `.gitignore`
- [ ] Smoke-run `npm run lint` / `npm test`
## Success Criteria
- `npm install` exits 0.
- `npm run lint` exits 0 (nothing to lint yet — biome treats this as pass).
- `npm test` exits 0.
- `npx wrangler --version` prints a version.
- `git status` shows no tracked `.dev.vars`, no `node_modules`.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| wrangler version pins conflict with Node version | Low | Med | Require Node ≥ 20 in `engines` field |
| biome default rules too strict | Med | Low | Start with `recommended`; relax only if blocking |
| `type: module` trips vitest | Low | Low | vitest supports ESM natively |
## Security Considerations
- `.dev.vars` MUST be gitignored. Double-check before first commit.
- `wrangler.toml` MUST NOT contain any secret values — only `[vars]` for non-sensitive `MODULES`.
## Next Steps
- Phase 02 needs the fetch handler skeleton in `src/index.js`.
- Phase 03 needs the KV binding wired in `wrangler.toml` (already scaffolded here).

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.

View File

@@ -0,0 +1,138 @@
# Phase 03 — DB abstraction
## Context Links
- Plan: [plan.md](plan.md)
- Reports: [Cloudflare KV basics](../reports/researcher-260411-0853-cloudflare-kv-basics.md)
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** define minimal KV-like interface (`get/put/delete/list` + `getJSON/putJSON` convenience) via JSDoc, implement `CFKVStore` against `KVNamespace`, expose `createStore(moduleName)` factory that auto-prefixes keys with `<module>:`.
## Key Insights
- Interface is deliberately small. YAGNI: no metadata, no bulk ops, no transactions.
- `expirationTtl` IS exposed on `put()` — useful for cooldowns / easter-egg throttling.
- `getJSON` / `putJSON` are thin wrappers (`JSON.parse` / `JSON.stringify`). Included because every planned module stores structured state — DRY, not speculative. `getJSON` returns `null` when the key is missing OR when the stored value fails to parse (log + swallow), so callers can treat both as "no record".
- `list()` returns a normalized shape `{ keys: string[], cursor?: string, done: boolean }` — strip KV-specific fields. Cursor enables pagination for modules that grow past 1000 keys.
- Factory-based prefixing means swapping `CFKVStore``D1Store` or `RedisStore` later is one-file change. NO module touches KV directly.
- When a module calls `list({ prefix: "games:" })`, the wrapper concatenates to `"wordle:games:"` before calling KV and strips `"wordle:"` from returned keys so the module sees its own namespace.
- KV hot-key limit (1 write/sec per key) — document in JSDoc so module authors are aware.
## Requirements
### Functional
- Interface exposes: `get(key)`, `put(key, value, options?)`, `delete(key)`, `list(options?)`, `getJSON(key)`, `putJSON(key, value, options?)`.
- `options` on `put` / `putJSON`: `{ expirationTtl?: number }` (seconds).
- `options` on `list`: `{ prefix?: string, limit?: number, cursor?: string }`.
- `createStore(moduleName, env)` returns a prefixed store bound to `env.KV`.
- `get` returns `string | null`.
- `put` accepts `string` only — structured data goes through `putJSON`.
- `getJSON(key)``any | null`. On missing key: `null`. On malformed JSON: log a warning and return `null` (do NOT throw — a single corrupt record must not crash the bot).
- `putJSON(key, value, opts?)` → serializes with `JSON.stringify` then delegates to `put`. Throws if `value` is `undefined` or contains a cycle.
- `list` returns `{ keys: string[], cursor?: string, done: boolean }`, with module prefix stripped. `cursor` is passed through from KV so callers can paginate.
### Non-functional
- JSDoc `@typedef` for `KVStore` interface so IDE completion works without TS.
- `src/db/` total < 150 LOC.
## Architecture
```
module code
│ createStore("wordle", env)
PrefixedStore ── prefixes all keys with "wordle:" ──┐
│ ▼
└──────────────────────────────────────────► CFKVStore (wraps env.KV)
env.KV (binding)
```
## Related Code Files
### Create
- `src/db/kv-store-interface.js` — JSDoc `@typedef` only, no runtime code
- `src/db/cf-kv-store.js``CFKVStore` class wrapping `KVNamespace`
- `src/db/create-store.js``createStore(moduleName, env)` prefixing factory
### Modify
- none
### Delete
- none
## Implementation Steps
1. `src/db/kv-store-interface.js`:
```js
/**
* @typedef {Object} KVStorePutOptions
* @property {number} [expirationTtl] seconds
*
* @typedef {Object} KVStoreListOptions
* @property {string} [prefix]
* @property {number} [limit]
* @property {string} [cursor]
*
* @typedef {Object} KVStoreListResult
* @property {string[]} keys
* @property {string} [cursor]
* @property {boolean} done
*
* @typedef {Object} KVStore
* @property {(key: string) => Promise<string|null>} get
* @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise<void>} put
* @property {(key: string) => Promise<void>} delete
* @property {(opts?: KVStoreListOptions) => Promise<KVStoreListResult>} list
* @property {(key: string) => Promise<any|null>} getJSON
* @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
*/
export {};
```
2. `src/db/cf-kv-store.js`:
- `export class CFKVStore` with `constructor(kvNamespace)` stashing binding.
- `get(key)` → `this.kv.get(key, { type: "text" })`.
- `put(key, value, opts)` → `this.kv.put(key, value, opts?.expirationTtl ? { expirationTtl: opts.expirationTtl } : undefined)`.
- `delete(key)` → `this.kv.delete(key)`.
- `list({ prefix, limit, cursor } = {})` → call `this.kv.list({ prefix, limit, cursor })`, map to normalized shape `{ keys: result.keys.map(k => k.name), cursor: result.cursor, done: result.list_complete }`.
- `getJSON(key)` → `const raw = await this.get(key); if (raw == null) return null; try { return JSON.parse(raw); } catch (e) { console.warn("getJSON parse failed", { key, err: String(e) }); return null; }`.
- `putJSON(key, value, opts)` → `if (value === undefined) throw new Error("putJSON: value is undefined"); return this.put(key, JSON.stringify(value), opts);`.
3. `src/db/create-store.js`:
- `export function createStore(moduleName, env)`:
- Validate `moduleName` is non-empty `[a-z0-9_-]+`.
- `const base = new CFKVStore(env.KV)`.
- `const prefix = \`${moduleName}:\``.
- Return object:
- `get(key)` → `base.get(prefix + key)`
- `put(key, value, opts)` → `base.put(prefix + key, value, opts)`
- `delete(key)` → `base.delete(prefix + key)`
- `list(opts)` → call `base.list({ ...opts, prefix: prefix + (opts?.prefix ?? "") })`, then strip the `<module>:` prefix from returned keys before returning.
- `getJSON(key)` → `base.getJSON(prefix + key)`
- `putJSON(key, value, opts)` → `base.putJSON(prefix + key, value, opts)`
4. JSDoc every exported function. Types on params + return.
5. `npm run lint`.
## Todo List
- [ ] `src/db/kv-store-interface.js` JSDoc types (incl. `getJSON` / `putJSON`)
- [ ] `src/db/cf-kv-store.js` CFKVStore class (incl. `getJSON` / `putJSON`)
- [ ] `src/db/create-store.js` prefixing factory (all six methods)
- [ ] Validate module name in factory
- [ ] JSDoc on every exported symbol
- [ ] Lint clean
## Success Criteria
- All four interface methods round-trip via `wrangler dev` + preview KV namespace (manual sanity check OK; unit tests land in phase-08).
- Prefix stripping verified: `createStore("wordle").put("k","v")` → raw KV key is `wordle:k`; `createStore("wordle").list()` returns `["k"]`.
- Swapping `CFKVStore` for a future backend requires changes ONLY inside `src/db/`.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| KV hot-key limit breaks a module (1 w/s per key) | Med | Med | Document in JSDoc; phase-08 adds a throttle util if needed |
| Eventual consistency confuses testers | High | Low | README note in phase-09 |
| Corrupt JSON crashes a module handler | Low | Med | `getJSON` swallows parse errors → `null`; logs warning |
| Module name collision with prefix characters | Low | Med | Validate regex `^[a-z0-9_-]+$` in factory |
## Security Considerations
- Colon in module names would let a malicious module escape its namespace — regex validation blocks this.
- Never expose raw `env.KV` outside `src/db/` — enforce by code review (module registry only passes the prefixed store, never the bare binding).
## Next Steps
- Phase 04 consumes `createStore` from the module registry's `init` hook.

View File

@@ -0,0 +1,172 @@
# Phase 04 — Module framework (contract, registry, loader, dispatcher)
## Context Links
- Plan: [plan.md](plan.md)
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md), [KV basics](../reports/researcher-260411-0853-cloudflare-kv-basics.md)
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** define the plugin contract, build the registry, implement the static-map loader filtered by `env.MODULES`, and write the grammY dispatcher middleware that routes commands and enforces visibility. **All three visibility levels (public / protected / private) are slash commands.** Visibility only controls which commands appear in Telegram's `/` menu (public only) and in `/help` output (public + protected). Private commands are hidden slash commands — easter eggs that still route through `bot.command()`.
## Key Insights
- wrangler bundles statically — dynamic `import(variablePath)` defeats tree-shaking and can fail at bundle time. **Solution:** static map `{ name: () => import("./name/index.js") }`, filtered at runtime by `env.MODULES.split(",")`.
- **Single routing path:** every command — regardless of visibility — is registered via `bot.command(name, handler)`. grammY handles slash-prefix parsing, case-sensitivity, and `/cmd@botname` suffix in groups automatically. No custom text-match code.
- Visibility is pure metadata. It affects two downstream consumers:
1. phase-07's `scripts/register.js``setMyCommands` payload (public only).
2. phase-05's `/help` renderer (public + protected, skip private).
- Command-name conflicts: two modules registering the same command name = registry throws at load. **One unified namespace across all visibility levels** — a public `/foo` in module A collides with a private `/foo` in module B. Fail fast > mystery last-wins.
- The registry is built ONCE per warm instance, inside `getBot(env)`. Not rebuilt per request.
- grammY's `bot.command()` matches exactly against the command name token — case-sensitive per Telegram's own semantics. This naturally satisfies the "exact, case-sensitive" requirement for private commands.
## Requirements
### Functional
- Module contract (locked):
```js
export default {
name: "wordle", // must match folder name, [a-z0-9_-]+
commands: [
{ name: "wordle", visibility: "public", description: "Play wordle", handler: async (ctx) => {...} },
{ name: "wstats", visibility: "protected", description: "Stats", handler: async (ctx) => {...} },
{ name: "konami", visibility: "private", description: "Easter egg", handler: async (ctx) => {...} },
],
init: async ({ db, env }) => {}, // optional
};
```
- `name` on a command: slash-command name without leading `/`, `[a-z0-9_]{1,32}` (Telegram's own limit). Same regex for all visibility levels — private commands are still `/foo`.
- `description`: required for all three visibility levels (private descriptions are used internally for debugging + not surfaced to Telegram/users). Max 256 chars (Telegram's limit on public command descriptions). Enforce uniformly.
- Loader reads `env.MODULES`, splits, trims, dedupes. For each name, look up static map; unknown name → throw.
- Each module's `init` is called once with `{ db: createStore(module.name, env), env }`.
- Registry builds three indexed maps (same shape, partitioned by visibility) PLUS one flat map of all commands for conflict detection + dispatch:
- `publicCommands: Map<string, {module, cmd}>` — source of truth for `setMyCommands` + `/help`.
- `protectedCommands: Map<string, {module, cmd}>` — source of truth for `/help`.
- `privateCommands: Map<string, {module, cmd}>` — bookkeeping only (not surfaced anywhere visible).
- `allCommands: Map<string, {module, cmd, visibility}>` — flat index used by the dispatcher to `bot.command()` every entry regardless of visibility.
- Name conflict across modules (any visibility combination) → throw `Error("command conflict: ...")` naming both modules and the command.
### Non-functional
- `src/modules/registry.js` < 150 LOC.
- `src/modules/dispatcher.js` < 60 LOC.
- No global mutable state outside the registry itself.
## Architecture
```
env.MODULES = "util,wordle,loldle,misc"
loadModules(env) ──► static map lookup ──► import() each ──► array of module objects
buildRegistry(modules, env)
├── for each module: call init({db, env}) if present
└── flatten commands → 3 visibility-partitioned maps + 1 flat `allCommands` map
│ (conflict check walks `allCommands`: one namespace, all visibilities)
installDispatcher(bot, registry)
└── for each entry in allCommands:
bot.command(cmd.name, cmd.handler)
```
**Why no text-match middleware:** grammY's `bot.command()` already handles exact-match slash routing, case sensitivity, and group-chat `/cmd@bot` disambiguation. Private commands ride that same path — they're just absent from `setMyCommands` and `/help`.
## Related Code Files
### Create
- `src/modules/index.js` — static import map
- `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry`
- `src/modules/dispatcher.js` — `installDispatcher(bot, env)` (replaces phase-02 stub)
- `src/modules/validate-command.js` — shared validators (name regex, visibility, description)
### Modify
- `src/bot.js` — call `installDispatcher(bot, env)` (was a no-op stub)
### Delete
- none
## Implementation Steps
1. `src/modules/index.js`:
```js
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
};
```
(Stub module folders land in phase-05/06 — tests in phase-08 can inject a fake map.)
2. `src/modules/validate-command.js`:
- `VISIBILITIES = ["public", "protected", "private"]`.
- `COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/` (uniform across all visibilities).
- `validateCommand(cmd, moduleName)`:
- `visibility` ∈ `VISIBILITIES` (throw otherwise).
- `name` matches `COMMAND_NAME_RE` (no leading `/`).
- `description` is non-empty string, ≤ 256 chars.
- `handler` is a function.
- All errors mention both the module name and the offending command name.
3. `src/modules/registry.js`:
- Module-scope `let currentRegistry = null;` (used by `/help` in phase-05).
- `async function loadModules(env)`:
- Parse `env.MODULES` → array, trim, dedupe, skip empty.
- Empty list → throw `Error("MODULES env var is empty")`.
- For each name, `const loader = moduleRegistry[name]`; unknown → throw `Error(\`unknown module: ${name}\`)`.
- `const mod = (await loader()).default`.
- Validate `mod.name === name`.
- Validate each command via `validateCommand`.
- Return ordered array of module objects.
- `async function buildRegistry(env)`:
- Call `loadModules`.
- Init four maps: `publicCommands`, `protectedCommands`, `privateCommands`, `allCommands`.
- For each module (in `env.MODULES` order):
- If `init`: `await mod.init({ db: createStore(mod.name, env), env })`. Wrap in try/catch; rethrow with module name prefix.
- For each cmd:
- If `allCommands.has(cmd.name)` → throw `Error(\`command conflict: /${cmd.name} registered by both ${existing.module.name} and ${mod.name}\`)`.
- `allCommands.set(cmd.name, { module: mod, cmd, visibility: cmd.visibility });`
- Push into the visibility-specific map too.
- `currentRegistry = { publicCommands, protectedCommands, privateCommands, allCommands, modules };`
- Return it.
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
- `export function resetRegistry() { currentRegistry = null; }` (test helper; phase-08 uses it in `beforeEach`).
4. `src/modules/dispatcher.js`:
- `export async function installDispatcher(bot, env)`:
- `const reg = await buildRegistry(env);`
- `for (const { cmd } of reg.allCommands.values()) { bot.command(cmd.name, cmd.handler); }`
- Return `reg` (caller may ignore; `/help` reads via `getCurrentRegistry()`).
5. Update `src/bot.js` to `await installDispatcher(bot, env)` before returning. This makes `getBot` async — update `src/index.js` to `await getBot(env)`.
6. Lint clean.
## Todo List
- [ ] `src/modules/index.js` static import map
- [ ] `src/modules/validate-command.js` (uniform regex + description length cap)
- [ ] `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry` + `resetRegistry`
- [ ] `src/modules/dispatcher.js` — single loop, all visibilities via `bot.command()`
- [ ] Update `src/bot.js` + `src/index.js` for async `getBot`
- [ ] Unified-namespace conflict detection
- [ ] Lint clean
## Success Criteria
- With `MODULES="util"` and util exposing one public cmd, `wrangler dev` + mocked webhook update correctly routes.
- Conflict test (phase-08): two fake modules both register `/foo` (regardless of visibility) → `buildRegistry` throws with both module names and the command name in the message.
- Unknown module name in `MODULES` → throws with clear message.
- Registry built once per warm instance (memoized via `getBot`).
- `/konami` (a private command) responds when typed; does NOT appear in `/help` output; does NOT appear in Telegram's `/` menu after `scripts/register.js` runs.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Dynamic import breaks bundler tree-shaking | N/A | N/A | Mitigated by static map design |
| `init` throws → entire bot broken | Med | High | Wrap in try/catch, log module name, re-throw with context |
| Module mutates passed `env` | Low | Med | Pass `env` as-is; document contract: modules must not mutate |
| Private command accidentally listed in `setMyCommands` | Low | Med | `scripts/register.js` reads `publicCommands` only (not `allCommands`) |
| Description > 256 chars breaks `setMyCommands` payload | Low | Low | Validator enforces cap at module load |
## Security Considerations
- A private command with the same name as a public one in another module would be ambiguous. Unified conflict detection blocks this at load.
- Module authors get an auto-prefixed DB store — they CANNOT read other modules' data unless they reconstruct prefixes manually (code review responsibility).
- `init` errors must log module name for audit, never the env object (may contain secrets).
- Private commands do NOT provide security — anyone who guesses the name can invoke them. They are for discoverability control, not access control.
## Next Steps
- Phase 05 implements util module (`/info`, `/help`) consuming the registry.
- Phase 06 adds stub modules proving the plugin system end-to-end.

View File

@@ -0,0 +1,139 @@
# Phase 05 — util module (`/info`, `/help`)
## Context Links
- Plan: [plan.md](plan.md)
- Phase 04: [module framework](phase-04-module-framework.md)
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** fully-implemented `util` module with two public commands. `/info` reports chat/thread/sender IDs. `/help` iterates the registry and prints public+protected commands grouped by module.
## Key Insights
- `/help` is a **renderer** over the registry — it does NOT hold its own command metadata. Single source of truth = registry.
- Forum topics: `message_thread_id` may be absent for normal chats. Output "n/a" rather than omitting, so debug users know the field was checked.
- Parse mode: **HTML** (decision locked). Easier escaping than MarkdownV2. Only 4 chars to escape: `&`, `<`, `>`, `"`. Write a small `escapeHtml()` util.
- `/help` must access the registry. Use an exported getter from `src/modules/dispatcher.js` or `src/modules/registry.js` that returns the currently-built registry. The util module reads it inside its handler — not at module load time — so the registry exists by then.
## Requirements
### Functional
- `/info` replies with:
```
chat id: 123
thread id: 456 (or "n/a" if undefined)
sender id: 789
```
Plain text, no parse mode needed.
- `/help` output grouped by module:
```html
<b>util</b>
/info — Show chat/thread/sender IDs
/help — Show this help
<b>wordle</b>
/wordle — Play wordle
/wstats — Stats (protected)
...
```
- Modules with zero visible commands omitted entirely.
- Private commands skipped.
- Protected commands appended with `" (protected)"` suffix so users understand the distinction.
- Module order: insertion order of `env.MODULES`.
- Sent with `parse_mode: "HTML"`.
- Both commands are **public** visibility.
### Non-functional
- `src/modules/util/index.js` < 150 LOC.
- No new deps.
## Architecture
```
src/modules/util/
├── index.js # module default export
├── info-command.js # /info handler
├── help-command.js # /help handler + HTML renderer
```
Split by command file for clarity. Each command file < 80 LOC.
Registry access: `src/modules/registry.js` exports `getCurrentRegistry()` returning the memoized instance (set by `buildRegistry`). `/help` calls this at handler time.
## Related Code Files
### Create
- `src/modules/util/index.js`
- `src/modules/util/info-command.js`
- `src/modules/util/help-command.js`
- `src/util/escape-html.js` (shared escaper)
### Modify
- `src/modules/registry.js` — add `getCurrentRegistry()` exported getter
### Delete
- none
## Implementation Steps
1. `src/util/escape-html.js`:
```js
export function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
```
2. `src/modules/registry.js`:
- Add module-scope `let currentRegistry = null;`
- `buildRegistry` assigns to it before returning.
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
3. `src/modules/util/info-command.js`:
- Exports `{ name: "info", visibility: "public", description: "Show chat/thread/sender IDs", handler }`.
- Handler reads `ctx.chat?.id`, `ctx.message?.message_thread_id`, `ctx.from?.id`.
- Reply: `\`chat id: ${chatId}\nthread id: ${threadId ?? "n/a"}\nsender id: ${senderId}\``.
4. `src/modules/util/help-command.js`:
- Exports `{ name: "help", visibility: "public", description: "Show this help", handler }`.
- Handler:
- `const reg = getCurrentRegistry();`
- Build `Map<moduleName, string[]>` of lines.
- Iterate `reg.publicCommands` + `reg.protectedCommands` (in insertion order; `Map` preserves it).
- For each entry, push `"/" + cmd.name + " — " + escapeHtml(cmd.description) + (visibility === "protected" ? " (protected)" : "")` under its module name.
- Iterate `reg.modules` in order; for each with non-empty lines, emit `<b>${escapeHtml(moduleName)}</b>\n` + lines joined by `\n` + blank line.
- `await ctx.reply(text, { parse_mode: "HTML" });`
5. `src/modules/util/index.js`:
- `import info from "./info-command.js"; import help from "./help-command.js";`
- `export default { name: "util", commands: [info, help] };`
6. Add `util` to `wrangler.toml` `MODULES` default if not already: `MODULES = "util,wordle,loldle,misc"`.
7. Lint.
## Todo List
- [ ] `escape-html.js`
- [ ] `getCurrentRegistry()` in registry.js
- [ ] `info-command.js`
- [ ] `help-command.js` renderer
- [ ] `util/index.js`
- [ ] Manual smoke test via `wrangler dev`
- [ ] Lint clean
## Success Criteria
- `/info` in a 1:1 chat shows chat id + "thread id: n/a" + sender id.
- `/info` in a forum topic shows a real thread id.
- `/help` shows `util` section with both commands, and stub module sections (after phase-06).
- `/help` does NOT show private commands.
- Protected commands show `(protected)` suffix.
- HTML injection attempt in module description (e.g. `<script>`) renders literally.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Registry not built before handler fires | Low | Med | `getCurrentRegistry()` throws with clear error; dispatcher ensures build before bot handles updates |
| `/help` output exceeds Telegram 4096-char limit | Low (at this scale) | Low | Phase-09 mentions future pagination; current scale is fine |
| Module descriptions contain raw HTML | Med | Med | `escapeHtml` all descriptions + module names |
| Missing `message_thread_id` crashes | Low | Low | `?? "n/a"` fallback |
## Security Considerations
- Escape ALL user-influenced strings (module names, descriptions) — even though modules are trusted code, future-proofing against dynamic registration.
- `/info` reveals sender id — that's the point. Document in help text that it's a debugging tool.
## Next Steps
- Phase 06 adds the stub modules that populate the `/help` output.
- Phase 08 tests `/help` rendering against a synthetic registry.

View File

@@ -0,0 +1,119 @@
# Phase 06 — Stub modules (wordle / loldle / misc)
## Context Links
- Plan: [plan.md](plan.md)
- Phase 04: [module framework](phase-04-module-framework.md)
## Overview
- **Priority:** P2
- **Status:** pending
- **Description:** three stub modules proving the plugin system. Each registers one `public`, one `protected`, and one `private` command. All commands are slash commands; private ones are simply absent from `setMyCommands` and `/help`. Handlers are one-liners that echo or reply.
## Key Insights
- Stubs are NOT feature-complete games. Their job: exercise the plugin loader, visibility levels, registry, dispatcher, and `/help` rendering.
- Each stub module demonstrates DB usage via `getJSON` / `putJSON` in one handler — proves `createStore` namespacing + JSON helpers work end-to-end.
- Private commands follow the same slash-name rules as public/protected (`[a-z0-9_]{1,32}`). They're hidden, not text-matched.
## Requirements
### Functional
- `wordle` module:
- public `/wordle``"Wordle stub — real game TBD."`
- protected `/wstats``await db.getJSON("stats")` → returns `"games played: ${stats?.gamesPlayed ?? 0}"`.
- private `/konami``"⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)"`.
- `loldle` module:
- public `/loldle``"Loldle stub."`
- protected `/lstats``"loldle stats stub"`.
- private `/ggwp``"gg well played (stub)"`.
- `misc` module:
- public `/ping``"pong"` + `await db.putJSON("last_ping", { at: Date.now() })`.
- protected `/mstats``const last = await db.getJSON("last_ping");` → echoes `last.at` or `"never"`.
- private `/fortytwo``"The answer."` (slash-command regex excludes bare numbers, so we spell it).
### Non-functional
- Each stub's `index.js` < 80 LOC.
- No additional utilities — stubs use only what phase-03/04/05 provide.
## Architecture
```
src/modules/
├── wordle/
│ └── index.js
├── loldle/
│ └── index.js
└── misc/
└── index.js
```
Each `index.js` exports `{ name, commands, init }` per the contract defined in phase 04. `init` stashes the injected `db` on a module-scope `let` so handlers can reach it.
Example shape (pseudo):
```js
let db;
export default {
name: "wordle",
init: async ({ db: store }) => { db = store; },
commands: [
{ name: "wordle", visibility: "public", description: "Play wordle",
handler: async (ctx) => ctx.reply("Wordle stub — real game TBD.") },
{ name: "wstats", visibility: "protected", description: "Wordle stats",
handler: async (ctx) => {
const stats = await db.getJSON("stats");
await ctx.reply(`games played: ${stats?.gamesPlayed ?? 0}`);
} },
{ name: "konami", visibility: "private", description: "Easter egg — retro code",
handler: async (ctx) => ctx.reply("⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)") },
],
};
```
## Related Code Files
### Create
- `src/modules/wordle/index.js`
- `src/modules/loldle/index.js`
- `src/modules/misc/index.js`
### Modify
- none (static map in `src/modules/index.js` already lists all four — from phase 04)
### Delete
- none
## Implementation Steps
1. Create `src/modules/wordle/index.js` per shape above (`/wordle`, `/wstats`, `/konami`).
2. Create `src/modules/loldle/index.js` (`/loldle`, `/lstats`, `/ggwp`).
3. Create `src/modules/misc/index.js` (`/ping`, `/mstats`, `/fortytwo`) including the `last_ping` `putJSON` / `getJSON` demonstrating DB usage.
4. Verify `src/modules/index.js` static map includes all three (added in phase-04).
5. `wrangler dev` smoke test: send each command via a mocked Telegram update; verify routing and KV writes land in the preview namespace with prefix `wordle:` / `loldle:` / `misc:`.
6. Lint clean.
## Todo List
- [ ] `wordle/index.js`
- [ ] `loldle/index.js`
- [ ] `misc/index.js`
- [ ] Verify KV writes are correctly prefixed (check via `wrangler kv key list --preview`)
- [ ] Manual webhook smoke test for all 9 commands
- [ ] Lint clean
## Success Criteria
- With `MODULES="util,wordle,loldle,misc"` the bot loads all four modules on cold start.
- `/help` output shows three stub sections (wordle, loldle, misc) with 2 commands each (public + protected), plus `util` section.
- `/help` does NOT list `/konami`, `/ggwp`, `/fortytwo`.
- Telegram's `/` menu (after `scripts/register.js` runs) shows `/wordle`, `/loldle`, `/ping`, `/info`, `/help` — nothing else.
- Typing `/konami` in Telegram invokes the handler. Typing `/Konami` does NOT (Telegram + grammY match case-sensitively).
- `/ping` writes `last_ping` via `putJSON`; subsequent `/mstats` reads it back via `getJSON`.
- Removing `wordle` from `MODULES` (`MODULES="util,loldle,misc"`) successfully boots without loading wordle; `/wordle` becomes unknown command (grammY default: silently ignored).
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Stub handlers accidentally leak into production behavior | Low | Low | Stubs clearly labeled in reply text |
| Private command name too guessable | Low | Low | These are stubs; real easter eggs can pick obscure names |
| DB write fails silently | Low | Med | Handlers `await` writes; errors propagate to grammY error handler |
## Security Considerations
- Stubs do NOT read user input for DB keys — they use fixed keys. Avoids injection.
- `/ping` timestamp is server time — no sensitive data.
## Next Steps
- Phase 07 adds `scripts/register.js` to run `setWebhook` + `setMyCommands` at deploy time.
- Phase 08 tests the full routing flow with these stubs as fixtures.

View File

@@ -0,0 +1,212 @@
# Phase 07 — Post-deploy register script (`setWebhook` + `setMyCommands`)
## Context Links
- Plan: [plan.md](plan.md)
- Phase 01: [scaffold project](phase-01-scaffold-project.md) — defines `npm run deploy` + `.env.deploy.example`
- Phase 04: [module framework](phase-04-module-framework.md) — defines `publicCommands` source of truth
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md)
## Overview
- **Priority:** P2
- **Status:** pending
- **Description:** a plain Node script at `scripts/register.js` that runs automatically after `wrangler deploy`. It calls Telegram's HTTP API directly to (1) register the Worker URL as the bot's webhook with a secret token and (2) push the `public` command list to Telegram via `setMyCommands`. Idempotent. No admin HTTP surface on the Worker itself.
## Key Insights
- The Worker has no post-deploy hook — wire this via `npm run deploy` = `wrangler deploy && npm run register`.
- The script runs in plain Node (not workerd), so it can `import` the module framework directly to derive the public-command list. This keeps a SINGLE source of truth: module files.
- Config loaded via `node --env-file=.env.deploy` (Node ≥ 20.6) — zero extra deps (no dotenv package). `.env.deploy` is gitignored; `.env.deploy.example` is committed.
- `setWebhook` is called with `secret_token` field equal to `TELEGRAM_WEBHOOK_SECRET`. Telegram echoes this in `X-Telegram-Bot-Api-Secret-Token` on every update; grammY's `webhookCallback` validates it.
- `setMyCommands` accepts an array of `{ command, description }`. Max 100 commands, 256 chars per description — already enforced at module load in phase-04.
- Script must be idempotent: running twice on identical state is a no-op in Telegram's eyes. Telegram accepts repeated `setWebhook` with the same URL.
- `private` commands are excluded from `setMyCommands`. `protected` commands also excluded (by definition — they're only in `/help`).
## Requirements
### Functional
- `scripts/register.js`:
1. Read required env: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `WORKER_URL`. Missing any → print which one and `process.exit(1)`.
2. Read `MODULES` from `.env.deploy` (or shell env) — same value that the Worker uses. If absent, default to reading `wrangler.toml` `[vars].MODULES`. Prefer `.env.deploy` for single-source-of-truth at deploy time.
3. Build the public command list by calling the same loader/registry code used by the Worker:
```js
import { buildRegistry } from "../src/modules/registry.js";
const fakeEnv = { MODULES: process.env.MODULES, KV: /* stub */ };
const reg = await buildRegistry(fakeEnv);
const publicCommands = [...reg.publicCommands.values()]
.map(({ cmd }) => ({ command: cmd.name, description: cmd.description }));
```
Pass a stub `KV` binding (a no-op object matching the `KVNamespace` shape) so `createStore` doesn't crash. Modules that do real KV work in `init` must tolerate this (or defer writes until first handler call — phase-06 stubs already do the latter).
4. `POST https://api.telegram.org/bot<TOKEN>/setWebhook` with body:
```json
{
"url": "<WORKER_URL>/webhook",
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>",
"allowed_updates": ["message", "edited_message", "callback_query"],
"drop_pending_updates": false
}
```
5. `POST https://api.telegram.org/bot<TOKEN>/setMyCommands` with body `{ "commands": [...] }`.
6. Print a short summary: webhook URL, count of registered public commands, list of commands. Exit 0.
7. On any non-2xx from Telegram: print the response body + exit 1. `npm run deploy` fails loudly.
- `package.json`:
- `"deploy": "wrangler deploy && npm run register"`
- `"register": "node --env-file=.env.deploy scripts/register.js"`
- `"register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run"` (prints the payloads without calling Telegram — useful before first real deploy)
### Non-functional
- `scripts/register.js` < 150 LOC.
- Zero dependencies beyond Node built-ins (`fetch`, `process`).
- No top-level `await` at module scope (wrap in `main()` + `.catch(exit)`).
## Architecture
```
npm run deploy
├── wrangler deploy (ships Worker code + wrangler.toml vars)
└── npm run register (= node --env-file=.env.deploy scripts/register.js)
├── load .env.deploy into process.env
├── import buildRegistry from src/modules/registry.js
│ └── uses stub KV, derives publicCommands
├── POST https://api.telegram.org/bot<T>/setWebhook {url, secret_token, allowed_updates}
├── POST https://api.telegram.org/bot<T>/setMyCommands {commands: [...]}
└── print summary → exit 0
```
## Related Code Files
### Create
- `scripts/register.js`
- `scripts/stub-kv.js` — tiny no-op `KVNamespace` stub for the registry build (exports a single object with async methods returning null/empty)
- `.env.deploy.example` (already created in phase-01; documented again here)
### Modify
- `package.json` — add `deploy` + `register` + `register:dry` scripts (phase-01 already wires `deploy`)
### Delete
- none
## Implementation Steps
1. `scripts/stub-kv.js`:
```js
// No-op KVNamespace stub for deploy-time registry build. Matches the minimum surface
// our CFKVStore wrapper calls — never actually reads/writes.
export const stubKv = {
async get() { return null; },
async put() {},
async delete() {},
async list() { return { keys: [], list_complete: true, cursor: undefined }; },
};
```
2. `scripts/register.js`:
```js
import { buildRegistry, resetRegistry } from "../src/modules/registry.js";
import { stubKv } from "./stub-kv.js";
const TELEGRAM_API = "https://api.telegram.org";
function requireEnv(name) {
const v = process.env[name];
if (!v) { console.error(`missing env: ${name}`); process.exit(1); }
return v;
}
async function tg(token, method, body) {
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
console.error(`${method} failed:`, res.status, json);
process.exit(1);
}
return json;
}
async function main() {
const token = requireEnv("TELEGRAM_BOT_TOKEN");
const secret = requireEnv("TELEGRAM_WEBHOOK_SECRET");
const workerUrl = requireEnv("WORKER_URL").replace(/\/$/, "");
const modules = requireEnv("MODULES");
const dry = process.argv.includes("--dry-run");
resetRegistry();
const reg = await buildRegistry({ MODULES: modules, KV: stubKv });
const commands = [...reg.publicCommands.values()]
.map(({ cmd }) => ({ command: cmd.name, description: cmd.description }));
const webhookBody = {
url: `${workerUrl}/webhook`,
secret_token: secret,
allowed_updates: ["message", "edited_message", "callback_query"],
drop_pending_updates: false,
};
const commandsBody = { commands };
if (dry) {
console.log("DRY RUN — not calling Telegram");
console.log("setWebhook:", webhookBody);
console.log("setMyCommands:", commandsBody);
return;
}
await tg(token, "setWebhook", webhookBody);
await tg(token, "setMyCommands", commandsBody);
console.log(`ok — webhook: ${webhookBody.url}`);
console.log(`ok — ${commands.length} public commands registered:`);
for (const c of commands) console.log(` /${c.command} — ${c.description}`);
}
main().catch((e) => { console.error(e); process.exit(1); });
```
3. Update `package.json` scripts:
```json
"deploy": "wrangler deploy && npm run register",
"register": "node --env-file=.env.deploy scripts/register.js",
"register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run"
```
4. Smoke test BEFORE first real deploy:
- Populate `.env.deploy` with real token + secret + worker URL + modules.
- `npm run register:dry` — verify the printed payloads match expectations.
- `npm run register` — verify Telegram returns ok, then in the Telegram client type `/` and confirm only public commands appear in the popup.
5. Lint clean (biome covers `scripts/` — phase-01 `lint` script already includes it).
## Todo List
- [ ] `scripts/stub-kv.js`
- [ ] `scripts/register.js` with `--dry-run` flag
- [ ] `package.json` scripts updated
- [ ] `.env.deploy.example` committed (created in phase-01, verify present)
- [ ] Dry-run prints correct payloads
- [ ] Real run succeeds against a test bot
- [ ] Verify Telegram `/` menu reflects public commands only
- [ ] Lint clean
## Success Criteria
- `npm run deploy` = `wrangler deploy` then automatic `setWebhook` + `setMyCommands`, no manual steps.
- `npm run register:dry` prints both payloads and exits 0 without calling Telegram.
- Missing env var produces a clear `missing env: NAME` message + exit 1.
- Telegram client `/` menu shows exactly the `public` commands (5 total with full stubs: `/info`, `/help`, `/wordle`, `/loldle`, `/ping`).
- Protected and private commands are NOT in Telegram's `/` menu.
- Running `npm run register` a second time with no code changes succeeds (idempotent).
- Wrong `TELEGRAM_WEBHOOK_SECRET` → subsequent Telegram update → 401 from Worker (validated via grammY, not our code) — this is the end-to-end proof of correct wiring.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `.env.deploy` committed by accident | Low | High | `.gitignore` from phase-01 + pre-commit grep recommended in phase-09 |
| Stub KV missing a method the registry calls | Med | Med | `buildRegistry` only uses KV via `createStore`; stub covers all four methods used |
| Module `init` tries to write to KV and crashes on stub | Med | Med | Contract: `init` may read, must tolerate empty results; all writes happen in handlers. Documented in phase-04. |
| `setMyCommands` rate-limited if spammed | Low | Low | `npm run deploy` is developer-driven, not per-request |
| Node < 20.6 doesn't support `--env-file` | Low | Med | `package.json` `"engines": { "node": ">=20.6" }` (add in phase-01) |
| Webhook secret leaks via CI logs | Low | High | Phase-09 documents: never pass `.env.deploy` through CI logs; prefer CF Pages/Workers CI with masked secrets |
## Security Considerations
- `.env.deploy` contains the bot token — MUST be gitignored and never logged by the script beyond `console.log(\`ok\`)`.
- The register script runs locally on the developer's machine — not in CI by default. If CI is added later, use masked secrets + `--env-file` pointing at a CI-provided file.
- No admin HTTP surface on the Worker means no timing-attack attack surface, no extra secret rotation, no `ADMIN_SECRET` to leak.
- `setWebhook` re-registration rotates nothing automatically — if the webhook secret is rotated, you MUST re-run `npm run register` so Telegram uses the new value on future updates.
## Next Steps
- Phase 08 tests the registry code that the script reuses (the script itself is thin).
- Phase 09 documents the full deploy workflow (`.env.deploy` setup, first-run checklist, troubleshooting).

View File

@@ -0,0 +1,166 @@
# Phase 08 — Tests
## Context Links
- Plan: [plan.md](plan.md)
- Phases 03, 04, 05
## Overview
- **Priority:** P1
- **Status:** pending
- **Description:** vitest unit tests for pure-logic modules — registry, DB prefixing wrapper (incl. `getJSON` / `putJSON`), dispatcher routing, help renderer, command validators, HTML escaper. NO tests that spin up workerd or hit Telegram.
## Key Insights
- Pure-logic testing > integration testing at this stage. Cloudflare's `@cloudflare/vitest-pool-workers` adds complexity and slow starts; skip for v1.
- Mock `env.KV` with an in-memory `Map`-backed fake that implements `get/put/delete/list` per the real shape (including `list_complete` and `keys: [{name}]`).
- For dispatcher tests, build a fake `bot` that records `command()` + `on()` registrations; assert the right handlers were wired.
- For help renderer tests, construct a synthetic registry directly — no need to load real modules.
## Requirements
### Functional
- Tests run via `npm test` (vitest, node env).
- No network. No `fetch`. No Telegram.
- Each test file covers ONE source file.
- Coverage target: registry, db wrapper, dispatcher, help renderer, validators, escaper — these are the logic seams.
### Non-functional
- Test suite runs in < 5s.
- No shared mutable state between tests; each test builds its fixtures.
## Architecture
```
tests/
├── fakes/
│ ├── fake-kv-namespace.js # Map-backed KVNamespace impl
│ ├── fake-bot.js # records command() calls
│ └── fake-modules.js # fixture modules for registry tests
├── db/
│ ├── cf-kv-store.test.js
│ └── create-store.test.js
├── modules/
│ ├── registry.test.js
│ ├── dispatcher.test.js
│ └── validate-command.test.js
├── util/
│ └── escape-html.test.js
└── modules/util/
└── help-command.test.js
```
## Related Code Files
### Create
- `tests/fakes/fake-kv-namespace.js`
- `tests/fakes/fake-bot.js`
- `tests/fakes/fake-modules.js`
- `tests/db/cf-kv-store.test.js`
- `tests/db/create-store.test.js`
- `tests/modules/registry.test.js`
- `tests/modules/dispatcher.test.js`
- `tests/modules/validate-command.test.js`
- `tests/util/escape-html.test.js`
- `tests/modules/util/help-command.test.js`
### Modify
- `vitest.config.js` — confirm `environment: "node"`, `include: ["tests/**/*.test.js"]`.
### Delete
- none
## Test cases (per file)
### `fake-kv-namespace.js`
- In-memory `Map`. `get(key, {type: "text"})` returns value or null. `put(key, value, opts?)` stores; records `opts` for assertions. `delete` removes. `list({prefix, limit, cursor})` filters by prefix, paginates, returns `{keys:[{name}], list_complete, cursor}`.
### `cf-kv-store.test.js`
- `get/put/delete` round-trip with fake KV.
- `list()` strips to normalized shape `{keys: string[], cursor?, done}`.
- `put` with `expirationTtl` passes through to underlying binding.
- `putJSON` serializes then calls `put`; recoverable via `getJSON`.
- `getJSON` on missing key returns `null`.
- `getJSON` on corrupt JSON (manually insert `"{not json"`) returns `null`, does NOT throw, emits a warning.
- `putJSON(key, undefined)` throws.
### `create-store.test.js`
- `createStore("wordle", env).put("k","v")` results in raw KV key `"wordle:k"`.
- `createStore("wordle", env).list({prefix:"games:"})` calls underlying `list` with `"wordle:games:"` prefix.
- Returned keys have the `"wordle:"` prefix STRIPPED.
- Two stores for different modules cannot read each other's keys.
- `getJSON` / `putJSON` through a prefixed store also land at `<module>:<key>` raw key.
- Invalid module name (contains `:`) throws.
### `validate-command.test.js`
- Valid command passes for each visibility (public / protected / private).
- Command with leading `/` rejected (any visibility).
- Command name > 32 chars rejected.
- Command name with uppercase rejected (`COMMAND_NAME_RE` = `/^[a-z0-9_]{1,32}$/`).
- Missing description rejected (all visibilities — private also requires description for internal debugging).
- Description > 256 chars rejected.
- Invalid visibility rejected.
### `registry.test.js`
- Fixture: fake modules passed via an injected `moduleRegistry` map (avoid `vi.mock` path-resolution flakiness on Windows — phase-04 exposes a loader injection point).
- `MODULES="a,b"` loads both; `buildRegistry` flattens commands into 3 visibility maps + 1 `allCommands` map.
- **Unified namespace conflict:** module A registers `/foo` as public, module B registers `/foo` as private → `buildRegistry` throws with a message naming both modules AND the command.
- Same-visibility conflict (two modules, both public `/foo`) → throws.
- Unknown module name → throws with message.
- Empty `MODULES` → throws.
- `init` called once per module with `{db, env}`; db is a namespaced store (assert key prefix by doing a `put` through the injected db and checking raw KV).
- After `buildRegistry`, `getCurrentRegistry()` returns the same instance; `resetRegistry()` clears it and subsequent `getCurrentRegistry()` throws.
### `dispatcher.test.js`
- Build registry from fake modules (one each: public, protected, private), install on fake bot.
- Assert `bot.command()` called for EVERY entry — including private ones (the whole point: unified routing).
- Assert no other bot wiring (no `bot.on`, no `bot.hears`). Dispatcher is minimal.
- Call count = total commands across all visibilities.
### `help-command.test.js`
- Build a synthetic registry with three modules: A (1 public), B (1 public + 1 protected), C (1 private only).
- Invoke help handler with a fake `ctx` that captures `reply(text, opts)`.
- Assert output:
- Contains `<b>A</b>` and `<b>B</b>`.
- Does NOT contain `<b>C</b>` (no visible commands — private is hidden from help).
- Does NOT contain C's private command name anywhere in output.
- Protected command has ` (protected)` suffix.
- `opts.parse_mode === "HTML"`.
- HTML-escapes a module description containing `<script>`.
### `escape-html.test.js`
- Escapes `&`, `<`, `>`, `"`.
- Leaves safe chars alone.
## Implementation Steps
1. Build fakes first — `fake-kv-namespace.js`, `fake-bot.js`, `fake-modules.js`.
2. Write tests file-by-file, running `npm test` after each.
3. If a test reveals a bug in the source, fix the source (not the test).
4. Final full run, assert all green.
## Todo List
- [ ] Fakes: kv namespace, bot, modules
- [ ] cf-kv-store tests (incl. `getJSON` / `putJSON` happy path + corrupt-JSON swallow)
- [ ] create-store tests (prefix round-trip, isolation, JSON helpers through prefixing)
- [ ] validate-command tests (uniform regex, leading-slash rejection)
- [ ] registry tests (load, unified-namespace conflicts, init injection, reset)
- [ ] dispatcher tests (every visibility registered via `bot.command()`)
- [ ] help renderer tests (grouping, escaping, private hidden, parse_mode)
- [ ] escape-html tests
- [ ] All green via `npm test`
## Success Criteria
- `npm test` passes with ≥ 95% line coverage on the logic seams (registry, db wrapper, dispatcher, help renderer, validators).
- No flaky tests on 5 consecutive runs.
- Unified-namespace conflict detection has dedicated tests covering same-visibility AND cross-visibility collisions.
- Prefix isolation (module A cannot see module B's keys) has a dedicated test.
- `getJSON` corrupt-JSON swallowing has a dedicated test.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Fake KV diverges from real behavior | Med | Med | Keep fake minimal, aligned to real shape from [KV basics report](../reports/researcher-260411-0853-cloudflare-kv-basics.md) |
| `vi.mock` path resolution differs on Windows | Med | Low | Use injected-dependency pattern instead — pass `moduleRegistry` map as a fn param in tests |
| Tests couple to grammY internals | Low | Med | Use fake bot; never import grammY in tests |
| Hidden state in registry module-scope leaks between tests | Med | Med | Export a `resetRegistry()` test helper; call in `beforeEach` |
## Security Considerations
- Tests must never read real `.dev.vars` or hit real KV. Keep everything in-memory.
## Next Steps
- Phase 09 documents running the test suite as part of the deploy preflight.

View File

@@ -0,0 +1,141 @@
# Phase 09 — Deploy + docs
## Context Links
- Plan: [plan.md](plan.md)
- Reports: [wrangler + secrets](../reports/researcher-260411-0853-wrangler-config-secrets.md)
- All prior phases
## Overview
- **Priority:** P1 (ship gate)
- **Status:** pending
- **Description:** first real deploy + documentation. Update README with setup/deploy steps, add an "adding a new module" guide, finalize `wrangler.toml` with real KV IDs.
## Key Insights
- Deploy is a single command — `npm run deploy` — which chains `wrangler deploy` + the register script from phase-07. No separate webhook registration or admin curl calls.
- First-time setup creates two env files: `.dev.vars` (for `wrangler dev`) and `.env.deploy` (for `scripts/register.js`). Both gitignored. Both have `.example` siblings committed.
- Production secrets for the Worker still live in CF (`wrangler secret put`). The register script reads its own copies from `.env.deploy` — same values, two homes (one for the Worker runtime, one for the deploy script). Document this clearly to avoid confusion.
- Adding a new module is a 3-step process: create folder, add to `src/modules/index.js`, add to `MODULES`. Document it.
## Requirements
### Functional
- `README.md` updated with: overview, architecture diagram, setup, local dev, deploy, adding a module, command visibility explanation.
- `wrangler.toml` has real KV namespace IDs (production + preview).
- `.dev.vars.example` + `.env.deploy.example` committed; `.dev.vars` + `.env.deploy` gitignored.
- A single `docs/` or inline README section covers how to add a new module with a minimal example.
### Non-functional
- README stays < 250 lines — link to plan phases for deep details if needed.
## Architecture
N/A — docs phase.
## Related Code Files
### Create
- `docs/adding-a-module.md` (standalone guide, referenced from README)
### Modify
- `README.md`
- `wrangler.toml` (real KV IDs)
### Delete
- none
## Implementation Steps
1. **Create KV namespaces:**
```bash
wrangler kv namespace create miti99bot-kv
wrangler kv namespace create miti99bot-kv --preview
```
Paste both IDs into `wrangler.toml`.
2. **Set Worker runtime secrets (used by the deployed Worker):**
```bash
wrangler secret put TELEGRAM_BOT_TOKEN
wrangler secret put TELEGRAM_WEBHOOK_SECRET
```
3. **Create `.env.deploy` (used by `scripts/register.js` locally):**
```bash
cp .env.deploy.example .env.deploy
# then fill in the same TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET + WORKER_URL + MODULES
```
NOTE: `WORKER_URL` is unknown until after the first `wrangler deploy`. For the very first deploy, either (a) deploy once with `wrangler deploy` to learn the URL, fill `.env.deploy`, then run `npm run deploy` again, or (b) use a custom route from the start and put that URL in `.env.deploy` upfront.
4. **Preflight:**
```bash
npm run lint
npm test
npm run register:dry # prints the payloads without calling Telegram
```
All green before deploy.
5. **Deploy (one command from here on):**
```bash
npm run deploy
```
This runs `wrangler deploy && npm run register`. The register step calls Telegram's `setWebhook` + `setMyCommands` with values from `.env.deploy`.
6. **Smoke test in Telegram:**
- Type `/` in a chat with the bot — confirm the popup lists exactly the public commands (`/info`, `/help`, `/wordle`, `/loldle`, `/ping`). No protected, no private.
- `/info` shows chat id / thread id / sender id.
- `/help` shows util + wordle + loldle + misc sections with public + protected commands; private commands absent.
- `/wordle`, `/loldle`, `/ping` respond.
- `/wstats`, `/lstats`, `/mstats` respond (visible in `/help` but NOT in the `/` popup).
- `/konami`, `/ggwp`, `/fortytwo` respond when typed, even though they're invisible everywhere.
7. **Write `README.md`:**
- Badge-free, plain.
- Sections: What it is, Architecture snapshot, Prereqs, Setup (KV + secrets + `.env.deploy`), Local dev, Deploy (`npm run deploy`), Command visibility levels, Adding a module, Troubleshooting.
- Link to `docs/adding-a-module.md` and to `plans/260411-0853-telegram-bot-plugin-framework/plan.md`.
8. **Write `docs/adding-a-module.md`:**
- Step 1: Create `src/modules/<name>/index.js` exporting default module object.
- Step 2: Add entry to `src/modules/index.js` static map.
- Step 3: Add `<name>` to `MODULES` in both `wrangler.toml` (runtime) and `.env.deploy` (so the register script picks up new public commands).
- Minimal skeleton code block.
- Note on DB namespacing (auto-prefixed).
- Note on command naming rules (`[a-z0-9_]{1,32}`, no leading slash, uniform across all visibilities).
- Note on visibility levels (public → in `/` menu + `/help`; protected → in `/help` only; private → hidden slash command).
9. **Troubleshooting section** — common issues:
- 401 on webhook → `TELEGRAM_WEBHOOK_SECRET` mismatch between `wrangler secret` and `.env.deploy`. They MUST match.
- `/help` empty for a module → check module exports `default` (not named export).
- Module loads but no commands → check `MODULES` includes it (in both `wrangler.toml` AND `.env.deploy`).
- Command conflict error at deploy → two modules registered the same command name; rename one.
- `npm run register` exits with `missing env: X` → fill `X` in `.env.deploy`.
- Node < 20.6 → `--env-file` flag unsupported; upgrade Node.
## Todo List
- [ ] Create real KV namespaces + paste IDs into `wrangler.toml`
- [ ] Set both runtime secrets via `wrangler secret put` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`)
- [ ] Create `.env.deploy` from example (token, webhook secret, worker URL, modules)
- [ ] `npm run lint` + `npm test` + `npm run register:dry` all green
- [ ] `npm run deploy` (chains `wrangler deploy` + register)
- [ ] End-to-end smoke test in Telegram (9 public/protected commands + 3 private slash commands)
- [ ] `README.md` rewrite
- [ ] `docs/adding-a-module.md` guide
- [ ] Commit + push
## Success Criteria
- Bot responds in Telegram to all 11 slash commands (util: `/info` + `/help` public; wordle: `/wordle` public, `/wstats` protected, `/konami` private; loldle: `/loldle` public, `/lstats` protected, `/ggwp` private; misc: `/ping` public, `/mstats` protected, `/fortytwo` private).
- Telegram `/` menu shows exactly 5 public commands (`/info`, `/help`, `/wordle`, `/loldle`, `/ping`) — no protected, no private.
- `/help` output lists all four modules and their public + protected commands only.
- Private slash commands (`/konami`, `/ggwp`, `/fortytwo`) respond when invoked but appear nowhere visible.
- `npm run deploy` is a single command that goes from clean checkout to live bot (after first-time KV + secret setup).
- README is enough for a new contributor to deploy their own instance without reading the plan files.
- `docs/adding-a-module.md` lets a new module be added in < 5 minutes.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Runtime and deploy-script secrets drift out of sync | Med | High | Document that both `.env.deploy` and `wrangler secret` must hold the SAME `TELEGRAM_WEBHOOK_SECRET`; mismatch → 401 loop |
| KV preview ID accidentally used in prod | Low | Med | Keep `preview_id` and `id` clearly labeled in wrangler.toml |
| `.dev.vars` or `.env.deploy` committed by mistake | Low | High | Gitignore both; pre-commit grep for common token prefixes |
| Bot token leaks via error responses | Low | High | grammY's default error handler does not echo tokens; double-check logs |
## Security Considerations
- `TELEGRAM_BOT_TOKEN` never appears in code, logs, or commits.
- `.dev.vars` + `.env.deploy` in `.gitignore` — verified before first commit.
- README explicitly warns against committing either env file.
- Webhook secret rotation: update `.env.deploy`, run `wrangler secret put TELEGRAM_WEBHOOK_SECRET`, then `npm run deploy`. The register step re-calls Telegram's `setWebhook` with the new secret on the same run. Single command does the whole rotation.
- Bot token rotation (BotFather reissue): update both `.env.deploy` and `wrangler secret put TELEGRAM_BOT_TOKEN`, then `npm run deploy`.
## Next Steps
- Ship. Feature work begins in a new plan after the user confirms v1 is live.
- Potential follow-ups (NOT in this plan):
- Replace KV with D1 for relational game state.
- Add per-user rate limiting.
- Real wordle/loldle/misc game logic.
- Logging to Cloudflare Logs or external sink.

View File

@@ -0,0 +1,54 @@
---
title: "Telegram Bot Plugin Framework on Cloudflare Workers"
description: "Greenfield JS Telegram bot with plug-n-play modules, KV-backed store, webhook on CF Workers."
status: pending
priority: P2
effort: 14h
branch: main
tags: [telegram, cloudflare-workers, grammy, plugin-system, javascript]
created: 2026-04-11
---
# Telegram Bot Plugin Framework
Greenfield JS bot on Cloudflare Workers. grammY + KV. Modules load from `MODULES` env var. Three command visibility levels (public/protected/private). Pluggable DB behind a minimal interface.
## Phases
| # | Phase | Status | Effort | Blockers |
|---|---|---|---|---|
| 01 | [Scaffold project](phase-01-scaffold-project.md) | pending | 1h | — |
| 02 | [Webhook entrypoint](phase-02-webhook-entrypoint.md) | pending | 1h | 01 |
| 03 | [DB abstraction](phase-03-db-abstraction.md) | pending | 1.5h | 01 |
| 04 | [Module framework](phase-04-module-framework.md) | pending | 2.5h | 02, 03 |
| 05 | [util module](phase-05-util-module.md) | pending | 1.5h | 04 |
| 06 | [Stub modules](phase-06-stub-modules.md) | pending | 1h | 04 |
| 07 | [Post-deploy register script](phase-07-deploy-register-script.md) | pending | 1h | 05 |
| 08 | [Tests](phase-08-tests.md) | pending | 2.5h | 04, 05, 06 |
| 09 | [Deploy + docs](phase-09-deploy-docs.md) | pending | 2h | 07, 08 |
## Key dependencies
- grammY `^1.30.0`, adapter `"cloudflare-mod"`
- wrangler (npm), Cloudflare account, KV namespace
- vitest (plain node pool — pure-logic tests only, no workerd)
- biome (lint + format, single tool)
## Architecture snapshot
- `src/index.js` — fetch handler: `POST /webhook` + `GET /` health. No admin HTTP surface.
- `src/bot.js` — memoized `Bot` instance + dispatcher wiring
- `src/db/``KVStore` interface (JSDoc with `getJSON/putJSON/list-cursor`), `cf-kv-store.js`, `create-store.js` (prefixing factory)
- `src/modules/registry.js` — load modules, build command tables (public/protected/private + unified `allCommands`), detect conflicts, expose `getCurrentRegistry`
- `src/modules/dispatcher.js` — grammY middleware: every command in `allCommands` registered via `bot.command()` regardless of visibility (private = hidden slash command)
- `src/modules/index.js` — static import map `{ util: () => import("./util/index.js"), ... }`
- `src/modules/{util,wordle,loldle,misc}/index.js` — module entry points
- `scripts/register.js` — post-deploy node script calling Telegram `setWebhook` + `setMyCommands`. Chained via `npm run deploy = wrangler deploy && npm run register`.
## Open questions
1. **grammY version pin** — pick exact version at phase-01 time (`npm view grammy version`). Plan assumes `^1.30.0`.
*Resolved in revision pass (2026-04-11):*
- Private commands are hidden slash commands (same regex, routed via `bot.command()`), not text-match easter eggs.
- No admin HTTP surface; post-deploy `scripts/register.js` handles `setWebhook` + `setMyCommands`. `ADMIN_SECRET` dropped entirely.
- KV interface exposes `expirationTtl`, `list()` cursor, and `getJSON` / `putJSON` helpers. No `metadata`.
- Conflict policy: unified namespace across all visibilities, throw at `buildRegistry`.
- `/help` parse mode: HTML.