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 04 — Module framework (contract, registry, loader, dispatcher)
Context Links
- Plan: plan.md
- Reports: grammY on CF Workers, KV basics
Overview
- Priority: P1
- Status: pending
- Description: define the plugin contract, build the registry, implement the static-map loader filtered by
env.MODULES, and write the grammY dispatcher middleware that routes commands and enforces visibility. All three visibility levels (public / protected / private) are slash commands. Visibility only controls which commands appear in Telegram's/menu (public only) and in/helpoutput (public + protected). Private commands are hidden slash commands — easter eggs that still route throughbot.command().
Key Insights
- wrangler bundles statically — dynamic
import(variablePath)defeats tree-shaking and can fail at bundle time. Solution: static map{ name: () => import("./name/index.js") }, filtered at runtime byenv.MODULES.split(","). - Single routing path: every command — regardless of visibility — is registered via
bot.command(name, handler). grammY handles slash-prefix parsing, case-sensitivity, and/cmd@botnamesuffix in groups automatically. No custom text-match code. - Visibility is pure metadata. It affects two downstream consumers:
- phase-07's
scripts/register.js→setMyCommandspayload (public only). - phase-05's
/helprenderer (public + protected, skip private).
- phase-07's
- Command-name conflicts: two modules registering the same command name = registry throws at load. One unified namespace across all visibility levels — a public
/fooin module A collides with a private/fooin module B. Fail fast > mystery last-wins. - The registry is built ONCE per warm instance, inside
getBot(env). Not rebuilt per request. - grammY's
bot.command()matches exactly against the command name token — case-sensitive per Telegram's own semantics. This naturally satisfies the "exact, case-sensitive" requirement for private commands.
Requirements
Functional
- Module contract (locked):
export default { name: "wordle", // must match folder name, [a-z0-9_-]+ commands: [ { name: "wordle", visibility: "public", description: "Play wordle", handler: async (ctx) => {...} }, { name: "wstats", visibility: "protected", description: "Stats", handler: async (ctx) => {...} }, { name: "konami", visibility: "private", description: "Easter egg", handler: async (ctx) => {...} }, ], init: async ({ db, env }) => {}, // optional }; nameon a command: slash-command name without leading/,[a-z0-9_]{1,32}(Telegram's own limit). Same regex for all visibility levels — private commands are still/foo.description: required for all three visibility levels (private descriptions are used internally for debugging + not surfaced to Telegram/users). Max 256 chars (Telegram's limit on public command descriptions). Enforce uniformly.- Loader reads
env.MODULES, splits, trims, dedupes. For each name, look up static map; unknown name → throw. - Each module's
initis called once with{ db: createStore(module.name, env), env }. - Registry builds three indexed maps (same shape, partitioned by visibility) PLUS one flat map of all commands for conflict detection + dispatch:
publicCommands: Map<string, {module, cmd}>— source of truth forsetMyCommands+/help.protectedCommands: Map<string, {module, cmd}>— source of truth for/help.privateCommands: Map<string, {module, cmd}>— bookkeeping only (not surfaced anywhere visible).allCommands: Map<string, {module, cmd, visibility}>— flat index used by the dispatcher tobot.command()every entry regardless of visibility.
- Name conflict across modules (any visibility combination) → throw
Error("command conflict: ...")naming both modules and the command.
Non-functional
src/modules/registry.js< 150 LOC.src/modules/dispatcher.js< 60 LOC.- No global mutable state outside the registry itself.
Architecture
env.MODULES = "util,wordle,loldle,misc"
│
▼
loadModules(env) ──► static map lookup ──► import() each ──► array of module objects
│
▼
buildRegistry(modules, env)
│
├── for each module: call init({db, env}) if present
└── flatten commands → 3 visibility-partitioned maps + 1 flat `allCommands` map
│ (conflict check walks `allCommands`: one namespace, all visibilities)
▼
installDispatcher(bot, registry)
│
└── for each entry in allCommands:
bot.command(cmd.name, cmd.handler)
Why no text-match middleware: grammY's bot.command() already handles exact-match slash routing, case sensitivity, and group-chat /cmd@bot disambiguation. Private commands ride that same path — they're just absent from setMyCommands and /help.
Related Code Files
Create
src/modules/index.js— static import mapsrc/modules/registry.js—loadModules+buildRegistry+getCurrentRegistrysrc/modules/dispatcher.js—installDispatcher(bot, env)(replaces phase-02 stub)src/modules/validate-command.js— shared validators (name regex, visibility, description)
Modify
src/bot.js— callinstallDispatcher(bot, env)(was a no-op stub)
Delete
- none
Implementation Steps
src/modules/index.js:(Stub module folders land in phase-05/06 — tests in phase-08 can inject a fake map.)export const moduleRegistry = { util: () => import("./util/index.js"), wordle: () => import("./wordle/index.js"), loldle: () => import("./loldle/index.js"), misc: () => import("./misc/index.js"), };src/modules/validate-command.js:VISIBILITIES = ["public", "protected", "private"].COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/(uniform across all visibilities).validateCommand(cmd, moduleName):visibility∈VISIBILITIES(throw otherwise).namematchesCOMMAND_NAME_RE(no leading/).descriptionis non-empty string, ≤ 256 chars.handleris a function.- All errors mention both the module name and the offending command name.
src/modules/registry.js:- Module-scope
let currentRegistry = null;(used by/helpin phase-05). async function loadModules(env):- Parse
env.MODULES→ array, trim, dedupe, skip empty. - Empty list → throw
Error("MODULES env var is empty"). - For each name,
const loader = moduleRegistry[name]; unknown → throwError(\unknown module: ${name}`)`. const mod = (await loader()).default.- Validate
mod.name === name. - Validate each command via
validateCommand. - Return ordered array of module objects.
- Parse
async function buildRegistry(env):- Call
loadModules. - Init four maps:
publicCommands,protectedCommands,privateCommands,allCommands. - For each module (in
env.MODULESorder):- If
init:await mod.init({ db: createStore(mod.name, env), env }). Wrap in try/catch; rethrow with module name prefix. - For each cmd:
- If
allCommands.has(cmd.name)→ throwError(\command conflict: /${cmd.name} registered by both ${existing.module.name} and ${mod.name}`)`. allCommands.set(cmd.name, { module: mod, cmd, visibility: cmd.visibility });- Push into the visibility-specific map too.
- If
- If
currentRegistry = { publicCommands, protectedCommands, privateCommands, allCommands, modules };- Return it.
- Call
export function getCurrentRegistry() { if (!currentRegistry) throw new Error("registry not built yet"); return currentRegistry; }export function resetRegistry() { currentRegistry = null; }(test helper; phase-08 uses it inbeforeEach).
- Module-scope
src/modules/dispatcher.js: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(caller may ignore;/helpreads viagetCurrentRegistry()).
- Update
src/bot.jstoawait installDispatcher(bot, env)before returning. This makesgetBotasync — updatesrc/index.jstoawait getBot(env). - Lint clean.
Todo List
src/modules/index.jsstatic import mapsrc/modules/validate-command.js(uniform regex + description length cap)src/modules/registry.js—loadModules+buildRegistry+getCurrentRegistry+resetRegistrysrc/modules/dispatcher.js— single loop, all visibilities viabot.command()- Update
src/bot.js+src/index.jsfor asyncgetBot - Unified-namespace conflict detection
- Lint clean
Success Criteria
- With
MODULES="util"and util exposing one public cmd,wrangler dev+ mocked webhook update correctly routes. - Conflict test (phase-08): two fake modules both register
/foo(regardless of visibility) →buildRegistrythrows with both module names and the command name in the message. - Unknown module name in
MODULES→ throws with clear message. - Registry built once per warm instance (memoized via
getBot). /konami(a private command) responds when typed; does NOT appear in/helpoutput; does NOT appear in Telegram's/menu afterscripts/register.jsruns.
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Dynamic import breaks bundler tree-shaking | N/A | N/A | Mitigated by static map design |
init throws → entire bot broken |
Med | High | Wrap in try/catch, log module name, re-throw with context |
Module mutates passed env |
Low | Med | Pass env as-is; document contract: modules must not mutate |
Private command accidentally listed in setMyCommands |
Low | Med | scripts/register.js reads publicCommands only (not allCommands) |
Description > 256 chars breaks setMyCommands payload |
Low | Low | Validator enforces cap at module load |
Security Considerations
- A private command with the same name as a public one in another module would be ambiguous. Unified conflict detection blocks this at load.
- Module authors get an auto-prefixed DB store — they CANNOT read other modules' data unless they reconstruct prefixes manually (code review responsibility).
initerrors must log module name for audit, never the env object (may contain secrets).- Private commands do NOT provide security — anyone who guesses the name can invoke them. They are for discoverability control, not access control.
Next Steps
- Phase 05 implements util module (
/info,/help) consuming the registry. - Phase 06 adds stub modules proving the plugin system end-to-end.