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);
}
});
});

View File

@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildRegistry,
getCurrentRegistry,
loadModules,
resetRegistry,
} from "../../src/modules/registry.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
const makeEnv = (modules) => ({ MODULES: modules, KV: makeFakeKv() });
describe("registry", () => {
beforeEach(() => resetRegistry());
describe("loadModules", () => {
it("loads modules listed in env.MODULES", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("bar", "public")]),
});
const mods = await loadModules({ MODULES: "a,b" }, map);
expect(mods.map((m) => m.name)).toEqual(["a", "b"]);
});
it("trims, dedupes, skips empty", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
const mods = await loadModules({ MODULES: " a , a , " }, map);
expect(mods).toHaveLength(1);
});
it("throws on empty MODULES", async () => {
await expect(loadModules({ MODULES: "" }, {})).rejects.toThrow(/empty/);
await expect(loadModules({}, {})).rejects.toThrow(/empty/);
});
it("throws on unknown module name", async () => {
await expect(loadModules({ MODULES: "ghost" }, {})).rejects.toThrow(/unknown module: ghost/);
});
it("throws when default export name mismatches key", async () => {
const map = makeFakeImportMap({ a: makeModule("b", []) });
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/mismatched name/);
});
it("throws when module has no commands array", async () => {
const map = {
a: async () => ({ default: { name: "a" } }),
};
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/commands array/);
});
it("runs validateCommand on each command", async () => {
const badMap = makeFakeImportMap({
a: makeModule("a", [
{ name: "/bad", visibility: "public", description: "x", handler: async () => {} },
]),
});
await expect(loadModules({ MODULES: "a" }, badMap)).rejects.toThrow();
});
});
describe("buildRegistry", () => {
it("partitions commands across visibility maps + flat allCommands", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [
makeCommand("pub", "public"),
makeCommand("prot", "protected"),
makeCommand("priv", "private"),
]),
});
const reg = await buildRegistry(makeEnv("a"), map);
expect([...reg.publicCommands.keys()]).toEqual(["pub"]);
expect([...reg.protectedCommands.keys()]).toEqual(["prot"]);
expect([...reg.privateCommands.keys()]).toEqual(["priv"]);
expect([...reg.allCommands.keys()].sort()).toEqual(["priv", "prot", "pub"]);
});
it("throws on same-visibility conflict", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("foo", "public")]),
});
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo.*a.*b/);
});
it("throws on CROSS-visibility conflict (unified namespace)", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
b: makeModule("b", [makeCommand("foo", "private")]),
});
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo/);
});
it("calls init({db, env}) exactly once per module, in order", async () => {
const calls = [];
const makeInit =
(name) =>
async ({ db, env }) => {
calls.push({ name, hasDb: !!db, hasEnv: !!env });
await db.put("sentinel", "x");
};
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("one", "public")], makeInit("a")),
b: makeModule("b", [makeCommand("two", "public")], makeInit("b")),
});
const env = makeEnv("a,b");
await buildRegistry(env, map);
expect(calls).toEqual([
{ name: "a", hasDb: true, hasEnv: true },
{ name: "b", hasDb: true, hasEnv: true },
]);
// Assert prefixing: keys landed as "a:sentinel" and "b:sentinel".
expect(env.KV.store.get("a:sentinel")).toBe("x");
expect(env.KV.store.get("b:sentinel")).toBe("x");
});
it("wraps init errors with module name", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")], async () => {
throw new Error("boom");
}),
});
await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(
/module "a" init failed.*boom/,
);
});
});
describe("getCurrentRegistry / resetRegistry", () => {
it("getCurrentRegistry throws before build", () => {
expect(() => getCurrentRegistry()).toThrow(/not built/);
});
it("getCurrentRegistry returns the same instance after build", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
const reg = await buildRegistry(makeEnv("a"), map);
expect(getCurrentRegistry()).toBe(reg);
});
it("resetRegistry clears the memoized registry", async () => {
const map = makeFakeImportMap({
a: makeModule("a", [makeCommand("foo", "public")]),
});
await buildRegistry(makeEnv("a"), map);
resetRegistry();
expect(() => getCurrentRegistry()).toThrow(/not built/);
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { renderHelp } from "../../../src/modules/util/help-command.js";
const noop = async () => {};
/**
* Build a synthetic registry directly — no loader involved. Lets us test
* the pure renderer with exact inputs.
*/
function makeRegistry(modules) {
const publicCommands = new Map();
const protectedCommands = new Map();
const privateCommands = new Map();
const allCommands = new Map();
for (const mod of modules) {
for (const cmd of mod.commands) {
const entry = { module: mod, cmd, visibility: cmd.visibility };
allCommands.set(cmd.name, entry);
if (cmd.visibility === "public") publicCommands.set(cmd.name, entry);
else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry);
else privateCommands.set(cmd.name, entry);
}
}
return {
publicCommands,
protectedCommands,
privateCommands,
allCommands,
modules,
};
}
const cmd = (name, visibility, description) => ({
name,
visibility,
description,
handler: noop,
});
describe("renderHelp", () => {
it("groups commands by module in env.MODULES order", () => {
const reg = makeRegistry([
{ name: "a", commands: [cmd("one", "public", "A-one")] },
{ name: "b", commands: [cmd("two", "public", "B-two")] },
]);
const out = renderHelp(reg);
const aIdx = out.indexOf("<b>a</b>");
const bIdx = out.indexOf("<b>b</b>");
expect(aIdx).toBeGreaterThanOrEqual(0);
expect(bIdx).toBeGreaterThanOrEqual(0);
expect(aIdx).toBeLessThan(bIdx);
});
it("appends (protected) suffix to protected commands", () => {
const reg = makeRegistry([{ name: "a", commands: [cmd("admin", "protected", "Admin tool")] }]);
const out = renderHelp(reg);
expect(out).toContain("/admin — Admin tool (protected)");
});
it("hides modules whose only commands are private", () => {
const reg = makeRegistry([
{ name: "a", commands: [cmd("visible", "public", "V")] },
{ name: "b", commands: [cmd("hidden", "private", "H")] },
]);
const out = renderHelp(reg);
expect(out).toContain("<b>a</b>");
expect(out).not.toContain("<b>b</b>");
expect(out).not.toContain("hidden");
});
it("NEVER leaks private command names into output", () => {
const reg = makeRegistry([
{
name: "a",
commands: [cmd("show", "public", "Public"), cmd("secret", "private", "Secret")],
},
]);
const out = renderHelp(reg);
expect(out).toContain("/show");
expect(out).not.toContain("/secret");
expect(out).not.toContain("Secret");
});
it("HTML-escapes module name and description", () => {
const reg = makeRegistry([
{
name: "a&b",
commands: [cmd("foo", "public", "runs <script>")],
},
]);
const out = renderHelp(reg);
expect(out).toContain("<b>a&amp;b</b>");
expect(out).toContain("runs &lt;script&gt;");
// The literal unescaped sequence must NOT appear in output.
expect(out).not.toContain("<script>");
});
it("returns a placeholder when no commands are visible", () => {
const reg = makeRegistry([{ name: "a", commands: [cmd("hidden", "private", "H")] }]);
expect(renderHelp(reg)).toBe("no commands registered");
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { validateCommand } from "../../src/modules/validate-command.js";
const noop = async () => {};
const base = (overrides = {}) => ({
name: "foo",
visibility: "public",
description: "does foo",
handler: noop,
...overrides,
});
describe("validateCommand", () => {
it("accepts valid public/protected/private commands", () => {
expect(() => validateCommand(base(), "m")).not.toThrow();
expect(() => validateCommand(base({ visibility: "protected" }), "m")).not.toThrow();
expect(() => validateCommand(base({ visibility: "private" }), "m")).not.toThrow();
});
it("rejects invalid visibility", () => {
expect(() => validateCommand(base({ visibility: "secret" }), "m")).toThrow(/visibility/);
});
it("rejects leading slash in name", () => {
expect(() => validateCommand(base({ name: "/foo" }), "m")).toThrow();
});
it("rejects uppercase in name", () => {
expect(() => validateCommand(base({ name: "Foo" }), "m")).toThrow();
});
it("rejects name > 32 chars", () => {
expect(() => validateCommand(base({ name: "a".repeat(33) }), "m")).toThrow();
});
it("rejects missing or empty description for all visibilities", () => {
expect(() => validateCommand(base({ description: "" }), "m")).toThrow(/description/);
expect(() => validateCommand(base({ visibility: "private", description: "" }), "m")).toThrow(
/description/,
);
});
it("rejects description > 256 chars", () => {
expect(() => validateCommand(base({ description: "x".repeat(257) }), "m")).toThrow(/256/);
});
it("rejects non-function handler", () => {
expect(() => validateCommand(base({ handler: null }), "m")).toThrow(/handler/);
});
it("error messages include module + command name", () => {
try {
validateCommand(base({ name: "Bad" }), "wordle");
} catch (err) {
expect(err.message).toContain("wordle");
expect(err.message).toContain("Bad");
}
});
});