Replace hardcoded 0.5% spread with live buy/sell rates from BIDV bank API. Buying USD uses bank's sell rate (higher), selling USD uses bank's buy rate (lower). Reply shows both rates and actual spread percentage.
miti99bot
My Telegram bot — a plug-n-play bot framework for 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.
Why
- Drop-in modules. Write a single file, list the folder name in
MODULES, redeploy. No registration boilerplate, no manual command wiring. - Three visibility levels out of the box. Public commands show in Telegram's
/menu and/help; protected show only in/help; private are hidden slash-command easter eggs. One namespace, loud conflict detection. - Storage is swappable. Modules talk to a small
KVStoreinterface — Cloudflare KV today, a different backend tomorrow, with a one-file change. - Zero admin surface. No in-Worker
/admin/*routes, no admin secret.setWebhook+setMyCommandsrun at deploy time from a local node script. - Tested. 110 vitest unit tests cover registry, storage, dispatcher, help renderer, validators, HTML escaping, and the trading module.
How a request flows
Telegram sends update
│
▼
POST /webhook ◄── grammY validates X-Telegram-Bot-Api-Secret-Token (401 on miss)
│
▼
getBot(env) ──► first call only: installDispatcher(bot, env)
│ │
│ ├── loadModules(env.MODULES.split(","))
│ ├── per module: init({ db: createStore(name, env), env })
│ ├── build publicCommands / protectedCommands / privateCommands
│ │ + unified allCommands map (conflict check)
│ └── for each entry: bot.command(name, handler)
▼
bot.handleUpdate(update) ──► grammY routes /cmd → registered handler
│
▼
handler reads/writes via db.getJSON / db.putJSON (auto-prefixed as "module:key")
│
▼
ctx.reply(...) → response back to Telegram
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)
│ ├── trading/ # fake paper trading — crypto, stocks, forex, gold
│ ├── 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. |
Further reading
docs/architecture.md— deeper dive: cold-start, module lifecycle, DB namespacing, deploy flow, design tradeoffs.docs/adding-a-module.md— step-by-step guide to authoring a new module.plans/260411-0853-telegram-bot-plugin-framework/— full phased implementation plan (9 phase files + researcher reports).