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,77 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createStore } from "../../src/db/create-store.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
describe("createStore (prefixing factory)", () => {
let kv;
let env;
beforeEach(() => {
kv = makeFakeKv();
env = { KV: kv };
});
it("rejects invalid module names", () => {
expect(() => createStore("", env)).toThrow();
expect(() => createStore("BadName", env)).toThrow(/invalid/);
expect(() => createStore("has:colon", env)).toThrow(/invalid/);
expect(() => createStore("ok_name", env)).not.toThrow();
expect(() => createStore("kebab-ok", env)).not.toThrow();
});
it("rejects missing env.KV", () => {
expect(() => createStore("wordle", {})).toThrow(/KV/);
});
it("put() writes raw key with module: prefix", async () => {
const store = createStore("wordle", env);
await store.put("games_played", "42");
expect(kv.store.get("wordle:games_played")).toBe("42");
});
it("get() reads through prefix", async () => {
const store = createStore("wordle", env);
await store.put("k", "v");
expect(await store.get("k")).toBe("v");
});
it("delete() deletes the prefixed raw key", async () => {
const store = createStore("wordle", env);
await store.put("k", "v");
await store.delete("k");
expect(kv.store.has("wordle:k")).toBe(false);
});
it("list() combines module prefix + caller prefix and strips the module prefix on return", async () => {
kv.store.set("wordle:games:1", "a");
kv.store.set("wordle:games:2", "b");
kv.store.set("wordle:stats", "c");
kv.store.set("loldle:games:1", "other");
const store = createStore("wordle", env);
const res = await store.list({ prefix: "games:" });
expect(res.keys.sort()).toEqual(["games:1", "games:2"]);
});
it("two stores are fully isolated — one cannot see the other's keys", async () => {
const wordle = createStore("wordle", env);
const loldle = createStore("loldle", env);
await wordle.put("secret", "w");
await loldle.put("secret", "l");
expect(await wordle.get("secret")).toBe("w");
expect(await loldle.get("secret")).toBe("l");
const wList = await wordle.list();
const lList = await loldle.list();
expect(wList.keys).toEqual(["secret"]);
expect(lList.keys).toEqual(["secret"]);
});
it("putJSON/getJSON through a prefixed store persist at <module>:<key>", async () => {
const store = createStore("misc", env);
await store.putJSON("last_ping", { at: 123 });
expect(kv.store.get("misc:last_ping")).toBe('{"at":123}');
expect(await store.getJSON("last_ping")).toEqual({ at: 123 });
});
});