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

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

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

4
.dev.vars.example Normal file
View File

@@ -0,0 +1,4 @@
# Local development secrets loaded by `wrangler dev`.
# Copy to .dev.vars (gitignored) and fill in real values.
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=

15
.env.deploy.example Normal file
View File

@@ -0,0 +1,15 @@
# Post-deploy registration env, consumed by `scripts/register.js` via
# `node --env-file=.env.deploy`. Copy to .env.deploy (gitignored) and fill in.
#
# TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET must match the values set via
# `wrangler secret put` so the Worker and Telegram agree on the same secret.
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
# Public URL of the deployed Worker (no trailing slash). Known after the first
# `wrangler deploy`. Example: https://miti99bot.your-subdomain.workers.dev
WORKER_URL=
# Same MODULES value as wrangler.toml [vars]. Duplicated here so the register
# script can derive the public command list without parsing wrangler.toml.
MODULES=util,wordle,loldle,misc

6
.gitignore vendored
View File

@@ -69,6 +69,12 @@ web_modules/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.deploy.example
# Cloudflare Wrangler local state + secrets
.dev.vars
!.dev.vars.example
.wrangler/
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache

145
README.md
View File

@@ -1,2 +1,145 @@
# miti99bot # miti99bot
[My Telegram bot](https://t.me/miti99bot) source code. A super Telegram bot can contain other modules, plug-n-play easily. Deployed via Cloudflare Workers
[My Telegram bot](https://t.me/miti99bot) — a plug-n-play bot on Cloudflare Workers.
Modules are added or removed via a single `MODULES` env var. Each module registers its own commands with three visibility levels (public / protected / private). Data lives in Cloudflare KV behind a thin `KVStore` interface, so swapping the backend later is a one-file change.
## Architecture snapshot
```
src/
├── index.js # fetch handler: POST /webhook + GET / health
├── bot.js # memoized grammY Bot, lazy dispatcher install
├── db/
│ ├── kv-store-interface.js # JSDoc typedefs (the contract)
│ ├── cf-kv-store.js # Cloudflare KV implementation
│ └── create-store.js # per-module prefixing factory
├── modules/
│ ├── index.js # static import map — register new modules here
│ ├── registry.js # load, validate, build command tables
│ ├── dispatcher.js # wires every command via bot.command()
│ ├── validate-command.js
│ ├── util/ # /info, /help (fully implemented)
│ ├── wordle/ # stub — proves plugin system
│ ├── loldle/ # stub
│ └── misc/ # stub
└── util/
└── escape-html.js
scripts/
├── register.js # post-deploy: setWebhook + setMyCommands
└── stub-kv.js
```
## Command visibility
| Level | In `/` menu | In `/help` | Callable |
|---|---|---|---|
| `public` | yes | yes | yes |
| `protected` | **no** | yes | yes |
| `private` | **no** | **no** | yes (hidden slash command — easter egg) |
All three are slash commands. Private commands are just hidden from both surfaces. They're not access control — anyone who knows the name can invoke them.
Command names must match `^[a-z0-9_]{1,32}$` (Telegram's slash-command limit). Conflict detection is unified across all visibility levels — two modules cannot register the same command name no matter the visibility. Registry build throws at load time.
## Prereqs
- Node.js ≥ 20.6 (for `node --env-file`)
- A Cloudflare account with Workers + KV
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
## Setup
1. **Install dependencies**
```bash
npm install
```
2. **Create KV namespaces** (production + preview)
```bash
npx wrangler kv namespace create miti99bot-kv
npx wrangler kv namespace create miti99bot-kv --preview
```
Paste the returned IDs into `wrangler.toml` under `[[kv_namespaces]]`, replacing both `REPLACE_ME` placeholders.
3. **Set Worker runtime secrets** (stored in Cloudflare, used by the deployed Worker)
```bash
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put TELEGRAM_WEBHOOK_SECRET
```
`TELEGRAM_WEBHOOK_SECRET` can be any high-entropy string — e.g. `openssl rand -hex 32`. It gates incoming webhook requests; grammY validates it on every update.
4. **Create `.dev.vars`** for local development
```bash
cp .dev.vars.example .dev.vars
# fill in the same TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET values
```
Used by `wrangler dev`. Gitignored.
5. **Create `.env.deploy`** for the post-deploy register script
```bash
cp .env.deploy.example .env.deploy
# fill in: token, webhook secret, WORKER_URL (known after first deploy), MODULES
```
Gitignored. The `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET` values MUST match what you set via `wrangler secret put` — mismatch means every incoming webhook returns 401.
## Local dev
```bash
npm run dev # wrangler dev — runs the Worker at http://localhost:8787
npm run lint # biome check
npm test # vitest
```
The local `wrangler dev` server exposes `GET /` (health) and `POST /webhook`. For end-to-end testing you'd ngrok/cloudflared the local port and point a test bot's `setWebhook` at it — but pure unit tests (`npm test`) cover the logic seams without Telegram.
## Deploy
Single command, idempotent:
```bash
npm run deploy
```
That runs `wrangler deploy` followed by `scripts/register.js`, which calls Telegram's `setWebhook` + `setMyCommands` using values from `.env.deploy`.
First-time deploy flow:
1. Run `wrangler deploy` once to learn the `*.workers.dev` URL printed at the end.
2. Paste it into `.env.deploy` as `WORKER_URL`.
3. Preview the register payloads without calling Telegram:
```bash
npm run register:dry
```
4. Run the real thing:
```bash
npm run deploy
```
Subsequent deploys: just `npm run deploy`.
## Adding a module
See [`docs/adding-a-module.md`](docs/adding-a-module.md) for the full guide.
TL;DR:
1. Create `src/modules/<name>/index.js` with a default export `{ name, commands, init? }`.
2. Add a line to `src/modules/index.js` static map.
3. Add `<name>` to `MODULES` in both `wrangler.toml` `[vars]` and `.env.deploy`.
4. `npm test` + `npm run deploy`.
## Troubleshooting
| Symptom | Cause |
|---|---|
| 401 on every webhook | `TELEGRAM_WEBHOOK_SECRET` differs between `wrangler secret` and `.env.deploy`. |
| `/help` is missing a module's section | Module has no public or protected commands — private-only modules are hidden. |
| Module loads but no commands respond | `MODULES` does not list the module. Check `wrangler.toml` AND `.env.deploy`. |
| `command conflict: /foo ...` at deploy | Two modules register the same command name. Rename one. |
| `npm run register` exits `missing env: X` | Add `X` to `.env.deploy`. |
| `--env-file` flag not recognized | Node < 20.6. Upgrade Node. |
## Planning docs
Full implementation plan in `plans/260411-0853-telegram-bot-plugin-framework/` — 9 phase files plus researcher reports.

33
biome.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off",
"useTemplate": "off"
},
"suspicious": {
"noConsoleLog": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"files": {
"ignore": ["node_modules", ".wrangler", "dist", "coverage"]
}
}

166
docs/adding-a-module.md Normal file
View File

@@ -0,0 +1,166 @@
# Adding a Module
A module is a plugin that registers commands with the bot. The framework handles loading, routing, visibility, `/help` rendering, and per-module database namespacing. A new module is usually under 100 lines.
## Three-step checklist
### 1. Create the module folder
```
src/modules/<name>/index.js
```
Module name must match `^[a-z0-9_-]+$`. The folder name, the `name` field in the default export, and the key in the static import map MUST all be identical.
### 2. Add it to the static import map
Edit `src/modules/index.js`:
```js
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
// ... existing entries ...
mynew: () => import("./mynew/index.js"), // add this line
};
```
Static imports are required — wrangler's bundler strips unused code based on static analysis. Dynamic `import(variablePath)` would bundle everything.
### 3. Enable the module via env
Add the name to `MODULES` in two places:
`wrangler.toml`:
```toml
[vars]
MODULES = "util,wordle,loldle,misc,mynew"
```
`.env.deploy` (so the register script sees it too):
```
MODULES=util,wordle,loldle,misc,mynew
```
Modules NOT listed in `MODULES` are simply not loaded — no errors, no overhead.
## Minimal module skeleton
```js
/** @type {import("../registry.js").BotModule} */
const mynewModule = {
name: "mynew",
commands: [
{
name: "hello",
visibility: "public",
description: "Say hello",
handler: async (ctx) => {
await ctx.reply("hi!");
},
},
],
};
export default mynewModule;
```
## Using the database
If your module needs storage, opt into it with an `init` hook:
```js
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
const mynewModule = {
name: "mynew",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "count",
visibility: "public",
description: "Increment a counter",
handler: async (ctx) => {
const state = (await db.getJSON("counter")) ?? { n: 0 };
state.n += 1;
await db.putJSON("counter", state);
await ctx.reply(`counter: ${state.n}`);
},
},
],
};
export default mynewModule;
```
**Key namespacing is automatic.** The `db` you receive is a `KVStore` whose keys are all prefixed with `mynew:` before hitting Cloudflare KV. The raw key for `counter` is `mynew:counter`. Two modules cannot read each other's data unless they reconstruct the prefix by hand.
### Available `KVStore` methods
```js
await db.get("key") // → string | null
await db.put("key", "value", { expirationTtl: 60 }) // seconds
await db.delete("key")
await db.list({ prefix: "games:", limit: 50, cursor }) // paginated
// JSON helpers (recommended — DRY for structured state)
await db.getJSON("key") // → parsed | null (swallows corrupt JSON)
await db.putJSON("key", { a: 1 }, { expirationTtl: 60 })
```
See `src/db/kv-store-interface.js` for the full JSDoc contract.
## Command contract
Each command is:
```js
{
name: "hello", // ^[a-z0-9_]{1,32}$, no leading slash
visibility: "public", // "public" | "protected" | "private"
description: "Say hello", // required, ≤ 256 chars
handler: async (ctx) => { ... }, // grammY context
}
```
### Name rules
- Lowercase letters, digits, underscore
- 1 to 32 characters
- No leading `/`
- Must be unique across ALL loaded modules, regardless of visibility
### Visibility levels
| Level | In `/` menu | In `/help` | When to use |
|---|---|---|---|
| `public` | yes | yes | Normal commands users should discover |
| `protected` | no | yes | Admin / debug tools you don't want in the menu but want discoverable via `/help` |
| `private` | no | no | Easter eggs, testing hooks — fully hidden |
Private commands are still slash commands — users type `/mycmd`. They're simply absent from Telegram's `/` popup and from `/help` output.
## Testing your module
Add a test in `tests/modules/<name>.test.js` or extend an existing suite. The `tests/fakes/` directory provides `fake-kv-namespace.js`, `fake-bot.js`, and `fake-modules.js` for hermetic unit tests that don't touch Cloudflare or Telegram.
Run:
```bash
npm test
```
Also worth running before deploying:
```bash
npm run register:dry
```
This prints the `setMyCommands` payload your module will push to Telegram — a fast way to verify the public command descriptions look right.
## Full example
See `src/modules/misc/index.js` — it's a minimal module that uses the DB (`putJSON` / `getJSON` via `/ping` + `/mstats`) and registers one command at each visibility level. Copy it as a starting point for your own module.

3228
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "miti99bot",
"version": "0.1.0",
"description": "Telegram bot with plug-n-play module system, deployed to Cloudflare Workers.",
"private": true,
"type": "module",
"engines": {
"node": ">=20.6"
},
"scripts": {
"dev": "wrangler dev",
"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",
"lint": "biome check src tests scripts",
"format": "biome format --write src tests scripts",
"test": "vitest run"
},
"dependencies": {
"grammy": "^1.30.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"vitest": "^2.1.0",
"wrangler": "^3.90.0"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
# 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

@@ -0,0 +1,57 @@
# 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

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

109
scripts/register.js Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* @file register — post-deploy Telegram registration.
*
* Runs after `wrangler deploy` (chained via `npm run deploy`). Reads env from
* `.env.deploy` (via `node --env-file`), imports the same module registry
* code the Worker uses, derives the public-command list, and calls Telegram's
* HTTP API to (1) register the webhook with a secret token and (2) publish
* setMyCommands.
*
* Idempotent — safe to re-run. Supports `--dry-run` to preview payloads
* without calling Telegram.
*
* Required env (in .env.deploy):
* TELEGRAM_BOT_TOKEN — bot token from BotFather
* TELEGRAM_WEBHOOK_SECRET — must match the value `wrangler secret put` set for the Worker
* WORKER_URL — https://<worker-subdomain>.workers.dev (no trailing slash)
* MODULES — same comma-separated list as wrangler.toml [vars]
*/
import { buildRegistry, resetRegistry } from "../src/modules/registry.js";
import { stubKv } from "./stub-kv.js";
const TELEGRAM_API = "https://api.telegram.org";
/**
* @param {string} name
* @returns {string}
*/
function requireEnv(name) {
const v = process.env[name];
if (!v || v.trim().length === 0) {
console.error(`missing env: ${name}`);
process.exit(1);
}
return v.trim();
}
/**
* @param {string} token
* @param {string} method
* @param {unknown} body
*/
async function callTelegram(token, method, body) {
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
/** @type {any} */
let json = {};
try {
json = await res.json();
} catch {
// Telegram always returns JSON on documented endpoints; only leave `json`
// empty if the transport itself died.
}
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 dryRun = process.argv.includes("--dry-run");
// Build the registry against the same code the Worker uses. Stub KV
// satisfies the binding so createStore() does not throw.
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 (dryRun) {
console.log("DRY RUN — not calling Telegram");
// Redact the secret in the printed payload.
console.log("setWebhook:", { ...webhookBody, secret_token: "<redacted>" });
console.log("setMyCommands:", commandsBody);
return;
}
await callTelegram(token, "setWebhook", webhookBody);
await callTelegram(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((err) => {
console.error(err);
process.exit(1);
});

35
scripts/stub-kv.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* @file stub-kv — minimal no-op KVNamespace stub used by scripts/register.js.
*
* The register script imports buildRegistry() to derive the public command
* list at deploy time. buildRegistry calls createStore() → CFKVStore → needs
* a KV binding. This stub satisfies the shape without doing any real IO,
* since module init hooks in this codebase read-only (or tolerate missing
* state). If a future module writes inside init(), update the stub to
* swallow writes or gate the write on a `process.env.REGISTER_DRYRUN` flag.
*/
/** @type {KVNamespace} */
export const stubKv = {
async get() {
return null;
},
async put() {
// no-op
},
async delete() {
// no-op
},
async list() {
return {
keys: [],
list_complete: true,
cursor: undefined,
};
},
// getWithMetadata is part of the KVNamespace type but unused by CFKVStore
// — provide a stub so duck-typing doesn't trip.
async getWithMetadata() {
return { value: null, metadata: null };
},
};

56
src/bot.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* @file bot — lazy, memoized grammY Bot factory.
*
* The Bot instance is constructed on the first request (env is not available
* at module-load time in CF Workers) and reused on subsequent warm requests.
* Dispatcher middleware is installed exactly once, as part of that same
* lazy init, so the registry is built before any update is routed.
*/
import { Bot } from "grammy";
import { installDispatcher } from "./modules/dispatcher.js";
/** @type {Bot | null} */
let botInstance = null;
/** @type {Promise<Bot> | null} */
let botInitPromise = null;
/**
* Fail fast if any required env var is missing — better a 500 on first webhook
* than a confusing runtime error inside grammY.
*
* @param {any} env
*/
function requireEnv(env) {
const required = ["TELEGRAM_BOT_TOKEN", "TELEGRAM_WEBHOOK_SECRET", "MODULES"];
const missing = required.filter((key) => !env?.[key]);
if (missing.length > 0) {
throw new Error(`missing required env vars: ${missing.join(", ")}`);
}
}
/**
* @param {any} env
* @returns {Promise<Bot>}
*/
export async function getBot(env) {
if (botInstance) return botInstance;
if (botInitPromise) return botInitPromise;
requireEnv(env);
botInitPromise = (async () => {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
await installDispatcher(bot, env);
botInstance = bot;
return bot;
})();
try {
return await botInitPromise;
} catch (err) {
// Clear the failed promise so the next request retries init.
botInitPromise = null;
throw err;
}
}

108
src/db/cf-kv-store.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* @file CFKVStore — Cloudflare Workers KV implementation of the KVStore interface.
*
* Wraps a `KVNamespace` binding. normalizes the list() response shape so the
* rest of the codebase never sees CF-specific fields like `list_complete`.
*
* @see ./kv-store-interface.js for the interface contract.
*/
/**
* @typedef {import("./kv-store-interface.js").KVStore} KVStore
* @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
*/
/**
* @implements {KVStore}
*/
export class CFKVStore {
/**
* @param {KVNamespace} kvNamespace — bound via wrangler.toml [[kv_namespaces]].
*/
constructor(kvNamespace) {
if (!kvNamespace) throw new Error("CFKVStore: kvNamespace is required");
this.kv = kvNamespace;
}
/**
* @param {string} key
* @returns {Promise<string|null>}
*/
async get(key) {
return this.kv.get(key, { type: "text" });
}
/**
* @param {string} key
* @param {string} value
* @param {KVStorePutOptions} [opts]
* @returns {Promise<void>}
*/
async put(key, value, opts) {
// CF KV rejects `{ expirationTtl: undefined }` on some wrangler versions,
// so only pass the options object when actually needed.
if (opts?.expirationTtl) {
await this.kv.put(key, value, { expirationTtl: opts.expirationTtl });
return;
}
await this.kv.put(key, value);
}
/**
* @param {string} key
* @returns {Promise<void>}
*/
async delete(key) {
await this.kv.delete(key);
}
/**
* @param {KVStoreListOptions} [opts]
* @returns {Promise<KVStoreListResult>}
*/
async list(opts = {}) {
const result = await this.kv.list({
prefix: opts.prefix,
limit: opts.limit,
cursor: opts.cursor,
});
return {
keys: result.keys.map((k) => k.name),
cursor: result.cursor,
done: result.list_complete === true,
};
}
/**
* @param {string} key
* @returns {Promise<any|null>}
*/
async getJSON(key) {
const raw = await this.get(key);
if (raw == null) return null;
try {
return JSON.parse(raw);
} catch (err) {
// Corrupt record — do not crash a handler. Log, return null, move on.
console.warn("getJSON: parse failed", { key, err: String(err) });
return null;
}
}
/**
* @param {string} key
* @param {any} value
* @param {KVStorePutOptions} [opts]
* @returns {Promise<void>}
*/
async putJSON(key, value, opts) {
if (value === undefined) {
throw new Error(`putJSON: value for key "${key}" is undefined`);
}
// JSON.stringify throws on cycles — let it propagate.
const serialized = JSON.stringify(value);
await this.put(key, serialized, opts);
}
}

79
src/db/create-store.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* @file createStore — factory that returns a namespaced KVStore for a module.
*
* Every module gets its own prefixed view: module `wordle` calling `put("k", v)`
* writes raw key `wordle:k`. list() automatically constrains to the module's
* namespace AND strips the prefix from returned keys so the module sees its
* own flat key-space. modules CANNOT escape their namespace without
* reconstructing prefixes manually — a code-review boundary, not a hard one.
*/
import { CFKVStore } from "./cf-kv-store.js";
/**
* @typedef {import("./kv-store-interface.js").KVStore} KVStore
* @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
*/
const MODULE_NAME_RE = /^[a-z0-9_-]+$/;
/**
* @param {string} moduleName — must match `[a-z0-9_-]+`. Used verbatim as the key prefix.
* @param {{ KV: KVNamespace }} env — worker env (or test double) with a `KV` binding.
* @returns {KVStore}
*/
export function createStore(moduleName, env) {
if (!moduleName || typeof moduleName !== "string") {
throw new Error("createStore: moduleName is required");
}
if (!MODULE_NAME_RE.test(moduleName)) {
throw new Error(
`createStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`,
);
}
if (!env?.KV) {
throw new Error("createStore: env.KV binding is missing");
}
const base = new CFKVStore(env.KV);
const prefix = `${moduleName}:`;
return {
async get(key) {
return base.get(prefix + key);
},
async put(key, value, opts) {
return base.put(prefix + key, value, opts);
},
async delete(key) {
return base.delete(prefix + key);
},
async list(opts = {}) {
const fullPrefix = prefix + (opts.prefix ?? "");
const result = await base.list({
prefix: fullPrefix,
limit: opts.limit,
cursor: opts.cursor,
});
// Strip the module namespace from returned keys so the caller sees its own flat space.
return {
keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
cursor: result.cursor,
done: result.done,
};
},
async getJSON(key) {
return base.getJSON(prefix + key);
},
async putJSON(key, value, opts) {
return base.putJSON(prefix + key, value, opts);
},
};
}

View File

@@ -0,0 +1,44 @@
/**
* @file KVStore interface — JSDoc typedefs only, no runtime code.
*
* This is the contract every storage backend must satisfy. modules receive
* a prefixed `KVStore` (via {@link module:db/create-store}) and must NEVER
* touch the underlying binding. to swap Cloudflare KV for a different
* backend (D1, Upstash Redis, ...) in the future, implement this interface
* in a new file and change the one import in create-store.js — no module
* code changes required.
*/
/**
* @typedef {Object} KVStorePutOptions
* @property {number} [expirationTtl] seconds — value auto-deletes after this many seconds.
*/
/**
* @typedef {Object} KVStoreListOptions
* @property {string} [prefix] additional prefix (appended AFTER the module namespace).
* @property {number} [limit]
* @property {string} [cursor] pagination cursor from a previous list() call.
*/
/**
* @typedef {Object} KVStoreListResult
* @property {string[]} keys — module namespace already stripped.
* @property {string} [cursor] — present if more pages available.
* @property {boolean} done — true when list_complete.
*/
/**
* @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
* returns null on missing key OR malformed JSON (logs a warning — does not throw).
* @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
* throws if value is undefined or contains a cycle.
*/
// JSDoc-only module. No runtime exports.
export {};

61
src/index.js Normal file
View File

@@ -0,0 +1,61 @@
/**
* @file fetch entry point for the Cloudflare Worker.
*
* Routes:
* GET / — "miti99bot ok" health check (unauthenticated).
* POST /webhook — Telegram webhook. grammY validates the
* X-Telegram-Bot-Api-Secret-Token header against
* env.TELEGRAM_WEBHOOK_SECRET and replies 401 on mismatch.
* * — 404.
*
* There is NO admin HTTP surface. `setWebhook` + `setMyCommands` run at
* deploy time from `scripts/register.js`, not from the Worker.
*/
import { webhookCallback } from "grammy";
import { getBot } from "./bot.js";
/** @type {ReturnType<typeof webhookCallback> | null} */
let cachedWebhookHandler = null;
/**
* @param {any} env
*/
async function getWebhookHandler(env) {
if (cachedWebhookHandler) return cachedWebhookHandler;
const bot = await getBot(env);
cachedWebhookHandler = webhookCallback(bot, "cloudflare-mod", {
secretToken: env.TELEGRAM_WEBHOOK_SECRET,
});
return cachedWebhookHandler;
}
export default {
/**
* @param {Request} request
* @param {any} env
* @param {any} _ctx
*/
async fetch(request, env, _ctx) {
const { pathname } = new URL(request.url);
if (request.method === "GET" && pathname === "/") {
return new Response("miti99bot ok", {
status: 200,
headers: { "content-type": "text/plain" },
});
}
if (request.method === "POST" && pathname === "/webhook") {
try {
const handler = await getWebhookHandler(env);
return await handler(request);
} catch (err) {
console.error("webhook handler failed", err);
return new Response("internal error", { status: 500 });
}
}
return new Response("not found", { status: 404 });
},
};

31
src/modules/dispatcher.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* @file dispatcher — wires every command (public, protected, AND private)
* into the grammY Bot via `bot.command()`.
*
* Visibility is pure metadata at this layer: the dispatcher does NOT care
* whether a command is hidden from the menu or from /help. All three paths
* share a single bot.command() registration. Visibility only affects:
* 1. What scripts/register.js pushes to Telegram's setMyCommands (public only).
* 2. What phase-05's /help renderer shows (public + protected).
*/
import { buildRegistry } from "./registry.js";
/**
* Build the registry (if not already built) and register every command with grammY.
*
* @param {import("grammy").Bot} bot
* @param {any} env
* @returns {Promise<import("./registry.js").Registry>}
*/
export async function installDispatcher(bot, env) {
const reg = await buildRegistry(env);
for (const { cmd } of reg.allCommands.values()) {
// grammY's bot.command() matches /cmd and /cmd@botname, case-sensitively,
// which naturally satisfies the "exact, case-sensitive" rule for private commands.
bot.command(cmd.name, cmd.handler);
}
return reg;
}

16
src/modules/index.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* @file moduleRegistry — static import map of every buildable module.
*
* wrangler bundles statically — dynamic `import(variablePath)` defeats
* tree-shaking and can fail at bundle time. So we enumerate every module here
* as a lazy loader, and {@link loadModules} filters the list at runtime
* against `env.MODULES` (comma-separated). Adding a new module is a two-step
* edit: create the folder, then add one line here.
*/
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
};

View File

@@ -0,0 +1,38 @@
/**
* @file loldle module stub — proves the plugin system end-to-end.
*
* One public, one protected, one private slash command.
*/
/** @type {import("../registry.js").BotModule} */
const loldleModule = {
name: "loldle",
commands: [
{
name: "loldle",
visibility: "public",
description: "Play loldle (stub)",
handler: async (ctx) => {
await ctx.reply("Loldle stub.");
},
},
{
name: "lstats",
visibility: "protected",
description: "Loldle stats",
handler: async (ctx) => {
await ctx.reply("loldle stats stub");
},
},
{
name: "ggwp",
visibility: "private",
description: "Easter egg — post-match courtesy",
handler: async (ctx) => {
await ctx.reply("gg well played (stub)");
},
},
],
};
export default loldleModule;

54
src/modules/misc/index.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* @file misc module stub — proves the plugin system end-to-end AND
* demonstrates DB usage via getJSON/putJSON in /ping.
*/
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const miscModule = {
name: "misc",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "ping",
visibility: "public",
description: "Health check — replies pong and records last ping",
handler: async (ctx) => {
// Best-effort write — if KV is unavailable, still reply.
try {
await db?.putJSON("last_ping", { at: Date.now() });
} catch (err) {
console.warn("misc /ping: putJSON failed", String(err));
}
await ctx.reply("pong");
},
},
{
name: "mstats",
visibility: "protected",
description: "Show the timestamp of the last /ping",
handler: async (ctx) => {
const last = await db?.getJSON("last_ping");
if (last?.at) {
await ctx.reply(`last ping: ${new Date(last.at).toISOString()}`);
} else {
await ctx.reply("last ping: never");
}
},
},
{
name: "fortytwo",
visibility: "private",
description: "Easter egg — the answer",
handler: async (ctx) => {
await ctx.reply("The answer.");
},
},
],
};
export default miscModule;

181
src/modules/registry.js Normal file
View File

@@ -0,0 +1,181 @@
/**
* @file registry — loads modules listed in env.MODULES, validates their
* commands, and builds the dispatcher's lookup tables.
*
* Design highlights (see plan phase-04):
* - Static import map (./index.js) filtered at runtime; preserves tree-shaking.
* - Three visibility-partitioned maps (`publicCommands`, `protectedCommands`,
* `privateCommands`) PLUS a flat `allCommands` map used by the dispatcher.
* - Unified namespace for conflict detection: two commands with the same name
* in different modules collide regardless of visibility. Fail loud at load.
* - Built once per warm instance (memoized behind `getCurrentRegistry`).
* - `resetRegistry()` exists for tests.
*/
import { createStore } from "../db/create-store.js";
import { moduleRegistry as defaultModuleRegistry } from "./index.js";
import { validateCommand } from "./validate-command.js";
/**
* @typedef {import("./validate-command.js").ModuleCommand} ModuleCommand
*
* @typedef {Object} BotModule
* @property {string} name
* @property {ModuleCommand[]} commands
* @property {({ db, env }: { db: any, env: any }) => Promise<void>|void} [init]
*
* @typedef {Object} RegistryEntry
* @property {BotModule} module
* @property {ModuleCommand} cmd
* @property {"public"|"protected"|"private"} [visibility]
*
* @typedef {Object} Registry
* @property {Map<string, RegistryEntry>} publicCommands
* @property {Map<string, RegistryEntry>} protectedCommands
* @property {Map<string, RegistryEntry>} privateCommands
* @property {Map<string, RegistryEntry>} allCommands
* @property {BotModule[]} modules — ordered per env.MODULES for /help rendering.
*/
/** @type {Registry | null} */
let currentRegistry = null;
/**
* Parse env.MODULES → trimmed, deduped array of module names.
*
* @param {{ MODULES?: string }} env
* @returns {string[]}
*/
function parseModulesEnv(env) {
const raw = env?.MODULES;
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("MODULES env var is empty");
}
const seen = new Set();
const out = [];
for (const piece of raw.split(",")) {
const name = piece.trim();
if (name.length === 0) continue;
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
if (out.length === 0) {
throw new Error("MODULES env var is empty after trim/dedupe");
}
return out;
}
/**
* Resolve each module name to its default export and validate shape.
*
* @param {any} env
* @param {Record<string, () => Promise<{ default: BotModule }>>} [importMap]
* Optional injection for tests. Defaults to the static production map.
* @returns {Promise<BotModule[]>}
*/
export async function loadModules(env, importMap = defaultModuleRegistry) {
const names = parseModulesEnv(env);
const modules = [];
for (const name of names) {
const loader = importMap[name];
if (typeof loader !== "function") {
throw new Error(`unknown module: ${name}`);
}
const loaded = await loader();
const mod = loaded?.default;
if (!mod || typeof mod !== "object") {
throw new Error(`module "${name}" must have a default export`);
}
if (mod.name !== name) {
throw new Error(`module "${name}" default export has mismatched name "${mod.name}"`);
}
if (!Array.isArray(mod.commands)) {
throw new Error(`module "${name}" must export a commands array`);
}
for (const cmd of mod.commands) validateCommand(cmd, name);
modules.push(mod);
}
return modules;
}
/**
* Build the registry: call each module's init, flatten commands into four maps,
* detect conflicts, and memoize for later getCurrentRegistry() calls.
*
* @param {any} env
* @param {Record<string, () => Promise<{ default: BotModule }>>} [importMap]
* @returns {Promise<Registry>}
*/
export async function buildRegistry(env, importMap) {
const modules = await loadModules(env, importMap);
/** @type {Map<string, RegistryEntry>} */
const publicCommands = new Map();
/** @type {Map<string, RegistryEntry>} */
const protectedCommands = new Map();
/** @type {Map<string, RegistryEntry>} */
const privateCommands = new Map();
/** @type {Map<string, RegistryEntry>} */
const allCommands = new Map();
for (const mod of modules) {
if (typeof mod.init === "function") {
try {
await mod.init({ db: createStore(mod.name, env), env });
} catch (err) {
throw new Error(
`module "${mod.name}" init failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
for (const cmd of mod.commands) {
const existing = allCommands.get(cmd.name);
if (existing) {
throw new Error(
`command conflict: /${cmd.name} registered by both "${existing.module.name}" and "${mod.name}"`,
);
}
const entry = { module: mod, cmd, visibility: cmd.visibility };
allCommands.set(cmd.name, entry);
if (cmd.visibility === "public") publicCommands.set(cmd.name, entry);
else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry);
else privateCommands.set(cmd.name, entry);
}
}
const registry = {
publicCommands,
protectedCommands,
privateCommands,
allCommands,
modules,
};
currentRegistry = registry;
return registry;
}
/**
* @returns {Registry}
* @throws if `buildRegistry` has not been called yet.
*/
export function getCurrentRegistry() {
if (!currentRegistry) {
throw new Error("registry not built yet — call buildRegistry(env) first");
}
return currentRegistry;
}
/**
* Test helper — forget the memoized registry so each test starts clean.
*/
export function resetRegistry() {
currentRegistry = null;
}

View File

@@ -0,0 +1,69 @@
/**
* @file /help — renders public + protected commands grouped by module.
*
* /help is a pure renderer over the registry; it holds no command metadata of
* its own. Modules with zero visible (public or protected) commands are
* omitted entirely. Private commands are always skipped — that's the point.
*
* Output uses Telegram HTML parse mode. Every user-influenced substring
* (module names, command descriptions) is HTML-escaped so a rogue `<script>`
* in a description renders literally.
*/
import { escapeHtml } from "../../util/escape-html.js";
import { getCurrentRegistry } from "../registry.js";
/**
* @typedef {import("../validate-command.js").ModuleCommand} ModuleCommand
*/
/**
* Pure render step — exported separately so tests can assert on the string
* without instantiating a bot context.
*
* @param {import("../registry.js").Registry} reg
* @returns {string}
*/
export function renderHelp(reg) {
/** @type {Map<string, string[]>} */
const byModule = new Map();
const visibleMaps = [
{ map: reg.publicCommands, suffix: "" },
{ map: reg.protectedCommands, suffix: " (protected)" },
];
for (const { map, suffix } of visibleMaps) {
for (const entry of map.values()) {
const modName = entry.module.name;
const line = `/${entry.cmd.name}${escapeHtml(entry.cmd.description)}${suffix}`;
const existing = byModule.get(modName);
if (existing) existing.push(line);
else byModule.set(modName, [line]);
}
}
// Render in env.MODULES order (reg.modules is already in that order).
const sections = [];
for (const mod of reg.modules) {
const lines = byModule.get(mod.name);
if (!lines || lines.length === 0) continue;
sections.push(`<b>${escapeHtml(mod.name)}</b>\n${lines.join("\n")}`);
}
return sections.length > 0 ? sections.join("\n\n") : "no commands registered";
}
/** @type {ModuleCommand} */
export const helpCommand = {
name: "help",
visibility: "public",
description: "Show all available commands",
handler: async (ctx) => {
const reg = getCurrentRegistry();
const text = renderHelp(reg);
await ctx.reply(text, { parse_mode: "HTML" });
},
};
export default helpCommand;

18
src/modules/util/index.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* @file util module — /info and /help.
*
* The only "fully implemented" module in v1. /help is a pure renderer over
* the current registry, so it has no module-specific state. /info just reads
* the grammY context.
*/
import { helpCommand } from "./help-command.js";
import { infoCommand } from "./info-command.js";
/** @type {import("../registry.js").BotModule} */
const utilModule = {
name: "util",
commands: [infoCommand, helpCommand],
};
export default utilModule;

View File

@@ -0,0 +1,22 @@
/**
* @file /info — debug helper that echoes chat id, thread id, and sender id.
*
* Plain text reply (no parse mode). `message_thread_id` is only present for
* forum-topic messages, so it's optional with a "n/a" fallback so debug users
* know the field was checked.
*/
/** @type {import("../validate-command.js").ModuleCommand} */
export const infoCommand = {
name: "info",
visibility: "public",
description: "Show chat id, thread id, and sender id (debug helper)",
handler: async (ctx) => {
const chatId = ctx.chat?.id ?? "n/a";
const threadId = ctx.message?.message_thread_id ?? "n/a";
const senderId = ctx.from?.id ?? "n/a";
await ctx.reply(`chat id: ${chatId}\nthread id: ${threadId}\nsender id: ${senderId}`);
},
};
export default infoCommand;

View File

@@ -0,0 +1,74 @@
/**
* @file validate-command — shared validators for module-registered commands.
*
* Enforces the contract defined in phase-04 of the plan:
* - visibility ∈ {public, protected, private}
* - name matches /^[a-z0-9_]{1,32}$/ (Telegram's slash-command limit; applied
* uniformly across all visibility levels because private commands are also
* slash commands, just hidden from the menu and /help)
* - description is non-empty, ≤256 chars (Telegram's setMyCommands limit)
* - handler is a function
*
* All errors include the module and command name for debuggability.
*/
export const VISIBILITIES = Object.freeze(["public", "protected", "private"]);
export const COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
export const MAX_DESCRIPTION_LENGTH = 256;
/**
* @typedef {Object} ModuleCommand
* @property {string} name — without leading slash; matches COMMAND_NAME_RE.
* @property {"public"|"protected"|"private"} visibility
* @property {string} description — ≤256 chars; required for all visibilities.
* @property {(ctx: any) => Promise<void>|void} handler
*/
/**
* Throws on any contract violation. Called once per command at registry build.
*
* @param {ModuleCommand} cmd
* @param {string} moduleName — for error messages.
*/
export function validateCommand(cmd, moduleName) {
const prefix = `module "${moduleName}" command`;
if (!cmd || typeof cmd !== "object") {
throw new Error(`${prefix}: command entry is not an object`);
}
// visibility
if (!VISIBILITIES.includes(cmd.visibility)) {
throw new Error(
`${prefix} "${cmd.name}": visibility must be one of ${VISIBILITIES.join("|")}, got "${cmd.visibility}"`,
);
}
// name
if (typeof cmd.name !== "string") {
throw new Error(`${prefix}: name must be a string`);
}
if (cmd.name.startsWith("/")) {
throw new Error(`${prefix} "${cmd.name}": name must not start with "/"`);
}
if (!COMMAND_NAME_RE.test(cmd.name)) {
throw new Error(
`${prefix} "${cmd.name}": name must match ${COMMAND_NAME_RE} (lowercase letters, digits, underscore; 132 chars)`,
);
}
// description — required for all visibilities (private commands need it for internal debugging)
if (typeof cmd.description !== "string" || cmd.description.length === 0) {
throw new Error(`${prefix} "${cmd.name}": description is required`);
}
if (cmd.description.length > MAX_DESCRIPTION_LENGTH) {
throw new Error(
`${prefix} "${cmd.name}": description exceeds ${MAX_DESCRIPTION_LENGTH} chars (got ${cmd.description.length})`,
);
}
// handler
if (typeof cmd.handler !== "function") {
throw new Error(`${prefix} "${cmd.name}": handler must be a function`);
}
}

View File

@@ -0,0 +1,48 @@
/**
* @file wordle module stub — proves the plugin system end-to-end.
*
* One public, one protected, one private (hidden) slash command. Real game
* logic is out of scope for v1; this exercises the loader, visibility levels,
* registry, dispatcher, help renderer, and namespaced DB.
*/
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const wordleModule = {
name: "wordle",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "wordle",
visibility: "public",
description: "Play wordle (stub)",
handler: async (ctx) => {
await ctx.reply("Wordle stub — real game TBD.");
},
},
{
name: "wstats",
visibility: "protected",
description: "Wordle stats",
handler: async (ctx) => {
const stats = (await db?.getJSON("stats")) ?? null;
const played = stats?.gamesPlayed ?? 0;
await ctx.reply(`games played: ${played}`);
},
},
{
name: "konami",
visibility: "private",
description: "Easter egg — retro code",
handler: async (ctx) => {
await ctx.reply("⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)");
},
},
],
};
export default wordleModule;

21
src/util/escape-html.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* @file escape-html — minimal HTML entity escaper for Telegram HTML parse mode.
*
* Telegram's HTML parse mode needs only four characters escaped: &, <, >, ".
* Keep this as a tiny hand-rolled function — pulling in a library for four
* replacements is YAGNI.
*
* @see https://core.telegram.org/bots/api#html-style
*/
/**
* @param {unknown} s — coerced to string.
* @returns {string}
*/
export function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CFKVStore } from "../../src/db/cf-kv-store.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
describe("CFKVStore", () => {
let kv;
let store;
beforeEach(() => {
kv = makeFakeKv();
store = new CFKVStore(kv);
});
it("throws when given no binding", () => {
expect(() => new CFKVStore(null)).toThrow(/required/);
});
it("round-trips get/put/delete", async () => {
expect(await store.get("k")).toBeNull();
await store.put("k", "v");
expect(await store.get("k")).toBe("v");
await store.delete("k");
expect(await store.get("k")).toBeNull();
});
it("list() returns normalized shape with module keys stripped of wrappers", async () => {
await store.put("a:1", "x");
await store.put("a:2", "y");
await store.put("b:1", "z");
const res = await store.list({ prefix: "a:" });
expect(res.keys.sort()).toEqual(["a:1", "a:2"]);
expect(res.done).toBe(true);
});
it("list() pagination — cursor propagates through", async () => {
for (let i = 0; i < 5; i++) await store.put(`k${i}`, String(i));
const page1 = await store.list({ limit: 2 });
expect(page1.keys.length).toBe(2);
expect(page1.done).toBe(false);
expect(page1.cursor).toBeTruthy();
const page2 = await store.list({ limit: 2, cursor: page1.cursor });
expect(page2.keys.length).toBe(2);
expect(page2.done).toBe(false);
const page3 = await store.list({ limit: 2, cursor: page2.cursor });
expect(page3.keys.length).toBe(1);
expect(page3.done).toBe(true);
expect(page3.cursor).toBeUndefined();
});
it("put() with expirationTtl passes through to binding", async () => {
await store.put("k", "v", { expirationTtl: 60 });
expect(kv.putLog).toEqual([{ key: "k", value: "v", opts: { expirationTtl: 60 } }]);
});
it("put() without options does NOT pass an options object", async () => {
await store.put("k", "v");
expect(kv.putLog[0]).toEqual({ key: "k", value: "v", opts: undefined });
});
it("getJSON/putJSON round-trip", async () => {
await store.putJSON("k", { a: 1, b: [2, 3] });
expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] });
});
it("getJSON returns null on missing key", async () => {
expect(await store.getJSON("missing")).toBeNull();
});
it("getJSON returns null on corrupt JSON and logs a warning", async () => {
kv.store.set("bad", "{not json");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await store.getJSON("bad")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("putJSON throws on undefined value", async () => {
await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/);
});
it("putJSON passes expirationTtl through", async () => {
await store.putJSON("k", { a: 1 }, { expirationTtl: 120 });
expect(kv.putLog[0].opts).toEqual({ expirationTtl: 120 });
});
});

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createStore } from "../../src/db/create-store.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
describe("createStore (prefixing factory)", () => {
let kv;
let env;
beforeEach(() => {
kv = makeFakeKv();
env = { KV: kv };
});
it("rejects invalid module names", () => {
expect(() => createStore("", env)).toThrow();
expect(() => createStore("BadName", env)).toThrow(/invalid/);
expect(() => createStore("has:colon", env)).toThrow(/invalid/);
expect(() => createStore("ok_name", env)).not.toThrow();
expect(() => createStore("kebab-ok", env)).not.toThrow();
});
it("rejects missing env.KV", () => {
expect(() => createStore("wordle", {})).toThrow(/KV/);
});
it("put() writes raw key with module: prefix", async () => {
const store = createStore("wordle", env);
await store.put("games_played", "42");
expect(kv.store.get("wordle:games_played")).toBe("42");
});
it("get() reads through prefix", async () => {
const store = createStore("wordle", env);
await store.put("k", "v");
expect(await store.get("k")).toBe("v");
});
it("delete() deletes the prefixed raw key", async () => {
const store = createStore("wordle", env);
await store.put("k", "v");
await store.delete("k");
expect(kv.store.has("wordle:k")).toBe(false);
});
it("list() combines module prefix + caller prefix and strips the module prefix on return", async () => {
kv.store.set("wordle:games:1", "a");
kv.store.set("wordle:games:2", "b");
kv.store.set("wordle:stats", "c");
kv.store.set("loldle:games:1", "other");
const store = createStore("wordle", env);
const res = await store.list({ prefix: "games:" });
expect(res.keys.sort()).toEqual(["games:1", "games:2"]);
});
it("two stores are fully isolated — one cannot see the other's keys", async () => {
const wordle = createStore("wordle", env);
const loldle = createStore("loldle", env);
await wordle.put("secret", "w");
await loldle.put("secret", "l");
expect(await wordle.get("secret")).toBe("w");
expect(await loldle.get("secret")).toBe("l");
const wList = await wordle.list();
const lList = await loldle.list();
expect(wList.keys).toEqual(["secret"]);
expect(lList.keys).toEqual(["secret"]);
});
it("putJSON/getJSON through a prefixed store persist at <module>:<key>", async () => {
const store = createStore("misc", env);
await store.putJSON("last_ping", { at: 123 });
expect(kv.store.get("misc:last_ping")).toBe('{"at":123}');
expect(await store.getJSON("last_ping")).toEqual({ at: 123 });
});
});

24
tests/fakes/fake-bot.js Normal file
View File

@@ -0,0 +1,24 @@
/**
* @file fake-bot — records bot.command() calls for dispatcher tests.
*
* We never import real grammY in unit tests — everything the dispatcher
* touches on the bot object is recorded here for assertions.
*/
export function makeFakeBot() {
/** @type {Array<{name: string, handler: Function}>} */
const commandCalls = [];
/** @type {Array<{event: string, handler: Function}>} */
const onCalls = [];
return {
commandCalls,
onCalls,
command(name, handler) {
commandCalls.push({ name, handler });
},
on(event, handler) {
onCalls.push({ event, handler });
},
};
}

View File

@@ -0,0 +1,46 @@
/**
* @file fake-kv-namespace — in-memory `Map`-backed KVNamespace for tests.
*
* Matches the real CF KVNamespace shape that `CFKVStore` depends on:
* get(key, {type: 'text'}) → string | null
* put(key, value, opts?) → stores, records opts for assertion
* delete(key) → removes
* list({prefix, limit, cursor}) → { keys: [{name}], list_complete, cursor }
*
* `listLimit` is applied BEFORE `list_complete` is computed so tests can
* exercise pagination.
*/
export function makeFakeKv() {
/** @type {Map<string, string>} */
const store = new Map();
/** @type {Array<{key: string, value: string, opts?: any}>} */
const putLog = [];
return {
store,
putLog,
async get(key, _opts) {
return store.has(key) ? store.get(key) : null;
},
async put(key, value, opts) {
store.set(key, value);
putLog.push({ key, value, opts });
},
async delete(key) {
store.delete(key);
},
async list({ prefix = "", limit = 1000, cursor } = {}) {
const allKeys = [...store.keys()].filter((k) => k.startsWith(prefix)).sort();
const start = cursor ? Number.parseInt(cursor, 10) : 0;
const slice = allKeys.slice(start, start + limit);
const nextStart = start + slice.length;
const complete = nextStart >= allKeys.length;
return {
keys: slice.map((name) => ({ name })),
list_complete: complete,
cursor: complete ? undefined : String(nextStart),
};
},
};
}

View File

@@ -0,0 +1,29 @@
/**
* @file fake-modules — fixture modules + a helper that builds an import-map
* shape compatible with `loadModules(env, importMap)`.
*
* Using dependency injection (instead of `vi.mock`) sidesteps path-resolution
* flakiness on Windows and keeps tests fully deterministic.
*/
/**
* @param {Record<string, import("../../src/modules/registry.js").BotModule>} modules
*/
export function makeFakeImportMap(modules) {
/** @type {Record<string, () => Promise<{default: any}>>} */
const map = {};
for (const [name, mod] of Object.entries(modules)) {
map[name] = async () => ({ default: mod });
}
return map;
}
export const noopHandler = async () => {};
export function makeCommand(name, visibility, description = "fixture command") {
return { name, visibility, description, handler: noopHandler };
}
export function makeModule(name, commands, init) {
return init ? { name, commands, init } : { name, commands };
}

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from "vitest";
import { installDispatcher } from "../../src/modules/dispatcher.js";
import { resetRegistry } from "../../src/modules/registry.js";
import { makeFakeBot } from "../fakes/fake-bot.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
// The dispatcher imports the registry's default static map at module-load
// time, so to keep the test hermetic we need to stub env.MODULES with names
// that resolve in the REAL map — OR we call buildRegistry directly with an
// injected map and then simulate what installDispatcher does. Since phase-04
// made loadModules accept an importMap injection but installDispatcher
// internally calls buildRegistry WITHOUT an injection, we test the same
// effect by wiring the bot manually against a registry built with a fake map.
//
// Keeping it simple: call installDispatcher against the real module map
// (util + wordle + loldle + misc) so we exercise the actual production path.
describe("installDispatcher", () => {
beforeEach(() => resetRegistry());
it("registers EVERY command via bot.command() regardless of visibility", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
// Expect 11 total commands (5 public + 3 protected + 3 private).
expect(bot.commandCalls).toHaveLength(11);
expect(reg.allCommands.size).toBe(11);
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
const expected = [...reg.allCommands.keys()].sort();
expect(registeredNames).toEqual(expected);
// Assert private commands ARE registered (the whole point of unified routing).
expect(registeredNames).toContain("konami");
expect(registeredNames).toContain("ggwp");
expect(registeredNames).toContain("fortytwo");
});
it("does NOT install any bot.on() middleware — dispatcher is minimal", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util", KV: makeFakeKv() };
await installDispatcher(bot, env);
expect(bot.onCalls).toHaveLength(0);
});
it("each registered handler matches the source module command handler", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
for (const { name, handler } of bot.commandCalls) {
expect(handler).toBe(reg.allCommands.get(name).cmd.handler);
}
});
});

View File

@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildRegistry,
getCurrentRegistry,
loadModules,
resetRegistry,
} from "../../src/modules/registry.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
const makeEnv = (modules) => ({ MODULES: modules, KV: makeFakeKv() });
describe("registry", () => {
beforeEach(() => resetRegistry());
describe("loadModules", () => {
it("loads modules listed in env.MODULES", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("bar", "public")]),
});
const mods = await loadModules({ MODULES: "a,b" }, map);
expect(mods.map((m) => m.name)).toEqual(["a", "b"]);
});
it("trims, dedupes, skips empty", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
const mods = await loadModules({ MODULES: " a , a , " }, map);
expect(mods).toHaveLength(1);
});
it("throws on empty MODULES", async () => {
await expect(loadModules({ MODULES: "" }, {})).rejects.toThrow(/empty/);
await expect(loadModules({}, {})).rejects.toThrow(/empty/);
});
it("throws on unknown module name", async () => {
await expect(loadModules({ MODULES: "ghost" }, {})).rejects.toThrow(/unknown module: ghost/);
});
it("throws when default export name mismatches key", async () => {
const map = makeFakeImportMap({ a: makeModule("b", []) });
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/mismatched name/);
});
it("throws when module has no commands array", async () => {
const map = {
a: async () => ({ default: { name: "a" } }),
};
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/commands array/);
});
it("runs validateCommand on each command", async () => {
const badMap = makeFakeImportMap({
a: makeModule("a", [
{ name: "/bad", visibility: "public", description: "x", handler: async () => {} },
]),
});
await expect(loadModules({ MODULES: "a" }, badMap)).rejects.toThrow();
});
});
describe("buildRegistry", () => {
it("partitions commands across visibility maps + flat allCommands", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [
makeCommand("pub", "public"),
makeCommand("prot", "protected"),
makeCommand("priv", "private"),
]),
});
const reg = await buildRegistry(makeEnv("a"), map);
expect([...reg.publicCommands.keys()]).toEqual(["pub"]);
expect([...reg.protectedCommands.keys()]).toEqual(["prot"]);
expect([...reg.privateCommands.keys()]).toEqual(["priv"]);
expect([...reg.allCommands.keys()].sort()).toEqual(["priv", "prot", "pub"]);
});
it("throws on same-visibility conflict", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("foo", "public")]),
});
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo.*a.*b/);
});
it("throws on CROSS-visibility conflict (unified namespace)", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("foo", "private")]),
});
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo/);
});
it("calls init({db, env}) exactly once per module, in order", async () => {
const calls = [];
const makeInit =
(name) =>
async ({ db, env }) => {
calls.push({ name, hasDb: !!db, hasEnv: !!env });
await db.put("sentinel", "x");
};
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("one", "public")], makeInit("a")),
b: makeModule("b", [makeCommand("two", "public")], makeInit("b")),
});
const env = makeEnv("a,b");
await buildRegistry(env, map);
expect(calls).toEqual([
{ name: "a", hasDb: true, hasEnv: true },
{ name: "b", hasDb: true, hasEnv: true },
]);
// Assert prefixing: keys landed as "a:sentinel" and "b:sentinel".
expect(env.KV.store.get("a:sentinel")).toBe("x");
expect(env.KV.store.get("b:sentinel")).toBe("x");
});
it("wraps init errors with module name", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")], async () => {
throw new Error("boom");
}),
});
await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(
/module "a" init failed.*boom/,
);
});
});
describe("getCurrentRegistry / resetRegistry", () => {
it("getCurrentRegistry throws before build", () => {
expect(() => getCurrentRegistry()).toThrow(/not built/);
});
it("getCurrentRegistry returns the same instance after build", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
const reg = await buildRegistry(makeEnv("a"), map);
expect(getCurrentRegistry()).toBe(reg);
});
it("resetRegistry clears the memoized registry", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
await buildRegistry(makeEnv("a"), map);
resetRegistry();
expect(() => getCurrentRegistry()).toThrow(/not built/);
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { renderHelp } from "../../../src/modules/util/help-command.js";
const noop = async () => {};
/**
* Build a synthetic registry directly — no loader involved. Lets us test
* the pure renderer with exact inputs.
*/
function makeRegistry(modules) {
const publicCommands = new Map();
const protectedCommands = new Map();
const privateCommands = new Map();
const allCommands = new Map();
for (const mod of modules) {
for (const cmd of mod.commands) {
const entry = { module: mod, cmd, visibility: cmd.visibility };
allCommands.set(cmd.name, entry);
if (cmd.visibility === "public") publicCommands.set(cmd.name, entry);
else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry);
else privateCommands.set(cmd.name, entry);
}
}
return {
publicCommands,
protectedCommands,
privateCommands,
allCommands,
modules,
};
}
const cmd = (name, visibility, description) => ({
name,
visibility,
description,
handler: noop,
});
describe("renderHelp", () => {
it("groups commands by module in env.MODULES order", () => {
const reg = makeRegistry([
{ name: "a", commands: [cmd("one", "public", "A-one")] },
{ name: "b", commands: [cmd("two", "public", "B-two")] },
]);
const out = renderHelp(reg);
const aIdx = out.indexOf("<b>a</b>");
const bIdx = out.indexOf("<b>b</b>");
expect(aIdx).toBeGreaterThanOrEqual(0);
expect(bIdx).toBeGreaterThanOrEqual(0);
expect(aIdx).toBeLessThan(bIdx);
});
it("appends (protected) suffix to protected commands", () => {
const reg = makeRegistry([{ name: "a", commands: [cmd("admin", "protected", "Admin tool")] }]);
const out = renderHelp(reg);
expect(out).toContain("/admin — Admin tool (protected)");
});
it("hides modules whose only commands are private", () => {
const reg = makeRegistry([
{ name: "a", commands: [cmd("visible", "public", "V")] },
{ name: "b", commands: [cmd("hidden", "private", "H")] },
]);
const out = renderHelp(reg);
expect(out).toContain("<b>a</b>");
expect(out).not.toContain("<b>b</b>");
expect(out).not.toContain("hidden");
});
it("NEVER leaks private command names into output", () => {
const reg = makeRegistry([
{
name: "a",
commands: [cmd("show", "public", "Public"), cmd("secret", "private", "Secret")],
},
]);
const out = renderHelp(reg);
expect(out).toContain("/show");
expect(out).not.toContain("/secret");
expect(out).not.toContain("Secret");
});
it("HTML-escapes module name and description", () => {
const reg = makeRegistry([
{
name: "a&b",
commands: [cmd("foo", "public", "runs <script>")],
},
]);
const out = renderHelp(reg);
expect(out).toContain("<b>a&amp;b</b>");
expect(out).toContain("runs &lt;script&gt;");
// The literal unescaped sequence must NOT appear in output.
expect(out).not.toContain("<script>");
});
it("returns a placeholder when no commands are visible", () => {
const reg = makeRegistry([{ name: "a", commands: [cmd("hidden", "private", "H")] }]);
expect(renderHelp(reg)).toBe("no commands registered");
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { validateCommand } from "../../src/modules/validate-command.js";
const noop = async () => {};
const base = (overrides = {}) => ({
name: "foo",
visibility: "public",
description: "does foo",
handler: noop,
...overrides,
});
describe("validateCommand", () => {
it("accepts valid public/protected/private commands", () => {
expect(() => validateCommand(base(), "m")).not.toThrow();
expect(() => validateCommand(base({ visibility: "protected" }), "m")).not.toThrow();
expect(() => validateCommand(base({ visibility: "private" }), "m")).not.toThrow();
});
it("rejects invalid visibility", () => {
expect(() => validateCommand(base({ visibility: "secret" }), "m")).toThrow(/visibility/);
});
it("rejects leading slash in name", () => {
expect(() => validateCommand(base({ name: "/foo" }), "m")).toThrow();
});
it("rejects uppercase in name", () => {
expect(() => validateCommand(base({ name: "Foo" }), "m")).toThrow();
});
it("rejects name > 32 chars", () => {
expect(() => validateCommand(base({ name: "a".repeat(33) }), "m")).toThrow();
});
it("rejects missing or empty description for all visibilities", () => {
expect(() => validateCommand(base({ description: "" }), "m")).toThrow(/description/);
expect(() => validateCommand(base({ visibility: "private", description: "" }), "m")).toThrow(
/description/,
);
});
it("rejects description > 256 chars", () => {
expect(() => validateCommand(base({ description: "x".repeat(257) }), "m")).toThrow(/256/);
});
it("rejects non-function handler", () => {
expect(() => validateCommand(base({ handler: null }), "m")).toThrow(/handler/);
});
it("error messages include module + command name", () => {
try {
validateCommand(base({ name: "Bad" }), "wordle");
} catch (err) {
expect(err.message).toContain("wordle");
expect(err.message).toContain("Bad");
}
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { escapeHtml } from "../../src/util/escape-html.js";
describe("escapeHtml", () => {
it('escapes &, <, >, "', () => {
expect(escapeHtml('&<>"')).toBe("&amp;&lt;&gt;&quot;");
});
it("leaves safe characters alone", () => {
expect(escapeHtml("hello world 123 /cmd — émoji 🎉")).toBe("hello world 123 /cmd — émoji 🎉");
});
it("escapes in order so ampersand doesn't double-escape later entities", () => {
// Input has literal & and <, output should have &amp; and &lt; — NOT &amp;lt;
expect(escapeHtml("a & <b>")).toBe("a &amp; &lt;b&gt;");
});
it("coerces non-strings", () => {
expect(escapeHtml(42)).toBe("42");
expect(escapeHtml(null)).toBe("null");
});
});

15
vitest.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
// Pure-logic tests only. No workerd pool — we stub KV and bot in-memory.
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.js"],
passWithNoTests: true,
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.js"],
},
},
});

22
wrangler.toml Normal file
View File

@@ -0,0 +1,22 @@
name = "miti99bot"
main = "src/index.js"
compatibility_date = "2025-10-01"
# Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
[vars]
MODULES = "util,wordle,loldle,misc"
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
# Create with:
# wrangler kv namespace create miti99bot-kv
# wrangler kv namespace create miti99bot-kv --preview
# then paste the returned IDs below.
[[kv_namespaces]]
binding = "KV"
id = "REPLACE_ME"
preview_id = "REPLACE_ME"
# Secrets (set via `wrangler secret put <name>`, NOT in this file):
# TELEGRAM_BOT_TOKEN — bot token from @BotFather
# TELEGRAM_WEBHOOK_SECRET — arbitrary high-entropy string, also set in .env.deploy