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

56
src/bot.js Normal file
View 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
View 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
View 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);
},
};
}

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

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

View 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
View 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;

View 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;

View 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; 132 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`);
}
}

View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}