Files
miti99bot/plans/260411-0853-telegram-bot-plugin-framework/phase-04-module-framework.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

11 KiB

Phase 04 — Module framework (contract, registry, loader, dispatcher)

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 /help output (public + protected). Private commands are hidden slash commands — easter eggs that still route through bot.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 by env.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@botname suffix in groups automatically. No custom text-match code.
  • Visibility is pure metadata. It affects two downstream consumers:
    1. phase-07's scripts/register.jssetMyCommands payload (public only).
    2. phase-05's /help renderer (public + protected, skip private).
  • Command-name conflicts: two modules registering the same command name = registry throws at load. One unified namespace across all visibility levels — a public /foo in module A collides with a private /foo in 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
    };
    
  • name on 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 init is 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 for setMyCommands + /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 to bot.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.

Create

  • src/modules/index.js — static import map
  • src/modules/registry.jsloadModules + buildRegistry + getCurrentRegistry
  • src/modules/dispatcher.jsinstallDispatcher(bot, env) (replaces phase-02 stub)
  • src/modules/validate-command.js — shared validators (name regex, visibility, description)

Modify

  • src/bot.js — call installDispatcher(bot, env) (was a no-op stub)

Delete

  • none

Implementation Steps

  1. 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"),
    };
    
    (Stub module folders land in phase-05/06 — tests in phase-08 can inject a fake map.)
  2. src/modules/validate-command.js:
    • VISIBILITIES = ["public", "protected", "private"].
    • COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/ (uniform across all visibilities).
    • validateCommand(cmd, moduleName):
      • visibilityVISIBILITIES (throw otherwise).
      • name matches COMMAND_NAME_RE (no leading /).
      • description is non-empty string, ≤ 256 chars.
      • handler is a function.
      • All errors mention both the module name and the offending command name.
  3. src/modules/registry.js:
    • Module-scope let currentRegistry = null; (used by /help in 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 → throw Error(\unknown module: ${name}`)`.
      • const mod = (await loader()).default.
      • Validate mod.name === name.
      • Validate each command via validateCommand.
      • Return ordered array of module objects.
    • async function buildRegistry(env):
      • Call loadModules.
      • Init four maps: publicCommands, protectedCommands, privateCommands, allCommands.
      • For each module (in env.MODULES order):
        • 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) → throw Error(\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.
      • currentRegistry = { publicCommands, protectedCommands, privateCommands, allCommands, modules };
      • Return it.
    • 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 in beforeEach).
  4. 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; /help reads via getCurrentRegistry()).
  5. Update src/bot.js to await installDispatcher(bot, env) before returning. This makes getBot async — update src/index.js to await getBot(env).
  6. Lint clean.

Todo List

  • src/modules/index.js static import map
  • src/modules/validate-command.js (uniform regex + description length cap)
  • src/modules/registry.jsloadModules + buildRegistry + getCurrentRegistry + resetRegistry
  • src/modules/dispatcher.js — single loop, all visibilities via bot.command()
  • Update src/bot.js + src/index.js for async getBot
  • 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) → buildRegistry throws 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 /help output; does NOT appear in Telegram's / menu after scripts/register.js runs.

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).
  • init errors 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.