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/
miti99bot
My Telegram bot — 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
Setup
-
Install dependencies
npm install -
Create KV namespaces (production + preview)
npx wrangler kv namespace create miti99bot-kv npx wrangler kv namespace create miti99bot-kv --previewPaste the returned IDs into
wrangler.tomlunder[[kv_namespaces]], replacing bothREPLACE_MEplaceholders. -
Set Worker runtime secrets (stored in Cloudflare, used by the deployed Worker)
npx wrangler secret put TELEGRAM_BOT_TOKEN npx wrangler secret put TELEGRAM_WEBHOOK_SECRETTELEGRAM_WEBHOOK_SECRETcan be any high-entropy string — e.g.openssl rand -hex 32. It gates incoming webhook requests; grammY validates it on every update. -
Create
.dev.varsfor local developmentcp .dev.vars.example .dev.vars # fill in the same TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET valuesUsed by
wrangler dev. Gitignored. -
Create
.env.deployfor the post-deploy register scriptcp .env.deploy.example .env.deploy # fill in: token, webhook secret, WORKER_URL (known after first deploy), MODULESGitignored. The
TELEGRAM_BOT_TOKENandTELEGRAM_WEBHOOK_SECRETvalues MUST match what you set viawrangler secret put— mismatch means every incoming webhook returns 401.
Local dev
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:
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:
- Run
wrangler deployonce to learn the*.workers.devURL printed at the end. - Paste it into
.env.deployasWORKER_URL. - Preview the register payloads without calling Telegram:
npm run register:dry - Run the real thing:
npm run deploy
Subsequent deploys: just npm run deploy.
Adding a module
See docs/adding-a-module.md for the full guide.
TL;DR:
- Create
src/modules/<name>/index.jswith a default export{ name, commands, init? }. - Add a line to
src/modules/index.jsstatic map. - Add
<name>toMODULESin bothwrangler.toml[vars]and.env.deploy. 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.