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