Files
miti99bot/plans/260411-0853-telegram-bot-plugin-framework/phase-07-deploy-register-script.md
tiennm99 c4314f21df feat: scaffold plug-n-play telegram bot on cloudflare workers
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/
2026-04-11 09:49:06 +07:00

213 lines
11 KiB
Markdown

# Phase 07 — Post-deploy register script (`setWebhook` + `setMyCommands`)
## Context Links
- Plan: [plan.md](plan.md)
- Phase 01: [scaffold project](phase-01-scaffold-project.md) — defines `npm run deploy` + `.env.deploy.example`
- Phase 04: [module framework](phase-04-module-framework.md) — defines `publicCommands` source of truth
- Reports: [grammY on CF Workers](../reports/researcher-260411-0853-grammy-on-cloudflare-workers.md)
## Overview
- **Priority:** P2
- **Status:** pending
- **Description:** a plain Node script at `scripts/register.js` that runs automatically after `wrangler 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 the `public` command list to Telegram via `setMyCommands`. 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 `import` the 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.deploy` is gitignored; `.env.deploy.example` is committed.
- `setWebhook` is called with `secret_token` field equal to `TELEGRAM_WEBHOOK_SECRET`. Telegram echoes this in `X-Telegram-Bot-Api-Secret-Token` on every update; grammY's `webhookCallback` validates it.
- `setMyCommands` accepts 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 `setWebhook` with the same URL.
- `private` commands are excluded from `setMyCommands`. `protected` commands also excluded (by definition — they're only in `/help`).
## Requirements
### Functional
- `scripts/register.js`:
1. Read required env: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `WORKER_URL`. Missing any → print which one and `process.exit(1)`.
2. Read `MODULES` from `.env.deploy` (or shell env) — same value that the Worker uses. If absent, default to reading `wrangler.toml` `[vars].MODULES`. Prefer `.env.deploy` for single-source-of-truth at deploy time.
3. Build the public command list by calling the same loader/registry code used by the Worker:
```js
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 }));
```
Pass a stub `KV` binding (a no-op object matching the `KVNamespace` shape) so `createStore` doesn't crash. Modules that do real KV work in `init` must tolerate this (or defer writes until first handler call — phase-06 stubs already do the latter).
4. `POST https://api.telegram.org/bot<TOKEN>/setWebhook` with body:
```json
{
"url": "<WORKER_URL>/webhook",
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>",
"allowed_updates": ["message", "edited_message", "callback_query"],
"drop_pending_updates": false
}
```
5. `POST https://api.telegram.org/bot<TOKEN>/setMyCommands` with body `{ "commands": [...] }`.
6. Print a short summary: webhook URL, count of registered public commands, list of commands. Exit 0.
7. On any non-2xx from Telegram: print the response body + exit 1. `npm run deploy` fails loudly.
- `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 `await` at module scope (wrap in `main()` + `.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.js`
- `scripts/stub-kv.js` — tiny no-op `KVNamespace` stub 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` — add `deploy` + `register` + `register:dry` scripts (phase-01 already wires `deploy`)
### Delete
- none
## Implementation Steps
1. `scripts/stub-kv.js`:
```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 }; },
};
```
2. `scripts/register.js`:
```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); });
```
3. Update `package.json` scripts:
```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"
```
4. Smoke test BEFORE first real deploy:
- Populate `.env.deploy` with 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.
5. Lint clean (biome covers `scripts/` — phase-01 `lint` script already includes it).
## Todo List
- [ ] `scripts/stub-kv.js`
- [ ] `scripts/register.js` with `--dry-run` flag
- [ ] `package.json` scripts updated
- [ ] `.env.deploy.example` committed (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 deploy` then automatic `setWebhook` + `setMyCommands`, no manual steps.
- `npm run register:dry` prints both payloads and exits 0 without calling Telegram.
- Missing env var produces a clear `missing env: NAME` message + exit 1.
- Telegram client `/` menu shows exactly the `public` commands (5 total with full stubs: `/info`, `/help`, `/wordle`, `/loldle`, `/ping`).
- Protected and private commands are NOT in Telegram's `/` menu.
- Running `npm run register` a 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.deploy` contains the bot token — MUST be gitignored and never logged by the script beyond `console.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-file` pointing at a CI-provided file.
- No admin HTTP surface on the Worker means no timing-attack attack surface, no extra secret rotation, no `ADMIN_SECRET` to leak.
- `setWebhook` re-registration rotates nothing automatically — if the webhook secret is rotated, you MUST re-run `npm run register` so 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.deploy` setup, first-run checklist, troubleshooting).