chore: remove completed plans and reports

This commit is contained in:
2026-04-15 13:30:44 +07:00
parent 6a4829e45b
commit 129a4cfd7d
33 changed files with 0 additions and 3177 deletions

View File

@@ -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 <pkg> version` and recording exact versions.
5. Create `wrangler.toml` from template in research report — leave KV IDs as `REPLACE_ME`.
6. Create `biome.json` with defaults + 2-space indent, double quotes, semicolons.
7. Create `vitest.config.js` with `environment: "node"` (pure logic tests only, no workerd pool).
8. Create `.dev.vars.example` (local dev secrets used by `wrangler dev`):
```
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
```
9. Create `.env.deploy.example` (consumed by `scripts/register.js` in phase-07; loaded via `node --env-file`):
```
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
WORKER_URL=https://<worker-subdomain>.workers.dev
```
10. Append `.gitignore` entries.
10. `npm run lint` + `npm test` + `wrangler --version` — all succeed.
## Todo List
- [ ] `npm init`, set `type: module`, scripts
- [ ] Install runtime + dev deps
- [ ] `wrangler.toml` template
- [ ] `biome.json`
- [ ] `vitest.config.js`
- [ ] `.dev.vars.example`
- [ ] Update `.gitignore`
- [ ] Smoke-run `npm run lint` / `npm test`
## Success Criteria
- `npm install` exits 0.
- `npm run lint` exits 0 (nothing to lint yet — biome treats this as pass).
- `npm test` exits 0.
- `npx wrangler --version` prints a version.
- `git status` shows no tracked `.dev.vars`, no `node_modules`.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| wrangler version pins conflict with Node version | Low | Med | Require Node ≥ 20 in `engines` field |
| biome default rules too strict | Med | Low | Start with `recommended`; relax only if blocking |
| `type: module` trips vitest | Low | Low | vitest supports ESM natively |
## Security Considerations
- `.dev.vars` MUST be gitignored. Double-check before first commit.
- `wrangler.toml` MUST NOT contain any secret values — only `[vars]` for non-sensitive `MODULES`.
## Next Steps
- Phase 02 needs the fetch handler skeleton in `src/index.js`.
- Phase 03 needs the KV binding wired in `wrangler.toml` (already scaffolded here).

View File

@@ -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.

View File

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

View File

@@ -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<string, {module, cmd}>` — source of truth for `setMyCommands` + `/help`.
- `protectedCommands: Map<string, {module, cmd}>` — source of truth for `/help`.
- `privateCommands: Map<string, {module, cmd}>` — bookkeeping only (not surfaced anywhere visible).
- `allCommands: Map<string, {module, cmd, visibility}>` — flat index used by the dispatcher to `bot.command()` every entry regardless of visibility.
- Name conflict across modules (any visibility combination) → throw `Error("command conflict: ...")` naming both modules and the command.
### Non-functional
- `src/modules/registry.js` < 150 LOC.
- `src/modules/dispatcher.js` < 60 LOC.
- No global mutable state outside the registry itself.
## Architecture
```
env.MODULES = "util,wordle,loldle,misc"
loadModules(env) ──► static map lookup ──► import() each ──► array of module objects
buildRegistry(modules, env)
├── for each module: call init({db, env}) if present
└── flatten commands → 3 visibility-partitioned maps + 1 flat `allCommands` map
│ (conflict check walks `allCommands`: one namespace, all visibilities)
installDispatcher(bot, registry)
└── for each entry in allCommands:
bot.command(cmd.name, cmd.handler)
```
**Why no text-match middleware:** grammY's `bot.command()` already handles exact-match slash routing, case sensitivity, and group-chat `/cmd@bot` disambiguation. Private commands ride that same path — they're just absent from `setMyCommands` and `/help`.
## Related Code Files
### Create
- `src/modules/index.js` — static import map
- `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry`
- `src/modules/dispatcher.js` — `installDispatcher(bot, env)` (replaces phase-02 stub)
- `src/modules/validate-command.js` — shared validators (name regex, visibility, description)
### Modify
- `src/bot.js` — call `installDispatcher(bot, env)` (was a no-op stub)
### Delete
- none
## Implementation Steps
1. `src/modules/index.js`:
```js
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
};
```
(Stub module folders land in phase-05/06 — tests in phase-08 can inject a fake map.)
2. `src/modules/validate-command.js`:
- `VISIBILITIES = ["public", "protected", "private"]`.
- `COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/` (uniform across all visibilities).
- `validateCommand(cmd, moduleName)`:
- `visibility` ∈ `VISIBILITIES` (throw otherwise).
- `name` matches `COMMAND_NAME_RE` (no leading `/`).
- `description` is non-empty string, ≤ 256 chars.
- `handler` is a function.
- All errors mention both the module name and the offending command name.
3. `src/modules/registry.js`:
- Module-scope `let currentRegistry = null;` (used by `/help` in phase-05).
- `async function loadModules(env)`:
- Parse `env.MODULES` → array, trim, dedupe, skip empty.
- Empty list → throw `Error("MODULES env var is empty")`.
- For each name, `const loader = moduleRegistry[name]`; unknown → throw `Error(\`unknown module: ${name}\`)`.
- `const mod = (await loader()).default`.
- Validate `mod.name === name`.
- Validate each command via `validateCommand`.
- Return ordered array of module objects.
- `async function buildRegistry(env)`:
- Call `loadModules`.
- Init four maps: `publicCommands`, `protectedCommands`, `privateCommands`, `allCommands`.
- For each module (in `env.MODULES` order):
- If `init`: `await mod.init({ db: createStore(mod.name, env), env })`. Wrap in try/catch; rethrow with module name prefix.
- For each cmd:
- If `allCommands.has(cmd.name)` → throw `Error(\`command conflict: /${cmd.name} registered by both ${existing.module.name} and ${mod.name}\`)`.
- `allCommands.set(cmd.name, { module: mod, cmd, visibility: cmd.visibility });`
- Push into the visibility-specific map too.
- `currentRegistry = { publicCommands, protectedCommands, privateCommands, allCommands, modules };`
- Return it.
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
- `export function resetRegistry() { currentRegistry = null; }` (test helper; phase-08 uses it in `beforeEach`).
4. `src/modules/dispatcher.js`:
- `export async function installDispatcher(bot, env)`:
- `const reg = await buildRegistry(env);`
- `for (const { cmd } of reg.allCommands.values()) { bot.command(cmd.name, cmd.handler); }`
- Return `reg` (caller may ignore; `/help` reads via `getCurrentRegistry()`).
5. Update `src/bot.js` to `await installDispatcher(bot, env)` before returning. This makes `getBot` async — update `src/index.js` to `await getBot(env)`.
6. Lint clean.
## Todo List
- [ ] `src/modules/index.js` static import map
- [ ] `src/modules/validate-command.js` (uniform regex + description length cap)
- [ ] `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry` + `resetRegistry`
- [ ] `src/modules/dispatcher.js` — single loop, all visibilities via `bot.command()`
- [ ] Update `src/bot.js` + `src/index.js` for async `getBot`
- [ ] Unified-namespace conflict detection
- [ ] Lint clean
## Success Criteria
- With `MODULES="util"` and util exposing one public cmd, `wrangler dev` + mocked webhook update correctly routes.
- Conflict test (phase-08): two fake modules both register `/foo` (regardless of visibility) → `buildRegistry` throws with both module names and the command name in the message.
- Unknown module name in `MODULES` → throws with clear message.
- Registry built once per warm instance (memoized via `getBot`).
- `/konami` (a private command) responds when typed; does NOT appear in `/help` output; does NOT appear in Telegram's `/` menu after `scripts/register.js` runs.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Dynamic import breaks bundler tree-shaking | N/A | N/A | Mitigated by static map design |
| `init` throws → entire bot broken | Med | High | Wrap in try/catch, log module name, re-throw with context |
| Module mutates passed `env` | Low | Med | Pass `env` as-is; document contract: modules must not mutate |
| Private command accidentally listed in `setMyCommands` | Low | Med | `scripts/register.js` reads `publicCommands` only (not `allCommands`) |
| Description > 256 chars breaks `setMyCommands` payload | Low | Low | Validator enforces cap at module load |
## Security Considerations
- A private command with the same name as a public one in another module would be ambiguous. Unified conflict detection blocks this at load.
- Module authors get an auto-prefixed DB store — they CANNOT read other modules' data unless they reconstruct prefixes manually (code review responsibility).
- `init` errors must log module name for audit, never the env object (may contain secrets).
- Private commands do NOT provide security — anyone who guesses the name can invoke them. They are for discoverability control, not access control.
## Next Steps
- Phase 05 implements util module (`/info`, `/help`) consuming the registry.
- Phase 06 adds stub modules proving the plugin system end-to-end.

View File

@@ -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
<b>util</b>
/info — Show chat/thread/sender IDs
/help — Show this help
<b>wordle</b>
/wordle — Play wordle
/wstats — Stats (protected)
...
```
- Modules with zero visible commands omitted entirely.
- Private commands skipped.
- Protected commands appended with `" (protected)"` suffix so users understand the distinction.
- Module order: insertion order of `env.MODULES`.
- Sent with `parse_mode: "HTML"`.
- Both commands are **public** visibility.
### Non-functional
- `src/modules/util/index.js` < 150 LOC.
- No new deps.
## Architecture
```
src/modules/util/
├── index.js # module default export
├── info-command.js # /info handler
├── help-command.js # /help handler + HTML renderer
```
Split by command file for clarity. Each command file < 80 LOC.
Registry access: `src/modules/registry.js` exports `getCurrentRegistry()` returning the memoized instance (set by `buildRegistry`). `/help` calls this at handler time.
## Related Code Files
### Create
- `src/modules/util/index.js`
- `src/modules/util/info-command.js`
- `src/modules/util/help-command.js`
- `src/util/escape-html.js` (shared escaper)
### Modify
- `src/modules/registry.js` — add `getCurrentRegistry()` exported getter
### Delete
- none
## Implementation Steps
1. `src/util/escape-html.js`:
```js
export function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
```
2. `src/modules/registry.js`:
- Add module-scope `let currentRegistry = null;`
- `buildRegistry` assigns to it before returning.
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
3. `src/modules/util/info-command.js`:
- Exports `{ name: "info", visibility: "public", description: "Show chat/thread/sender IDs", handler }`.
- Handler reads `ctx.chat?.id`, `ctx.message?.message_thread_id`, `ctx.from?.id`.
- Reply: `\`chat id: ${chatId}\nthread id: ${threadId ?? "n/a"}\nsender id: ${senderId}\``.
4. `src/modules/util/help-command.js`:
- Exports `{ name: "help", visibility: "public", description: "Show this help", handler }`.
- Handler:
- `const reg = getCurrentRegistry();`
- Build `Map<moduleName, string[]>` of lines.
- Iterate `reg.publicCommands` + `reg.protectedCommands` (in insertion order; `Map` preserves it).
- For each entry, push `"/" + cmd.name + " — " + escapeHtml(cmd.description) + (visibility === "protected" ? " (protected)" : "")` under its module name.
- Iterate `reg.modules` in order; for each with non-empty lines, emit `<b>${escapeHtml(moduleName)}</b>\n` + lines joined by `\n` + blank line.
- `await ctx.reply(text, { parse_mode: "HTML" });`
5. `src/modules/util/index.js`:
- `import info from "./info-command.js"; import help from "./help-command.js";`
- `export default { name: "util", commands: [info, help] };`
6. Add `util` to `wrangler.toml` `MODULES` default if not already: `MODULES = "util,wordle,loldle,misc"`.
7. Lint.
## Todo List
- [ ] `escape-html.js`
- [ ] `getCurrentRegistry()` in registry.js
- [ ] `info-command.js`
- [ ] `help-command.js` renderer
- [ ] `util/index.js`
- [ ] Manual smoke test via `wrangler dev`
- [ ] Lint clean
## Success Criteria
- `/info` in a 1:1 chat shows chat id + "thread id: n/a" + sender id.
- `/info` in a forum topic shows a real thread id.
- `/help` shows `util` section with both commands, and stub module sections (after phase-06).
- `/help` does NOT show private commands.
- Protected commands show `(protected)` suffix.
- HTML injection attempt in module description (e.g. `<script>`) renders literally.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Registry not built before handler fires | Low | Med | `getCurrentRegistry()` throws with clear error; dispatcher ensures build before bot handles updates |
| `/help` output exceeds Telegram 4096-char limit | Low (at this scale) | Low | Phase-09 mentions future pagination; current scale is fine |
| Module descriptions contain raw HTML | Med | Med | `escapeHtml` all descriptions + module names |
| Missing `message_thread_id` crashes | Low | Low | `?? "n/a"` fallback |
## Security Considerations
- Escape ALL user-influenced strings (module names, descriptions) — even though modules are trusted code, future-proofing against dynamic registration.
- `/info` reveals sender id — that's the point. Document in help text that it's a debugging tool.
## Next Steps
- Phase 06 adds the stub modules that populate the `/help` output.
- Phase 08 tests `/help` rendering against a synthetic registry.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
---
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.

View File

@@ -1,102 +0,0 @@
---
phase: 1
title: "Symbol Registry + Formatters"
status: Pending
priority: P2
effort: 45m
---
# Phase 1: Symbol Registry + Formatters
## Context
- [Module pattern](../../docs/adding-a-module.md)
- [KV interface](../../src/db/kv-store-interface.js)
## Overview
Two pure-data/pure-function files with zero side effects. Foundation for all other phases.
## File: `src/modules/trading/symbols.js`
### Requirements
- Export `SYMBOLS` — frozen object keyed by uppercase symbol name
- Export `CURRENCIES` — frozen Set of supported fiat: `VND`, `USD`
- Export helper `getSymbol(name)` — case-insensitive lookup, returns entry or `undefined`
- Export helper `listSymbols()` — returns formatted string of all symbols grouped by category
### Data shape
```js
export const SYMBOLS = Object.freeze({
// crypto
BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" },
ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" },
SOL: { category: "crypto", apiId: "solana", label: "Solana" },
// stock (Vietnam)
TCB: { category: "stock", apiId: "TCB", label: "Techcombank" },
VPB: { category: "stock", apiId: "VPB", label: "VPBank" },
FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" },
VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" },
HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" },
// others
GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" },
});
```
### Implementation steps
1. Create `src/modules/trading/symbols.js`
2. Define `SYMBOLS` constant with all entries above
3. Define `CURRENCIES = Object.freeze(new Set(["VND", "USD"]))`
4. `getSymbol(name)``SYMBOLS[name.toUpperCase()]` with guard for falsy input
5. `listSymbols()` — group by category, format as `SYMBOL — Label` per line
6. Keep under 60 lines
---
## File: `src/modules/trading/format.js`
### Requirements
- `formatVND(n)` — integer, dot thousands separator, suffix ` VND`. Example: `15.000.000 VND`
- `formatUSD(n)` — 2 decimals, comma thousands, prefix `$`. Example: `$1,234.56`
- `formatCrypto(n)` — up to 8 decimals, strip trailing zeros. Example: `0.00125`
- `formatStock(n)` — integer (Math.floor), no decimals. Example: `150`
- `formatAmount(n, symbol)` — dispatcher: looks up symbol category, calls correct formatter
- `formatCurrency(n, currency)` — VND or USD formatter based on currency string
### Implementation steps
1. Create `src/modules/trading/format.js`
2. `formatVND`: `Math.round(n).toLocaleString("vi-VN")` + ` VND` — verify dot separator (or manual impl for CF Workers locale support)
3. `formatUSD`: `n.toFixed(2)` with comma grouping + `$` prefix
4. `formatCrypto`: `parseFloat(n.toFixed(8)).toString()` to strip trailing zeros
5. `formatStock`: `Math.floor(n).toString()`
6. `formatAmount`: switch on `getSymbol(sym).category`
7. `formatCurrency`: switch on currency string
8. Keep under 80 lines
### Edge cases
- `formatVND(0)` -> `0 VND`
- `formatCrypto(1.00000000)` -> `1`
- `formatAmount` with unknown symbol -> return raw number string
- CF Workers may not have full locale support — implement manual dot-separator for VND
### Failure modes
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| `toLocaleString` not available in CF Workers runtime | Medium | Medium | Manual formatter fallback: split on groups of 3, join with `.` |
## Success criteria
- [ ] `SYMBOLS` has 9 entries across 3 categories
- [ ] `getSymbol("btc")` returns BTC entry (case-insensitive)
- [ ] `getSymbol("NOPE")` returns `undefined`
- [ ] `formatVND(15000000)` === `"15.000.000 VND"`
- [ ] `formatCrypto(0.001)` === `"0.001"` (no trailing zeros)
- [ ] `formatStock(1.7)` === `"1"`
- [ ] Both files under 200 lines

View File

@@ -1,120 +0,0 @@
---
phase: 2
title: "Price Fetching + Caching"
status: Pending
priority: P2
effort: 1h
---
# Phase 2: Price Fetching + Caching
## Context
- [KV store interface](../../src/db/kv-store-interface.js) — `putJSON` supports `expirationTtl`
- [Symbol registry](phase-01-symbols-and-format.md)
## Overview
Fetches live prices from three free APIs, merges into a single cache object in KV with 60s TTL. All prices normalized to VND.
## File: `src/modules/trading/prices.js`
### Data shape — KV key `prices:latest`
```js
{
ts: 1713100000000, // Date.now() at fetch time
crypto: { BTC: 2500000000, ETH: 75000000, SOL: 3500000 },
stock: { TCB: 25000, VPB: 18000, FPT: 120000, VNM: 70000, HPG: 28000 },
forex: { USD: 25400 }, // 1 USD = 25400 VND
others: { GOLD: 75000000 } // per troy oz in VND
}
```
### Exports
- `fetchPrices(db)` — fetch all APIs in parallel, merge, cache in KV, return merged object
- `getPrices(db)` — cache-first: read KV, if exists and < 60s old return it, else call `fetchPrices`
- `getPrice(db, symbol)` — convenience: calls `getPrices`, looks up by symbol + category
- `getForexRate(db, currency)` — returns VND equivalent of 1 unit of currency
### API calls
1. **Crypto + Gold (CoinGecko)**
```
GET https://api.coingecko.com/api/v3/simple/price
?ids=bitcoin,ethereum,solana,pax-gold
&vs_currencies=vnd
```
Response: `{ bitcoin: { vnd: N }, ... }`
Map `apiId -> VND price` using SYMBOLS registry.
2. **Vietnam stocks (TCBS)**
For each stock symbol, fetch:
```
GET https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term
?ticker={SYMBOL}&type=stock&resolution=D&countBack=1&to={unix_seconds}
```
Response: `{ data: [{ close: N }] }` — price in VND (already VND, multiply by 1000 for actual price per TCBS convention).
Fetch all 5 stocks in parallel via `Promise.allSettled`.
3. **Forex (Exchange Rate API)**
```
GET https://open.er-api.com/v6/latest/USD
```
Response: `{ rates: { VND: N } }`
Store as `forex.USD = rates.VND`.
### Implementation steps
1. Create `src/modules/trading/prices.js`
2. Implement `fetchCrypto()` — single CoinGecko call, map apiId->VND
3. Implement `fetchStocks()` — `Promise.allSettled` for all stock symbols, extract `close * 1000`
4. Implement `fetchForex()` — single call, extract VND rate
5. Implement `fetchPrices(db)`:
- `Promise.allSettled([fetchCrypto(), fetchStocks(), fetchForex()])`
- Merge results, set `ts: Date.now()`
- `db.putJSON("prices:latest", merged)` — no expirationTtl (we manage staleness manually)
- Return merged
6. Implement `getPrices(db)`:
- `db.getJSON("prices:latest")`
- If exists and `Date.now() - ts < 60_000`, return cached
- Else call `fetchPrices(db)`
7. Implement `getPrice(db, symbol)`:
- Get symbol info from registry
- Get prices via `getPrices(db)`
- Return `prices[category][symbol]`
8. Implement `getForexRate(db, currency)`:
- If `currency === "VND"` return 1
- If `currency === "USD"` return `prices.forex.USD`
### Edge cases
- Any single API fails -> `Promise.allSettled` catches it, use partial results + stale cache for missing category
- All APIs fail -> if cache < 5 min old, use it; else throw with user-friendly message
- CoinGecko rate-limited (30 calls/min free tier) -> 60s cache makes this safe for normal use
- TCBS returns empty data array -> skip that stock, log warning
### Failure modes
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| CoinGecko rate limit | Low (60s cache) | Medium | Cache prevents rapid re-fetch; degrade gracefully |
| TCBS API changes response shape | Medium | Medium | Defensive access `data?.[0]?.close`; skip stock on parse failure |
| Forex API down | Low | Low | USD conversion unavailable; VND operations still work |
| All APIs down simultaneously | Very Low | High | Fall back to cache if < 5min; clear error message if no cache |
### Security
- No API keys needed (all free public endpoints)
- No user data sent to external APIs
## Success criteria
- [ ] `fetchPrices` calls 3 APIs in parallel, returns merged object
- [ ] `getPrices` returns cached data within 60s window
- [ ] `getPrices` refetches when cache is stale
- [ ] Partial API failure doesn't crash — missing data logged, rest returned
- [ ] `getPrice(db, "BTC")` returns a number (VND)
- [ ] `getForexRate(db, "VND")` returns 1
- [ ] File under 200 lines

View File

@@ -1,90 +0,0 @@
---
phase: 3
title: "Portfolio Data Layer"
status: Pending
priority: P2
effort: 45m
---
# Phase 3: Portfolio Data Layer
## Context
- [KV interface](../../src/db/kv-store-interface.js) — `getJSON`/`putJSON`
- [Symbols](phase-01-symbols-and-format.md)
## Overview
CRUD operations on per-user portfolio KV objects. Pure data logic, no Telegram ctx dependency.
## File: `src/modules/trading/portfolio.js`
### Data shape — KV key `user:{telegramId}`
```js
{
currency: { VND: 0, USD: 0 },
stock: {}, // e.g. { TCB: 10, FPT: 5 }
crypto: {}, // e.g. { BTC: 0.5, ETH: 2.1 }
others: {}, // e.g. { GOLD: 0.1 }
totalvnd: 0 // cumulative VND topped up (cost basis)
}
```
### Exports
- `getPortfolio(db, userId)` — returns portfolio object; inits empty if first-time user
- `savePortfolio(db, userId, portfolio)` — writes to KV
- `addCurrency(portfolio, currency, amount)` — mutates + returns portfolio
- `deductCurrency(portfolio, currency, amount)` — returns `{ ok, portfolio, balance }`. `ok=false` if insufficient
- `addAsset(portfolio, symbol, qty)` — adds to correct category bucket
- `deductAsset(portfolio, symbol, qty)` — returns `{ ok, portfolio, held }`. `ok=false` if insufficient
- `emptyPortfolio()` — returns fresh empty portfolio object
### Implementation steps
1. Create `src/modules/trading/portfolio.js`
2. `emptyPortfolio()` — returns deep clone of default shape
3. `getPortfolio(db, userId)`:
- `db.getJSON("user:" + userId)`
- If null, return `emptyPortfolio()`
- Validate shape: ensure all category keys exist (migration-safe)
4. `savePortfolio(db, userId, portfolio)`:
- `db.putJSON("user:" + userId, portfolio)`
5. `addCurrency(portfolio, currency, amount)`:
- `portfolio.currency[currency] += amount`
- Return portfolio
6. `deductCurrency(portfolio, currency, amount)`:
- Check `portfolio.currency[currency] >= amount`
- If not, return `{ ok: false, portfolio, balance: portfolio.currency[currency] }`
- Deduct, return `{ ok: true, portfolio }`
7. `addAsset(portfolio, symbol, qty)`:
- Lookup category from SYMBOLS
- `portfolio[category][symbol] = (portfolio[category][symbol] || 0) + qty`
8. `deductAsset(portfolio, symbol, qty)`:
- Lookup category, check held >= qty
- If not, return `{ ok: false, portfolio, held: portfolio[category][symbol] || 0 }`
- Deduct (remove key if 0), return `{ ok: true, portfolio }`
### Edge cases
- First-time user -> `getPortfolio` returns empty, no KV write until explicit `savePortfolio`
- Deduct exactly full balance -> ok, set to 0
- Deduct asset to exactly 0 -> delete key from object (keep portfolio clean)
- Portfolio shape migration: if old KV entry missing a category key, fill with `{}`
### Failure modes
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Race condition (two concurrent commands) | Low (paper trading) | Low | Acceptable for paper trading; last write wins |
| KV write fails | Low | Medium | Command handler catches, reports error to user |
## Success criteria
- [ ] `getPortfolio` returns empty portfolio for new user
- [ ] `addCurrency` + `deductCurrency` correctly modify balances
- [ ] `deductCurrency` returns `ok: false` when insufficient
- [ ] `addAsset` / `deductAsset` work across all 3 categories
- [ ] `deductAsset` to zero removes key from category object
- [ ] File under 120 lines

View File

@@ -1,134 +0,0 @@
---
phase: 4
title: "Command Handlers + Module Entry"
status: Pending
priority: P2
effort: 1.5h
depends_on: [1, 2, 3]
---
# Phase 4: Command Handlers + Module Entry
## Context
- [Module pattern](../../src/modules/misc/index.js) — reference for `init`, command shape
- [Registry types](../../src/modules/registry.js) — `BotModule` typedef
- Phases 1-3 provide: symbols, prices, portfolio, format
## Overview
Thin `index.js` wiring five `trade_*` commands. Each handler: parse args -> validate -> call data layer -> format reply.
## File: `src/modules/trading/index.js`
### Module shape
```js
const tradingModule = {
name: "trading",
init: async ({ db: store }) => { db = store; },
commands: [
{ name: "trade_topup", visibility: "public", description: "...", handler: handleTopup },
{ name: "trade_buy", visibility: "public", description: "...", handler: handleBuy },
{ name: "trade_sell", visibility: "public", description: "...", handler: handleSell },
{ name: "trade_convert", visibility: "public", description: "...", handler: handleConvert },
{ name: "trade_stats", visibility: "public", description: "...", handler: handleStats },
],
};
export default tradingModule;
```
### Command implementations
#### `trade_topup <amount> [currency=VND]`
1. Parse: `ctx.match.trim().split(/\s+/)` -> `[amountStr, currencyStr?]`
2. Validate: amount > 0, numeric; currency in CURRENCIES (default VND)
3. Get portfolio, add currency
4. If currency !== VND: fetch forex rate, add `amount * rate` to `totalvnd`
5. If currency === VND: add amount to `totalvnd`
6. Save portfolio
7. Reply: `Topped up {formatCurrency(amount, currency)}. Balance: {formatCurrency(balance, currency)}`
#### `trade_buy <amount> <symbol>`
1. Parse args: amount + symbol
2. Validate: amount > 0; symbol exists in SYMBOLS
3. If stock: amount must be integer (`Number.isInteger(parseFloat(amount))`)
4. Fetch price via `getPrice(db, symbol)`
5. Cost = amount * price (in VND)
6. Deduct VND from portfolio; if insufficient -> error with current balance
7. Add asset to portfolio
8. Save, reply with purchase summary
#### `trade_sell <amount> <symbol>`
1. Parse + validate (same as buy)
2. Deduct asset; if insufficient -> error with current holding
3. Fetch price, revenue = amount * price
4. Add VND to portfolio
5. Save, reply with sale summary
#### `trade_convert <amount> <from> <to>`
1. Parse: amount, from-currency, to-currency
2. Validate: both in CURRENCIES, from !== to, amount > 0
3. Deduct `from` currency; if insufficient -> error
4. Fetch forex rates, compute converted amount
5. Add `to` currency
6. Save, reply with conversion summary
#### `trade_stats`
1. Get portfolio
2. Fetch all prices
3. For each category, compute current VND value
4. Sum all = total current value
5. P&L = total current value + currency.VND - totalvnd
6. Reply with formatted breakdown table
### Arg parsing helper
Extract into a local `parseArgs(ctx, specs)` at top of file:
- `specs` = array of `{ name, required, type: "number"|"string", default? }`
- Returns parsed object or null (replies usage hint on failure)
- Keeps handlers DRY
### Implementation steps
1. Create `src/modules/trading/index.js`
2. Module-level `let db = null;` set in `init`
3. Implement `parseArgs` helper (inline, ~20 lines)
4. Implement each handler function (~25-35 lines each)
5. Wire into `commands` array
6. Ensure file stays under 200 lines. If approaching limit, extract `parseArgs` to a `helpers.js` file
### Edge cases
| Input | Response |
|-------|----------|
| `/trade_buy` (no args) | Usage: `/trade_buy <amount> <symbol>` |
| `/trade_buy -5 BTC` | Amount must be positive |
| `/trade_buy 0.5 NOPE` | Unknown symbol. Supported: BTC, ETH, ... |
| `/trade_buy 1.5 TCB` | Stock quantities must be whole numbers |
| `/trade_buy 1 BTC` (no VND) | Insufficient VND. Balance: 0 VND |
| `/trade_sell 10 BTC` (only have 5) | Insufficient BTC. You have: 5 |
| `/trade_convert 100 VND VND` | Cannot convert to same currency |
| API failure during buy | Could not fetch price. Try again later. |
### Failure modes
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| File exceeds 200 lines | Medium | Low | Extract parseArgs to helpers.js |
| Price fetch fails mid-trade | Low | Medium | Catch, reply error, don't modify portfolio |
| User sends concurrent commands | Low | Low | Last write wins; acceptable for paper trading |
## Success criteria
- [ ] All 5 commands registered as public
- [ ] Each command validates input and replies helpful errors
- [ ] Buy/sell correctly modify both VND and asset balances
- [ ] Convert works between VND and USD
- [ ] Stats shows breakdown with P&L
- [ ] File under 200 lines (or split cleanly)

View File

@@ -1,67 +0,0 @@
---
phase: 5
title: "Integration Wiring"
status: Pending
priority: P2
effort: 15m
depends_on: [4]
---
# Phase 5: Integration Wiring
## Overview
Two one-line edits to register the trading module in the bot framework.
## File changes
### `src/modules/index.js`
Add one line to `moduleRegistry`:
```js
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
trading: () => import("./trading/index.js"), // <-- add
};
```
### `wrangler.toml`
Append `trading` to MODULES var:
```toml
[vars]
MODULES = "util,wordle,loldle,misc,trading"
```
### `.env.deploy` (manual — not committed)
User must also add `trading` to MODULES in `.env.deploy` for the register script to pick up public commands.
## Implementation steps
1. Edit `src/modules/index.js` — add `trading` entry
2. Edit `wrangler.toml` — append `,trading` to MODULES
3. Run `npm run lint` to verify
4. Run `npm test` to verify existing tests still pass (trading tests in Phase 6)
## Verification
- `npm test` — all 56 existing tests pass
- `npm run lint` — no errors
- `npm run register:dry` — trading commands appear in output
## Rollback
Remove `trading` from both files. Existing KV data becomes inert (never read).
## Success criteria
- [ ] `src/modules/index.js` has trading entry
- [ ] `wrangler.toml` MODULES includes trading
- [ ] Existing tests pass unchanged
- [ ] Lint passes

View File

@@ -1,140 +0,0 @@
---
phase: 6
title: "Tests"
status: Pending
priority: P2
effort: 1.5h
depends_on: [1, 2, 3, 4]
---
# Phase 6: Tests
## Overview
Unit tests for all trading module files. Use existing test infrastructure: vitest, `makeFakeKv`, fake bot helpers.
## Test files
### `tests/modules/trading/symbols.test.js`
| Test | Assertion |
|------|-----------|
| SYMBOLS has 9 entries | `Object.keys(SYMBOLS).length === 9` |
| Every entry has category, apiId, label | Shape check |
| getSymbol case-insensitive | `getSymbol("btc")` === `getSymbol("BTC")` |
| getSymbol unknown returns undefined | `getSymbol("NOPE")` === `undefined` |
| getSymbol falsy input returns undefined | `getSymbol("")`, `getSymbol(null)` |
| listSymbols groups by category | Contains "crypto", "stock", "others" headers |
| CURRENCIES has VND and USD | Set membership |
### `tests/modules/trading/format.test.js`
| Test | Assertion |
|------|-----------|
| formatVND(15000000) | `"15.000.000 VND"` |
| formatVND(0) | `"0 VND"` |
| formatVND(500) | `"500 VND"` |
| formatUSD(1234.5) | `"$1,234.50"` |
| formatUSD(0) | `"$0.00"` |
| formatCrypto(0.001) | `"0.001"` |
| formatCrypto(1.00000000) | `"1"` |
| formatCrypto(0.12345678) | `"0.12345678"` |
| formatStock(1.7) | `"1"` |
| formatStock(100) | `"100"` |
| formatAmount dispatches correctly | BTC->crypto, TCB->stock, GOLD->others |
| formatCurrency dispatches | VND->formatVND, USD->formatUSD |
### `tests/modules/trading/portfolio.test.js`
| Test | Assertion |
|------|-----------|
| emptyPortfolio has correct shape | All keys present, zeroed |
| getPortfolio returns empty for new user | Uses fake KV |
| getPortfolio returns stored data | Pre-seed KV |
| addCurrency increases balance | `addCurrency(p, "VND", 1000)` |
| deductCurrency succeeds | Sufficient balance |
| deductCurrency fails insufficient | Returns `{ ok: false, balance }` |
| deductCurrency exact balance | Returns `{ ok: true }`, balance = 0 |
| addAsset correct category | BTC -> crypto, TCB -> stock |
| deductAsset succeeds | Sufficient holding |
| deductAsset fails insufficient | Returns `{ ok: false, held }` |
| deductAsset to zero removes key | Key deleted from category |
| savePortfolio round-trips | Write then read |
### `tests/modules/trading/prices.test.js`
Strategy: Mock `fetch` globally in vitest to return canned API responses. Do NOT call real APIs.
| Test | Assertion |
|------|-----------|
| fetchPrices merges all 3 sources | Correct shape with all categories |
| getPrices returns cache when fresh | Only 1 fetch call if called twice within 60s |
| getPrices refetches when stale | Simulated stale timestamp |
| getPrice returns correct value | `getPrice(db, "BTC")` returns mocked VND price |
| getForexRate VND returns 1 | No fetch needed |
| getForexRate USD returns rate | From mocked forex response |
| Partial API failure | One API rejects; others still returned |
| All APIs fail, stale cache < 5min | Returns stale cache |
| All APIs fail, no cache | Throws with user-friendly message |
### `tests/modules/trading/commands.test.js`
Strategy: Integration-style tests. Use `makeFakeKv` for real KV behavior. Mock `fetch` for price APIs. Simulate grammY `ctx` with `ctx.match` and `ctx.reply` spy.
Helper: `makeCtx(match, userId?)` — returns `{ match, from: { id: userId }, reply: vi.fn() }`
| Test | Assertion |
|------|-----------|
| trade_topup adds VND | Portfolio balance increases |
| trade_topup adds USD + totalvnd | USD balance + totalvnd updated |
| trade_topup no args | Reply contains "Usage" |
| trade_topup negative amount | Reply contains error |
| trade_buy deducts VND, adds asset | Both modified |
| trade_buy stock fractional | Reply contains "whole numbers" |
| trade_buy insufficient VND | Reply contains balance |
| trade_buy unknown symbol | Reply lists supported symbols |
| trade_sell adds VND, deducts asset | Both modified |
| trade_sell insufficient holding | Reply contains current holding |
| trade_convert VND->USD | Both currencies modified |
| trade_convert same currency | Error message |
| trade_stats empty portfolio | Shows zero values |
| trade_stats with holdings | Shows breakdown + P&L |
| Price API failure during buy | Error message, portfolio unchanged |
## Implementation steps
1. Create test directory: `tests/modules/trading/`
2. Create `symbols.test.js` (~40 lines)
3. Create `format.test.js` (~60 lines)
4. Create `portfolio.test.js` (~80 lines)
5. Create `prices.test.js` (~90 lines) — mock global fetch
6. Create `commands.test.js` (~120 lines) — mock fetch + fake KV
7. Run `npm test` — all pass
8. Run `npm run lint` — clean
### Fetch mocking pattern
```js
import { vi, beforeEach } from "vitest";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => mockFetch.mockReset());
// Per-test setup:
mockFetch.mockImplementation((url) => {
if (url.includes("coingecko")) return Response.json({ bitcoin: { vnd: 2500000000 } });
if (url.includes("tcbs")) return Response.json({ data: [{ close: 25 }] });
if (url.includes("er-api")) return Response.json({ rates: { VND: 25400 } });
});
```
## Success criteria
- [ ] All new tests pass
- [ ] All 56 existing tests still pass
- [ ] Coverage: every public export of symbols, format, portfolio, prices tested
- [ ] Command handler tests cover happy path + all error branches
- [ ] Lint passes
- [ ] No real HTTP calls in tests

View File

@@ -1,56 +0,0 @@
---
title: "Fake Trading Module"
description: "Paper trading module for Telegram bot — virtual portfolio with crypto, stocks, forex, gold"
status: pending
priority: P2
effort: 6h
branch: main
tags: [feature, module, trading]
created: 2026-04-14
---
# Fake Trading Module
## Phases
| # | Phase | Status | Effort | Files |
|---|-------|--------|--------|-------|
| 1 | [Symbol registry + formatters](phase-01-symbols-and-format.md) | Pending | 45m | `src/modules/trading/symbols.js`, `src/modules/trading/format.js` |
| 2 | [Price fetching + caching](phase-02-prices.md) | Pending | 1h | `src/modules/trading/prices.js` |
| 3 | [Portfolio data layer](phase-03-portfolio.md) | Pending | 45m | `src/modules/trading/portfolio.js` |
| 4 | [Command handlers + module entry](phase-04-commands.md) | Pending | 1.5h | `src/modules/trading/index.js` |
| 5 | [Integration wiring](phase-05-wiring.md) | Pending | 15m | `src/modules/index.js`, `wrangler.toml` |
| 6 | [Tests](phase-06-tests.md) | Pending | 1.5h | `tests/modules/trading/*.test.js` |
## Dependencies
```
Phase 1 ──┐
Phase 2 ──┼──► Phase 4 ──► Phase 5
Phase 3 ──┘ │
Phase 1,2,3,4 ────────────► Phase 6
```
## Data flow
```
User /trade_buy 0.5 BTC
-> index.js parses args, validates
-> prices.js fetches BTC/VND (cache-first, 60s TTL)
-> portfolio.js reads user KV, checks VND balance
-> portfolio.js deducts VND, adds BTC qty, writes KV
-> format.js renders reply
-> ctx.reply()
```
## Rollback
Remove `trading` from `MODULES` in `wrangler.toml` + `src/modules/index.js`. KV data inert.
## Key decisions
- VND sole settlement currency for buy/sell
- Single KV object per user (acceptable race for paper trading)
- 60s price cache TTL via KV putJSON with expirationTtl
- Gold via PAX Gold on CoinGecko (troy ounces)
- Stocks integer-only quantities

View File

@@ -1,105 +0,0 @@
# Phase 01 — D1 Setup
**Priority:** P0 (blocker for 02/03/04)
**Status:** Complete
## Overview
Wire Cloudflare D1 into the framework: binding, per-module migrations, `SqlStore` factory mirroring the `KVStore` shape, Miniflare-backed tests.
## Key Insights
- D1 is SQLite at the edge; prepared statements + `db.prepare().bind().all()/first()/run()`.
- `vitest-pool-workers` (or plain Miniflare) exposes D1 in tests without real Cloudflare calls.
- Per-module table prefixing mirrors the existing KV prefixing — module authors never touch raw `env.DB`.
## Requirements
**Functional**
- Module init receives `sql` alongside `db` in `init({ db, sql, env })`.
- `sql.prepare(query, ...binds)` / `sql.run(query, ...binds)` / `sql.all(query, ...binds)` / `sql.first(query, ...binds)`.
- Table names referenced in queries are left literal — authors write `trading_trades` directly (prefix is convention, not rewriting).
- `sql.tablePrefix` exposed for authors who want to interpolate.
- Migrations auto-discovered from `src/modules/*/migrations/*.sql`, applied via `wrangler d1 migrations apply` in deploy script.
**Non-functional**
- Zero overhead when a module does not use SQL.
- Tests run fully offline against Miniflare.
## Architecture
```
src/db/
├── kv-store-interface.js # existing
├── cf-kv-store.js # existing
├── create-store.js # existing (KV)
├── sql-store-interface.js # NEW — JSDoc typedef
├── cf-sql-store.js # NEW — wraps env.DB
└── create-sql-store.js # NEW — factory, sets tablePrefix = `${moduleName}_`
```
`createSqlStore(moduleName, env)` returns an object exposing `prepare`, `run`, `all`, `first`, `batch`, `tablePrefix`.
## Related Code Files
**Create**
- `src/db/sql-store-interface.js`
- `src/db/cf-sql-store.js`
- `src/db/create-sql-store.js`
- `tests/db/create-sql-store.test.js`
- `tests/fakes/fake-d1.js` (Miniflare D1 helper for tests)
**Modify**
- `src/modules/registry.js` — pass `sql` into `init({ db, sql, env })`
- `src/modules/dispatcher.js` — same
- `wrangler.toml` — add `[[d1_databases]]` block, `migrations_dir` optional
- `package.json` — add `db:migrate` script: `wrangler d1 migrations apply miti99bot-db --remote`; chain into `deploy`
- `scripts/register.js` — no change needed, but verify no breakage
## Implementation Steps
1. Create D1 database: `npx wrangler d1 create miti99bot-db`. Record UUID in `wrangler.toml`.
2. Author `sql-store-interface.js` with JSDoc `@typedef` for `SqlStore`.
3. Implement `cf-sql-store.js` — thin wrapper around `env.DB.prepare()`.
4. Implement `create-sql-store.js` — returns wrapper + `tablePrefix`.
5. Update `registry.js` + `dispatcher.js` to pass `sql` into module `init` + command handler contexts (via `ctx.sql`? **decision below**).
6. Add migration discovery: walk `src/modules/*/migrations/` at deploy time, consolidate into a central `migrations/` or use wrangler's default per-dir.
7. Wire `db:migrate` into `npm run deploy`: `wrangler deploy && npm run db:migrate && npm run register`.
8. Add `fake-d1.js` using `@miniflare/d1` or `better-sqlite3`-backed fake.
9. Tests: `create-sql-store.test.js` verifying prefix exposure + basic CRUD.
## Open Decisions
- **Command handler access to `sql`:** expose via `ctx.sql` (grammY context extension in dispatcher) or require modules to close over `sql` captured in `init`? Lean **close over in init** — matches how `db` is currently used.
- **Migration runner:** wrangler's native `d1 migrations apply` requires a single `migrations_dir`. Options:
- (a) consolidate all per-module SQL into root `migrations/` at build time via a prebuild script.
- (b) custom runner script that applies each `src/modules/*/migrations/*.sql` in order.
- **Lean (b)** — keeps per-module locality.
## Todo List
- [x] Create D1 database + update `wrangler.toml`
- [x] `sql-store-interface.js` with typedefs
- [x] `cf-sql-store.js` implementation
- [x] `create-sql-store.js` factory
- [x] Update `registry.js` init signature
- [x] Update `dispatcher.js` to pass `sql` (no change needed — delegates to buildRegistry)
- [x] Write custom migration runner at `scripts/migrate.js`
- [x] Wire into `npm run deploy`
- [x] `fake-d1.js` test helper
- [x] Unit tests for `create-sql-store`
## Success Criteria
- A module can define `init({ sql }) => sql.run("INSERT INTO mymod_foo VALUES (?)", "x")` and it works in dev + test + prod.
- `npm test` green.
- No regression in existing KV-only modules.
## Risks
- Wrangler migration tooling may not support per-module layout → fallback to custom runner.
- D1 read-after-write consistency in eventually-consistent replicas — document for module authors.
## Next Steps
- Phase 02 can start once `sql` threads through `init`.

View File

@@ -1,92 +0,0 @@
# Phase 02 — Cron Wiring
**Priority:** P0
**Status:** Complete
**Depends on:** Phase 01
## Overview
Add Cloudflare Cron Triggers support to the module framework. Modules can declare `crons: [{ schedule, handler }]` alongside `commands`.
## Requirements
**Functional**
- Module contract extended with optional `crons[]`.
- Each cron entry: `{ schedule: string, name: string, handler: async (event, ctx) => void }`.
- `schedule` is a cron expression (e.g. `"0 1 * * *"`).
- `name` required for logging + conflict detection (unique within module).
- `handler` receives `(event, { db, sql, env })`.
- `src/index.js` exports `scheduled(event, env, ctx)` in addition to `fetch`.
- `scheduled()` dispatches to all modules whose `schedule` matches `event.cron`.
- Multiple modules can share the same schedule — all their handlers fire.
- `wrangler.toml` requires `[triggers] crons = [...]` — populated by a build step OR manually (decision below).
**Non-functional**
- Errors in one cron handler do not block others (`Promise.allSettled`).
- Handler timeouts bounded by Workers cron execution limits (15min max).
## Architecture
```
Cron Trigger fires
src/index.js → scheduled(event, env, ctx)
getRegistry(env) ◄── reuses existing memoized registry
for each module.crons[] where entry.schedule === event.cron:
ctx.waitUntil(entry.handler(event, {
db: createStore(module.name, env),
sql: createSqlStore(module.name, env),
env,
}))
```
## Related Code Files
**Create**
- `src/modules/cron-dispatcher.js` — dispatches `event.cron` to matching handlers
- `tests/modules/cron-dispatcher.test.js`
**Modify**
- `src/index.js` — add `scheduled` export
- `src/modules/registry.js` — collect + validate `crons[]` per module; conflict check on `(module, cronName)` duplicates
- `src/modules/validate-command.js` → add `validate-cron.js` sibling
- `wrangler.toml` — add `[triggers] crons = ["0 1 * * *", ...]` (union of all schedules)
- `scripts/register.js` — no change (cron triggers are set by `wrangler deploy` from toml)
- Docs for module contract
## Open Decisions
- **`wrangler.toml` crons population:**
- (a) manual — module author adds schedule to toml when adding cron.
- (b) generated — prebuild script scans modules, writes toml triggers.
- **Lean (a)** for simplicity — YAGNI. Document in `adding-a-module.md`.
## Todo List
- [x] `cron-dispatcher.js`
- [x] `validate-cron.js`
- [x] Extend `registry.js` to surface `crons[]`
- [x] Add `scheduled` export in `src/index.js`
- [x] Update module contract JSDoc typedef
- [x] Unit tests for dispatcher (schedule match, fan-out, error isolation)
- [ ] Document in `docs/using-cron.md` (done in Phase 06)
## Success Criteria
- A module declaring `crons: [{ schedule: "*/5 * * * *", name: "tick", handler }]` has `handler` invoked every 5 min locally via `wrangler dev --test-scheduled` and in prod.
- Error in one handler doesn't prevent others.
- `npm test` green.
## Risks
- `wrangler dev --test-scheduled` integration — document the `curl "http://localhost:8787/__scheduled?cron=..."` pattern.
- Cold start on cron: registry memoization across fetch+scheduled invocations — ensure single shared cache.
## Next Steps
- Phase 04 (retention cron) consumes this.

View File

@@ -1,84 +0,0 @@
# Phase 03 — Trading Trade History
**Priority:** P1
**Status:** Complete
**Depends on:** Phase 01
## Overview
Persist every buy/sell in `trading_trades` table. Add `/history [n]` command to show last N trades for the caller (default 10, max 50).
## Requirements
**Functional**
- Every successful buy/sell inserts a row: `(id, user_id, symbol, side, qty, price_vnd, ts)`.
- `/history` → last 10 trades (newest first).
- `/history 25` → last 25 (clamp 1..50).
- Rendered as compact table (HTML-escaped).
- **No inline cap enforcement** — cleanup cron (Phase 04) handles it.
**Non-functional**
- Insert is fire-and-forget from the user's perspective but must complete before `ctx.reply` (use `await`).
- Failure to persist does NOT fail the trade — log + swallow (portfolio KV is source of truth for positions).
## Architecture
```
src/modules/trading/
├── index.js # export crons[] + commands[] (unchanged shape, new cron + new command)
├── handlers.js # buy/sell call recordTrade() after portfolio update
├── history.js # NEW — recordTrade(), listTrades(), /history handler, format
├── migrations/
│ └── 0001_trades.sql # NEW
```
### Schema (`trading_trades`)
```sql
CREATE TABLE trading_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('buy','sell')),
qty INTEGER NOT NULL,
price_vnd INTEGER NOT NULL,
ts INTEGER NOT NULL -- unix ms
);
CREATE INDEX idx_trading_trades_user_ts ON trading_trades(user_id, ts DESC);
CREATE INDEX idx_trading_trades_ts ON trading_trades(ts); -- for global FIFO trim
```
## Related Code Files
**Create**
- `src/modules/trading/history.js`
- `src/modules/trading/migrations/0001_trades.sql`
- `tests/modules/trading/history.test.js`
**Modify**
- `src/modules/trading/index.js` — register `/history` command, accept `sql` in init
- `src/modules/trading/handlers.js` — call `recordTrade()` on buy/sell
## Todo List
- [x] Migration SQL
- [x] `recordTrade(sql, { userId, symbol, side, qty, priceVnd })`
- [x] `listTrades(sql, userId, n)`
- [x] `/history` command handler + HTML formatter
- [x] Wire into buy/sell handlers
- [x] Tests (Miniflare D1): record + list + max-cap clamp
## Success Criteria
- Buy + sell produce rows in `trading_trades`.
- `/history` returns last 10 newest-first.
- `/history 50` returns 50. `/history 999` clamps to 50. `/history 0` falls back to default.
- Persistence failure is logged but does not break the trade reply.
## Risks
- D1 write latency inside a user-facing handler — measured in tests; if >300ms, consider `ctx.waitUntil(insert)` non-blocking.
## Next Steps
- Phase 04 adds the retention cron consuming this table.

View File

@@ -1,86 +0,0 @@
# Phase 04 — Retention Cron
**Priority:** P1
**Status:** Complete
**Depends on:** Phases 02 + 03
## Overview
Daily cron trims `trading_trades` to enforce caps:
- **Per user:** keep newest 1000 rows, delete older.
- **Global:** keep newest 10000 rows across all users, delete oldest (FIFO).
## Requirements
**Functional**
- Schedule: `"0 17 * * *"` (daily 17:00 UTC = 00:00 ICT).
- Per-user pass first (bounded by user count), then global FIFO.
- Report count deleted via `console.log` (shows in Cloudflare logs).
**Non-functional**
- Safe to retry — idempotent (just deletes excess rows).
- Bounded execution time — large backlog handled in batches of 1000 deletes per statement if needed.
## Architecture
Added to `src/modules/trading/index.js`:
```js
crons: [{
schedule: "0 17 * * *",
name: "trim-trades",
handler: trimTradesHandler,
}]
```
### Per-user trim query
```sql
DELETE FROM trading_trades
WHERE id IN (
SELECT id FROM trading_trades
WHERE user_id = ?
ORDER BY ts DESC
LIMIT -1 OFFSET 1000
);
```
Run once per distinct `user_id` from a `SELECT DISTINCT user_id FROM trading_trades`.
### Global FIFO trim
```sql
DELETE FROM trading_trades
WHERE id IN (
SELECT id FROM trading_trades
ORDER BY ts DESC
LIMIT -1 OFFSET 10000
);
```
## Related Code Files
**Create**
- `src/modules/trading/retention.js``trimTradesHandler(event, { sql })`
- `tests/modules/trading/retention.test.js`
**Modify**
- `src/modules/trading/index.js` — wire cron entry
- `wrangler.toml` — add `"0 17 * * *"` to `[triggers] crons`
## Todo List
- [x] `retention.js` with per-user + global trim
- [x] Wire cron entry
- [x] Tests: seed >1000 per user + >10000 global, assert trimmed counts
## Success Criteria
- Given 1500 rows for user A, cron leaves 1000 newest.
- Given 15000 rows globally across users, cron leaves 10000 newest.
- Console logs deletion counts.
## Risks
- Per-user pass cost scales with user count. At current scale (hobby) irrelevant; document limit for future.
- Competing writes during trim — acceptable since trades append-only.

View File

@@ -1,104 +0,0 @@
# Phase 05 — JSDoc Pass
**Priority:** P2 (can run in parallel with 0104)
**Status:** Complete
## Overview
Add ESLint + `eslint-plugin-jsdoc` for JSDoc syntax/completeness linting. Add `@typedef` definitions and annotate public functions. No `tsc`, no `jsconfig.json` type checking — JSDoc is documentation only.
## Requirements
**Functional**
- `npm run lint` runs Biome (existing) + ESLint (new, JSDoc-only rules).
- ESLint config scoped narrowly: only `jsdoc/*` rules enabled, no stylistic rules (Biome owns those).
- CI-friendly: lint failures exit non-zero.
**Non-functional**
- No build step, no emit.
## Architecture
### ESLint config
`eslint.config.js` (flat config, ESLint 9+):
```js
import jsdoc from "eslint-plugin-jsdoc";
export default [
{
files: ["src/**/*.js"],
plugins: { jsdoc },
rules: {
"jsdoc/check-alignment": "warn",
"jsdoc/check-param-names": "error",
"jsdoc/check-tag-names": "error",
"jsdoc/check-types": "error",
"jsdoc/no-undefined-types": "error",
"jsdoc/require-param-type": "error",
"jsdoc/require-returns-type": "error",
"jsdoc/valid-types": "error",
},
},
];
```
Do NOT require JSDoc on every function — only lint the ones that have it.
### Typedefs to add
Central file: `src/types.js` (JSDoc-only module, re-exported nothing).
- `Env` — Cloudflare bindings + vars (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `KV`, `DB`, `MODULES`).
- `Module``{ name, init?, commands[], crons? }`.
- `Command``{ name, visibility, description, handler }`.
- `Cron``{ schedule, name, handler }`.
- `ModuleContext``{ db: KVStore, sql: SqlStore, env: Env }`.
- `KVStore` — existing file; ensure typedef complete.
- `SqlStore` — created in Phase 01.
- `Trade``{ id, userId, symbol, side, qty, priceVnd, ts }`.
- `Portfolio` — existing shape.
### Files to annotate
- `src/index.js`
- `src/bot.js`
- `src/db/*.js`
- `src/modules/registry.js`
- `src/modules/dispatcher.js`
- `src/modules/cron-dispatcher.js` (from Phase 02)
- `src/modules/validate-command.js`
- `src/modules/trading/*.js`
## Related Code Files
**Create**
- `eslint.config.js`
- `src/types.js`
**Modify**
- `package.json` — add `eslint` + `eslint-plugin-jsdoc` devDeps; update `lint` script to `biome check ... && eslint src`
- All files listed above — add `@param`/`@returns`/`@typedef` where missing
## Todo List
- [x] Install `eslint`, `eslint-plugin-jsdoc`
- [x] `eslint.config.js` with JSDoc-only rules
- [x] Update `lint` script
- [x] `src/types.js` central typedef file
- [x] Annotate `src/index.js`, `src/bot.js`
- [x] Annotate `src/db/*`
- [x] Annotate `src/modules/registry.js`, `dispatcher.js`, `validate-command.js` (skipped `cron-dispatcher.js` — Phase 02 owns it)
- [x] Annotate `src/modules/trading/*`
- [x] Run `npm run lint` — clean
## Success Criteria
- `npm run lint` exits 0.
- Module contract typedef visible to editor tooling (hover shows shape).
- No new runtime behavior.
## Risks
- ESLint 9 flat config quirks with `eslint-plugin-jsdoc` — pin versions known to work together.

View File

@@ -1,84 +0,0 @@
# Phase 06 — Docs
**Priority:** P1 (last)
**Status:** Complete
**Depends on:** All prior phases (✓ complete)
## Overview
Document D1 + Cron usage for module authors. Update existing architecture + module-authoring docs. Update `CLAUDE.md` module contract snippet.
## Deliverables
### Create
- `docs/using-d1.md` — how to add a D1-backed feature to a module
- When to choose D1 vs KV (query patterns, relational needs, scans)
- Writing migrations (`src/modules/<name>/migrations/NNNN_name.sql`)
- `sql.prepare/run/all/first/batch` API reference
- Table naming convention (`{module}_{table}`)
- Accessing `sql` from `init` and command handlers
- Testing with Miniflare D1 (`tests/fakes/fake-d1.js`)
- Running migrations: `npm run db:migrate`
- `docs/using-cron.md` — how to add a scheduled job to a module
- Declaring `crons: [{ schedule, name, handler }]`
- Cron expression syntax + Cloudflare limits
- Registering schedule in `wrangler.toml` `[triggers] crons`
- Handler signature `(event, { db, sql, env })`
- Local testing: `curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*"`
- Error isolation (one handler fail ≠ others fail)
- Execution time limits
### Update
- `docs/adding-a-module.md` — add sections:
- Optional `crons[]` field
- Optional `init({ sql })` for D1
- Migration file placement
- Link to `using-d1.md` + `using-cron.md`
- `docs/architecture.md` — add:
- `scheduled()` flow diagram alongside existing fetch flow
- D1 store layer in storage section
- Migration runner role in deploy
- `docs/codebase-summary.md` — reflect new `src/db/cf-sql-store.js`, `src/modules/cron-dispatcher.js`, `src/types.js`
- `docs/code-standards.md` — JSDoc expectations (when to add, ESLint rules enforced)
- `CLAUDE.md` (project) — update module contract code block:
```js
{
name: "mymod",
init: async ({ db, sql, env }) => { ... },
commands: [ /* ... */ ],
crons: [{ schedule: "0 1 * * *", name: "daily", handler: async (event, { sql }) => { ... } }],
}
```
- `CLAUDE.md` — add D1/cron bullets in "Commands" (npm run db:migrate)
- `README.md` — light touch: mention D1 + cron in "Why" bullets + update architecture snapshot tree
### Plan Output Docs
- Update `plans/260415-1010-d1-cron-infra/plan.md` — mark all phases Complete
## Todo List
- [x] Draft `docs/using-d1.md`
- [x] Draft `docs/using-cron.md`
- [x] Update `docs/adding-a-module.md`
- [x] Update `docs/architecture.md`
- [x] Update `docs/codebase-summary.md`
- [x] Update `docs/code-standards.md`
- [x] Update project `CLAUDE.md`
- [x] Update `README.md`
- [x] Mark plan phases complete
## Success Criteria
- A dev unfamiliar with Cloudflare Workers can follow `using-d1.md` + `using-cron.md` to add a persistent scheduled feature without reading framework internals.
- Internal docs reflect shipped state (no stale references to KV-only).

View File

@@ -1,56 +0,0 @@
# D1 + Cron Infra + JSDoc + Trading History
**Created:** 2026-04-15
**Status:** Complete
**Branch:** main
## Goal
Add Cloudflare D1 + Cron Triggers to the plug-n-play module framework, document them for future module authors, add JSDoc tooling via ESLint, and ship one real D1-backed feature: trading trade history.
## Non-Goals
- No module renames (`wordle`, `loldle` stay as-is).
- No demo D1/cron features for wordle/loldle — infra + docs only.
- No TypeScript migration, no `tsc`, no `jsconfig.json`.
- No preview D1 — single production DB; Miniflare for tests.
- No inline retention — cleanup is a separate cron.
## Locked Decisions
| # | Decision |
|---|---|
| D1 scope | Single prod DB. Tests use Miniflare. |
| Table prefix | `{module}_{table}` (e.g. `trading_trades`). Enforced by `SqlStore`. |
| Migrations | Per-module at `src/modules/<name>/migrations/*.sql`. Applied on `npm run deploy`. |
| Cron contract | `crons: [{ schedule, handler }]`; handler signature `(event, { db, sql, env })`. |
| Trade retention | 1000/user + 10000/global, FIFO. Enforced by daily cleanup cron. |
| JSDoc tooling | ESLint + `eslint-plugin-jsdoc`. Runs alongside Biome. |
## Phases
| # | File | Status |
|---|---|---|
| 01 | [phase-01-d1-setup.md](phase-01-d1-setup.md) | Complete |
| 02 | [phase-02-cron-wiring.md](phase-02-cron-wiring.md) | Complete |
| 03 | [phase-03-trading-history.md](phase-03-trading-history.md) | Complete |
| 04 | [phase-04-retention-cron.md](phase-04-retention-cron.md) | Complete |
| 05 | [phase-05-jsdoc-pass.md](phase-05-jsdoc-pass.md) | Complete |
| 06 | [phase-06-docs.md](phase-06-docs.md) | Complete |
## Key Dependencies
- Phase 02 depends on Phase 01 (needs `SqlStore` available in cron handler context).
- Phase 03 depends on Phase 01 (needs D1 + `SqlStore`).
- Phase 04 depends on Phases 02 + 03 (needs cron wiring + trades table).
- Phase 05 can run in parallel with 0104.
- Phase 06 last — documents final state.
## Success Criteria
- `npm test` green (Miniflare-backed D1 tests pass).
- `npm run deploy` applies pending migrations + deploys worker + registers webhook/commands.
- A module author can add a D1-backed feature + a cron by following `docs/using-d1.md` + `docs/using-cron.md` without reading framework internals.
- Trading `/history` returns last N trades for caller.
- Daily cleanup cron trims trades to caps.
- `npm run lint` runs Biome + ESLint (JSDoc rules) clean.

View File

@@ -1,132 +0,0 @@
# Phase 06 Documentation — Completion Report
**Date:** 2026-04-15
**Status:** DONE
**Plan:** D1 + Cron Infra + JSDoc + Trading History (Phase 06)
## Summary
Executed Phase 06 of the D1+Cron infra plan — created comprehensive documentation for D1 + cron usage and updated all existing docs to reflect current implementation. All module authors now have clear guidance for adopting persistent storage and scheduled jobs without needing to read framework internals.
## Files Created
1. **`docs/using-d1.md`** (~320 LOC)
- When to choose D1 vs KV (decision matrix included)
- Module initialization pattern: `init({ db, sql, env })`
- Table naming convention: `{moduleName}_{table}`
- Writing migrations: `src/modules/<name>/migrations/NNNN_*.sql` with lexical sorting
- SQL API reference: `run()`, `all()`, `first()`, `prepare()`, `batch()`
- Migration execution: `npm run db:migrate` with `--local` and `--dry-run` flags
- Testing with `FakeD1` from `tests/fakes/fake-d1.js`
- First-time D1 setup: `wrangler d1 create` workflow
- Worked example: simple counter with migration + handler
2. **`docs/using-cron.md`** (~300 LOC)
- Cron declaration: `crons: [{ schedule, name, handler }]` in module export
- Handler signature: `(event, { db, sql, env })` receives module context
- Cron expression syntax: 5-field standard with Cloudflare docs reference
- Critical: manual `wrangler.toml` registration required (`[triggers] crons`)
- Error isolation: one handler failing doesn't block others
- Execution limits: 15-minute wall-clock per task
- Local testing: `curl "http://localhost:8787/__scheduled?cron=<schedule>"`
- Multiple modules can share a schedule (fan-out)
- Worked example: trade retention cleanup cron
- Worked example: stats recalculation
## Files Updated
1. **`docs/adding-a-module.md`** (+95 LOC)
- New section: "Optional: D1 Storage" — init pattern, migrations, npm run db:migrate
- New section: "Optional: Scheduled Jobs" — crons array, wrangler.toml requirement
- Cross-links to `using-d1.md` and `using-cron.md`
- Updated testing section to mention `fake-d1.js`
- Referenced trading module as full example (D1 + crons)
2. **`docs/architecture.md`** (+100 LOC)
- Module contract code block: added `sql` parameter and `crons` array
- Storage section: separate subsections for KVStore and SqlStore
- New scheduled event flow diagram (flow from Cloudflare cron to handler dispatch)
- Added `dispatchScheduled` and cron isolation details
3. **`docs/codebase-summary.md`** (+30 LOC)
- Module table: added Storage + Crons columns
- Key data flows: added scheduled job flow diagram
- Deploy pipeline: noted `npm run db:migrate` step
- Test coverage: updated to mention D1 coverage + fake-d1.js
4. **`docs/code-standards.md`** (+40 LOC)
- Module conventions: updated to show `sql` parameter and null-guards
- New section: "JSDoc & Type Definitions" — central location `src/types.js`, when to add, validation rules
- Noted ESLint (`eslint src`) runs alongside Biome
5. **`CLAUDE.md`** (project root) (+15 LOC)
- Commands section: added `npm run db:migrate` with all flags
- Module contract: updated to show `sql`, `env`, and `crons`
- Cross-linked to `using-d1.md` and `using-cron.md`
6. **`docs/deployment-guide.md`** (+80 LOC)
- Prerequisites: added D1 to "Cloudflare account with" checklist
- New section: "Cloudflare D1 Database" (optional but recommended) — `wrangler d1 create`, paste UUID, run migrations
- Renumbered sections (KV is now section 2, secrets is now section 3, local config is now section 4)
- New section in Deploy: "Cron Configuration" — explain wrangler.toml `[triggers] crons` requirement
- Updated first-time deploy flow: added D1 setup + migration steps
7. **`README.md`** (+80 LOC)
- Why section: added D1 + crons to feature bullets
- Architecture snapshot tree: added `types.js`, D1 files (`cf-sql-store.js`, `create-sql-store.js`), cron files (`cron-dispatcher.js`, `validate-cron.js`), migrations example, fake-d1.js, migrate.js
- Local dev: added `npm run db:migrate` and noted `/__scheduled` endpoint
- Deploy section: added D1 setup and migration steps to first-time flow
- Further reading: added links to `using-d1.md` and `using-cron.md`, updated plan references
8. **`plans/260415-1010-d1-cron-infra/phase-06-docs.md`**
- Status: Todo → Complete
- All todos: checked off
9. **`plans/260415-1010-d1-cron-infra/plan.md`**
- Status: Draft → Complete
- Phase 06: Todo → Complete
## Verification
All doc references verified against shipped code:
-`createSqlStore(moduleName, env)` returns `null` when `env.DB` not bound (checked `src/db/create-sql-store.js`)
-`sql.run/all/first/prepare/batch` API matches `src/db/sql-store-interface.js` JSDoc
- ✓ Table prefix pattern `{moduleName}_{table}` enforced by `createSqlStore`
- ✓ Migration runner at `scripts/migrate.js` walks `src/modules/*/migrations/*.sql`, applies via `wrangler d1 execute`, tracks in `_migrations`
- ✓ Cron handler signature `(event, { db, sql, env })` matches `src/modules/cron-dispatcher.js`
- ✓ Trading module exports `crons` with schedule `"0 17 * * *"` (verified `src/modules/trading/index.js`)
-`wrangler.toml` has `[triggers] crons = ["0 17 * * *"]` matching module declaration
-`src/index.js` exports both `fetch` and `scheduled(event, env, ctx)` handlers
-`src/types.js` defines all central typedefs (Env, Module, Command, Cron, ModuleContext, etc.)
-`validateCron` enforces name regex and schedule format (verified `src/modules/validate-cron.js`)
## Documentation Quality
- **Tone consistency:** All new docs match existing style (clear, code-first, practical examples)
- **Cross-linking:** New docs link to each other + existing docs; no orphaned pages
- **Code examples:** All examples based on actual shipped code (trading module, fake-d1 tests, migration runner)
- **Completeness:** Covers happy path (module author perspective) + guard clauses (null-safety, error handling)
- **Searchability:** Docs well-organized by topic (when to use, how to implement, testing, examples, troubleshooting)
## Deliverables Checklist
- [x] `docs/using-d1.md` created
- [x] `docs/using-cron.md` created
- [x] `docs/adding-a-module.md` updated with cron + D1 sections
- [x] `docs/architecture.md` updated with storage details + scheduled flow
- [x] `docs/codebase-summary.md` updated (module table, flows, coverage)
- [x] `docs/code-standards.md` updated (JSDoc section, module conventions)
- [x] `docs/deployment-guide.md` updated (D1 setup, migration steps, cron registration)
- [x] `CLAUDE.md` updated (module contract, commands)
- [x] `README.md` updated (feature bullets, architecture tree, deploy flow, further reading)
- [x] Plan phase 06 marked complete
- [x] Overall plan marked complete
All 9 deliverable targets from phase-06-docs.md completed.
## Concerns
None. All deliverables shipped to spec. Documentation is current, accurate, and immediately actionable for future module authors. No stale references, broken links, or incomplete examples.
**Status:** DONE

View File

@@ -1,27 +0,0 @@
# Phase 01 — D1 Setup — Implementation Report
## Files Created
- `src/db/sql-store-interface.js` — JSDoc typedefs for SqlStore/SqlRunResult
- `src/db/cf-sql-store.js` — CFSqlStore class wrapping env.DB prepare/run/all/first/batch
- `src/db/create-sql-store.js` — factory; returns null when env.DB absent, SqlStore otherwise; exposes tablePrefix
- `tests/fakes/fake-d1.js` — in-memory D1 fake with seed(), runLog, queryLog; naive table extraction from SQL text
- `tests/db/create-sql-store.test.js` — 13 tests: validation, tablePrefix, run/all/first/batch round-trips
- `scripts/migrate.js` — custom migration runner; walks src/modules/*/migrations/*.sql sorted, tracks applied in _migrations table, supports --dry-run and --local flags
## Files Modified
- `src/modules/registry.js` — added createSqlStore import; passes `sql: createSqlStore(mod.name, env)` into init alongside db
- `wrangler.toml` — added [[d1_databases]] block; database_id = REPLACE_ME_D1_UUID (requires manual fill after `wrangler d1 create`)
- `package.json` — added `"db:migrate": "node scripts/migrate.js"`; chained into deploy: `wrangler deploy && npm run db:migrate && npm run register`
## Deviations
- `dispatcher.js`: no change needed — it delegates entirely to buildRegistry which already handles init; spec note resolved.
- `basename` import in migrate.js kept (unused but Biome didn't flag it as unused import — left for future use). Actually removed by format — clean.
## Test Results
- npm test: 118/118 pass (12 files)
- npm run lint: clean (0 errors)
- register:dry: exits with "not found" for .env.deploy — expected in dev, no code regression
## Notes
- `wrangler.toml` placeholder `REPLACE_ME_D1_UUID` must be replaced with real UUID from `npx wrangler d1 create miti99bot-db` before deploying.
- fake-d1 is a minimal fake (no SQL parser); tests that need real SQL semantics should use better-sqlite3 or Miniflare.

View File

@@ -1,26 +0,0 @@
# Phase 02 Report — Cron Wiring
## Files Modified/Created
- **Created** `src/modules/validate-cron.js` — validates `{ name, schedule, handler }` entries; 5/6-field cron regex check
- **Created** `src/modules/cron-dispatcher.js``dispatchScheduled(event, env, ctx, registry)` fan-out with per-handler try/catch
- **Modified** `src/modules/registry.js` — added `validateCron` import; cron validation + duplicate-name check in `loadModules`; `CronEntry[]` collection in `buildRegistry`; `registry.crons` exposed in typedef and return value
- **Modified** `src/bot.js` — added `getRegistry(env)` export; shares same memoized registry with `fetch` handler
- **Modified** `src/index.js` — added `scheduled(event, env, ctx)` export; calls `getRegistry` then `dispatchScheduled`
- **Modified** `wrangler.toml` — added `[triggers] crons = []` placeholder with authoring instructions
- **Created** `tests/modules/validate-cron.test.js` — 9 tests (valid entry, bad name, bad schedule, bad handler)
- **Created** `tests/modules/cron-dispatcher.test.js` — 6 tests (schedule match, no-match, fan-out, error isolation, ctx pass-through, empty registry)
- **Modified** `tests/modules/registry.test.js` — added 6 cron collection tests (empty, collect, fan-out, duplicate name, non-array, invalid entry)
## Tests
- All 139 tests pass (`14 passed` files)
- 0 new lint errors introduced (17 eslint errors are pre-existing: KVNamespace/D1Database/Request undefined types + CRLF line-ending biome format noise from Windows git autocrlf)
## Deviations
- `getRegistry(env)` added to `bot.js` rather than importing `buildRegistry` directly in `index.js` — avoids bypassing the Bot init path and ensures single shared memoized registry across `fetch` + `scheduled`.
- Test fixture module names use kebab-case (`mod-a`, `mod-b`) to satisfy `createStore`'s `^[a-z0-9_-]+$` constraint (initial camelCase caused 2 failures, fixed immediately).
**Status:** DONE
**Summary:** Cron Triggers wired end-to-end — module contract extended with `crons[]`, dispatcher dispatches per schedule, registry collects + validates, `scheduled()` exported from worker entry. All tests green, no new lint issues.

View File

@@ -1,38 +0,0 @@
# Phase 03 — Trading Trade History: Implementation Report
## Files Modified / Created
| File | Action |
|---|---|
| `src/modules/trading/migrations/0001_trades.sql` | Created — schema + 2 indexes |
| `src/modules/trading/history.js` | Created — `recordTrade`, `listTrades`, `formatTradesHtml`, `createHistoryHandler` |
| `src/modules/trading/handlers.js` | Modified — `handleBuy`/`handleSell` accept optional `onTrade` callback |
| `src/modules/trading/index.js` | Modified — accept `sql` in init, wire `onTrade`, register `/history` command |
| `tests/modules/trading/history.test.js` | Created — 21 tests |
| `plans/260415-1010-d1-cron-infra/phase-03-trading-history.md` | Status → Complete, todos ticked |
| `plans/260415-1010-d1-cron-infra/plan.md` | Phase 03 → Complete |
## Tasks Completed
- [x] Migration SQL (`trading_trades` + 2 indexes)
- [x] `recordTrade` — inserts row, logs+swallows on failure, skips silently when sql=null
- [x] `listTrades` — camelCase mapping, limit clamp [1..50], returns [] when sql=null
- [x] `formatTradesHtml` — HTML-escaped symbols, BUY/SELL labels, Telegram HTML mode
- [x] `createHistoryHandler` — parses N, defaults to 10, clamps to 50
- [x] Wired into buy/sell via `onTrade` callback pattern (keeps handlers.js clean)
- [x] `/history` registered as public command in index.js
## Tests Status
- Type check: N/A (plain JS)
- Unit tests: **160/160 pass** (21 new in history.test.js)
- Lint: **clean** (Biome + ESLint)
## Design Notes
- `onTrade` callback pattern chosen over passing `sql` directly into handlers — keeps handlers.js unaware of D1, easier to test in isolation.
- `createHistoryHandler` takes `sql` at factory time; `index.js` uses a lazy wrapper `(ctx) => createHistoryHandler(sql)(ctx)` so the module-level `sql` variable (set in `init`) is captured correctly after startup.
- `recordTrade` failure path: try/catch logs `console.error` and returns — portfolio KV remains source of truth.
**Status:** DONE
**Summary:** Phase 03 complete — trade history table, `/history` command, buy/sell wiring, 21 tests all green, lint clean.

View File

@@ -1,35 +0,0 @@
# Phase 04 — Retention Cron Implementation Report
**Date:** 2026-04-15
**Status:** DONE
## Files Modified
- `tests/fakes/fake-d1.js` — enhanced with SQL-semantic SELECT/DELETE support (DISTINCT, ORDER BY ts DESC, LIMIT/OFFSET with bind params, DELETE WHERE id IN)
- `src/modules/trading/retention.js` — created; per-user + global trim handler, overridable caps
- `src/modules/trading/index.js` — imported `trimTradesHandler`, added `crons` array
- `wrangler.toml``crons = ["0 17 * * *"]`
- `tests/modules/trading/retention.test.js` — created; 9 tests covering all scenarios
- `plans/260415-1010-d1-cron-infra/phase-04-retention-cron.md` — Status → Complete, todos ticked
- `plans/260415-1010-d1-cron-infra/plan.md` — phase 04 → Complete
## Tasks Completed
- [x] `retention.js` with per-user + global trim (hybrid SELECT-then-DELETE approach)
- [x] Caps exported as `PER_USER_CAP=1000`, `GLOBAL_CAP=10000`; overridable via optional arg for small-seed tests
- [x] Cron entry wired in trading module (`schedule: "0 17 * * *"`, `name: "trim-trades"`)
- [x] `wrangler.toml` schedule added
- [x] 9 retention tests: per-user trim, small-user no-op, exact-cap no-op, multi-user, global trim, combined pass, idempotence, sql=null guard
## Tests Status
- Unit tests: 169/169 passed (all files)
- Lint: clean (Biome + ESLint, 54 files)
## Key Decision
fake-d1 is SQL-less — DELETE naively cleared entire table. Chose option (b): hybrid SELECT-IDs-then-DELETE-by-id-list in `retention.js`. Enhanced fake-d1 to support targeted `DELETE WHERE id IN (?,...)` and `SELECT ... ORDER BY ts DESC LIMIT/OFFSET` with bind param resolution (`?` tokens resolved against binds iterator). This keeps tests meaningful without adding better-sqlite3 dependency.
## Concerns
None.

View File

@@ -1,51 +0,0 @@
# Phase 05 — JSDoc Pass Report
**Date:** 2026-04-15
**Phase:** phase-05-jsdoc-pass
**Plan:** plans/260415-1010-d1-cron-infra/
## Files Modified
| File | Change |
|------|--------|
| `package.json` | Added `eslint ^10.2.0`, `eslint-plugin-jsdoc ^62.9.0` devDeps; updated `lint` script |
| `eslint.config.js` | Created — flat config, JSDoc-only rules, `definedTypes` for CF/custom globals |
| `src/types.js` | Created — central typedef module: `Env`, `Command`, `Cron`, `ModuleContext`, `Module`, `Trade` + re-exports of `KVStore`, `SqlStore`, `Portfolio` |
| `src/db/kv-store-interface.js` | `Object``object` in all typedefs |
| `src/db/sql-store-interface.js` | `Object``object` in all typedefs |
| `src/modules/registry.js` | `Object``object`; fixed invalid TS destructure syntax in `init` property type |
| `src/modules/validate-command.js` | `Object``object` |
| `src/modules/validate-cron.js` | `Object``object` (Phase 02 owns impl; only typedef fixed) |
| `src/modules/trading/portfolio.js` | `Object``object` |
| `src/modules/trading/symbols.js` | `Object``object` |
| `src/modules/index.js` | Removed `{@link loadModules}` curly-brace syntax misread as a type |
Files already fully annotated (no changes needed): `src/index.js`, `src/bot.js`, `src/db/cf-kv-store.js`, `src/db/create-store.js`, `src/db/cf-sql-store.js`, `src/db/create-sql-store.js`, `src/modules/dispatcher.js`, `src/modules/trading/handlers.js`, `src/modules/trading/prices.js`, `src/modules/trading/format.js`, `src/modules/trading/stats-handler.js`.
## Tasks Completed
- [x] Install `eslint`, `eslint-plugin-jsdoc`
- [x] `eslint.config.js` with JSDoc-only rules
- [x] Update `lint` script
- [x] `src/types.js` central typedef file
- [x] All targeted files annotated / typedef-corrected
- [x] `npm run lint` — clean (Biome + ESLint both exit 0)
## Tests Status
- Biome: pass
- ESLint: pass (0 errors, 0 warnings)
- Unit tests: 139/139 pass (14 test files)
## Issues Encountered
1. `eslint-plugin-jsdoc` uses `definedTypes` (rule option) not `definedNames` (settings key) — corrected in `eslint.config.js`.
2. Several files had CRLF endings introduced by `node` script edits — resolved via `biome format --write`.
3. `{@link loadModules}` inside `@file` JSDoc was parsed as a type reference by the plugin — removed curly braces.
4. Registry `BotModule.init` used TypeScript destructure syntax `({ db, sql, env }: {...})` which `jsdoc/valid-types` rejects — changed to plain `(ctx: {...}) => Promise<void>`.
5. `validate-cron.js` is Phase 02-owned but had an `Object` typedef that caused lint errors — fixed only the typedef line (no logic changes).
---
**Status:** DONE
**Summary:** `npm run lint` exits 0 (Biome + ESLint clean), 139 tests pass, no runtime changes. Central `src/types.js` typedef file created; all JSDoc issues corrected across 11 files.

View File

@@ -1,56 +0,0 @@
# Researcher Report: Cloudflare Workers KV basics
**Date:** 2026-04-11
**Scope:** KV API surface, wrangler binding, limits relevant to plugin framework.
## API surface (KVNamespace binding)
```js
await env.KV.get(key, { type: "text" | "json" | "arrayBuffer" | "stream" });
await env.KV.put(key, value, { expirationTtl, expiration, metadata });
await env.KV.delete(key);
await env.KV.list({ prefix, limit, cursor });
```
### `list()` shape
```js
{
keys: [{ name, expiration?, metadata? }, ...],
list_complete: boolean,
cursor: string, // present when list_complete === false
}
```
- Max `limit` per call: **1000** (also the default).
- Pagination via `cursor`. Loop until `list_complete === true`.
- Prefix filter is server-side — efficient for per-module namespacing (`wordle:` prefix).
## Limits that shape the module API
| Limit | Value | Impact on design |
|---|---|---|
| Write/sec **per key** | 1 | Counters / leaderboards must avoid hot keys. Plugin authors must know this. Document in phase-03. |
| Value size | 25 MiB | Non-issue for bot state. |
| Key size | 512 bytes | Prefixing adds ~10 bytes — no issue. |
| Consistency | Eventual (up to ~60s globally) | Read-after-write may not see update immediately from a different edge. OK for game state, NOT OK for auth sessions. |
| `list()` | Eventually consistent, max 1000/call | Paginate. |
## wrangler.toml binding
```toml
[[kv_namespaces]]
binding = "KV"
id = "<namespace-id-from-dashboard-or-wrangler-kv-create>"
preview_id = "<separate-id-for-wrangler-dev>"
```
- Access in code: `env.KV`.
- `preview_id` lets `wrangler dev` use a separate namespace — recommended.
- Create namespace: `wrangler kv namespace create miti99bot-kv` (prints IDs to paste).
## Design implications for the DB abstraction
- Interface must support `get / put / delete / list({ prefix })` — all four map 1:1 to KV.
- Namespaced factory auto-prefixes with `<module>:``list()` from a module only sees its own keys because prefix is applied on top of the requested prefix (e.g. module `wordle` calls `list({ prefix: "games:" })` → final KV prefix becomes `wordle:games:`).
- Return shape normalization: wrap KV's `list()` output in a simpler `{ keys: string[], cursor?: string, done: boolean }` to hide KV-specific metadata fields. Modules that need metadata can take the hit later.
- `get` default type: return string. Modules do their own JSON parse, or expose a `getJSON/putJSON` helper.
## Unresolved questions
- Do we need `metadata` and `expirationTtl` passthrough in v1? **Recommendation: yes for `expirationTtl`** (useful for easter-egg cooldowns), **no for metadata** (YAGNI).

View File

@@ -1,57 +0,0 @@
# Researcher Report: grammY on Cloudflare Workers
**Date:** 2026-04-11
**Scope:** grammY entry point, webhook adapter, secret-token verification, setMyCommands usage.
## Key findings
### Adapter
- Use **`"cloudflare-mod"`** adapter for ES module (fetch handler) Workers. Source: grammY `src/convenience/frameworks.ts`.
- The legacy `"cloudflare"` adapter targets service-worker style Workers. Do NOT use — CF has moved on to module workers.
- Import path (npm, not Deno): `import { Bot, webhookCallback } from "grammy";`
### Minimal fetch handler
```js
import { Bot, webhookCallback } from "grammy";
export default {
async fetch(request, env, ctx) {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
// ... register handlers
const handle = webhookCallback(bot, "cloudflare-mod", {
secretToken: env.TELEGRAM_WEBHOOK_SECRET,
});
return handle(request);
},
};
```
### Secret-token verification
- `webhookCallback` accepts `secretToken` in its `WebhookOptions`. When set, grammY validates the incoming `X-Telegram-Bot-Api-Secret-Token` header and rejects mismatches with 401.
- **No need** to manually read the header — delegate to grammY.
- The same secret must be passed to Telegram when calling `setWebhook` (`secret_token` field).
### Bot instantiation cost
- `new Bot()` per request is acceptable for Workers (no persistent state between requests anyway). Global-scope instantiation also works and caches across warm invocations. Prefer **global-scope** for reuse but be aware env bindings are not available at module load — must instantiate lazily inside `fetch`. Recommended pattern: memoize `Bot` in a module-scope variable initialized on first request.
### setMyCommands
- Call via `bot.api.setMyCommands([{ command, description }, ...])`.
- Should be called **on demand**, not on every webhook request (rate-limit risk, latency). Two options:
1. Dedicated admin HTTP route (e.g. `POST /admin/setup`) guarded by a second secret. Runs on demand.
2. One-shot `wrangler` script. Adds tooling complexity.
- **Recommendation:** admin route. Keeps deploy flow in one place (`wrangler deploy` + `curl`). No extra script.
### Init flow
- `bot.init()` is NOT required if you only use `webhookCallback`; grammY handles lazy init.
- For `/admin/setup` that directly calls `bot.api.*`, call `await bot.init()` once to populate `bot.botInfo`.
## Resolved technical answers
| Question | Answer |
|---|---|
| Adapter string | `"cloudflare-mod"` |
| Import | `import { Bot, webhookCallback } from "grammy"` |
| Secret verify | pass `secretToken` in `webhookCallback` options |
| setMyCommands trigger | admin HTTP route guarded by separate secret |
## Unresolved questions
- None blocking. grammY version pin: recommend `^1.30.0` or latest stable at implementation time; phase-01 should `npm view grammy version` to confirm.

View File

@@ -1,68 +0,0 @@
# Researcher Report: wrangler.toml, secrets, and MODULES env var
**Date:** 2026-04-11
**Scope:** how to declare secrets vs vars, local dev via `.dev.vars`, and the list-env-var question.
## Secrets vs vars
| Kind | Where declared | Deployed via | Local dev | Use for |
|---|---|---|---|---|
| **Secret** | NOT in wrangler.toml | `wrangler secret put NAME` | `.dev.vars` file (gitignored) | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `ADMIN_SECRET` |
| **Var** | `[vars]` in wrangler.toml | `wrangler deploy` | `.dev.vars` overrides | `MODULES`, non-sensitive config |
- Both appear on `env.NAME` at runtime — indistinguishable in code.
- `.dev.vars` is a dotenv file (`KEY=value` lines, no quotes required). Gitignore it.
- `wrangler secret put` encrypts into CF's secret store — never visible again after set.
## `[vars]` value types
- Per wrangler docs, `[vars]` accepts **strings and JSON objects**, not top-level arrays.
- Therefore `MODULES` must be a **comma-separated string**:
```toml
[vars]
MODULES = "util,wordle,loldle,misc"
```
- Code parses with `env.MODULES.split(",").map(s => s.trim()).filter(Boolean)`.
- **Rejected alternative:** JSON-string `MODULES = '["util","wordle"]'` + `JSON.parse`. More ceremony, no benefit, looks ugly in TOML. Stick with CSV.
## Full wrangler.toml template (proposed)
```toml
name = "miti99bot"
main = "src/index.js"
compatibility_date = "2026-04-01"
# No nodejs_compat — grammY + our code is pure Web APIs. Smaller bundle.
[vars]
MODULES = "util,wordle,loldle,misc"
[[kv_namespaces]]
binding = "KV"
id = "REPLACE_ME"
preview_id = "REPLACE_ME"
# Secrets (set via `wrangler secret put`):
# TELEGRAM_BOT_TOKEN
# TELEGRAM_WEBHOOK_SECRET
# ADMIN_SECRET
```
## Local dev flow
1. `.dev.vars` contains:
```
TELEGRAM_BOT_TOKEN=xxx
TELEGRAM_WEBHOOK_SECRET=yyy
ADMIN_SECRET=zzz
```
2. `wrangler dev` picks up `.dev.vars` + `[vars]` + `preview_id` KV.
3. For local Telegram testing, expose via `cloudflared tunnel` or ngrok, then `setWebhook` to the public URL.
## Secrets setup commands (for README/phase-09)
```bash
wrangler secret put TELEGRAM_BOT_TOKEN
wrangler secret put TELEGRAM_WEBHOOK_SECRET
wrangler secret put ADMIN_SECRET
wrangler kv namespace create miti99bot-kv
wrangler kv namespace create miti99bot-kv --preview
```
## Unresolved questions
- Should `MODULES` default to a hard-coded list in code if the env var is empty? **Recommendation:** no — fail loudly on empty/missing MODULES so misconfiguration is obvious. `/info` still works since `util` is always in the list.