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/
This commit is contained in:
2026-04-11 09:49:06 +07:00
parent e76ad8c0ee
commit c4314f21df
51 changed files with 6928 additions and 1 deletions

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from "vitest";
import { installDispatcher } from "../../src/modules/dispatcher.js";
import { resetRegistry } from "../../src/modules/registry.js";
import { makeFakeBot } from "../fakes/fake-bot.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
// The dispatcher imports the registry's default static map at module-load
// time, so to keep the test hermetic we need to stub env.MODULES with names
// that resolve in the REAL map — OR we call buildRegistry directly with an
// injected map and then simulate what installDispatcher does. Since phase-04
// made loadModules accept an importMap injection but installDispatcher
// internally calls buildRegistry WITHOUT an injection, we test the same
// effect by wiring the bot manually against a registry built with a fake map.
//
// Keeping it simple: call installDispatcher against the real module map
// (util + wordle + loldle + misc) so we exercise the actual production path.
describe("installDispatcher", () => {
beforeEach(() => resetRegistry());
it("registers EVERY command via bot.command() regardless of visibility", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
// Expect 11 total commands (5 public + 3 protected + 3 private).
expect(bot.commandCalls).toHaveLength(11);
expect(reg.allCommands.size).toBe(11);
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
const expected = [...reg.allCommands.keys()].sort();
expect(registeredNames).toEqual(expected);
// Assert private commands ARE registered (the whole point of unified routing).
expect(registeredNames).toContain("konami");
expect(registeredNames).toContain("ggwp");
expect(registeredNames).toContain("fortytwo");
});
it("does NOT install any bot.on() middleware — dispatcher is minimal", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util", KV: makeFakeKv() };
await installDispatcher(bot, env);
expect(bot.onCalls).toHaveLength(0);
});
it("each registered handler matches the source module command handler", async () => {
const bot = makeFakeBot();
const env = { MODULES: "util", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
for (const { name, handler } of bot.commandCalls) {
expect(handler).toBe(reg.allCommands.get(name).cmd.handler);
}
});
});