mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 17:21:30 +00:00
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:
56
src/bot.js
Normal file
56
src/bot.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file bot — lazy, memoized grammY Bot factory.
|
||||
*
|
||||
* The Bot instance is constructed on the first request (env is not available
|
||||
* at module-load time in CF Workers) and reused on subsequent warm requests.
|
||||
* Dispatcher middleware is installed exactly once, as part of that same
|
||||
* lazy init, so the registry is built before any update is routed.
|
||||
*/
|
||||
|
||||
import { Bot } from "grammy";
|
||||
import { installDispatcher } from "./modules/dispatcher.js";
|
||||
|
||||
/** @type {Bot | null} */
|
||||
let botInstance = null;
|
||||
/** @type {Promise<Bot> | null} */
|
||||
let botInitPromise = null;
|
||||
|
||||
/**
|
||||
* Fail fast if any required env var is missing — better a 500 on first webhook
|
||||
* than a confusing runtime error inside grammY.
|
||||
*
|
||||
* @param {any} env
|
||||
*/
|
||||
function requireEnv(env) {
|
||||
const required = ["TELEGRAM_BOT_TOKEN", "TELEGRAM_WEBHOOK_SECRET", "MODULES"];
|
||||
const missing = required.filter((key) => !env?.[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`missing required env vars: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} env
|
||||
* @returns {Promise<Bot>}
|
||||
*/
|
||||
export async function getBot(env) {
|
||||
if (botInstance) return botInstance;
|
||||
if (botInitPromise) return botInitPromise;
|
||||
|
||||
requireEnv(env);
|
||||
|
||||
botInitPromise = (async () => {
|
||||
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
|
||||
await installDispatcher(bot, env);
|
||||
botInstance = bot;
|
||||
return bot;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await botInitPromise;
|
||||
} catch (err) {
|
||||
// Clear the failed promise so the next request retries init.
|
||||
botInitPromise = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
108
src/db/cf-kv-store.js
Normal file
108
src/db/cf-kv-store.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file CFKVStore — Cloudflare Workers KV implementation of the KVStore interface.
|
||||
*
|
||||
* Wraps a `KVNamespace` binding. normalizes the list() response shape so the
|
||||
* rest of the codebase never sees CF-specific fields like `list_complete`.
|
||||
*
|
||||
* @see ./kv-store-interface.js for the interface contract.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import("./kv-store-interface.js").KVStore} KVStore
|
||||
* @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions
|
||||
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
|
||||
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @implements {KVStore}
|
||||
*/
|
||||
export class CFKVStore {
|
||||
/**
|
||||
* @param {KVNamespace} kvNamespace — bound via wrangler.toml [[kv_namespaces]].
|
||||
*/
|
||||
constructor(kvNamespace) {
|
||||
if (!kvNamespace) throw new Error("CFKVStore: kvNamespace is required");
|
||||
this.kv = kvNamespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async get(key) {
|
||||
return this.kv.get(key, { type: "text" });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @param {KVStorePutOptions} [opts]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async put(key, value, opts) {
|
||||
// CF KV rejects `{ expirationTtl: undefined }` on some wrangler versions,
|
||||
// so only pass the options object when actually needed.
|
||||
if (opts?.expirationTtl) {
|
||||
await this.kv.put(key, value, { expirationTtl: opts.expirationTtl });
|
||||
return;
|
||||
}
|
||||
await this.kv.put(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delete(key) {
|
||||
await this.kv.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KVStoreListOptions} [opts]
|
||||
* @returns {Promise<KVStoreListResult>}
|
||||
*/
|
||||
async list(opts = {}) {
|
||||
const result = await this.kv.list({
|
||||
prefix: opts.prefix,
|
||||
limit: opts.limit,
|
||||
cursor: opts.cursor,
|
||||
});
|
||||
return {
|
||||
keys: result.keys.map((k) => k.name),
|
||||
cursor: result.cursor,
|
||||
done: result.list_complete === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {Promise<any|null>}
|
||||
*/
|
||||
async getJSON(key) {
|
||||
const raw = await this.get(key);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
// Corrupt record — do not crash a handler. Log, return null, move on.
|
||||
console.warn("getJSON: parse failed", { key, err: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @param {KVStorePutOptions} [opts]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async putJSON(key, value, opts) {
|
||||
if (value === undefined) {
|
||||
throw new Error(`putJSON: value for key "${key}" is undefined`);
|
||||
}
|
||||
// JSON.stringify throws on cycles — let it propagate.
|
||||
const serialized = JSON.stringify(value);
|
||||
await this.put(key, serialized, opts);
|
||||
}
|
||||
}
|
||||
79
src/db/create-store.js
Normal file
79
src/db/create-store.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file createStore — factory that returns a namespaced KVStore for a module.
|
||||
*
|
||||
* Every module gets its own prefixed view: module `wordle` calling `put("k", v)`
|
||||
* writes raw key `wordle:k`. list() automatically constrains to the module's
|
||||
* namespace AND strips the prefix from returned keys so the module sees its
|
||||
* own flat key-space. modules CANNOT escape their namespace without
|
||||
* reconstructing prefixes manually — a code-review boundary, not a hard one.
|
||||
*/
|
||||
|
||||
import { CFKVStore } from "./cf-kv-store.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("./kv-store-interface.js").KVStore} KVStore
|
||||
* @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions
|
||||
* @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions
|
||||
* @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult
|
||||
*/
|
||||
|
||||
const MODULE_NAME_RE = /^[a-z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* @param {string} moduleName — must match `[a-z0-9_-]+`. Used verbatim as the key prefix.
|
||||
* @param {{ KV: KVNamespace }} env — worker env (or test double) with a `KV` binding.
|
||||
* @returns {KVStore}
|
||||
*/
|
||||
export function createStore(moduleName, env) {
|
||||
if (!moduleName || typeof moduleName !== "string") {
|
||||
throw new Error("createStore: moduleName is required");
|
||||
}
|
||||
if (!MODULE_NAME_RE.test(moduleName)) {
|
||||
throw new Error(
|
||||
`createStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`,
|
||||
);
|
||||
}
|
||||
if (!env?.KV) {
|
||||
throw new Error("createStore: env.KV binding is missing");
|
||||
}
|
||||
|
||||
const base = new CFKVStore(env.KV);
|
||||
const prefix = `${moduleName}:`;
|
||||
|
||||
return {
|
||||
async get(key) {
|
||||
return base.get(prefix + key);
|
||||
},
|
||||
|
||||
async put(key, value, opts) {
|
||||
return base.put(prefix + key, value, opts);
|
||||
},
|
||||
|
||||
async delete(key) {
|
||||
return base.delete(prefix + key);
|
||||
},
|
||||
|
||||
async list(opts = {}) {
|
||||
const fullPrefix = prefix + (opts.prefix ?? "");
|
||||
const result = await base.list({
|
||||
prefix: fullPrefix,
|
||||
limit: opts.limit,
|
||||
cursor: opts.cursor,
|
||||
});
|
||||
// Strip the module namespace from returned keys so the caller sees its own flat space.
|
||||
return {
|
||||
keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
|
||||
cursor: result.cursor,
|
||||
done: result.done,
|
||||
};
|
||||
},
|
||||
|
||||
async getJSON(key) {
|
||||
return base.getJSON(prefix + key);
|
||||
},
|
||||
|
||||
async putJSON(key, value, opts) {
|
||||
return base.putJSON(prefix + key, value, opts);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
src/db/kv-store-interface.js
Normal file
44
src/db/kv-store-interface.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file KVStore interface — JSDoc typedefs only, no runtime code.
|
||||
*
|
||||
* This is the contract every storage backend must satisfy. modules receive
|
||||
* a prefixed `KVStore` (via {@link module:db/create-store}) and must NEVER
|
||||
* touch the underlying binding. to swap Cloudflare KV for a different
|
||||
* backend (D1, Upstash Redis, ...) in the future, implement this interface
|
||||
* in a new file and change the one import in create-store.js — no module
|
||||
* code changes required.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} KVStorePutOptions
|
||||
* @property {number} [expirationTtl] seconds — value auto-deletes after this many seconds.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} KVStoreListOptions
|
||||
* @property {string} [prefix] additional prefix (appended AFTER the module namespace).
|
||||
* @property {number} [limit]
|
||||
* @property {string} [cursor] pagination cursor from a previous list() call.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} KVStoreListResult
|
||||
* @property {string[]} keys — module namespace already stripped.
|
||||
* @property {string} [cursor] — present if more pages available.
|
||||
* @property {boolean} done — true when list_complete.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} KVStore
|
||||
* @property {(key: string) => Promise<string|null>} get
|
||||
* @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise<void>} put
|
||||
* @property {(key: string) => Promise<void>} delete
|
||||
* @property {(opts?: KVStoreListOptions) => Promise<KVStoreListResult>} list
|
||||
* @property {(key: string) => Promise<any|null>} getJSON
|
||||
* returns null on missing key OR malformed JSON (logs a warning — does not throw).
|
||||
* @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
|
||||
* throws if value is undefined or contains a cycle.
|
||||
*/
|
||||
|
||||
// JSDoc-only module. No runtime exports.
|
||||
export {};
|
||||
61
src/index.js
Normal file
61
src/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file fetch entry point for the Cloudflare Worker.
|
||||
*
|
||||
* Routes:
|
||||
* GET / — "miti99bot ok" health check (unauthenticated).
|
||||
* POST /webhook — Telegram webhook. grammY validates the
|
||||
* X-Telegram-Bot-Api-Secret-Token header against
|
||||
* env.TELEGRAM_WEBHOOK_SECRET and replies 401 on mismatch.
|
||||
* * — 404.
|
||||
*
|
||||
* There is NO admin HTTP surface. `setWebhook` + `setMyCommands` run at
|
||||
* deploy time from `scripts/register.js`, not from the Worker.
|
||||
*/
|
||||
|
||||
import { webhookCallback } from "grammy";
|
||||
import { getBot } from "./bot.js";
|
||||
|
||||
/** @type {ReturnType<typeof webhookCallback> | null} */
|
||||
let cachedWebhookHandler = null;
|
||||
|
||||
/**
|
||||
* @param {any} env
|
||||
*/
|
||||
async function getWebhookHandler(env) {
|
||||
if (cachedWebhookHandler) return cachedWebhookHandler;
|
||||
const bot = await getBot(env);
|
||||
cachedWebhookHandler = webhookCallback(bot, "cloudflare-mod", {
|
||||
secretToken: env.TELEGRAM_WEBHOOK_SECRET,
|
||||
});
|
||||
return cachedWebhookHandler;
|
||||
}
|
||||
|
||||
export default {
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {any} env
|
||||
* @param {any} _ctx
|
||||
*/
|
||||
async fetch(request, env, _ctx) {
|
||||
const { pathname } = new URL(request.url);
|
||||
|
||||
if (request.method === "GET" && pathname === "/") {
|
||||
return new Response("miti99bot ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && pathname === "/webhook") {
|
||||
try {
|
||||
const handler = await getWebhookHandler(env);
|
||||
return await handler(request);
|
||||
} catch (err) {
|
||||
console.error("webhook handler failed", err);
|
||||
return new Response("internal error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
};
|
||||
31
src/modules/dispatcher.js
Normal file
31
src/modules/dispatcher.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file dispatcher — wires every command (public, protected, AND private)
|
||||
* into the grammY Bot via `bot.command()`.
|
||||
*
|
||||
* Visibility is pure metadata at this layer: the dispatcher does NOT care
|
||||
* whether a command is hidden from the menu or from /help. All three paths
|
||||
* share a single bot.command() registration. Visibility only affects:
|
||||
* 1. What scripts/register.js pushes to Telegram's setMyCommands (public only).
|
||||
* 2. What phase-05's /help renderer shows (public + protected).
|
||||
*/
|
||||
|
||||
import { buildRegistry } from "./registry.js";
|
||||
|
||||
/**
|
||||
* Build the registry (if not already built) and register every command with grammY.
|
||||
*
|
||||
* @param {import("grammy").Bot} bot
|
||||
* @param {any} env
|
||||
* @returns {Promise<import("./registry.js").Registry>}
|
||||
*/
|
||||
export async function installDispatcher(bot, env) {
|
||||
const reg = await buildRegistry(env);
|
||||
|
||||
for (const { cmd } of reg.allCommands.values()) {
|
||||
// grammY's bot.command() matches /cmd and /cmd@botname, case-sensitively,
|
||||
// which naturally satisfies the "exact, case-sensitive" rule for private commands.
|
||||
bot.command(cmd.name, cmd.handler);
|
||||
}
|
||||
|
||||
return reg;
|
||||
}
|
||||
16
src/modules/index.js
Normal file
16
src/modules/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file moduleRegistry — static import map of every buildable module.
|
||||
*
|
||||
* wrangler bundles statically — dynamic `import(variablePath)` defeats
|
||||
* tree-shaking and can fail at bundle time. So we enumerate every module here
|
||||
* as a lazy loader, and {@link loadModules} filters the list at runtime
|
||||
* against `env.MODULES` (comma-separated). Adding a new module is a two-step
|
||||
* edit: create the folder, then add one line here.
|
||||
*/
|
||||
|
||||
export const moduleRegistry = {
|
||||
util: () => import("./util/index.js"),
|
||||
wordle: () => import("./wordle/index.js"),
|
||||
loldle: () => import("./loldle/index.js"),
|
||||
misc: () => import("./misc/index.js"),
|
||||
};
|
||||
38
src/modules/loldle/index.js
Normal file
38
src/modules/loldle/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @file loldle module stub — proves the plugin system end-to-end.
|
||||
*
|
||||
* One public, one protected, one private slash command.
|
||||
*/
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const loldleModule = {
|
||||
name: "loldle",
|
||||
commands: [
|
||||
{
|
||||
name: "loldle",
|
||||
visibility: "public",
|
||||
description: "Play loldle (stub)",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("Loldle stub.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lstats",
|
||||
visibility: "protected",
|
||||
description: "Loldle stats",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("loldle stats stub");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ggwp",
|
||||
visibility: "private",
|
||||
description: "Easter egg — post-match courtesy",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("gg well played (stub)");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default loldleModule;
|
||||
54
src/modules/misc/index.js
Normal file
54
src/modules/misc/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file misc module stub — proves the plugin system end-to-end AND
|
||||
* demonstrates DB usage via getJSON/putJSON in /ping.
|
||||
*/
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const miscModule = {
|
||||
name: "misc",
|
||||
init: async ({ db: store }) => {
|
||||
db = store;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "ping",
|
||||
visibility: "public",
|
||||
description: "Health check — replies pong and records last ping",
|
||||
handler: async (ctx) => {
|
||||
// Best-effort write — if KV is unavailable, still reply.
|
||||
try {
|
||||
await db?.putJSON("last_ping", { at: Date.now() });
|
||||
} catch (err) {
|
||||
console.warn("misc /ping: putJSON failed", String(err));
|
||||
}
|
||||
await ctx.reply("pong");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mstats",
|
||||
visibility: "protected",
|
||||
description: "Show the timestamp of the last /ping",
|
||||
handler: async (ctx) => {
|
||||
const last = await db?.getJSON("last_ping");
|
||||
if (last?.at) {
|
||||
await ctx.reply(`last ping: ${new Date(last.at).toISOString()}`);
|
||||
} else {
|
||||
await ctx.reply("last ping: never");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fortytwo",
|
||||
visibility: "private",
|
||||
description: "Easter egg — the answer",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("The answer.");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default miscModule;
|
||||
181
src/modules/registry.js
Normal file
181
src/modules/registry.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file registry — loads modules listed in env.MODULES, validates their
|
||||
* commands, and builds the dispatcher's lookup tables.
|
||||
*
|
||||
* Design highlights (see plan phase-04):
|
||||
* - Static import map (./index.js) filtered at runtime; preserves tree-shaking.
|
||||
* - Three visibility-partitioned maps (`publicCommands`, `protectedCommands`,
|
||||
* `privateCommands`) PLUS a flat `allCommands` map used by the dispatcher.
|
||||
* - Unified namespace for conflict detection: two commands with the same name
|
||||
* in different modules collide regardless of visibility. Fail loud at load.
|
||||
* - Built once per warm instance (memoized behind `getCurrentRegistry`).
|
||||
* - `resetRegistry()` exists for tests.
|
||||
*/
|
||||
|
||||
import { createStore } from "../db/create-store.js";
|
||||
import { moduleRegistry as defaultModuleRegistry } from "./index.js";
|
||||
import { validateCommand } from "./validate-command.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("./validate-command.js").ModuleCommand} ModuleCommand
|
||||
*
|
||||
* @typedef {Object} BotModule
|
||||
* @property {string} name
|
||||
* @property {ModuleCommand[]} commands
|
||||
* @property {({ db, env }: { db: any, env: any }) => Promise<void>|void} [init]
|
||||
*
|
||||
* @typedef {Object} RegistryEntry
|
||||
* @property {BotModule} module
|
||||
* @property {ModuleCommand} cmd
|
||||
* @property {"public"|"protected"|"private"} [visibility]
|
||||
*
|
||||
* @typedef {Object} Registry
|
||||
* @property {Map<string, RegistryEntry>} publicCommands
|
||||
* @property {Map<string, RegistryEntry>} protectedCommands
|
||||
* @property {Map<string, RegistryEntry>} privateCommands
|
||||
* @property {Map<string, RegistryEntry>} allCommands
|
||||
* @property {BotModule[]} modules — ordered per env.MODULES for /help rendering.
|
||||
*/
|
||||
|
||||
/** @type {Registry | null} */
|
||||
let currentRegistry = null;
|
||||
|
||||
/**
|
||||
* Parse env.MODULES → trimmed, deduped array of module names.
|
||||
*
|
||||
* @param {{ MODULES?: string }} env
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseModulesEnv(env) {
|
||||
const raw = env?.MODULES;
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("MODULES env var is empty");
|
||||
}
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const piece of raw.split(",")) {
|
||||
const name = piece.trim();
|
||||
if (name.length === 0) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
if (out.length === 0) {
|
||||
throw new Error("MODULES env var is empty after trim/dedupe");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each module name to its default export and validate shape.
|
||||
*
|
||||
* @param {any} env
|
||||
* @param {Record<string, () => Promise<{ default: BotModule }>>} [importMap]
|
||||
* Optional injection for tests. Defaults to the static production map.
|
||||
* @returns {Promise<BotModule[]>}
|
||||
*/
|
||||
export async function loadModules(env, importMap = defaultModuleRegistry) {
|
||||
const names = parseModulesEnv(env);
|
||||
const modules = [];
|
||||
|
||||
for (const name of names) {
|
||||
const loader = importMap[name];
|
||||
if (typeof loader !== "function") {
|
||||
throw new Error(`unknown module: ${name}`);
|
||||
}
|
||||
|
||||
const loaded = await loader();
|
||||
const mod = loaded?.default;
|
||||
if (!mod || typeof mod !== "object") {
|
||||
throw new Error(`module "${name}" must have a default export`);
|
||||
}
|
||||
if (mod.name !== name) {
|
||||
throw new Error(`module "${name}" default export has mismatched name "${mod.name}"`);
|
||||
}
|
||||
if (!Array.isArray(mod.commands)) {
|
||||
throw new Error(`module "${name}" must export a commands array`);
|
||||
}
|
||||
for (const cmd of mod.commands) validateCommand(cmd, name);
|
||||
|
||||
modules.push(mod);
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the registry: call each module's init, flatten commands into four maps,
|
||||
* detect conflicts, and memoize for later getCurrentRegistry() calls.
|
||||
*
|
||||
* @param {any} env
|
||||
* @param {Record<string, () => Promise<{ default: BotModule }>>} [importMap]
|
||||
* @returns {Promise<Registry>}
|
||||
*/
|
||||
export async function buildRegistry(env, importMap) {
|
||||
const modules = await loadModules(env, importMap);
|
||||
|
||||
/** @type {Map<string, RegistryEntry>} */
|
||||
const publicCommands = new Map();
|
||||
/** @type {Map<string, RegistryEntry>} */
|
||||
const protectedCommands = new Map();
|
||||
/** @type {Map<string, RegistryEntry>} */
|
||||
const privateCommands = new Map();
|
||||
/** @type {Map<string, RegistryEntry>} */
|
||||
const allCommands = new Map();
|
||||
|
||||
for (const mod of modules) {
|
||||
if (typeof mod.init === "function") {
|
||||
try {
|
||||
await mod.init({ db: createStore(mod.name, env), env });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`module "${mod.name}" init failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cmd of mod.commands) {
|
||||
const existing = allCommands.get(cmd.name);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`command conflict: /${cmd.name} registered by both "${existing.module.name}" and "${mod.name}"`,
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = {
|
||||
publicCommands,
|
||||
protectedCommands,
|
||||
privateCommands,
|
||||
allCommands,
|
||||
modules,
|
||||
};
|
||||
currentRegistry = registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Registry}
|
||||
* @throws if `buildRegistry` has not been called yet.
|
||||
*/
|
||||
export function getCurrentRegistry() {
|
||||
if (!currentRegistry) {
|
||||
throw new Error("registry not built yet — call buildRegistry(env) first");
|
||||
}
|
||||
return currentRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper — forget the memoized registry so each test starts clean.
|
||||
*/
|
||||
export function resetRegistry() {
|
||||
currentRegistry = null;
|
||||
}
|
||||
69
src/modules/util/help-command.js
Normal file
69
src/modules/util/help-command.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file /help — renders public + protected commands grouped by module.
|
||||
*
|
||||
* /help is a pure renderer over the registry; it holds no command metadata of
|
||||
* its own. Modules with zero visible (public or protected) commands are
|
||||
* omitted entirely. Private commands are always skipped — that's the point.
|
||||
*
|
||||
* Output uses Telegram HTML parse mode. Every user-influenced substring
|
||||
* (module names, command descriptions) is HTML-escaped so a rogue `<script>`
|
||||
* in a description renders literally.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
import { getCurrentRegistry } from "../registry.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../validate-command.js").ModuleCommand} ModuleCommand
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure render step — exported separately so tests can assert on the string
|
||||
* without instantiating a bot context.
|
||||
*
|
||||
* @param {import("../registry.js").Registry} reg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderHelp(reg) {
|
||||
/** @type {Map<string, string[]>} */
|
||||
const byModule = new Map();
|
||||
|
||||
const visibleMaps = [
|
||||
{ map: reg.publicCommands, suffix: "" },
|
||||
{ map: reg.protectedCommands, suffix: " (protected)" },
|
||||
];
|
||||
|
||||
for (const { map, suffix } of visibleMaps) {
|
||||
for (const entry of map.values()) {
|
||||
const modName = entry.module.name;
|
||||
const line = `/${entry.cmd.name} — ${escapeHtml(entry.cmd.description)}${suffix}`;
|
||||
const existing = byModule.get(modName);
|
||||
if (existing) existing.push(line);
|
||||
else byModule.set(modName, [line]);
|
||||
}
|
||||
}
|
||||
|
||||
// Render in env.MODULES order (reg.modules is already in that order).
|
||||
const sections = [];
|
||||
for (const mod of reg.modules) {
|
||||
const lines = byModule.get(mod.name);
|
||||
if (!lines || lines.length === 0) continue;
|
||||
sections.push(`<b>${escapeHtml(mod.name)}</b>\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
return sections.length > 0 ? sections.join("\n\n") : "no commands registered";
|
||||
}
|
||||
|
||||
/** @type {ModuleCommand} */
|
||||
export const helpCommand = {
|
||||
name: "help",
|
||||
visibility: "public",
|
||||
description: "Show all available commands",
|
||||
handler: async (ctx) => {
|
||||
const reg = getCurrentRegistry();
|
||||
const text = renderHelp(reg);
|
||||
await ctx.reply(text, { parse_mode: "HTML" });
|
||||
},
|
||||
};
|
||||
|
||||
export default helpCommand;
|
||||
18
src/modules/util/index.js
Normal file
18
src/modules/util/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @file util module — /info and /help.
|
||||
*
|
||||
* The only "fully implemented" module in v1. /help is a pure renderer over
|
||||
* the current registry, so it has no module-specific state. /info just reads
|
||||
* the grammY context.
|
||||
*/
|
||||
|
||||
import { helpCommand } from "./help-command.js";
|
||||
import { infoCommand } from "./info-command.js";
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const utilModule = {
|
||||
name: "util",
|
||||
commands: [infoCommand, helpCommand],
|
||||
};
|
||||
|
||||
export default utilModule;
|
||||
22
src/modules/util/info-command.js
Normal file
22
src/modules/util/info-command.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file /info — debug helper that echoes chat id, thread id, and sender id.
|
||||
*
|
||||
* Plain text reply (no parse mode). `message_thread_id` is only present for
|
||||
* forum-topic messages, so it's optional with a "n/a" fallback so debug users
|
||||
* know the field was checked.
|
||||
*/
|
||||
|
||||
/** @type {import("../validate-command.js").ModuleCommand} */
|
||||
export const infoCommand = {
|
||||
name: "info",
|
||||
visibility: "public",
|
||||
description: "Show chat id, thread id, and sender id (debug helper)",
|
||||
handler: async (ctx) => {
|
||||
const chatId = ctx.chat?.id ?? "n/a";
|
||||
const threadId = ctx.message?.message_thread_id ?? "n/a";
|
||||
const senderId = ctx.from?.id ?? "n/a";
|
||||
await ctx.reply(`chat id: ${chatId}\nthread id: ${threadId}\nsender id: ${senderId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default infoCommand;
|
||||
74
src/modules/validate-command.js
Normal file
74
src/modules/validate-command.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file validate-command — shared validators for module-registered commands.
|
||||
*
|
||||
* Enforces the contract defined in phase-04 of the plan:
|
||||
* - visibility ∈ {public, protected, private}
|
||||
* - name matches /^[a-z0-9_]{1,32}$/ (Telegram's slash-command limit; applied
|
||||
* uniformly across all visibility levels because private commands are also
|
||||
* slash commands, just hidden from the menu and /help)
|
||||
* - description is non-empty, ≤256 chars (Telegram's setMyCommands limit)
|
||||
* - handler is a function
|
||||
*
|
||||
* All errors include the module and command name for debuggability.
|
||||
*/
|
||||
|
||||
export const VISIBILITIES = Object.freeze(["public", "protected", "private"]);
|
||||
export const COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
|
||||
export const MAX_DESCRIPTION_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ModuleCommand
|
||||
* @property {string} name — without leading slash; matches COMMAND_NAME_RE.
|
||||
* @property {"public"|"protected"|"private"} visibility
|
||||
* @property {string} description — ≤256 chars; required for all visibilities.
|
||||
* @property {(ctx: any) => Promise<void>|void} handler
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throws on any contract violation. Called once per command at registry build.
|
||||
*
|
||||
* @param {ModuleCommand} cmd
|
||||
* @param {string} moduleName — for error messages.
|
||||
*/
|
||||
export function validateCommand(cmd, moduleName) {
|
||||
const prefix = `module "${moduleName}" command`;
|
||||
|
||||
if (!cmd || typeof cmd !== "object") {
|
||||
throw new Error(`${prefix}: command entry is not an object`);
|
||||
}
|
||||
|
||||
// visibility
|
||||
if (!VISIBILITIES.includes(cmd.visibility)) {
|
||||
throw new Error(
|
||||
`${prefix} "${cmd.name}": visibility must be one of ${VISIBILITIES.join("|")}, got "${cmd.visibility}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// name
|
||||
if (typeof cmd.name !== "string") {
|
||||
throw new Error(`${prefix}: name must be a string`);
|
||||
}
|
||||
if (cmd.name.startsWith("/")) {
|
||||
throw new Error(`${prefix} "${cmd.name}": name must not start with "/"`);
|
||||
}
|
||||
if (!COMMAND_NAME_RE.test(cmd.name)) {
|
||||
throw new Error(
|
||||
`${prefix} "${cmd.name}": name must match ${COMMAND_NAME_RE} (lowercase letters, digits, underscore; 1–32 chars)`,
|
||||
);
|
||||
}
|
||||
|
||||
// description — required for all visibilities (private commands need it for internal debugging)
|
||||
if (typeof cmd.description !== "string" || cmd.description.length === 0) {
|
||||
throw new Error(`${prefix} "${cmd.name}": description is required`);
|
||||
}
|
||||
if (cmd.description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
throw new Error(
|
||||
`${prefix} "${cmd.name}": description exceeds ${MAX_DESCRIPTION_LENGTH} chars (got ${cmd.description.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
// handler
|
||||
if (typeof cmd.handler !== "function") {
|
||||
throw new Error(`${prefix} "${cmd.name}": handler must be a function`);
|
||||
}
|
||||
}
|
||||
48
src/modules/wordle/index.js
Normal file
48
src/modules/wordle/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file wordle module stub — proves the plugin system end-to-end.
|
||||
*
|
||||
* One public, one protected, one private (hidden) slash command. Real game
|
||||
* logic is out of scope for v1; this exercises the loader, visibility levels,
|
||||
* registry, dispatcher, help renderer, and namespaced DB.
|
||||
*/
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
|
||||
/** @type {import("../registry.js").BotModule} */
|
||||
const wordleModule = {
|
||||
name: "wordle",
|
||||
init: async ({ db: store }) => {
|
||||
db = store;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "wordle",
|
||||
visibility: "public",
|
||||
description: "Play wordle (stub)",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("Wordle stub — real game TBD.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wstats",
|
||||
visibility: "protected",
|
||||
description: "Wordle stats",
|
||||
handler: async (ctx) => {
|
||||
const stats = (await db?.getJSON("stats")) ?? null;
|
||||
const played = stats?.gamesPlayed ?? 0;
|
||||
await ctx.reply(`games played: ${played}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "konami",
|
||||
visibility: "private",
|
||||
description: "Easter egg — retro code",
|
||||
handler: async (ctx) => {
|
||||
await ctx.reply("⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default wordleModule;
|
||||
21
src/util/escape-html.js
Normal file
21
src/util/escape-html.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file escape-html — minimal HTML entity escaper for Telegram HTML parse mode.
|
||||
*
|
||||
* Telegram's HTML parse mode needs only four characters escaped: &, <, >, ".
|
||||
* Keep this as a tiny hand-rolled function — pulling in a library for four
|
||||
* replacements is YAGNI.
|
||||
*
|
||||
* @see https://core.telegram.org/bots/api#html-style
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} s — coerced to string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
Reference in New Issue
Block a user