mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 11:20:53 +00:00
feat: scaffold plug-n-play telegram bot on cloudflare workers
grammY-based bot with a module plugin system loaded from the MODULES env var. Three command visibility levels (public/protected/private) share a unified command namespace with conflict detection at registry build. - 4 initial modules (util, wordle, loldle, misc); util fully implemented, others are stubs proving the plugin system end-to-end - util: /info (chat/thread/sender ids) + /help (pure renderer over the registry, HTML parse mode, escapes user-influenced strings) - KVStore interface with CFKVStore and a per-module prefixing factory; getJSON/putJSON convenience helpers; other backends drop in via one file - Webhook at POST /webhook with secret-token validation via grammY's webhookCallback; no admin HTTP surface - Post-deploy register script (npm run deploy = wrangler deploy && node --env-file=.env.deploy scripts/register.js) for setWebhook and setMyCommands; --dry-run flag for preview - 56 vitest unit tests across 7 suites covering registry, db wrapper, dispatcher, help renderer, validators, and HTML escaper - biome for lint + format; phased implementation plan under plans/
This commit is contained in:
4
.dev.vars.example
Normal file
4
.dev.vars.example
Normal 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
15
.env.deploy.example
Normal 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
6
.gitignore
vendored
@@ -69,6 +69,12 @@ web_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.deploy.example
|
||||
|
||||
# Cloudflare Wrangler local state + secrets
|
||||
.dev.vars
|
||||
!.dev.vars.example
|
||||
.wrangler/
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
145
README.md
145
README.md
@@ -1,2 +1,145 @@
|
||||
# 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
33
biome.json
Normal 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
166
docs/adding-a-module.md
Normal 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
3228
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# Phase 01 — Scaffold project
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Reports: [wrangler + secrets](../reports/researcher-260411-0853-wrangler-config-secrets.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (blocker for everything)
|
||||
- **Status:** pending
|
||||
- **Description:** bootstrap repo structure, package.json, wrangler.toml template, biome, vitest, .gitignore, .dev.vars.example. No runtime code yet.
|
||||
|
||||
## Key Insights
|
||||
- `nodejs_compat` flag NOT needed — grammY + our code uses Web APIs only. Keeps bundle small.
|
||||
- `MODULES` must be declared as a comma-separated string in `[vars]` (wrangler does not accept top-level TOML arrays in `[vars]`).
|
||||
- `.dev.vars` is a dotenv-format file for local secrets — gitignore it. Commit `.dev.vars.example`.
|
||||
- biome handles lint + format in one binary. Single config file (`biome.json`). No eslint/prettier.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `npm install` produces a working dev environment.
|
||||
- `npm run dev` starts `wrangler dev` on localhost.
|
||||
- `npm run lint` / `npm run format` work via biome.
|
||||
- `npm test` runs vitest (zero tests initially — exit 0).
|
||||
- `wrangler deploy` pushes to CF (will fail without real KV ID — expected).
|
||||
|
||||
### Non-functional
|
||||
- No TypeScript. `.js` + JSDoc.
|
||||
- Zero extra dev deps beyond: `wrangler`, `grammy`, `@biomejs/biome`, `vitest`.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
miti99bot/
|
||||
├── src/
|
||||
│ └── (empty — filled by later phases)
|
||||
├── scripts/
|
||||
│ └── (empty — register.js added in phase-07)
|
||||
├── tests/
|
||||
│ └── (empty)
|
||||
├── package.json
|
||||
├── wrangler.toml
|
||||
├── biome.json
|
||||
├── vitest.config.js
|
||||
├── .dev.vars.example
|
||||
├── .env.deploy.example
|
||||
├── .gitignore # add: node_modules, .dev.vars, .env.deploy, .wrangler, dist
|
||||
├── README.md # (already exists — updated in phase-09)
|
||||
└── LICENSE # (already exists)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `package.json`
|
||||
- `wrangler.toml`
|
||||
- `biome.json`
|
||||
- `vitest.config.js`
|
||||
- `.dev.vars.example`
|
||||
- `.env.deploy.example`
|
||||
|
||||
### Modify
|
||||
- `.gitignore` (add `node_modules/`, `.dev.vars`, `.env.deploy`, `.wrangler/`, `dist/`, `coverage/`)
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. `npm init -y`, then edit `package.json`:
|
||||
- `"type": "module"`
|
||||
- scripts:
|
||||
- `dev` → `wrangler dev`
|
||||
- `deploy` → `wrangler deploy && npm run register`
|
||||
- `register` → `node --env-file=.env.deploy scripts/register.js` (auto-runs `setWebhook` + `setMyCommands` — see phase-07)
|
||||
- `lint` → `biome check src tests scripts`
|
||||
- `format` → `biome format --write src tests scripts`
|
||||
- `test` → `vitest run`
|
||||
2. `npm install grammy`
|
||||
3. `npm install -D wrangler @biomejs/biome vitest`
|
||||
4. Pin versions by checking `npm view <pkg> version` and recording exact versions.
|
||||
5. Create `wrangler.toml` from template in research report — leave KV IDs as `REPLACE_ME`.
|
||||
6. Create `biome.json` with defaults + 2-space indent, double quotes, semicolons.
|
||||
7. Create `vitest.config.js` with `environment: "node"` (pure logic tests only, no workerd pool).
|
||||
8. Create `.dev.vars.example` (local dev secrets used by `wrangler dev`):
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
```
|
||||
9. Create `.env.deploy.example` (consumed by `scripts/register.js` in phase-07; loaded via `node --env-file`):
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
WORKER_URL=https://<worker-subdomain>.workers.dev
|
||||
```
|
||||
10. Append `.gitignore` entries.
|
||||
10. `npm run lint` + `npm test` + `wrangler --version` — all succeed.
|
||||
|
||||
## Todo List
|
||||
- [ ] `npm init`, set `type: module`, scripts
|
||||
- [ ] Install runtime + dev deps
|
||||
- [ ] `wrangler.toml` template
|
||||
- [ ] `biome.json`
|
||||
- [ ] `vitest.config.js`
|
||||
- [ ] `.dev.vars.example`
|
||||
- [ ] Update `.gitignore`
|
||||
- [ ] Smoke-run `npm run lint` / `npm test`
|
||||
|
||||
## Success Criteria
|
||||
- `npm install` exits 0.
|
||||
- `npm run lint` exits 0 (nothing to lint yet — biome treats this as pass).
|
||||
- `npm test` exits 0.
|
||||
- `npx wrangler --version` prints a version.
|
||||
- `git status` shows no tracked `.dev.vars`, no `node_modules`.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| wrangler version pins conflict with Node version | Low | Med | Require Node ≥ 20 in `engines` field |
|
||||
| biome default rules too strict | Med | Low | Start with `recommended`; relax only if blocking |
|
||||
| `type: module` trips vitest | Low | Low | vitest supports ESM natively |
|
||||
|
||||
## Security Considerations
|
||||
- `.dev.vars` MUST be gitignored. Double-check before first commit.
|
||||
- `wrangler.toml` MUST NOT contain any secret values — only `[vars]` for non-sensitive `MODULES`.
|
||||
|
||||
## Next Steps
|
||||
- Phase 02 needs the fetch handler skeleton in `src/index.js`.
|
||||
- Phase 03 needs the KV binding wired in `wrangler.toml` (already scaffolded here).
|
||||
@@ -0,0 +1,101 @@
|
||||
# Phase 02 — Webhook entrypoint
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** pending
|
||||
- **Description:** fetch handler with URL routing (`/webhook`, `GET /` health, 404 otherwise), memoized `Bot` instance, grammY webhook secret-token validation wired through `webhookCallback`. Webhook + command-menu registration with Telegram is handled OUT OF BAND via a post-deploy node script (phase-07) — the Worker itself exposes no admin surface.
|
||||
|
||||
## Key Insights
|
||||
- Use **`"cloudflare-mod"`** adapter (NOT `"cloudflare"` — that's the legacy service-worker variant).
|
||||
- `webhookCallback(bot, "cloudflare-mod", { secretToken })` delegates `X-Telegram-Bot-Api-Secret-Token` validation to grammY — no manual header parsing.
|
||||
- Bot instance must be memoized at module scope but lazily constructed (env not available at import time).
|
||||
- No admin HTTP surface on the Worker — `setWebhook` + `setMyCommands` run from a local node script at deploy time, not via the Worker.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `POST /webhook` → delegate to `webhookCallback`. Wrong/missing secret → 401 (handled by grammY).
|
||||
- `GET /` → 200 `"miti99bot ok"` (health check, unauthenticated).
|
||||
- Anything else → 404.
|
||||
|
||||
### Non-functional
|
||||
- Single `fetch` function, <80 LOC.
|
||||
- No top-level await.
|
||||
- No global state besides memoized Bot.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
Request
|
||||
│
|
||||
▼
|
||||
fetch(req, env, ctx)
|
||||
│
|
||||
├── GET / → 200 "ok"
|
||||
├── POST /webhook → webhookCallback(bot, "cloudflare-mod", {secretToken})(req)
|
||||
└── * → 404
|
||||
```
|
||||
|
||||
`getBot(env)` lazily constructs and memoizes the `Bot`, installs dispatcher middleware (from phase-04), and returns the instance.
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `src/index.js` (fetch handler + URL router)
|
||||
- `src/bot.js` (memoized `getBot(env)` factory — wires grammY middleware from registry/dispatcher)
|
||||
|
||||
### Modify
|
||||
- none
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. Create `src/index.js`:
|
||||
- Import `getBot` from `./bot.js`.
|
||||
- Export default object with `async fetch(request, env, ctx)`.
|
||||
- Parse `new URL(request.url)`, switch on `pathname`.
|
||||
- For `POST /webhook`: `return webhookCallback(getBot(env), "cloudflare-mod", { secretToken: env.TELEGRAM_WEBHOOK_SECRET })(request)`.
|
||||
- For `GET /`: return 200 `"miti99bot ok"`.
|
||||
- Default: 404.
|
||||
2. Create `src/bot.js`:
|
||||
- Module-scope `let botInstance = null`.
|
||||
- `export function getBot(env)`:
|
||||
- If `botInstance` exists, return it.
|
||||
- Construct `new Bot(env.TELEGRAM_BOT_TOKEN)`.
|
||||
- `installDispatcher(bot, env)` — imported from `src/modules/dispatcher.js` (phase-04 — stub import now, real impl later).
|
||||
- Assign + return.
|
||||
- Temporary stub: if `installDispatcher` not yet implemented, create a placeholder function in `src/modules/dispatcher.js` that does nothing so this phase compiles.
|
||||
3. Env validation: on first `getBot` call, throw if `TELEGRAM_BOT_TOKEN` / `TELEGRAM_WEBHOOK_SECRET` / `MODULES` missing. Fail fast is a feature.
|
||||
4. `npm run lint` — fix any issues.
|
||||
5. `wrangler dev` — hit `GET /` locally, confirm 200. Hit `POST /webhook` without secret header, confirm 401.
|
||||
|
||||
## Todo List
|
||||
- [ ] `src/index.js` fetch handler + URL router
|
||||
- [ ] `src/bot.js` memoized factory
|
||||
- [ ] Placeholder `src/modules/dispatcher.js` exporting `installDispatcher(bot, env)` no-op
|
||||
- [ ] Env var validation with clear error messages
|
||||
- [ ] Manual smoke test via `wrangler dev`
|
||||
|
||||
## Success Criteria
|
||||
- `GET /` returns 200 `"miti99bot ok"`.
|
||||
- `POST /webhook` without header → 401 (via grammY).
|
||||
- `POST /webhook` with correct `X-Telegram-Bot-Api-Secret-Token` header and a valid Telegram update JSON body → 200.
|
||||
- Unknown path → 404.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Wrong adapter string breaks webhook | Low | High | Pin `"cloudflare-mod"`; test with `wrangler dev` + curl |
|
||||
| Memoized Bot leaks state between deploys | Low | Low | Warm-restart resets module scope; documented behavior |
|
||||
| Cold-start latency from first Bot() construction | Med | Low | Acceptable for bot use case |
|
||||
|
||||
## Security Considerations
|
||||
- `TELEGRAM_WEBHOOK_SECRET` MUST be configured before enabling webhook in Telegram; grammY's `secretToken` option gives 401 on mismatch.
|
||||
- Worker has NO admin HTTP surface — no attack surface beyond `/webhook` (secret-gated by grammY) and the public health check.
|
||||
- Never log secrets, even on error paths.
|
||||
|
||||
## Next Steps
|
||||
- Phase 03 creates the DB abstraction that modules will use in phase 04+.
|
||||
- Phase 04 replaces the dispatcher stub with real middleware.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 03 — DB abstraction
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Reports: [Cloudflare KV basics](../reports/researcher-260411-0853-cloudflare-kv-basics.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** pending
|
||||
- **Description:** define minimal KV-like interface (`get/put/delete/list` + `getJSON/putJSON` convenience) via JSDoc, implement `CFKVStore` against `KVNamespace`, expose `createStore(moduleName)` factory that auto-prefixes keys with `<module>:`.
|
||||
|
||||
## Key Insights
|
||||
- Interface is deliberately small. YAGNI: no metadata, no bulk ops, no transactions.
|
||||
- `expirationTtl` IS exposed on `put()` — useful for cooldowns / easter-egg throttling.
|
||||
- `getJSON` / `putJSON` are thin wrappers (`JSON.parse` / `JSON.stringify`). Included because every planned module stores structured state — DRY, not speculative. `getJSON` returns `null` when the key is missing OR when the stored value fails to parse (log + swallow), so callers can treat both as "no record".
|
||||
- `list()` returns a normalized shape `{ keys: string[], cursor?: string, done: boolean }` — strip KV-specific fields. Cursor enables pagination for modules that grow past 1000 keys.
|
||||
- Factory-based prefixing means swapping `CFKVStore` → `D1Store` or `RedisStore` later is one-file change. NO module touches KV directly.
|
||||
- When a module calls `list({ prefix: "games:" })`, the wrapper concatenates to `"wordle:games:"` before calling KV and strips `"wordle:"` from returned keys so the module sees its own namespace.
|
||||
- KV hot-key limit (1 write/sec per key) — document in JSDoc so module authors are aware.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- Interface exposes: `get(key)`, `put(key, value, options?)`, `delete(key)`, `list(options?)`, `getJSON(key)`, `putJSON(key, value, options?)`.
|
||||
- `options` on `put` / `putJSON`: `{ expirationTtl?: number }` (seconds).
|
||||
- `options` on `list`: `{ prefix?: string, limit?: number, cursor?: string }`.
|
||||
- `createStore(moduleName, env)` returns a prefixed store bound to `env.KV`.
|
||||
- `get` returns `string | null`.
|
||||
- `put` accepts `string` only — structured data goes through `putJSON`.
|
||||
- `getJSON(key)` → `any | null`. On missing key: `null`. On malformed JSON: log a warning and return `null` (do NOT throw — a single corrupt record must not crash the bot).
|
||||
- `putJSON(key, value, opts?)` → serializes with `JSON.stringify` then delegates to `put`. Throws if `value` is `undefined` or contains a cycle.
|
||||
- `list` returns `{ keys: string[], cursor?: string, done: boolean }`, with module prefix stripped. `cursor` is passed through from KV so callers can paginate.
|
||||
|
||||
### Non-functional
|
||||
- JSDoc `@typedef` for `KVStore` interface so IDE completion works without TS.
|
||||
- `src/db/` total < 150 LOC.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
module code
|
||||
│ createStore("wordle", env)
|
||||
▼
|
||||
PrefixedStore ── prefixes all keys with "wordle:" ──┐
|
||||
│ ▼
|
||||
└──────────────────────────────────────────► CFKVStore (wraps env.KV)
|
||||
│
|
||||
▼
|
||||
env.KV (binding)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `src/db/kv-store-interface.js` — JSDoc `@typedef` only, no runtime code
|
||||
- `src/db/cf-kv-store.js` — `CFKVStore` class wrapping `KVNamespace`
|
||||
- `src/db/create-store.js` — `createStore(moduleName, env)` prefixing factory
|
||||
|
||||
### Modify
|
||||
- none
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. `src/db/kv-store-interface.js`:
|
||||
```js
|
||||
/**
|
||||
* @typedef {Object} KVStorePutOptions
|
||||
* @property {number} [expirationTtl] seconds
|
||||
*
|
||||
* @typedef {Object} KVStoreListOptions
|
||||
* @property {string} [prefix]
|
||||
* @property {number} [limit]
|
||||
* @property {string} [cursor]
|
||||
*
|
||||
* @typedef {Object} KVStoreListResult
|
||||
* @property {string[]} keys
|
||||
* @property {string} [cursor]
|
||||
* @property {boolean} done
|
||||
*
|
||||
* @typedef {Object} KVStore
|
||||
* @property {(key: string) => Promise<string|null>} get
|
||||
* @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise<void>} put
|
||||
* @property {(key: string) => Promise<void>} delete
|
||||
* @property {(opts?: KVStoreListOptions) => Promise<KVStoreListResult>} list
|
||||
* @property {(key: string) => Promise<any|null>} getJSON
|
||||
* @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
|
||||
*/
|
||||
export {};
|
||||
```
|
||||
2. `src/db/cf-kv-store.js`:
|
||||
- `export class CFKVStore` with `constructor(kvNamespace)` stashing binding.
|
||||
- `get(key)` → `this.kv.get(key, { type: "text" })`.
|
||||
- `put(key, value, opts)` → `this.kv.put(key, value, opts?.expirationTtl ? { expirationTtl: opts.expirationTtl } : undefined)`.
|
||||
- `delete(key)` → `this.kv.delete(key)`.
|
||||
- `list({ prefix, limit, cursor } = {})` → call `this.kv.list({ prefix, limit, cursor })`, map to normalized shape `{ keys: result.keys.map(k => k.name), cursor: result.cursor, done: result.list_complete }`.
|
||||
- `getJSON(key)` → `const raw = await this.get(key); if (raw == null) return null; try { return JSON.parse(raw); } catch (e) { console.warn("getJSON parse failed", { key, err: String(e) }); return null; }`.
|
||||
- `putJSON(key, value, opts)` → `if (value === undefined) throw new Error("putJSON: value is undefined"); return this.put(key, JSON.stringify(value), opts);`.
|
||||
3. `src/db/create-store.js`:
|
||||
- `export function createStore(moduleName, env)`:
|
||||
- Validate `moduleName` is non-empty `[a-z0-9_-]+`.
|
||||
- `const base = new CFKVStore(env.KV)`.
|
||||
- `const prefix = \`${moduleName}:\``.
|
||||
- Return object:
|
||||
- `get(key)` → `base.get(prefix + key)`
|
||||
- `put(key, value, opts)` → `base.put(prefix + key, value, opts)`
|
||||
- `delete(key)` → `base.delete(prefix + key)`
|
||||
- `list(opts)` → call `base.list({ ...opts, prefix: prefix + (opts?.prefix ?? "") })`, then strip the `<module>:` prefix from returned keys before returning.
|
||||
- `getJSON(key)` → `base.getJSON(prefix + key)`
|
||||
- `putJSON(key, value, opts)` → `base.putJSON(prefix + key, value, opts)`
|
||||
4. JSDoc every exported function. Types on params + return.
|
||||
5. `npm run lint`.
|
||||
|
||||
## Todo List
|
||||
- [ ] `src/db/kv-store-interface.js` JSDoc types (incl. `getJSON` / `putJSON`)
|
||||
- [ ] `src/db/cf-kv-store.js` CFKVStore class (incl. `getJSON` / `putJSON`)
|
||||
- [ ] `src/db/create-store.js` prefixing factory (all six methods)
|
||||
- [ ] Validate module name in factory
|
||||
- [ ] JSDoc on every exported symbol
|
||||
- [ ] Lint clean
|
||||
|
||||
## Success Criteria
|
||||
- All four interface methods round-trip via `wrangler dev` + preview KV namespace (manual sanity check OK; unit tests land in phase-08).
|
||||
- Prefix stripping verified: `createStore("wordle").put("k","v")` → raw KV key is `wordle:k`; `createStore("wordle").list()` returns `["k"]`.
|
||||
- Swapping `CFKVStore` for a future backend requires changes ONLY inside `src/db/`.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| KV hot-key limit breaks a module (1 w/s per key) | Med | Med | Document in JSDoc; phase-08 adds a throttle util if needed |
|
||||
| Eventual consistency confuses testers | High | Low | README note in phase-09 |
|
||||
| Corrupt JSON crashes a module handler | Low | Med | `getJSON` swallows parse errors → `null`; logs warning |
|
||||
| Module name collision with prefix characters | Low | Med | Validate regex `^[a-z0-9_-]+$` in factory |
|
||||
|
||||
## Security Considerations
|
||||
- Colon in module names would let a malicious module escape its namespace — regex validation blocks this.
|
||||
- Never expose raw `env.KV` outside `src/db/` — enforce by code review (module registry only passes the prefixed store, never the bare binding).
|
||||
|
||||
## Next Steps
|
||||
- Phase 04 consumes `createStore` from the module registry's `init` hook.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Phase 04 — Module framework (contract, registry, loader, dispatcher)
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md), [KV basics](../reports/researcher-260411-0853-cloudflare-kv-basics.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** pending
|
||||
- **Description:** define the plugin contract, build the registry, implement the static-map loader filtered by `env.MODULES`, and write the grammY dispatcher middleware that routes commands and enforces visibility. **All three visibility levels (public / protected / private) are slash commands.** Visibility only controls which commands appear in Telegram's `/` menu (public only) and in `/help` output (public + protected). Private commands are hidden slash commands — easter eggs that still route through `bot.command()`.
|
||||
|
||||
## Key Insights
|
||||
- wrangler bundles statically — dynamic `import(variablePath)` defeats tree-shaking and can fail at bundle time. **Solution:** static map `{ name: () => import("./name/index.js") }`, filtered at runtime by `env.MODULES.split(",")`.
|
||||
- **Single routing path:** every command — regardless of visibility — is registered via `bot.command(name, handler)`. grammY handles slash-prefix parsing, case-sensitivity, and `/cmd@botname` suffix in groups automatically. No custom text-match code.
|
||||
- Visibility is pure metadata. It affects two downstream consumers:
|
||||
1. phase-07's `scripts/register.js` → `setMyCommands` payload (public only).
|
||||
2. phase-05's `/help` renderer (public + protected, skip private).
|
||||
- Command-name conflicts: two modules registering the same command name = registry throws at load. **One unified namespace across all visibility levels** — a public `/foo` in module A collides with a private `/foo` in module B. Fail fast > mystery last-wins.
|
||||
- The registry is built ONCE per warm instance, inside `getBot(env)`. Not rebuilt per request.
|
||||
- grammY's `bot.command()` matches exactly against the command name token — case-sensitive per Telegram's own semantics. This naturally satisfies the "exact, case-sensitive" requirement for private commands.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- Module contract (locked):
|
||||
```js
|
||||
export default {
|
||||
name: "wordle", // must match folder name, [a-z0-9_-]+
|
||||
commands: [
|
||||
{ name: "wordle", visibility: "public", description: "Play wordle", handler: async (ctx) => {...} },
|
||||
{ name: "wstats", visibility: "protected", description: "Stats", handler: async (ctx) => {...} },
|
||||
{ name: "konami", visibility: "private", description: "Easter egg", handler: async (ctx) => {...} },
|
||||
],
|
||||
init: async ({ db, env }) => {}, // optional
|
||||
};
|
||||
```
|
||||
- `name` on a command: slash-command name without leading `/`, `[a-z0-9_]{1,32}` (Telegram's own limit). Same regex for all visibility levels — private commands are still `/foo`.
|
||||
- `description`: required for all three visibility levels (private descriptions are used internally for debugging + not surfaced to Telegram/users). Max 256 chars (Telegram's limit on public command descriptions). Enforce uniformly.
|
||||
- Loader reads `env.MODULES`, splits, trims, dedupes. For each name, look up static map; unknown name → throw.
|
||||
- Each module's `init` is called once with `{ db: createStore(module.name, env), env }`.
|
||||
- Registry builds three indexed maps (same shape, partitioned by visibility) PLUS one flat map of all commands for conflict detection + dispatch:
|
||||
- `publicCommands: Map<string, {module, cmd}>` — source of truth for `setMyCommands` + `/help`.
|
||||
- `protectedCommands: Map<string, {module, cmd}>` — source of truth for `/help`.
|
||||
- `privateCommands: Map<string, {module, cmd}>` — bookkeeping only (not surfaced anywhere visible).
|
||||
- `allCommands: Map<string, {module, cmd, visibility}>` — flat index used by the dispatcher to `bot.command()` every entry regardless of visibility.
|
||||
- Name conflict across modules (any visibility combination) → throw `Error("command conflict: ...")` naming both modules and the command.
|
||||
|
||||
### Non-functional
|
||||
- `src/modules/registry.js` < 150 LOC.
|
||||
- `src/modules/dispatcher.js` < 60 LOC.
|
||||
- No global mutable state outside the registry itself.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
env.MODULES = "util,wordle,loldle,misc"
|
||||
│
|
||||
▼
|
||||
loadModules(env) ──► static map lookup ──► import() each ──► array of module objects
|
||||
│
|
||||
▼
|
||||
buildRegistry(modules, env)
|
||||
│
|
||||
├── for each module: call init({db, env}) if present
|
||||
└── flatten commands → 3 visibility-partitioned maps + 1 flat `allCommands` map
|
||||
│ (conflict check walks `allCommands`: one namespace, all visibilities)
|
||||
▼
|
||||
installDispatcher(bot, registry)
|
||||
│
|
||||
└── for each entry in allCommands:
|
||||
bot.command(cmd.name, cmd.handler)
|
||||
```
|
||||
|
||||
**Why no text-match middleware:** grammY's `bot.command()` already handles exact-match slash routing, case sensitivity, and group-chat `/cmd@bot` disambiguation. Private commands ride that same path — they're just absent from `setMyCommands` and `/help`.
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `src/modules/index.js` — static import map
|
||||
- `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry`
|
||||
- `src/modules/dispatcher.js` — `installDispatcher(bot, env)` (replaces phase-02 stub)
|
||||
- `src/modules/validate-command.js` — shared validators (name regex, visibility, description)
|
||||
|
||||
### Modify
|
||||
- `src/bot.js` — call `installDispatcher(bot, env)` (was a no-op stub)
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. `src/modules/index.js`:
|
||||
```js
|
||||
export const moduleRegistry = {
|
||||
util: () => import("./util/index.js"),
|
||||
wordle: () => import("./wordle/index.js"),
|
||||
loldle: () => import("./loldle/index.js"),
|
||||
misc: () => import("./misc/index.js"),
|
||||
};
|
||||
```
|
||||
(Stub module folders land in phase-05/06 — tests in phase-08 can inject a fake map.)
|
||||
2. `src/modules/validate-command.js`:
|
||||
- `VISIBILITIES = ["public", "protected", "private"]`.
|
||||
- `COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/` (uniform across all visibilities).
|
||||
- `validateCommand(cmd, moduleName)`:
|
||||
- `visibility` ∈ `VISIBILITIES` (throw otherwise).
|
||||
- `name` matches `COMMAND_NAME_RE` (no leading `/`).
|
||||
- `description` is non-empty string, ≤ 256 chars.
|
||||
- `handler` is a function.
|
||||
- All errors mention both the module name and the offending command name.
|
||||
3. `src/modules/registry.js`:
|
||||
- Module-scope `let currentRegistry = null;` (used by `/help` in phase-05).
|
||||
- `async function loadModules(env)`:
|
||||
- Parse `env.MODULES` → array, trim, dedupe, skip empty.
|
||||
- Empty list → throw `Error("MODULES env var is empty")`.
|
||||
- For each name, `const loader = moduleRegistry[name]`; unknown → throw `Error(\`unknown module: ${name}\`)`.
|
||||
- `const mod = (await loader()).default`.
|
||||
- Validate `mod.name === name`.
|
||||
- Validate each command via `validateCommand`.
|
||||
- Return ordered array of module objects.
|
||||
- `async function buildRegistry(env)`:
|
||||
- Call `loadModules`.
|
||||
- Init four maps: `publicCommands`, `protectedCommands`, `privateCommands`, `allCommands`.
|
||||
- For each module (in `env.MODULES` order):
|
||||
- If `init`: `await mod.init({ db: createStore(mod.name, env), env })`. Wrap in try/catch; rethrow with module name prefix.
|
||||
- For each cmd:
|
||||
- If `allCommands.has(cmd.name)` → throw `Error(\`command conflict: /${cmd.name} registered by both ${existing.module.name} and ${mod.name}\`)`.
|
||||
- `allCommands.set(cmd.name, { module: mod, cmd, visibility: cmd.visibility });`
|
||||
- Push into the visibility-specific map too.
|
||||
- `currentRegistry = { publicCommands, protectedCommands, privateCommands, allCommands, modules };`
|
||||
- Return it.
|
||||
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
|
||||
- `export function resetRegistry() { currentRegistry = null; }` (test helper; phase-08 uses it in `beforeEach`).
|
||||
4. `src/modules/dispatcher.js`:
|
||||
- `export async function installDispatcher(bot, env)`:
|
||||
- `const reg = await buildRegistry(env);`
|
||||
- `for (const { cmd } of reg.allCommands.values()) { bot.command(cmd.name, cmd.handler); }`
|
||||
- Return `reg` (caller may ignore; `/help` reads via `getCurrentRegistry()`).
|
||||
5. Update `src/bot.js` to `await installDispatcher(bot, env)` before returning. This makes `getBot` async — update `src/index.js` to `await getBot(env)`.
|
||||
6. Lint clean.
|
||||
|
||||
## Todo List
|
||||
- [ ] `src/modules/index.js` static import map
|
||||
- [ ] `src/modules/validate-command.js` (uniform regex + description length cap)
|
||||
- [ ] `src/modules/registry.js` — `loadModules` + `buildRegistry` + `getCurrentRegistry` + `resetRegistry`
|
||||
- [ ] `src/modules/dispatcher.js` — single loop, all visibilities via `bot.command()`
|
||||
- [ ] Update `src/bot.js` + `src/index.js` for async `getBot`
|
||||
- [ ] Unified-namespace conflict detection
|
||||
- [ ] Lint clean
|
||||
|
||||
## Success Criteria
|
||||
- With `MODULES="util"` and util exposing one public cmd, `wrangler dev` + mocked webhook update correctly routes.
|
||||
- Conflict test (phase-08): two fake modules both register `/foo` (regardless of visibility) → `buildRegistry` throws with both module names and the command name in the message.
|
||||
- Unknown module name in `MODULES` → throws with clear message.
|
||||
- Registry built once per warm instance (memoized via `getBot`).
|
||||
- `/konami` (a private command) responds when typed; does NOT appear in `/help` output; does NOT appear in Telegram's `/` menu after `scripts/register.js` runs.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Dynamic import breaks bundler tree-shaking | N/A | N/A | Mitigated by static map design |
|
||||
| `init` throws → entire bot broken | Med | High | Wrap in try/catch, log module name, re-throw with context |
|
||||
| Module mutates passed `env` | Low | Med | Pass `env` as-is; document contract: modules must not mutate |
|
||||
| Private command accidentally listed in `setMyCommands` | Low | Med | `scripts/register.js` reads `publicCommands` only (not `allCommands`) |
|
||||
| Description > 256 chars breaks `setMyCommands` payload | Low | Low | Validator enforces cap at module load |
|
||||
|
||||
## Security Considerations
|
||||
- A private command with the same name as a public one in another module would be ambiguous. Unified conflict detection blocks this at load.
|
||||
- Module authors get an auto-prefixed DB store — they CANNOT read other modules' data unless they reconstruct prefixes manually (code review responsibility).
|
||||
- `init` errors must log module name for audit, never the env object (may contain secrets).
|
||||
- Private commands do NOT provide security — anyone who guesses the name can invoke them. They are for discoverability control, not access control.
|
||||
|
||||
## Next Steps
|
||||
- Phase 05 implements util module (`/info`, `/help`) consuming the registry.
|
||||
- Phase 06 adds stub modules proving the plugin system end-to-end.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Phase 05 — util module (`/info`, `/help`)
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Phase 04: [module framework](phase-04-module-framework.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** pending
|
||||
- **Description:** fully-implemented `util` module with two public commands. `/info` reports chat/thread/sender IDs. `/help` iterates the registry and prints public+protected commands grouped by module.
|
||||
|
||||
## Key Insights
|
||||
- `/help` is a **renderer** over the registry — it does NOT hold its own command metadata. Single source of truth = registry.
|
||||
- Forum topics: `message_thread_id` may be absent for normal chats. Output "n/a" rather than omitting, so debug users know the field was checked.
|
||||
- Parse mode: **HTML** (decision locked). Easier escaping than MarkdownV2. Only 4 chars to escape: `&`, `<`, `>`, `"`. Write a small `escapeHtml()` util.
|
||||
- `/help` must access the registry. Use an exported getter from `src/modules/dispatcher.js` or `src/modules/registry.js` that returns the currently-built registry. The util module reads it inside its handler — not at module load time — so the registry exists by then.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `/info` replies with:
|
||||
```
|
||||
chat id: 123
|
||||
thread id: 456 (or "n/a" if undefined)
|
||||
sender id: 789
|
||||
```
|
||||
Plain text, no parse mode needed.
|
||||
- `/help` output grouped by module:
|
||||
```html
|
||||
<b>util</b>
|
||||
/info — Show chat/thread/sender IDs
|
||||
/help — Show this help
|
||||
|
||||
<b>wordle</b>
|
||||
/wordle — Play wordle
|
||||
/wstats — Stats (protected)
|
||||
...
|
||||
```
|
||||
- Modules with zero visible commands omitted entirely.
|
||||
- Private commands skipped.
|
||||
- Protected commands appended with `" (protected)"` suffix so users understand the distinction.
|
||||
- Module order: insertion order of `env.MODULES`.
|
||||
- Sent with `parse_mode: "HTML"`.
|
||||
- Both commands are **public** visibility.
|
||||
|
||||
### Non-functional
|
||||
- `src/modules/util/index.js` < 150 LOC.
|
||||
- No new deps.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
src/modules/util/
|
||||
├── index.js # module default export
|
||||
├── info-command.js # /info handler
|
||||
├── help-command.js # /help handler + HTML renderer
|
||||
```
|
||||
|
||||
Split by command file for clarity. Each command file < 80 LOC.
|
||||
|
||||
Registry access: `src/modules/registry.js` exports `getCurrentRegistry()` returning the memoized instance (set by `buildRegistry`). `/help` calls this at handler time.
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `src/modules/util/index.js`
|
||||
- `src/modules/util/info-command.js`
|
||||
- `src/modules/util/help-command.js`
|
||||
- `src/util/escape-html.js` (shared escaper)
|
||||
|
||||
### Modify
|
||||
- `src/modules/registry.js` — add `getCurrentRegistry()` exported getter
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. `src/util/escape-html.js`:
|
||||
```js
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
```
|
||||
2. `src/modules/registry.js`:
|
||||
- Add module-scope `let currentRegistry = null;`
|
||||
- `buildRegistry` assigns to it before returning.
|
||||
- `export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }`
|
||||
3. `src/modules/util/info-command.js`:
|
||||
- Exports `{ name: "info", visibility: "public", description: "Show chat/thread/sender IDs", handler }`.
|
||||
- Handler reads `ctx.chat?.id`, `ctx.message?.message_thread_id`, `ctx.from?.id`.
|
||||
- Reply: `\`chat id: ${chatId}\nthread id: ${threadId ?? "n/a"}\nsender id: ${senderId}\``.
|
||||
4. `src/modules/util/help-command.js`:
|
||||
- Exports `{ name: "help", visibility: "public", description: "Show this help", handler }`.
|
||||
- Handler:
|
||||
- `const reg = getCurrentRegistry();`
|
||||
- Build `Map<moduleName, string[]>` of lines.
|
||||
- Iterate `reg.publicCommands` + `reg.protectedCommands` (in insertion order; `Map` preserves it).
|
||||
- For each entry, push `"/" + cmd.name + " — " + escapeHtml(cmd.description) + (visibility === "protected" ? " (protected)" : "")` under its module name.
|
||||
- Iterate `reg.modules` in order; for each with non-empty lines, emit `<b>${escapeHtml(moduleName)}</b>\n` + lines joined by `\n` + blank line.
|
||||
- `await ctx.reply(text, { parse_mode: "HTML" });`
|
||||
5. `src/modules/util/index.js`:
|
||||
- `import info from "./info-command.js"; import help from "./help-command.js";`
|
||||
- `export default { name: "util", commands: [info, help] };`
|
||||
6. Add `util` to `wrangler.toml` `MODULES` default if not already: `MODULES = "util,wordle,loldle,misc"`.
|
||||
7. Lint.
|
||||
|
||||
## Todo List
|
||||
- [ ] `escape-html.js`
|
||||
- [ ] `getCurrentRegistry()` in registry.js
|
||||
- [ ] `info-command.js`
|
||||
- [ ] `help-command.js` renderer
|
||||
- [ ] `util/index.js`
|
||||
- [ ] Manual smoke test via `wrangler dev`
|
||||
- [ ] Lint clean
|
||||
|
||||
## Success Criteria
|
||||
- `/info` in a 1:1 chat shows chat id + "thread id: n/a" + sender id.
|
||||
- `/info` in a forum topic shows a real thread id.
|
||||
- `/help` shows `util` section with both commands, and stub module sections (after phase-06).
|
||||
- `/help` does NOT show private commands.
|
||||
- Protected commands show `(protected)` suffix.
|
||||
- HTML injection attempt in module description (e.g. `<script>`) renders literally.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Registry not built before handler fires | Low | Med | `getCurrentRegistry()` throws with clear error; dispatcher ensures build before bot handles updates |
|
||||
| `/help` output exceeds Telegram 4096-char limit | Low (at this scale) | Low | Phase-09 mentions future pagination; current scale is fine |
|
||||
| Module descriptions contain raw HTML | Med | Med | `escapeHtml` all descriptions + module names |
|
||||
| Missing `message_thread_id` crashes | Low | Low | `?? "n/a"` fallback |
|
||||
|
||||
## Security Considerations
|
||||
- Escape ALL user-influenced strings (module names, descriptions) — even though modules are trusted code, future-proofing against dynamic registration.
|
||||
- `/info` reveals sender id — that's the point. Document in help text that it's a debugging tool.
|
||||
|
||||
## Next Steps
|
||||
- Phase 06 adds the stub modules that populate the `/help` output.
|
||||
- Phase 08 tests `/help` rendering against a synthetic registry.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Phase 06 — Stub modules (wordle / loldle / misc)
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Phase 04: [module framework](phase-04-module-framework.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P2
|
||||
- **Status:** pending
|
||||
- **Description:** three stub modules proving the plugin system. Each registers one `public`, one `protected`, and one `private` command. All commands are slash commands; private ones are simply absent from `setMyCommands` and `/help`. Handlers are one-liners that echo or reply.
|
||||
|
||||
## Key Insights
|
||||
- Stubs are NOT feature-complete games. Their job: exercise the plugin loader, visibility levels, registry, dispatcher, and `/help` rendering.
|
||||
- Each stub module demonstrates DB usage via `getJSON` / `putJSON` in one handler — proves `createStore` namespacing + JSON helpers work end-to-end.
|
||||
- Private commands follow the same slash-name rules as public/protected (`[a-z0-9_]{1,32}`). They're hidden, not text-matched.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `wordle` module:
|
||||
- public `/wordle` → `"Wordle stub — real game TBD."`
|
||||
- protected `/wstats` → `await db.getJSON("stats")` → returns `"games played: ${stats?.gamesPlayed ?? 0}"`.
|
||||
- private `/konami` → `"⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)"`.
|
||||
- `loldle` module:
|
||||
- public `/loldle` → `"Loldle stub."`
|
||||
- protected `/lstats` → `"loldle stats stub"`.
|
||||
- private `/ggwp` → `"gg well played (stub)"`.
|
||||
- `misc` module:
|
||||
- public `/ping` → `"pong"` + `await db.putJSON("last_ping", { at: Date.now() })`.
|
||||
- protected `/mstats` → `const last = await db.getJSON("last_ping");` → echoes `last.at` or `"never"`.
|
||||
- private `/fortytwo` → `"The answer."` (slash-command regex excludes bare numbers, so we spell it).
|
||||
|
||||
### Non-functional
|
||||
- Each stub's `index.js` < 80 LOC.
|
||||
- No additional utilities — stubs use only what phase-03/04/05 provide.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
src/modules/
|
||||
├── wordle/
|
||||
│ └── index.js
|
||||
├── loldle/
|
||||
│ └── index.js
|
||||
└── misc/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
Each `index.js` exports `{ name, commands, init }` per the contract defined in phase 04. `init` stashes the injected `db` on a module-scope `let` so handlers can reach it.
|
||||
|
||||
Example shape (pseudo):
|
||||
```js
|
||||
let db;
|
||||
export default {
|
||||
name: "wordle",
|
||||
init: async ({ db: store }) => { db = store; },
|
||||
commands: [
|
||||
{ name: "wordle", visibility: "public", description: "Play wordle",
|
||||
handler: async (ctx) => ctx.reply("Wordle stub — real game TBD.") },
|
||||
{ name: "wstats", visibility: "protected", description: "Wordle stats",
|
||||
handler: async (ctx) => {
|
||||
const stats = await db.getJSON("stats");
|
||||
await ctx.reply(`games played: ${stats?.gamesPlayed ?? 0}`);
|
||||
} },
|
||||
{ name: "konami", visibility: "private", description: "Easter egg — retro code",
|
||||
handler: async (ctx) => ctx.reply("⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)") },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `src/modules/wordle/index.js`
|
||||
- `src/modules/loldle/index.js`
|
||||
- `src/modules/misc/index.js`
|
||||
|
||||
### Modify
|
||||
- none (static map in `src/modules/index.js` already lists all four — from phase 04)
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. Create `src/modules/wordle/index.js` per shape above (`/wordle`, `/wstats`, `/konami`).
|
||||
2. Create `src/modules/loldle/index.js` (`/loldle`, `/lstats`, `/ggwp`).
|
||||
3. Create `src/modules/misc/index.js` (`/ping`, `/mstats`, `/fortytwo`) including the `last_ping` `putJSON` / `getJSON` demonstrating DB usage.
|
||||
4. Verify `src/modules/index.js` static map includes all three (added in phase-04).
|
||||
5. `wrangler dev` smoke test: send each command via a mocked Telegram update; verify routing and KV writes land in the preview namespace with prefix `wordle:` / `loldle:` / `misc:`.
|
||||
6. Lint clean.
|
||||
|
||||
## Todo List
|
||||
- [ ] `wordle/index.js`
|
||||
- [ ] `loldle/index.js`
|
||||
- [ ] `misc/index.js`
|
||||
- [ ] Verify KV writes are correctly prefixed (check via `wrangler kv key list --preview`)
|
||||
- [ ] Manual webhook smoke test for all 9 commands
|
||||
- [ ] Lint clean
|
||||
|
||||
## Success Criteria
|
||||
- With `MODULES="util,wordle,loldle,misc"` the bot loads all four modules on cold start.
|
||||
- `/help` output shows three stub sections (wordle, loldle, misc) with 2 commands each (public + protected), plus `util` section.
|
||||
- `/help` does NOT list `/konami`, `/ggwp`, `/fortytwo`.
|
||||
- Telegram's `/` menu (after `scripts/register.js` runs) shows `/wordle`, `/loldle`, `/ping`, `/info`, `/help` — nothing else.
|
||||
- Typing `/konami` in Telegram invokes the handler. Typing `/Konami` does NOT (Telegram + grammY match case-sensitively).
|
||||
- `/ping` writes `last_ping` via `putJSON`; subsequent `/mstats` reads it back via `getJSON`.
|
||||
- Removing `wordle` from `MODULES` (`MODULES="util,loldle,misc"`) successfully boots without loading wordle; `/wordle` becomes unknown command (grammY default: silently ignored).
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Stub handlers accidentally leak into production behavior | Low | Low | Stubs clearly labeled in reply text |
|
||||
| Private command name too guessable | Low | Low | These are stubs; real easter eggs can pick obscure names |
|
||||
| DB write fails silently | Low | Med | Handlers `await` writes; errors propagate to grammY error handler |
|
||||
|
||||
## Security Considerations
|
||||
- Stubs do NOT read user input for DB keys — they use fixed keys. Avoids injection.
|
||||
- `/ping` timestamp is server time — no sensitive data.
|
||||
|
||||
## Next Steps
|
||||
- Phase 07 adds `scripts/register.js` to run `setWebhook` + `setMyCommands` at deploy time.
|
||||
- Phase 08 tests the full routing flow with these stubs as fixtures.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Phase 07 — Post-deploy register script (`setWebhook` + `setMyCommands`)
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Phase 01: [scaffold project](phase-01-scaffold-project.md) — defines `npm run deploy` + `.env.deploy.example`
|
||||
- Phase 04: [module framework](phase-04-module-framework.md) — defines `publicCommands` source of truth
|
||||
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P2
|
||||
- **Status:** pending
|
||||
- **Description:** a plain Node script at `scripts/register.js` that runs automatically after `wrangler deploy`. It calls Telegram's HTTP API directly to (1) register the Worker URL as the bot's webhook with a secret token and (2) push the `public` command list to Telegram via `setMyCommands`. Idempotent. No admin HTTP surface on the Worker itself.
|
||||
|
||||
## Key Insights
|
||||
- The Worker has no post-deploy hook — wire this via `npm run deploy` = `wrangler deploy && npm run register`.
|
||||
- The script runs in plain Node (not workerd), so it can `import` the module framework directly to derive the public-command list. This keeps a SINGLE source of truth: module files.
|
||||
- Config loaded via `node --env-file=.env.deploy` (Node ≥ 20.6) — zero extra deps (no dotenv package). `.env.deploy` is gitignored; `.env.deploy.example` is committed.
|
||||
- `setWebhook` is called with `secret_token` field equal to `TELEGRAM_WEBHOOK_SECRET`. Telegram echoes this in `X-Telegram-Bot-Api-Secret-Token` on every update; grammY's `webhookCallback` validates it.
|
||||
- `setMyCommands` accepts an array of `{ command, description }`. Max 100 commands, 256 chars per description — already enforced at module load in phase-04.
|
||||
- Script must be idempotent: running twice on identical state is a no-op in Telegram's eyes. Telegram accepts repeated `setWebhook` with the same URL.
|
||||
- `private` commands are excluded from `setMyCommands`. `protected` commands also excluded (by definition — they're only in `/help`).
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `scripts/register.js`:
|
||||
1. Read required env: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `WORKER_URL`. Missing any → print which one and `process.exit(1)`.
|
||||
2. Read `MODULES` from `.env.deploy` (or shell env) — same value that the Worker uses. If absent, default to reading `wrangler.toml` `[vars].MODULES`. Prefer `.env.deploy` for single-source-of-truth at deploy time.
|
||||
3. Build the public command list by calling the same loader/registry code used by the Worker:
|
||||
```js
|
||||
import { buildRegistry } from "../src/modules/registry.js";
|
||||
const fakeEnv = { MODULES: process.env.MODULES, KV: /* stub */ };
|
||||
const reg = await buildRegistry(fakeEnv);
|
||||
const publicCommands = [...reg.publicCommands.values()]
|
||||
.map(({ cmd }) => ({ command: cmd.name, description: cmd.description }));
|
||||
```
|
||||
Pass a stub `KV` binding (a no-op object matching the `KVNamespace` shape) so `createStore` doesn't crash. Modules that do real KV work in `init` must tolerate this (or defer writes until first handler call — phase-06 stubs already do the latter).
|
||||
4. `POST https://api.telegram.org/bot<TOKEN>/setWebhook` with body:
|
||||
```json
|
||||
{
|
||||
"url": "<WORKER_URL>/webhook",
|
||||
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>",
|
||||
"allowed_updates": ["message", "edited_message", "callback_query"],
|
||||
"drop_pending_updates": false
|
||||
}
|
||||
```
|
||||
5. `POST https://api.telegram.org/bot<TOKEN>/setMyCommands` with body `{ "commands": [...] }`.
|
||||
6. Print a short summary: webhook URL, count of registered public commands, list of commands. Exit 0.
|
||||
7. On any non-2xx from Telegram: print the response body + exit 1. `npm run deploy` fails loudly.
|
||||
- `package.json`:
|
||||
- `"deploy": "wrangler deploy && npm run register"`
|
||||
- `"register": "node --env-file=.env.deploy scripts/register.js"`
|
||||
- `"register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run"` (prints the payloads without calling Telegram — useful before first real deploy)
|
||||
|
||||
### Non-functional
|
||||
- `scripts/register.js` < 150 LOC.
|
||||
- Zero dependencies beyond Node built-ins (`fetch`, `process`).
|
||||
- No top-level `await` at module scope (wrap in `main()` + `.catch(exit)`).
|
||||
|
||||
## Architecture
|
||||
```
|
||||
npm run deploy
|
||||
│
|
||||
├── wrangler deploy (ships Worker code + wrangler.toml vars)
|
||||
│
|
||||
└── npm run register (= node --env-file=.env.deploy scripts/register.js)
|
||||
│
|
||||
├── load .env.deploy into process.env
|
||||
├── import buildRegistry from src/modules/registry.js
|
||||
│ └── uses stub KV, derives publicCommands
|
||||
├── POST https://api.telegram.org/bot<T>/setWebhook {url, secret_token, allowed_updates}
|
||||
├── POST https://api.telegram.org/bot<T>/setMyCommands {commands: [...]}
|
||||
└── print summary → exit 0
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `scripts/register.js`
|
||||
- `scripts/stub-kv.js` — tiny no-op `KVNamespace` stub for the registry build (exports a single object with async methods returning null/empty)
|
||||
- `.env.deploy.example` (already created in phase-01; documented again here)
|
||||
|
||||
### Modify
|
||||
- `package.json` — add `deploy` + `register` + `register:dry` scripts (phase-01 already wires `deploy`)
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. `scripts/stub-kv.js`:
|
||||
```js
|
||||
// No-op KVNamespace stub for deploy-time registry build. Matches the minimum surface
|
||||
// our CFKVStore wrapper calls — never actually reads/writes.
|
||||
export const stubKv = {
|
||||
async get() { return null; },
|
||||
async put() {},
|
||||
async delete() {},
|
||||
async list() { return { keys: [], list_complete: true, cursor: undefined }; },
|
||||
};
|
||||
```
|
||||
2. `scripts/register.js`:
|
||||
```js
|
||||
import { buildRegistry, resetRegistry } from "../src/modules/registry.js";
|
||||
import { stubKv } from "./stub-kv.js";
|
||||
|
||||
const TELEGRAM_API = "https://api.telegram.org";
|
||||
|
||||
function requireEnv(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) { console.error(`missing env: ${name}`); process.exit(1); }
|
||||
return v;
|
||||
}
|
||||
|
||||
async function tg(token, method, body) {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || json.ok === false) {
|
||||
console.error(`${method} failed:`, res.status, json);
|
||||
process.exit(1);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = requireEnv("TELEGRAM_BOT_TOKEN");
|
||||
const secret = requireEnv("TELEGRAM_WEBHOOK_SECRET");
|
||||
const workerUrl = requireEnv("WORKER_URL").replace(/\/$/, "");
|
||||
const modules = requireEnv("MODULES");
|
||||
const dry = process.argv.includes("--dry-run");
|
||||
|
||||
resetRegistry();
|
||||
const reg = await buildRegistry({ MODULES: modules, KV: stubKv });
|
||||
const commands = [...reg.publicCommands.values()]
|
||||
.map(({ cmd }) => ({ command: cmd.name, description: cmd.description }));
|
||||
|
||||
const webhookBody = {
|
||||
url: `${workerUrl}/webhook`,
|
||||
secret_token: secret,
|
||||
allowed_updates: ["message", "edited_message", "callback_query"],
|
||||
drop_pending_updates: false,
|
||||
};
|
||||
const commandsBody = { commands };
|
||||
|
||||
if (dry) {
|
||||
console.log("DRY RUN — not calling Telegram");
|
||||
console.log("setWebhook:", webhookBody);
|
||||
console.log("setMyCommands:", commandsBody);
|
||||
return;
|
||||
}
|
||||
|
||||
await tg(token, "setWebhook", webhookBody);
|
||||
await tg(token, "setMyCommands", commandsBody);
|
||||
|
||||
console.log(`ok — webhook: ${webhookBody.url}`);
|
||||
console.log(`ok — ${commands.length} public commands registered:`);
|
||||
for (const c of commands) console.log(` /${c.command} — ${c.description}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
```
|
||||
3. Update `package.json` scripts:
|
||||
```json
|
||||
"deploy": "wrangler deploy && npm run register",
|
||||
"register": "node --env-file=.env.deploy scripts/register.js",
|
||||
"register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run"
|
||||
```
|
||||
4. Smoke test BEFORE first real deploy:
|
||||
- Populate `.env.deploy` with real token + secret + worker URL + modules.
|
||||
- `npm run register:dry` — verify the printed payloads match expectations.
|
||||
- `npm run register` — verify Telegram returns ok, then in the Telegram client type `/` and confirm only public commands appear in the popup.
|
||||
5. Lint clean (biome covers `scripts/` — phase-01 `lint` script already includes it).
|
||||
|
||||
## Todo List
|
||||
- [ ] `scripts/stub-kv.js`
|
||||
- [ ] `scripts/register.js` with `--dry-run` flag
|
||||
- [ ] `package.json` scripts updated
|
||||
- [ ] `.env.deploy.example` committed (created in phase-01, verify present)
|
||||
- [ ] Dry-run prints correct payloads
|
||||
- [ ] Real run succeeds against a test bot
|
||||
- [ ] Verify Telegram `/` menu reflects public commands only
|
||||
- [ ] Lint clean
|
||||
|
||||
## Success Criteria
|
||||
- `npm run deploy` = `wrangler deploy` then automatic `setWebhook` + `setMyCommands`, no manual steps.
|
||||
- `npm run register:dry` prints both payloads and exits 0 without calling Telegram.
|
||||
- Missing env var produces a clear `missing env: NAME` message + exit 1.
|
||||
- Telegram client `/` menu shows exactly the `public` commands (5 total with full stubs: `/info`, `/help`, `/wordle`, `/loldle`, `/ping`).
|
||||
- Protected and private commands are NOT in Telegram's `/` menu.
|
||||
- Running `npm run register` a second time with no code changes succeeds (idempotent).
|
||||
- Wrong `TELEGRAM_WEBHOOK_SECRET` → subsequent Telegram update → 401 from Worker (validated via grammY, not our code) — this is the end-to-end proof of correct wiring.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `.env.deploy` committed by accident | Low | High | `.gitignore` from phase-01 + pre-commit grep recommended in phase-09 |
|
||||
| Stub KV missing a method the registry calls | Med | Med | `buildRegistry` only uses KV via `createStore`; stub covers all four methods used |
|
||||
| Module `init` tries to write to KV and crashes on stub | Med | Med | Contract: `init` may read, must tolerate empty results; all writes happen in handlers. Documented in phase-04. |
|
||||
| `setMyCommands` rate-limited if spammed | Low | Low | `npm run deploy` is developer-driven, not per-request |
|
||||
| Node < 20.6 doesn't support `--env-file` | Low | Med | `package.json` `"engines": { "node": ">=20.6" }` (add in phase-01) |
|
||||
| Webhook secret leaks via CI logs | Low | High | Phase-09 documents: never pass `.env.deploy` through CI logs; prefer CF Pages/Workers CI with masked secrets |
|
||||
|
||||
## Security Considerations
|
||||
- `.env.deploy` contains the bot token — MUST be gitignored and never logged by the script beyond `console.log(\`ok\`)`.
|
||||
- The register script runs locally on the developer's machine — not in CI by default. If CI is added later, use masked secrets + `--env-file` pointing at a CI-provided file.
|
||||
- No admin HTTP surface on the Worker means no timing-attack attack surface, no extra secret rotation, no `ADMIN_SECRET` to leak.
|
||||
- `setWebhook` re-registration rotates nothing automatically — if the webhook secret is rotated, you MUST re-run `npm run register` so Telegram uses the new value on future updates.
|
||||
|
||||
## Next Steps
|
||||
- Phase 08 tests the registry code that the script reuses (the script itself is thin).
|
||||
- Phase 09 documents the full deploy workflow (`.env.deploy` setup, first-run checklist, troubleshooting).
|
||||
@@ -0,0 +1,166 @@
|
||||
# Phase 08 — Tests
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Phases 03, 04, 05
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** pending
|
||||
- **Description:** vitest unit tests for pure-logic modules — registry, DB prefixing wrapper (incl. `getJSON` / `putJSON`), dispatcher routing, help renderer, command validators, HTML escaper. NO tests that spin up workerd or hit Telegram.
|
||||
|
||||
## Key Insights
|
||||
- Pure-logic testing > integration testing at this stage. Cloudflare's `@cloudflare/vitest-pool-workers` adds complexity and slow starts; skip for v1.
|
||||
- Mock `env.KV` with an in-memory `Map`-backed fake that implements `get/put/delete/list` per the real shape (including `list_complete` and `keys: [{name}]`).
|
||||
- For dispatcher tests, build a fake `bot` that records `command()` + `on()` registrations; assert the right handlers were wired.
|
||||
- For help renderer tests, construct a synthetic registry directly — no need to load real modules.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- Tests run via `npm test` (vitest, node env).
|
||||
- No network. No `fetch`. No Telegram.
|
||||
- Each test file covers ONE source file.
|
||||
- Coverage target: registry, db wrapper, dispatcher, help renderer, validators, escaper — these are the logic seams.
|
||||
|
||||
### Non-functional
|
||||
- Test suite runs in < 5s.
|
||||
- No shared mutable state between tests; each test builds its fixtures.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
tests/
|
||||
├── fakes/
|
||||
│ ├── fake-kv-namespace.js # Map-backed KVNamespace impl
|
||||
│ ├── fake-bot.js # records command() calls
|
||||
│ └── fake-modules.js # fixture modules for registry tests
|
||||
├── db/
|
||||
│ ├── cf-kv-store.test.js
|
||||
│ └── create-store.test.js
|
||||
├── modules/
|
||||
│ ├── registry.test.js
|
||||
│ ├── dispatcher.test.js
|
||||
│ └── validate-command.test.js
|
||||
├── util/
|
||||
│ └── escape-html.test.js
|
||||
└── modules/util/
|
||||
└── help-command.test.js
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `tests/fakes/fake-kv-namespace.js`
|
||||
- `tests/fakes/fake-bot.js`
|
||||
- `tests/fakes/fake-modules.js`
|
||||
- `tests/db/cf-kv-store.test.js`
|
||||
- `tests/db/create-store.test.js`
|
||||
- `tests/modules/registry.test.js`
|
||||
- `tests/modules/dispatcher.test.js`
|
||||
- `tests/modules/validate-command.test.js`
|
||||
- `tests/util/escape-html.test.js`
|
||||
- `tests/modules/util/help-command.test.js`
|
||||
|
||||
### Modify
|
||||
- `vitest.config.js` — confirm `environment: "node"`, `include: ["tests/**/*.test.js"]`.
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Test cases (per file)
|
||||
|
||||
### `fake-kv-namespace.js`
|
||||
- In-memory `Map`. `get(key, {type: "text"})` returns value or null. `put(key, value, opts?)` stores; records `opts` for assertions. `delete` removes. `list({prefix, limit, cursor})` filters by prefix, paginates, returns `{keys:[{name}], list_complete, cursor}`.
|
||||
|
||||
### `cf-kv-store.test.js`
|
||||
- `get/put/delete` round-trip with fake KV.
|
||||
- `list()` strips to normalized shape `{keys: string[], cursor?, done}`.
|
||||
- `put` with `expirationTtl` passes through to underlying binding.
|
||||
- `putJSON` serializes then calls `put`; recoverable via `getJSON`.
|
||||
- `getJSON` on missing key returns `null`.
|
||||
- `getJSON` on corrupt JSON (manually insert `"{not json"`) returns `null`, does NOT throw, emits a warning.
|
||||
- `putJSON(key, undefined)` throws.
|
||||
|
||||
### `create-store.test.js`
|
||||
- `createStore("wordle", env).put("k","v")` results in raw KV key `"wordle:k"`.
|
||||
- `createStore("wordle", env).list({prefix:"games:"})` calls underlying `list` with `"wordle:games:"` prefix.
|
||||
- Returned keys have the `"wordle:"` prefix STRIPPED.
|
||||
- Two stores for different modules cannot read each other's keys.
|
||||
- `getJSON` / `putJSON` through a prefixed store also land at `<module>:<key>` raw key.
|
||||
- Invalid module name (contains `:`) throws.
|
||||
|
||||
### `validate-command.test.js`
|
||||
- Valid command passes for each visibility (public / protected / private).
|
||||
- Command with leading `/` rejected (any visibility).
|
||||
- Command name > 32 chars rejected.
|
||||
- Command name with uppercase rejected (`COMMAND_NAME_RE` = `/^[a-z0-9_]{1,32}$/`).
|
||||
- Missing description rejected (all visibilities — private also requires description for internal debugging).
|
||||
- Description > 256 chars rejected.
|
||||
- Invalid visibility rejected.
|
||||
|
||||
### `registry.test.js`
|
||||
- Fixture: fake modules passed via an injected `moduleRegistry` map (avoid `vi.mock` path-resolution flakiness on Windows — phase-04 exposes a loader injection point).
|
||||
- `MODULES="a,b"` loads both; `buildRegistry` flattens commands into 3 visibility maps + 1 `allCommands` map.
|
||||
- **Unified namespace conflict:** module A registers `/foo` as public, module B registers `/foo` as private → `buildRegistry` throws with a message naming both modules AND the command.
|
||||
- Same-visibility conflict (two modules, both public `/foo`) → throws.
|
||||
- Unknown module name → throws with message.
|
||||
- Empty `MODULES` → throws.
|
||||
- `init` called once per module with `{db, env}`; db is a namespaced store (assert key prefix by doing a `put` through the injected db and checking raw KV).
|
||||
- After `buildRegistry`, `getCurrentRegistry()` returns the same instance; `resetRegistry()` clears it and subsequent `getCurrentRegistry()` throws.
|
||||
|
||||
### `dispatcher.test.js`
|
||||
- Build registry from fake modules (one each: public, protected, private), install on fake bot.
|
||||
- Assert `bot.command()` called for EVERY entry — including private ones (the whole point: unified routing).
|
||||
- Assert no other bot wiring (no `bot.on`, no `bot.hears`). Dispatcher is minimal.
|
||||
- Call count = total commands across all visibilities.
|
||||
|
||||
### `help-command.test.js`
|
||||
- Build a synthetic registry with three modules: A (1 public), B (1 public + 1 protected), C (1 private only).
|
||||
- Invoke help handler with a fake `ctx` that captures `reply(text, opts)`.
|
||||
- Assert output:
|
||||
- Contains `<b>A</b>` and `<b>B</b>`.
|
||||
- Does NOT contain `<b>C</b>` (no visible commands — private is hidden from help).
|
||||
- Does NOT contain C's private command name anywhere in output.
|
||||
- Protected command has ` (protected)` suffix.
|
||||
- `opts.parse_mode === "HTML"`.
|
||||
- HTML-escapes a module description containing `<script>`.
|
||||
|
||||
### `escape-html.test.js`
|
||||
- Escapes `&`, `<`, `>`, `"`.
|
||||
- Leaves safe chars alone.
|
||||
|
||||
## Implementation Steps
|
||||
1. Build fakes first — `fake-kv-namespace.js`, `fake-bot.js`, `fake-modules.js`.
|
||||
2. Write tests file-by-file, running `npm test` after each.
|
||||
3. If a test reveals a bug in the source, fix the source (not the test).
|
||||
4. Final full run, assert all green.
|
||||
|
||||
## Todo List
|
||||
- [ ] Fakes: kv namespace, bot, modules
|
||||
- [ ] cf-kv-store tests (incl. `getJSON` / `putJSON` happy path + corrupt-JSON swallow)
|
||||
- [ ] create-store tests (prefix round-trip, isolation, JSON helpers through prefixing)
|
||||
- [ ] validate-command tests (uniform regex, leading-slash rejection)
|
||||
- [ ] registry tests (load, unified-namespace conflicts, init injection, reset)
|
||||
- [ ] dispatcher tests (every visibility registered via `bot.command()`)
|
||||
- [ ] help renderer tests (grouping, escaping, private hidden, parse_mode)
|
||||
- [ ] escape-html tests
|
||||
- [ ] All green via `npm test`
|
||||
|
||||
## Success Criteria
|
||||
- `npm test` passes with ≥ 95% line coverage on the logic seams (registry, db wrapper, dispatcher, help renderer, validators).
|
||||
- No flaky tests on 5 consecutive runs.
|
||||
- Unified-namespace conflict detection has dedicated tests covering same-visibility AND cross-visibility collisions.
|
||||
- Prefix isolation (module A cannot see module B's keys) has a dedicated test.
|
||||
- `getJSON` corrupt-JSON swallowing has a dedicated test.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Fake KV diverges from real behavior | Med | Med | Keep fake minimal, aligned to real shape from [KV basics report](../reports/researcher-260411-0853-cloudflare-kv-basics.md) |
|
||||
| `vi.mock` path resolution differs on Windows | Med | Low | Use injected-dependency pattern instead — pass `moduleRegistry` map as a fn param in tests |
|
||||
| Tests couple to grammY internals | Low | Med | Use fake bot; never import grammY in tests |
|
||||
| Hidden state in registry module-scope leaks between tests | Med | Med | Export a `resetRegistry()` test helper; call in `beforeEach` |
|
||||
|
||||
## Security Considerations
|
||||
- Tests must never read real `.dev.vars` or hit real KV. Keep everything in-memory.
|
||||
|
||||
## Next Steps
|
||||
- Phase 09 documents running the test suite as part of the deploy preflight.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Phase 09 — Deploy + docs
|
||||
|
||||
## Context Links
|
||||
- Plan: [plan.md](plan.md)
|
||||
- Reports: [wrangler + secrets](../reports/researcher-260411-0853-wrangler-config-secrets.md)
|
||||
- All prior phases
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (ship gate)
|
||||
- **Status:** pending
|
||||
- **Description:** first real deploy + documentation. Update README with setup/deploy steps, add an "adding a new module" guide, finalize `wrangler.toml` with real KV IDs.
|
||||
|
||||
## Key Insights
|
||||
- Deploy is a single command — `npm run deploy` — which chains `wrangler deploy` + the register script from phase-07. No separate webhook registration or admin curl calls.
|
||||
- First-time setup creates two env files: `.dev.vars` (for `wrangler dev`) and `.env.deploy` (for `scripts/register.js`). Both gitignored. Both have `.example` siblings committed.
|
||||
- Production secrets for the Worker still live in CF (`wrangler secret put`). The register script reads its own copies from `.env.deploy` — same values, two homes (one for the Worker runtime, one for the deploy script). Document this clearly to avoid confusion.
|
||||
- Adding a new module is a 3-step process: create folder, add to `src/modules/index.js`, add to `MODULES`. Document it.
|
||||
|
||||
## Requirements
|
||||
### Functional
|
||||
- `README.md` updated with: overview, architecture diagram, setup, local dev, deploy, adding a module, command visibility explanation.
|
||||
- `wrangler.toml` has real KV namespace IDs (production + preview).
|
||||
- `.dev.vars.example` + `.env.deploy.example` committed; `.dev.vars` + `.env.deploy` gitignored.
|
||||
- A single `docs/` or inline README section covers how to add a new module with a minimal example.
|
||||
|
||||
### Non-functional
|
||||
- README stays < 250 lines — link to plan phases for deep details if needed.
|
||||
|
||||
## Architecture
|
||||
N/A — docs phase.
|
||||
|
||||
## Related Code Files
|
||||
### Create
|
||||
- `docs/adding-a-module.md` (standalone guide, referenced from README)
|
||||
|
||||
### Modify
|
||||
- `README.md`
|
||||
- `wrangler.toml` (real KV IDs)
|
||||
|
||||
### Delete
|
||||
- none
|
||||
|
||||
## Implementation Steps
|
||||
1. **Create KV namespaces:**
|
||||
```bash
|
||||
wrangler kv namespace create miti99bot-kv
|
||||
wrangler kv namespace create miti99bot-kv --preview
|
||||
```
|
||||
Paste both IDs into `wrangler.toml`.
|
||||
2. **Set Worker runtime secrets (used by the deployed Worker):**
|
||||
```bash
|
||||
wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
wrangler secret put TELEGRAM_WEBHOOK_SECRET
|
||||
```
|
||||
3. **Create `.env.deploy` (used by `scripts/register.js` locally):**
|
||||
```bash
|
||||
cp .env.deploy.example .env.deploy
|
||||
# then fill in the same TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET + WORKER_URL + MODULES
|
||||
```
|
||||
NOTE: `WORKER_URL` is unknown until after the first `wrangler deploy`. For the very first deploy, either (a) deploy once with `wrangler deploy` to learn the URL, fill `.env.deploy`, then run `npm run deploy` again, or (b) use a custom route from the start and put that URL in `.env.deploy` upfront.
|
||||
4. **Preflight:**
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
npm run register:dry # prints the payloads without calling Telegram
|
||||
```
|
||||
All green before deploy.
|
||||
5. **Deploy (one command from here on):**
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
This runs `wrangler deploy && npm run register`. The register step calls Telegram's `setWebhook` + `setMyCommands` with values from `.env.deploy`.
|
||||
6. **Smoke test in Telegram:**
|
||||
- Type `/` in a chat with the bot — confirm the popup lists exactly the public commands (`/info`, `/help`, `/wordle`, `/loldle`, `/ping`). No protected, no private.
|
||||
- `/info` shows chat id / thread id / sender id.
|
||||
- `/help` shows util + wordle + loldle + misc sections with public + protected commands; private commands absent.
|
||||
- `/wordle`, `/loldle`, `/ping` respond.
|
||||
- `/wstats`, `/lstats`, `/mstats` respond (visible in `/help` but NOT in the `/` popup).
|
||||
- `/konami`, `/ggwp`, `/fortytwo` respond when typed, even though they're invisible everywhere.
|
||||
7. **Write `README.md`:**
|
||||
- Badge-free, plain.
|
||||
- Sections: What it is, Architecture snapshot, Prereqs, Setup (KV + secrets + `.env.deploy`), Local dev, Deploy (`npm run deploy`), Command visibility levels, Adding a module, Troubleshooting.
|
||||
- Link to `docs/adding-a-module.md` and to `plans/260411-0853-telegram-bot-plugin-framework/plan.md`.
|
||||
8. **Write `docs/adding-a-module.md`:**
|
||||
- Step 1: Create `src/modules/<name>/index.js` exporting default module object.
|
||||
- Step 2: Add entry to `src/modules/index.js` static map.
|
||||
- Step 3: Add `<name>` to `MODULES` in both `wrangler.toml` (runtime) and `.env.deploy` (so the register script picks up new public commands).
|
||||
- Minimal skeleton code block.
|
||||
- Note on DB namespacing (auto-prefixed).
|
||||
- Note on command naming rules (`[a-z0-9_]{1,32}`, no leading slash, uniform across all visibilities).
|
||||
- Note on visibility levels (public → in `/` menu + `/help`; protected → in `/help` only; private → hidden slash command).
|
||||
9. **Troubleshooting section** — common issues:
|
||||
- 401 on webhook → `TELEGRAM_WEBHOOK_SECRET` mismatch between `wrangler secret` and `.env.deploy`. They MUST match.
|
||||
- `/help` empty for a module → check module exports `default` (not named export).
|
||||
- Module loads but no commands → check `MODULES` includes it (in both `wrangler.toml` AND `.env.deploy`).
|
||||
- Command conflict error at deploy → two modules registered the same command name; rename one.
|
||||
- `npm run register` exits with `missing env: X` → fill `X` in `.env.deploy`.
|
||||
- Node < 20.6 → `--env-file` flag unsupported; upgrade Node.
|
||||
|
||||
## Todo List
|
||||
- [ ] Create real KV namespaces + paste IDs into `wrangler.toml`
|
||||
- [ ] Set both runtime secrets via `wrangler secret put` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`)
|
||||
- [ ] Create `.env.deploy` from example (token, webhook secret, worker URL, modules)
|
||||
- [ ] `npm run lint` + `npm test` + `npm run register:dry` all green
|
||||
- [ ] `npm run deploy` (chains `wrangler deploy` + register)
|
||||
- [ ] End-to-end smoke test in Telegram (9 public/protected commands + 3 private slash commands)
|
||||
- [ ] `README.md` rewrite
|
||||
- [ ] `docs/adding-a-module.md` guide
|
||||
- [ ] Commit + push
|
||||
|
||||
## Success Criteria
|
||||
- Bot responds in Telegram to all 11 slash commands (util: `/info` + `/help` public; wordle: `/wordle` public, `/wstats` protected, `/konami` private; loldle: `/loldle` public, `/lstats` protected, `/ggwp` private; misc: `/ping` public, `/mstats` protected, `/fortytwo` private).
|
||||
- Telegram `/` menu shows exactly 5 public commands (`/info`, `/help`, `/wordle`, `/loldle`, `/ping`) — no protected, no private.
|
||||
- `/help` output lists all four modules and their public + protected commands only.
|
||||
- Private slash commands (`/konami`, `/ggwp`, `/fortytwo`) respond when invoked but appear nowhere visible.
|
||||
- `npm run deploy` is a single command that goes from clean checkout to live bot (after first-time KV + secret setup).
|
||||
- README is enough for a new contributor to deploy their own instance without reading the plan files.
|
||||
- `docs/adding-a-module.md` lets a new module be added in < 5 minutes.
|
||||
|
||||
## Risk Assessment
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Runtime and deploy-script secrets drift out of sync | Med | High | Document that both `.env.deploy` and `wrangler secret` must hold the SAME `TELEGRAM_WEBHOOK_SECRET`; mismatch → 401 loop |
|
||||
| KV preview ID accidentally used in prod | Low | Med | Keep `preview_id` and `id` clearly labeled in wrangler.toml |
|
||||
| `.dev.vars` or `.env.deploy` committed by mistake | Low | High | Gitignore both; pre-commit grep for common token prefixes |
|
||||
| Bot token leaks via error responses | Low | High | grammY's default error handler does not echo tokens; double-check logs |
|
||||
|
||||
## Security Considerations
|
||||
- `TELEGRAM_BOT_TOKEN` never appears in code, logs, or commits.
|
||||
- `.dev.vars` + `.env.deploy` in `.gitignore` — verified before first commit.
|
||||
- README explicitly warns against committing either env file.
|
||||
- Webhook secret rotation: update `.env.deploy`, run `wrangler secret put TELEGRAM_WEBHOOK_SECRET`, then `npm run deploy`. The register step re-calls Telegram's `setWebhook` with the new secret on the same run. Single command does the whole rotation.
|
||||
- Bot token rotation (BotFather reissue): update both `.env.deploy` and `wrangler secret put TELEGRAM_BOT_TOKEN`, then `npm run deploy`.
|
||||
|
||||
## Next Steps
|
||||
- Ship. Feature work begins in a new plan after the user confirms v1 is live.
|
||||
- Potential follow-ups (NOT in this plan):
|
||||
- Replace KV with D1 for relational game state.
|
||||
- Add per-user rate limiting.
|
||||
- Real wordle/loldle/misc game logic.
|
||||
- Logging to Cloudflare Logs or external sink.
|
||||
54
plans/260411-0853-telegram-bot-plugin-framework/plan.md
Normal file
54
plans/260411-0853-telegram-bot-plugin-framework/plan.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "Telegram Bot Plugin Framework on Cloudflare Workers"
|
||||
description: "Greenfield JS Telegram bot with plug-n-play modules, KV-backed store, webhook on CF Workers."
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: 14h
|
||||
branch: main
|
||||
tags: [telegram, cloudflare-workers, grammy, plugin-system, javascript]
|
||||
created: 2026-04-11
|
||||
---
|
||||
|
||||
# Telegram Bot Plugin Framework
|
||||
|
||||
Greenfield JS bot on Cloudflare Workers. grammY + KV. Modules load from `MODULES` env var. Three command visibility levels (public/protected/private). Pluggable DB behind a minimal interface.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | Effort | Blockers |
|
||||
|---|---|---|---|---|
|
||||
| 01 | [Scaffold project](phase-01-scaffold-project.md) | pending | 1h | — |
|
||||
| 02 | [Webhook entrypoint](phase-02-webhook-entrypoint.md) | pending | 1h | 01 |
|
||||
| 03 | [DB abstraction](phase-03-db-abstraction.md) | pending | 1.5h | 01 |
|
||||
| 04 | [Module framework](phase-04-module-framework.md) | pending | 2.5h | 02, 03 |
|
||||
| 05 | [util module](phase-05-util-module.md) | pending | 1.5h | 04 |
|
||||
| 06 | [Stub modules](phase-06-stub-modules.md) | pending | 1h | 04 |
|
||||
| 07 | [Post-deploy register script](phase-07-deploy-register-script.md) | pending | 1h | 05 |
|
||||
| 08 | [Tests](phase-08-tests.md) | pending | 2.5h | 04, 05, 06 |
|
||||
| 09 | [Deploy + docs](phase-09-deploy-docs.md) | pending | 2h | 07, 08 |
|
||||
|
||||
## Key dependencies
|
||||
- grammY `^1.30.0`, adapter `"cloudflare-mod"`
|
||||
- wrangler (npm), Cloudflare account, KV namespace
|
||||
- vitest (plain node pool — pure-logic tests only, no workerd)
|
||||
- biome (lint + format, single tool)
|
||||
|
||||
## Architecture snapshot
|
||||
- `src/index.js` — fetch handler: `POST /webhook` + `GET /` health. No admin HTTP surface.
|
||||
- `src/bot.js` — memoized `Bot` instance + dispatcher wiring
|
||||
- `src/db/` — `KVStore` interface (JSDoc with `getJSON/putJSON/list-cursor`), `cf-kv-store.js`, `create-store.js` (prefixing factory)
|
||||
- `src/modules/registry.js` — load modules, build command tables (public/protected/private + unified `allCommands`), detect conflicts, expose `getCurrentRegistry`
|
||||
- `src/modules/dispatcher.js` — grammY middleware: every command in `allCommands` registered via `bot.command()` regardless of visibility (private = hidden slash command)
|
||||
- `src/modules/index.js` — static import map `{ util: () => import("./util/index.js"), ... }`
|
||||
- `src/modules/{util,wordle,loldle,misc}/index.js` — module entry points
|
||||
- `scripts/register.js` — post-deploy node script calling Telegram `setWebhook` + `setMyCommands`. Chained via `npm run deploy = wrangler deploy && npm run register`.
|
||||
|
||||
## Open questions
|
||||
1. **grammY version pin** — pick exact version at phase-01 time (`npm view grammy version`). Plan assumes `^1.30.0`.
|
||||
|
||||
*Resolved in revision pass (2026-04-11):*
|
||||
- Private commands are hidden slash commands (same regex, routed via `bot.command()`), not text-match easter eggs.
|
||||
- No admin HTTP surface; post-deploy `scripts/register.js` handles `setWebhook` + `setMyCommands`. `ADMIN_SECRET` dropped entirely.
|
||||
- KV interface exposes `expirationTtl`, `list()` cursor, and `getJSON` / `putJSON` helpers. No `metadata`.
|
||||
- Conflict policy: unified namespace across all visibilities, throw at `buildRegistry`.
|
||||
- `/help` parse mode: HTML.
|
||||
56
plans/reports/researcher-260411-0853-cloudflare-kv-basics.md
Normal file
56
plans/reports/researcher-260411-0853-cloudflare-kv-basics.md
Normal 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).
|
||||
@@ -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.
|
||||
@@ -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
109
scripts/register.js
Normal 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
35
scripts/stub-kv.js
Normal 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
56
src/bot.js
Normal 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
108
src/db/cf-kv-store.js
Normal 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
79
src/db/create-store.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
src/db/kv-store-interface.js
Normal file
44
src/db/kv-store-interface.js
Normal 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
61
src/index.js
Normal 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
31
src/modules/dispatcher.js
Normal 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
16
src/modules/index.js
Normal 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"),
|
||||
};
|
||||
38
src/modules/loldle/index.js
Normal file
38
src/modules/loldle/index.js
Normal 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
54
src/modules/misc/index.js
Normal 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
181
src/modules/registry.js
Normal 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;
|
||||
}
|
||||
69
src/modules/util/help-command.js
Normal file
69
src/modules/util/help-command.js
Normal 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
18
src/modules/util/index.js
Normal 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;
|
||||
22
src/modules/util/info-command.js
Normal file
22
src/modules/util/info-command.js
Normal 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;
|
||||
74
src/modules/validate-command.js
Normal file
74
src/modules/validate-command.js
Normal 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; 1–32 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`);
|
||||
}
|
||||
}
|
||||
48
src/modules/wordle/index.js
Normal file
48
src/modules/wordle/index.js
Normal 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
21
src/util/escape-html.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
85
tests/db/cf-kv-store.test.js
Normal file
85
tests/db/cf-kv-store.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
77
tests/db/create-store.test.js
Normal file
77
tests/db/create-store.test.js
Normal 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
24
tests/fakes/fake-bot.js
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
46
tests/fakes/fake-kv-namespace.js
Normal file
46
tests/fakes/fake-kv-namespace.js
Normal 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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
29
tests/fakes/fake-modules.js
Normal file
29
tests/fakes/fake-modules.js
Normal 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 };
|
||||
}
|
||||
56
tests/modules/dispatcher.test.js
Normal file
56
tests/modules/dispatcher.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
154
tests/modules/registry.test.js
Normal file
154
tests/modules/registry.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/modules/util/help-command.test.js
Normal file
104
tests/modules/util/help-command.test.js
Normal 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&b</b>");
|
||||
expect(out).toContain("runs <script>");
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
59
tests/modules/validate-command.test.js
Normal file
59
tests/modules/validate-command.test.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
22
tests/util/escape-html.test.js
Normal file
22
tests/util/escape-html.test.js
Normal 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("&<>"");
|
||||
});
|
||||
|
||||
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 & and < — NOT &lt;
|
||||
expect(escapeHtml("a & <b>")).toBe("a & <b>");
|
||||
});
|
||||
|
||||
it("coerces non-strings", () => {
|
||||
expect(escapeHtml(42)).toBe("42");
|
||||
expect(escapeHtml(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
15
vitest.config.js
Normal file
15
vitest.config.js
Normal 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
22
wrangler.toml
Normal 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
|
||||
Reference in New Issue
Block a user