Files
miti99bot/docs/adding-a-module.md
tiennm99 f5e03cfff2 docs: add D1 and Cron guides, update module contract across docs
- docs/using-d1.md and docs/using-cron.md for module authors
- architecture, codebase-summary, adding-a-module, code-standards, deployment-guide refreshed
- CLAUDE.md module contract shows optional crons[] and sql in init
- docs/todo.md tracks manual follow-ups (D1 UUID, first deploy, smoke tests)
2026-04-15 13:29:31 +07:00

6.5 KiB

Adding a Module

A module is a plugin that registers commands with the bot. The framework handles loading, routing, visibility, /help rendering, and per-module database namespacing. A new module is usually under 100 lines.

Three-step checklist

1. Create the module folder

src/modules/<name>/index.js

Module name must match ^[a-z0-9_-]+$. The folder name, the name field in the default export, and the key in the static import map MUST all be identical.

2. Add it to the static import map

Edit src/modules/index.js:

export const moduleRegistry = {
  util: () => import("./util/index.js"),
  wordle: () => import("./wordle/index.js"),
  // ... existing entries ...
  mynew: () => import("./mynew/index.js"), // add this line
};

Static imports are required — wrangler's bundler strips unused code based on static analysis. Dynamic import(variablePath) would bundle everything.

3. Enable the module via env

Add the name to MODULES in two places:

wrangler.toml:

[vars]
MODULES = "util,wordle,loldle,misc,mynew"

.env.deploy (so the register script sees it too):

MODULES=util,wordle,loldle,misc,mynew

Modules NOT listed in MODULES are simply not loaded — no errors, no overhead.

Minimal module skeleton

/** @type {import("../registry.js").BotModule} */
const mynewModule = {
  name: "mynew",
  commands: [
    {
      name: "hello",
      visibility: "public",
      description: "Say hello",
      handler: async (ctx) => {
        await ctx.reply("hi!");
      },
    },
  ],
};

export default mynewModule;

Using the database

If your module needs storage, opt into it with an init hook:

/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;

const mynewModule = {
  name: "mynew",
  init: async ({ db: store }) => {
    db = store;
  },
  commands: [
    {
      name: "count",
      visibility: "public",
      description: "Increment a counter",
      handler: async (ctx) => {
        const state = (await db.getJSON("counter")) ?? { n: 0 };
        state.n += 1;
        await db.putJSON("counter", state);
        await ctx.reply(`counter: ${state.n}`);
      },
    },
  ],
};

export default mynewModule;

Key namespacing is automatic. The db you receive is a KVStore whose keys are all prefixed with mynew: before hitting Cloudflare KV. The raw key for counter is mynew:counter. Two modules cannot read each other's data unless they reconstruct the prefix by hand.

Available KVStore methods

await db.get("key")                        // → string | null
await db.put("key", "value", { expirationTtl: 60 })  // seconds
await db.delete("key")
await db.list({ prefix: "games:", limit: 50, cursor })  // paginated

// JSON helpers (recommended — DRY for structured state)
await db.getJSON("key")                    // → parsed | null (swallows corrupt JSON)
await db.putJSON("key", { a: 1 }, { expirationTtl: 60 })

See src/db/kv-store-interface.js for the full JSDoc contract.

Command contract

Each command is:

{
  name: "hello",            // ^[a-z0-9_]{1,32}$, no leading slash
  visibility: "public",     // "public" | "protected" | "private"
  description: "Say hello", // required, ≤ 256 chars
  handler: async (ctx) => { ... },  // grammY context
}

Name rules

  • Lowercase letters, digits, underscore
  • 1 to 32 characters
  • No leading /
  • Must be unique across ALL loaded modules, regardless of visibility

Visibility levels

Level In / menu In /help When to use
public yes yes Normal commands users should discover
protected no yes Admin / debug tools you don't want in the menu but want discoverable via /help
private no no Easter eggs, testing hooks — fully hidden

Private commands are still slash commands — users type /mycmd. They're simply absent from Telegram's / popup and from /help output.

Optional: D1 Storage

If your module needs a SQL database for relational queries, scans, or append-only history, add an init hook that receives sql:

/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */
let sql = null;

const myModule = {
  name: "mymod",
  init: async ({ db, sql: sqlStore, env }) => {
    db = store;
    sql = sqlStore;  // null when env.DB is not configured
  },
  commands: [ /* ... */ ],
};

Create migration files in src/modules/<name>/migrations/:

src/modules/mymod/migrations/
├── 0001_initial.sql
├── 0002_add_index.sql
└── ...

Run migrations at deploy time:

npm run db:migrate           # production
npm run db:migrate -- --local # local dev
npm run db:migrate -- --dry-run # preview

For full details on D1 usage, table naming, and the SQL API, see docs/using-d1.md.

Optional: Scheduled Jobs

If your module needs to run maintenance tasks (cleanup, stats refresh) on a schedule, add a crons array:

const myModule = {
  name: "mymod",
  init: async ({ db, sql, env }) => { /* ... */ },
  commands: [ /* ... */ ],
  crons: [
    {
      schedule: "0 2 * * *",  // 2 AM UTC daily
      name: "daily-cleanup",
      handler: async (event, { db, sql, env }) => {
        // handler receives same context as init
        await sql.run("DELETE FROM mymod_old WHERE created < ?", oldTimestamp);
      },
    },
  ],
};

Important: Every cron schedule declared in a module MUST also be registered in wrangler.toml:

[triggers]
crons = ["0 2 * * *"]  # matches module declaration

For full details on cron syntax, local testing, and worked examples, see docs/using-cron.md.

Testing your module

Add a test in tests/modules/<name>.test.js or extend an existing suite. The tests/fakes/ directory provides fake-kv-namespace.js, fake-bot.js, fake-d1.js, and fake-modules.js for hermetic unit tests that don't touch Cloudflare or Telegram.

Run:

npm test

Also worth running before deploying:

npm run register:dry

This prints the setMyCommands payload your module will push to Telegram — a fast way to verify the public command descriptions look right.

Full example

See src/modules/misc/index.js — it's a minimal module that uses the DB (putJSON / getJSON via /ping + /mstats) and registers one command at each visibility level. Copy it as a starting point for your own module. See src/modules/trading/ for a full example with D1 storage and scheduled crons.