mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
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:
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
```
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
54
plans/260411-0853-telegram-bot-plugin-framework/plan.md
Normal file
54
plans/260411-0853-telegram-bot-plugin-framework/plan.md
Normal 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.
|
||||
Reference in New Issue
Block a user