mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 19:22:09 +00:00
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/
11 KiB
11 KiB
Phase 07 — Post-deploy register script (setWebhook + setMyCommands)
Context Links
- Plan: plan.md
- Phase 01: scaffold project — defines
npm run deploy+.env.deploy.example - Phase 04: module framework — defines
publicCommandssource of truth - Reports: grammY on CF Workers
Overview
- Priority: P2
- Status: pending
- Description: a plain Node script at
scripts/register.jsthat runs automatically afterwrangler 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 thepubliccommand list to Telegram viasetMyCommands. 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
importthe 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.deployis gitignored;.env.deploy.exampleis committed. setWebhookis called withsecret_tokenfield equal toTELEGRAM_WEBHOOK_SECRET. Telegram echoes this inX-Telegram-Bot-Api-Secret-Tokenon every update; grammY'swebhookCallbackvalidates it.setMyCommandsaccepts 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
setWebhookwith the same URL. privatecommands are excluded fromsetMyCommands.protectedcommands also excluded (by definition — they're only in/help).
Requirements
Functional
scripts/register.js:- Read required env:
TELEGRAM_BOT_TOKEN,TELEGRAM_WEBHOOK_SECRET,WORKER_URL. Missing any → print which one andprocess.exit(1). - Read
MODULESfrom.env.deploy(or shell env) — same value that the Worker uses. If absent, default to readingwrangler.toml[vars].MODULES. Prefer.env.deployfor single-source-of-truth at deploy time. - Build the public command list by calling the same loader/registry code used by the Worker:
Pass a stub
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 }));KVbinding (a no-op object matching theKVNamespaceshape) socreateStoredoesn't crash. Modules that do real KV work ininitmust tolerate this (or defer writes until first handler call — phase-06 stubs already do the latter). POST https://api.telegram.org/bot<TOKEN>/setWebhookwith body:{ "url": "<WORKER_URL>/webhook", "secret_token": "<TELEGRAM_WEBHOOK_SECRET>", "allowed_updates": ["message", "edited_message", "callback_query"], "drop_pending_updates": false }POST https://api.telegram.org/bot<TOKEN>/setMyCommandswith body{ "commands": [...] }.- Print a short summary: webhook URL, count of registered public commands, list of commands. Exit 0.
- On any non-2xx from Telegram: print the response body + exit 1.
npm run deployfails loudly.
- Read required env:
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
awaitat module scope (wrap inmain()+.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.jsscripts/stub-kv.js— tiny no-opKVNamespacestub 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— adddeploy+register+register:dryscripts (phase-01 already wiresdeploy)
Delete
- none
Implementation Steps
scripts/stub-kv.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 }; }, };scripts/register.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); });- Update
package.jsonscripts:"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" - Smoke test BEFORE first real deploy:
- Populate
.env.deploywith 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.
- Populate
- Lint clean (biome covers
scripts/— phase-01lintscript already includes it).
Todo List
scripts/stub-kv.jsscripts/register.jswith--dry-runflagpackage.jsonscripts updated.env.deploy.examplecommitted (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 deploythen automaticsetWebhook+setMyCommands, no manual steps.npm run register:dryprints both payloads and exits 0 without calling Telegram.- Missing env var produces a clear
missing env: NAMEmessage + exit 1. - Telegram client
/menu shows exactly thepubliccommands (5 total with full stubs:/info,/help,/wordle,/loldle,/ping). - Protected and private commands are NOT in Telegram's
/menu. - Running
npm run registera 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.deploycontains the bot token — MUST be gitignored and never logged by the script beyondconsole.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-filepointing at a CI-provided file. - No admin HTTP surface on the Worker means no timing-attack attack surface, no extra secret rotation, no
ADMIN_SECRETto leak. setWebhookre-registration rotates nothing automatically — if the webhook secret is rotated, you MUST re-runnpm run registerso 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.deploysetup, first-run checklist, troubleshooting).