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