From 129a4cfd7d0f1234b6e3d22fac9fa13fec3899e7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 15 Apr 2026 13:30:44 +0700 Subject: [PATCH] chore: remove completed plans and reports --- .../phase-01-scaffold-project.md | 125 ----------- .../phase-02-webhook-entrypoint.md | 101 --------- .../phase-03-db-abstraction.md | 138 ------------ .../phase-04-module-framework.md | 172 -------------- .../phase-05-util-module.md | 139 ------------ .../phase-06-stub-modules.md | 119 ---------- .../phase-07-deploy-register-script.md | 212 ------------------ .../phase-08-tests.md | 166 -------------- .../phase-09-deploy-docs.md | 141 ------------ .../plan.md | 54 ----- .../phase-01-symbols-and-format.md | 102 --------- .../phase-02-prices.md | 120 ---------- .../phase-03-portfolio.md | 90 -------- .../phase-04-commands.md | 134 ----------- .../phase-05-wiring.md | 67 ------ .../phase-06-tests.md | 140 ------------ plans/260414-1457-trading-module/plan.md | 56 ----- .../phase-01-d1-setup.md | 105 --------- .../phase-02-cron-wiring.md | 92 -------- .../phase-03-trading-history.md | 84 ------- .../phase-04-retention-cron.md | 86 ------- .../phase-05-jsdoc-pass.md | 104 --------- .../phase-06-docs.md | 84 ------- plans/260415-1010-d1-cron-infra/plan.md | 56 ----- .../docs-manager-260415-1304-phase-06-docs.md | 132 ----------- ...fullstack-260415-1052-phase-01-d1-setup.md | 27 --- ...lstack-260415-1052-phase-02-cron-wiring.md | 26 --- ...ck-260415-1052-phase-03-trading-history.md | 38 ---- ...ack-260415-1052-phase-04-retention-cron.md | 35 --- .../fullstack-260415-1052-phase-05-jsdoc.md | 51 ----- ...archer-260411-0853-cloudflare-kv-basics.md | 56 ----- ...60411-0853-grammy-on-cloudflare-workers.md | 57 ----- ...her-260411-0853-wrangler-config-secrets.md | 68 ------ 33 files changed, 3177 deletions(-) delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-01-scaffold-project.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-02-webhook-entrypoint.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-03-db-abstraction.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-04-module-framework.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-05-util-module.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-06-stub-modules.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-07-deploy-register-script.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-08-tests.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/phase-09-deploy-docs.md delete mode 100644 plans/260411-0853-telegram-bot-plugin-framework/plan.md delete mode 100644 plans/260414-1457-trading-module/phase-01-symbols-and-format.md delete mode 100644 plans/260414-1457-trading-module/phase-02-prices.md delete mode 100644 plans/260414-1457-trading-module/phase-03-portfolio.md delete mode 100644 plans/260414-1457-trading-module/phase-04-commands.md delete mode 100644 plans/260414-1457-trading-module/phase-05-wiring.md delete mode 100644 plans/260414-1457-trading-module/phase-06-tests.md delete mode 100644 plans/260414-1457-trading-module/plan.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-01-d1-setup.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-02-cron-wiring.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-03-trading-history.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-04-retention-cron.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-05-jsdoc-pass.md delete mode 100644 plans/260415-1010-d1-cron-infra/phase-06-docs.md delete mode 100644 plans/260415-1010-d1-cron-infra/plan.md delete mode 100644 plans/reports/docs-manager-260415-1304-phase-06-docs.md delete mode 100644 plans/reports/fullstack-260415-1052-phase-01-d1-setup.md delete mode 100644 plans/reports/fullstack-260415-1052-phase-02-cron-wiring.md delete mode 100644 plans/reports/fullstack-260415-1052-phase-03-trading-history.md delete mode 100644 plans/reports/fullstack-260415-1052-phase-04-retention-cron.md delete mode 100644 plans/reports/fullstack-260415-1052-phase-05-jsdoc.md delete mode 100644 plans/reports/researcher-260411-0853-cloudflare-kv-basics.md delete mode 100644 plans/reports/researcher-260411-0853-grammy-on-cloudflare-workers.md delete mode 100644 plans/reports/researcher-260411-0853-wrangler-config-secrets.md diff --git a/plans/260411-0853-telegram-bot-plugin-framework/phase-01-scaffold-project.md b/plans/260411-0853-telegram-bot-plugin-framework/phase-01-scaffold-project.md deleted file mode 100644 index 2f7a8ad..0000000 --- a/plans/260411-0853-telegram-bot-plugin-framework/phase-01-scaffold-project.md +++ /dev/null @@ -1,125 +0,0 @@ -# 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 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://.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). diff --git a/plans/260411-0853-telegram-bot-plugin-framework/phase-02-webhook-entrypoint.md b/plans/260411-0853-telegram-bot-plugin-framework/phase-02-webhook-entrypoint.md deleted file mode 100644 index 2b10805..0000000 --- a/plans/260411-0853-telegram-bot-plugin-framework/phase-02-webhook-entrypoint.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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. diff --git a/plans/260411-0853-telegram-bot-plugin-framework/phase-03-db-abstraction.md b/plans/260411-0853-telegram-bot-plugin-framework/phase-03-db-abstraction.md deleted file mode 100644 index e9d2105..0000000 --- a/plans/260411-0853-telegram-bot-plugin-framework/phase-03-db-abstraction.md +++ /dev/null @@ -1,138 +0,0 @@ -# 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 `:`. - -## 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} get - * @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise} put - * @property {(key: string) => Promise} delete - * @property {(opts?: KVStoreListOptions) => Promise} list - * @property {(key: string) => Promise} getJSON - * @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise} 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 `:` 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. diff --git a/plans/260411-0853-telegram-bot-plugin-framework/phase-04-module-framework.md b/plans/260411-0853-telegram-bot-plugin-framework/phase-04-module-framework.md deleted file mode 100644 index c0d4a49..0000000 --- a/plans/260411-0853-telegram-bot-plugin-framework/phase-04-module-framework.md +++ /dev/null @@ -1,172 +0,0 @@ -# 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` — source of truth for `setMyCommands` + `/help`. - - `protectedCommands: Map` — source of truth for `/help`. - - `privateCommands: Map` — bookkeeping only (not surfaced anywhere visible). - - `allCommands: Map` — 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. diff --git a/plans/260411-0853-telegram-bot-plugin-framework/phase-05-util-module.md b/plans/260411-0853-telegram-bot-plugin-framework/phase-05-util-module.md deleted file mode 100644 index 1de3591..0000000 --- a/plans/260411-0853-telegram-bot-plugin-framework/phase-05-util-module.md +++ /dev/null @@ -1,139 +0,0 @@ -# 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 - util - /info — Show chat/thread/sender IDs - /help — Show this help - - wordle - /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` 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 `${escapeHtml(moduleName)}\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. `