Files
miti99bot/src/modules/registry.js
tiennm99 c4314f21df 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/
2026-04-11 09:49:06 +07:00

182 lines
5.6 KiB
JavaScript

/**
* @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;
}