mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 15:20:58 +00:00
- 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)
235 lines
6.5 KiB
Markdown
235 lines
6.5 KiB
Markdown
# 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`:
|
|
|
|
```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`:
|
|
```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
|
|
|
|
```js
|
|
/** @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:
|
|
|
|
```js
|
|
/** @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
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
{
|
|
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`:
|
|
|
|
```js
|
|
/** @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:
|
|
|
|
```bash
|
|
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`](./using-d1.md).
|
|
|
|
## Optional: Scheduled Jobs
|
|
|
|
If your module needs to run maintenance tasks (cleanup, stats refresh) on a schedule, add a `crons` array:
|
|
|
|
```js
|
|
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`:
|
|
|
|
```toml
|
|
[triggers]
|
|
crons = ["0 2 * * *"] # matches module declaration
|
|
```
|
|
|
|
For full details on cron syntax, local testing, and worked examples, see [`docs/using-cron.md`](./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:
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Also worth running before deploying:
|
|
|
|
```bash
|
|
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.
|