Code prerequisites for the Phase 06 cold-start soak gate. The 24-72h soak
itself is operator-run; this commit ships the instrumentation + analysis
tools needed to make the PROCEED-or-PIVOT decision.
Telemetry
- src/util/timing.js: startTiming(cmd) returns {mark, end} that emits a
structured cmd_timing log. takeColdFlag() returns {cold, isolateAgeMs}
using a module-scoped boolean — first request in an isolate is cold,
subsequent are warm. This replaces the originally-planned
isolate_age_ms < 200ms classifier (broken because Mongo cold-connect
itself is ~1500ms; cold requests would always bucket as warm —
code-reviewer #11).
- src/util/request-context.js: setLastCold/getLastCold shared state
bridges fetch-level cold detection into the dispatcher middleware
without a circular import.
- src/index.js: takeColdFlag at the top of fetch() emits a request log
and primes the request context for the dispatcher.
- src/modules/dispatcher.js: bot.use() middleware times every command.
Chosen over per-handler wrapping to preserve the existing identity
assertion in tests (handler === reg.allCommands.get(name).cmd.handler)
— single instrumentation point, no contract change.
Soak tools (operator-run)
- scripts/analyze-soak.js: parses CF Logs export (NDJSON or CSV), filters
cmd_timing events, computes p50/p95/p99 per (cmd, cold/warm). Counts
dual-write secondary failures, mongo connection errors, CPU-time
exceeded events. Writes markdown report.
- scripts/synthetic-burst.js: fires N parallel synthetic Telegram updates
at the deployed Worker URL with cache-busting tokens. Used for the
pre-deploy connection-cap stress test (debugger #2 — 20 parallel cold
requests, abort if Atlas peak > 60% of 500-conn cap).
- package.json: analyze:soak + burst:synthetic scripts wired.
Tests
- tests/util/timing.test.js: 8 tests — timing semantics, cold flag flip.
- tests/scripts/analyze-soak.test.js: 22 tests — percentile math, NDJSON
+ CSV parse, aggregation, markdown formatting.
Tests: 667 → 697 (+30). Lint clean.
Operator runbook for Phase 06 (NOT executed by this commit):
1. Verify telemetry live via wrangler tail.
2. Run synthetic burst test: npm run burst:synthetic -- --url <prod>
3. Configure Atlas + CF Observability email alerts.
4. 24h soak (extend to 72h on stop-conditions per phase plan).
5. Daily npm run verify:mongo.
6. npm run analyze:soak -- --input <cf-logs.json> → soak-decision.md.
7. PROCEED to Phase 07 if cold-start P95 ≤ 2.5 × BASELINE_COLD_PING_MS;
else execute phase-07-alt-pivot.md (Upstash standby).
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. - Dual storage backends. Modules talk to a small
KVStoreinterface (Cloudflare KV for simple state) orSqlStoreinterface (D1 for relational data, scans, leaderboards). Swappable with one-file changes. - Scheduled jobs. Modules declare cron-based cleanup, stats refresh, or maintenance tasks — registered via
wrangler.tomland dispatched automatically. - Zero admin surface. No in-Worker
/admin/*routes, no admin secret.setWebhook+setMyCommandsrun at deploy time from a local node script. - Tested. 200+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading / loldle / wordle modules.
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 + scheduled handlers: POST /webhook + cron triggers
├── bot.js # memoized grammY Bot, lazy dispatcher + registry install
├── types.js # JSDoc typedefs (central: Env, Module, Command, Cron, etc.)
├── db/
│ ├── kv-store-interface.js # KVStore contract (JSDoc)
│ ├── cf-kv-store.js # Cloudflare KV implementation
│ ├── create-store.js # KV per-module prefixing factory
│ ├── sql-store-interface.js # SqlStore contract (JSDoc)
│ ├── cf-sql-store.js # Cloudflare D1 implementation
│ └── create-sql-store.js # D1 per-module prefixing factory
├── modules/
│ ├── index.js # static import map — register new modules here
│ ├── registry.js # load, validate, build command + cron tables
│ ├── dispatcher.js # wires every command via bot.command()
│ ├── cron-dispatcher.js # dispatches cron handlers by schedule match
│ ├── validate-command.js # command contract validator
│ ├── validate-cron.js # cron contract validator
│ ├── util/ # /info, /help (fully implemented)
│ ├── trading/ # paper trading — VN stocks (D1 storage, daily cron)
│ │ └── migrations/
│ │ └── 0001_trades.sql
│ ├── wordle/ # 5-letter guessing game (KV storage, 14k-word dict)
│ ├── loldle/ # classic-mode LoL champion guessing (KV storage)
│ ├── loldle-emoji/ # emoji-clue LoL champion guessing (KV storage)
│ ├── loldle-quote/ # lore-blurb LoL champion guessing (KV storage)
│ ├── loldle-ability/ # ability-icon LoL champion guessing (KV storage)
│ ├── loldle-splash/ # splash-art LoL champion guessing (KV storage)
│ └── misc/ # stub (KV storage)
└── util/
└── escape-html.js
scripts/
├── register.js # post-deploy: setWebhook + setMyCommands
├── migrate.js # discover + apply D1 migrations
└── stub-kv.js # no-op KV binding for deploy-time registry build
tests/
└── fakes/
├── fake-kv-namespace.js
├── fake-d1.js # in-memory SQL for testing
├── fake-bot.js
└── fake-modules.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 + eslint
npm test # vitest
npm run db:migrate # apply D1 migrations (--local for local dev, --dry-run to preview)
The local wrangler dev server exposes GET / (health), POST /webhook (Telegram), and /__scheduled?cron=... (cron simulation). 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, applies D1 migrations, then scripts/register.js, which calls Telegram's setWebhook + setMyCommands using values from .env.deploy.
First-time deploy flow:
- Create D1 database:
npx wrangler d1 create miti99bot-dband paste ID intowrangler.toml. - Run
wrangler deployonce to learn the*.workers.devURL printed at the end. - Paste it into
.env.deployasWORKER_URL. - Apply migrations:
npm run db:migrate. - Preview the register payloads without calling Telegram:
npm run register:dry - Run the real deploy:
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. |
Credits
The loldle module family (classic, emoji, quote, ability, splash) is
inspired by loldle.net. Classic's champion
metadata and splash mode's skin pool are scraped from their JS bundle;
other modes derive or generate data from Riot Data Dragon.
League of Legends, champion art, and ability icons are © Riot Games.
Further reading
docs/architecture.md— deeper dive: cold-start, module lifecycle, KV + D1 storage, cron dispatch, deploy flow, design tradeoffs.docs/adding-a-module.md— step-by-step guide to authoring a new module (commands, KV storage, D1 + migrations, crons).docs/using-d1.md— when to use D1, writing migrations, SQL API reference, worked examples.docs/using-cron.md— scheduling syntax, handler signature, wrangler.toml registration, local testing, worked examples.docs/deployment-guide.md— D1 + KV setup, migration, secret rotation, rollback.plans/260415-1010-d1-cron-infra/— phased implementation plan for D1 + cron support (6 phases + reports).plans/260411-0853-telegram-bot-plugin-framework/— original plugin framework implementation plan (9 phases + reports).