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,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CFKVStore } from "../../src/db/cf-kv-store.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
describe("CFKVStore", () => {
let kv;
let store;
beforeEach(() => {
kv = makeFakeKv();
store = new CFKVStore(kv);
});
it("throws when given no binding", () => {
expect(() => new CFKVStore(null)).toThrow(/required/);
});
it("round-trips get/put/delete", async () => {
expect(await store.get("k")).toBeNull();
await store.put("k", "v");
expect(await store.get("k")).toBe("v");
await store.delete("k");
expect(await store.get("k")).toBeNull();
});
it("list() returns normalized shape with module keys stripped of wrappers", async () => {
await store.put("a:1", "x");
await store.put("a:2", "y");
await store.put("b:1", "z");
const res = await store.list({ prefix: "a:" });
expect(res.keys.sort()).toEqual(["a:1", "a:2"]);
expect(res.done).toBe(true);
});
it("list() pagination — cursor propagates through", async () => {
for (let i = 0; i < 5; i++) await store.put(`k${i}`, String(i));
const page1 = await store.list({ limit: 2 });
expect(page1.keys.length).toBe(2);
expect(page1.done).toBe(false);
expect(page1.cursor).toBeTruthy();
const page2 = await store.list({ limit: 2, cursor: page1.cursor });
expect(page2.keys.length).toBe(2);
expect(page2.done).toBe(false);
const page3 = await store.list({ limit: 2, cursor: page2.cursor });
expect(page3.keys.length).toBe(1);
expect(page3.done).toBe(true);
expect(page3.cursor).toBeUndefined();
});
it("put() with expirationTtl passes through to binding", async () => {
await store.put("k", "v", { expirationTtl: 60 });
expect(kv.putLog).toEqual([{ key: "k", value: "v", opts: { expirationTtl: 60 } }]);
});
it("put() without options does NOT pass an options object", async () => {
await store.put("k", "v");
expect(kv.putLog[0]).toEqual({ key: "k", value: "v", opts: undefined });
});
it("getJSON/putJSON round-trip", async () => {
await store.putJSON("k", { a: 1, b: [2, 3] });
expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] });
});
it("getJSON returns null on missing key", async () => {
expect(await store.getJSON("missing")).toBeNull();
});
it("getJSON returns null on corrupt JSON and logs a warning", async () => {
kv.store.set("bad", "{not json");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await store.getJSON("bad")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("putJSON throws on undefined value", async () => {
await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/);
});
it("putJSON passes expirationTtl through", async () => {
await store.putJSON("k", { a: 1 }, { expirationTtl: 120 });
expect(kv.putLog[0].opts).toEqual({ expirationTtl: 120 });
});
});

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