Extract trading module details (commands, data model, price APIs, file layout) from architecture.md and codebase-summary.md into src/modules/trading/README.md. Project-level docs now only contain global framework info with pointers to module-local READMEs.
18 KiB
Architecture
A deeper look at how miti99bot is wired: what loads when, where data lives, how commands get from Telegram into a handler, and why the boring parts are boring on purpose.
For setup and day-to-day commands, see the top-level README.
For authoring a new plugin module, see adding-a-module.md.
1. Design goals
- Plug-n-play modules. A module = one folder + one line in a static import map + one name in
MODULES. Adding or removing one must never require touching framework code. - YAGNI / KISS / DRY. Small surface area. No speculative abstractions beyond the KV interface (which is explicitly required so storage can be swapped).
- Fail loud at load, not at runtime. Invalid commands, unknown modules, name conflicts, missing env — all throw during registry build so the first request never sees a half-configured bot.
- Single source of truth.
/helprenders the registry. The register script reads the registry.setMyCommandsis derived from the registry. Modules define commands in exactly one place. - No admin HTTP surface. One less attack surface, one less secret. Webhook + menu registration happen out-of-band via a post-deploy node script.
2. Component overview
src/
├── index.js ── fetch router: POST /webhook + GET / health
├── bot.js ── memoized grammY Bot factory, lazy dispatcher install
├── db/
│ ├── kv-store-interface.js ── JSDoc typedefs only — the contract
│ ├── cf-kv-store.js ── Cloudflare KV adapter
│ └── create-store.js ── per-module prefixing factory
├── modules/
│ ├── index.js ── static import map (add new modules here)
│ ├── registry.js ── loader + builder + conflict detection + memoization
│ ├── dispatcher.js ── bot.command() for every visibility
│ ├── validate-command.js ── shared validators
│ ├── util/ ── fully implemented: /info + /help
│ ├── trading/ ── paper trading: crypto, VN stocks, forex, gold
│ ├── wordle/ loldle/ ── stub modules proving the plugin system
│ └── misc/ ── stub that exercises the DB (ping/mstats)
└── util/
└── escape-html.js
scripts/
├── register.js ── post-deploy: setWebhook + setMyCommands
└── stub-kv.js ── no-op KV binding for deploy-time registry build
3. Cold-start and the bot factory
The Cloudflare Worker runtime hands your fetch(request, env, ctx) function fresh on every cold start. Warm requests on the same instance reuse module-scope state. We exploit that to initialize the grammY Bot exactly once per warm instance:
first request ──► getBot(env) ──► new Bot(TOKEN)
└── installDispatcher(bot, env)
├── buildRegistry(env)
│ ├── loadModules(env.MODULES)
│ ├── init() each module
│ └── flatten commands into 4 maps
└── for each: bot.command(name, handler)
▼
return bot (cached at module scope)
later requests ──► getBot(env) returns cached bot
getBot uses both a resolved instance (botInstance) and an in-flight promise (botInitPromise) to handle the case where two concurrent requests race the first init. If init throws, the promise is cleared so the next request retries — a failed init should not permanently wedge the worker.
Required env vars (TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, MODULES) are checked upfront: a missing var surfaces as a 500 with a clear error message on the first request, rather than a confusing runtime error deep inside grammY.
4. The module contract
Every module is a single default export with this shape:
export default {
name: "wordle", // must match folder + import map key
init: async ({ db, env }) => { ... }, // optional, called once at build time
commands: [
{
name: "wordle", // ^[a-z0-9_]{1,32}$, no leading slash
visibility: "public", // "public" | "protected" | "private"
description: "Play wordle", // required, ≤256 chars
handler: async (ctx) => { ... }, // grammY context
},
// ...
],
};
- The command name regex is uniform across all visibility levels. A private command is still a slash command (
/konami) — it is simply absent from Telegram's/menu and from/helpoutput. It is NOT a hidden text-match easter egg. descriptionis required for all visibilities. Private descriptions never reach Telegram; they exist so the registry remains self-documenting for debugging.init({ db, env })is the one place where a module should do setup work. Thedbparameter is aKVStorewhose keys are automatically prefixed with<moduleName>:.envis the raw worker env (read-only by convention).
Validation runs per-command at registry load, and cross-module conflict detection runs at the same step. Any violation throws — deployment fails loudly before any request is served.
5. Module loading: why the static map
Cloudflare Workers bundle statically via wrangler. A dynamic import from a variable path (import(name)) either fails at bundle time or forces the bundler to include every possible import target, defeating tree-shaking. So we have an explicit map:
// src/modules/index.js
export const moduleRegistry = {
util: () => import("./util/index.js"),
wordle: () => import("./wordle/index.js"),
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
trading: () => import("./trading/index.js"),
};
At runtime, loadModules(env) parses env.MODULES (comma-separated), trims, dedupes, and calls only the loaders for the listed names. Modules NOT listed are never imported — wrangler tree-shakes them out of the bundle if they reference code that is otherwise unused.
Adding a new module is a two-line change: create the folder, add one line to this map. Removing a module is a zero-line change: just drop the name from MODULES.
6. The registry and unified conflict detection
buildRegistry(env) produces four maps:
publicCommands: Map<name, entry>— source of truth for/helppublic section +setMyCommandspayloadprotectedCommands: Map<name, entry>— source of truth for/helpprotected sectionprivateCommands: Map<name, entry>— bookkeeping only (hidden from/helpandsetMyCommands)allCommands: Map<name, entry>— unified flat index used by the dispatcher and by conflict detection
Conflict detection walks allCommands as commands are added. If two modules (in any visibility combination) both try to register foo, build throws:
command conflict: /foo registered by both "a" and "b"
This is stricter than a visibility-scoped key space. Rationale: a user typing /foo sees exactly one response, regardless of visibility. If the framework silently picks one or the other, the behavior becomes order-dependent and confusing. Throwing at load means the ambiguity must be resolved in code.
The memoized registry is also exposed via getCurrentRegistry() so /help can read it at handler time without rebuilding. resetRegistry() exists for tests.
7. The dispatcher
Minimalism is the point:
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;
}
Every command — public, protected, and private — is registered via bot.command(). grammY handles:
- Slash prefix parsing
- Case sensitivity (Telegram commands are case-sensitive in practice)
/cmd@botnamesuffix matching in group chats- Argument capture via the grammY context
There is no custom text-match middleware, no bot.on("message:text", ...) handler, no private-command-specific path. One routing path for all three visibilities. This is what reduced the original two-path design (slash + text-match) to one during the revision pass.
8. Storage: the KVStore interface
Modules NEVER touch env.KV directly. They get a KVStore from createStore(moduleName, env):
// In a module's init:
init: async ({ db, env }) => {
moduleDb = db; // stash for handlers
},
// In a handler:
const state = await moduleDb.getJSON("game:42");
await moduleDb.putJSON("game:42", { score: 100 }, { expirationTtl: 3600 });
The interface (full JSDoc in src/db/kv-store-interface.js):
get(key) // → string | null
put(key, value, { expirationTtl? })
delete(key)
list({ prefix?, limit?, cursor? }) // → { keys, cursor?, done }
getJSON(key) // → any | null (swallows corrupt JSON)
putJSON(key, value, { expirationTtl? })
Prefix mechanics
createStore("wordle", env) returns a wrapped store where every key is rewritten:
module calls: wrapper sends to CFKVStore: raw KV key:
───────────────────────── ───────────────────────────── ─────────────
put("games:42", v) ──► put("wordle:games:42", v) ──► wordle:games:42
get("games:42") ──► get("wordle:games:42") ──► wordle:games:42
list({prefix:"games:"})──► list({prefix:"wordle:games:"}) (then strips "wordle:" from returned keys)
Two stores for different modules cannot read each other's data unless they reconstruct prefixes by hand — a code-review boundary, not a cryptographic one.
Why getJSON/putJSON are in the interface
Every planned module stores structured state (game state, user stats, timestamps). Without helpers, every module would repeat JSON.parse(await store.get(k)) and store.put(k, JSON.stringify(v)). That's genuine DRY.
getJSON is deliberately forgiving: if the stored value is not valid JSON (a corrupt record, a partial write, manual tampering), it logs a warning and returns null. A single bad record must not crash the handler.
Swapping the backend
To replace Cloudflare KV with a different store (e.g. Upstash Redis, D1, Postgres):
- Create a new
src/db/<name>-store.jsthat implements theKVStoreinterface. - Change the one
new CFKVStore(env.KV)line insrc/db/create-store.jsto construct your new adapter. - Update
wrangler.tomlbindings.
That's the full change. No module code moves.
9. The webhook entry point
// src/index.js — simplified
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (request.method === "GET" && pathname === "/") {
return new Response("miti99bot ok", { status: 200 });
}
if (request.method === "POST" && pathname === "/webhook") {
const handler = await getWebhookHandler(env);
return handler(request);
}
return new Response("not found", { status: 404 });
},
};
getWebhookHandler is itself memoized — it constructs webhookCallback(bot, "cloudflare-mod", { secretToken: env.TELEGRAM_WEBHOOK_SECRET }) once and reuses it.
grammY's webhookCallback validates the X-Telegram-Bot-Api-Secret-Token header on every request, so a missing or mismatched secret returns 401 before the update reaches any handler. There is no manual header parsing in this codebase.
10. Deploy flow and the register script
Deploy is a single idempotent command:
npm run deploy
# = wrangler deploy && node --env-file=.env.deploy scripts/register.js
npm run deploy
│
├── wrangler deploy
│ └── uploads src/ + wrangler.toml vars to CF
│
└── scripts/register.js
├── reads .env.deploy into process.env (Node --env-file)
├── imports buildRegistry from src/modules/registry.js
├── calls buildRegistry({ MODULES, KV: stubKv }) to derive public cmds
│ └── stubKv satisfies the binding without real IO
├── POST /bot<T>/setWebhook { url, secret_token, allowed_updates }
└── POST /bot<T>/setMyCommands { commands: [...public only] }
The register script imports the same module loader + registry the Worker uses. That means the set of public commands pushed to Telegram's / menu is always consistent with the set of public commands the Worker will actually respond to. No chance of drift. No duplicate command list maintained somewhere.
stubKv is a no-op KV binding provided so createStore doesn't crash during the deploy-time build. Module init hooks are expected to tolerate missing state at deploy time — either by reading only (no writes), or by deferring writes until the first handler call.
--dry-run prints both payloads with the webhook secret redacted, without calling Telegram. Use this to sanity-check what will be pushed before a real deploy.
Why the register step is not in the Worker
A previous design sketched a POST /admin/setup route inside the Worker, gated by a third ADMIN_SECRET. It was scrapped because:
- The Worker gains no capability from it — it can just as easily run from a node script.
- It adds a third secret to manage and rotate.
- It adds an attack surface (even a gated one) to a Worker whose only other route is the Telegram webhook.
- Running locally + idempotently means the exact same script works whether invoked by a human, CI, or a git hook.
11. Security posture
TELEGRAM_BOT_TOKENlives in two places: Cloudflare Workers secrets (wrangler secret put) for runtime, and.env.deploy(gitignored, local-only) for the register script. These two copies must match.TELEGRAM_WEBHOOK_SECRETis validated by grammY on every webhook request. Telegram echoes it viaX-Telegram-Bot-Api-Secret-Tokenon every update; wrong or missing header →401. Rotate by updating both the CF secret and.env.deploy, then re-runningnpm run deploy(the register step re-callssetWebhookwith the new value on the same run)..dev.varsand.env.deployare in.gitignore; their*.examplesiblings are committed.- Module authors get a prefixed store — they cannot accidentally read another module's keys, but the boundary is a code-review one. A motivated module could reconstruct prefixes by hand. This is fine for first-party modules; it is NOT a sandbox.
- Private commands provide discoverability control, not access control. Anyone who knows the name can invoke them.
- HTML injection in
/helpoutput is blocked byescapeHtmlon module names and descriptions.
12. Testing philosophy
Pure-logic unit tests only. No workerd pool, no Telegram fixtures, no integration-level tooling. 110 tests run in ~500ms.
Test seams:
cf-kv-store.test.js— round-trips,list()pagination cursor,expirationTtlpassthrough,getJSON/putJSON(including corrupt-JSON swallow),undefinedvalue rejection.create-store.test.js— module-name validation, prefix mechanics, module-to-module isolation, JSON helpers through the prefix layer.validate-command.test.js— uniform regex, leading-slash rejection, description length cap, all visibilities.registry.test.js— module loading, trim/dedupe, unknown/missing/emptyMODULES, unified-namespace conflict detection (same AND cross-visibility),initinjection,getCurrentRegistry/resetRegistry.dispatcher.test.js— every visibility registered viabot.command(), dispatcher does NOT install anybot.on()middleware, handler identity preserved.help-command.test.js— module grouping,(protected)suffix, zero private-command leakage, HTML escaping of module names + descriptions, placeholder when no commands are visible.escape-html.test.js— the four HTML entities, non-double-escaping, non-string coercion.
Each module adds its own tests under tests/modules/<name>/. See module READMEs for coverage details.
Tests inject fakes (fake-kv-namespace, fake-bot, fake-modules) via parameter passing — no vi.mock, no path-resolution flakiness.
13. Module-specific documentation
Each module maintains its own README.md with commands, data model, and implementation details. See src/modules/<name>/README.md for module-specific docs.
14. Non-goals (for now)
- Real game logic in
wordle/loldle/misc— they're stubs that exercise the framework. Real implementations can land later. - A sandbox between modules. Same-origin trust model: all modules are first-party code.
- Per-user rate limiting. Cloudflare's own rate limiting is available as a higher layer if needed.
nodejs_compatflag. Not needed — grammY + this codebase use only Web APIs.- A CI pipeline. Deploys are developer-driven in v1.
- Internationalization. The bot replies in English; add i18n per-module if a module needs it.
15. Further reading
- The phased implementation plan:
plans/260411-0853-telegram-bot-plugin-framework/— 9 phase files with detailed rationale, risk assessments, and todo lists. - Researcher reports:
plans/reports/researcher-260411-0853-*.md— grammY on Cloudflare Workers, Cloudflare KV basics, wrangler config and secrets. - grammY docs: https://grammy.dev
- Cloudflare Workers KV: https://developers.cloudflare.com/kv/