mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
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/
139 lines
7.7 KiB
Markdown
139 lines
7.7 KiB
Markdown
# Phase 03 — DB abstraction
|
|
|
|
## Context Links
|
|
- Plan: [plan.md](plan.md)
|
|
- Reports: [Cloudflare KV basics](../reports/researcher-260411-0853-cloudflare-kv-basics.md)
|
|
|
|
## Overview
|
|
- **Priority:** P1
|
|
- **Status:** pending
|
|
- **Description:** define minimal KV-like interface (`get/put/delete/list` + `getJSON/putJSON` convenience) via JSDoc, implement `CFKVStore` against `KVNamespace`, expose `createStore(moduleName)` factory that auto-prefixes keys with `<module>:`.
|
|
|
|
## Key Insights
|
|
- Interface is deliberately small. YAGNI: no metadata, no bulk ops, no transactions.
|
|
- `expirationTtl` IS exposed on `put()` — useful for cooldowns / easter-egg throttling.
|
|
- `getJSON` / `putJSON` are thin wrappers (`JSON.parse` / `JSON.stringify`). Included because every planned module stores structured state — DRY, not speculative. `getJSON` returns `null` when the key is missing OR when the stored value fails to parse (log + swallow), so callers can treat both as "no record".
|
|
- `list()` returns a normalized shape `{ keys: string[], cursor?: string, done: boolean }` — strip KV-specific fields. Cursor enables pagination for modules that grow past 1000 keys.
|
|
- Factory-based prefixing means swapping `CFKVStore` → `D1Store` or `RedisStore` later is one-file change. NO module touches KV directly.
|
|
- When a module calls `list({ prefix: "games:" })`, the wrapper concatenates to `"wordle:games:"` before calling KV and strips `"wordle:"` from returned keys so the module sees its own namespace.
|
|
- KV hot-key limit (1 write/sec per key) — document in JSDoc so module authors are aware.
|
|
|
|
## Requirements
|
|
### Functional
|
|
- Interface exposes: `get(key)`, `put(key, value, options?)`, `delete(key)`, `list(options?)`, `getJSON(key)`, `putJSON(key, value, options?)`.
|
|
- `options` on `put` / `putJSON`: `{ expirationTtl?: number }` (seconds).
|
|
- `options` on `list`: `{ prefix?: string, limit?: number, cursor?: string }`.
|
|
- `createStore(moduleName, env)` returns a prefixed store bound to `env.KV`.
|
|
- `get` returns `string | null`.
|
|
- `put` accepts `string` only — structured data goes through `putJSON`.
|
|
- `getJSON(key)` → `any | null`. On missing key: `null`. On malformed JSON: log a warning and return `null` (do NOT throw — a single corrupt record must not crash the bot).
|
|
- `putJSON(key, value, opts?)` → serializes with `JSON.stringify` then delegates to `put`. Throws if `value` is `undefined` or contains a cycle.
|
|
- `list` returns `{ keys: string[], cursor?: string, done: boolean }`, with module prefix stripped. `cursor` is passed through from KV so callers can paginate.
|
|
|
|
### Non-functional
|
|
- JSDoc `@typedef` for `KVStore` interface so IDE completion works without TS.
|
|
- `src/db/` total < 150 LOC.
|
|
|
|
## Architecture
|
|
```
|
|
module code
|
|
│ createStore("wordle", env)
|
|
▼
|
|
PrefixedStore ── prefixes all keys with "wordle:" ──┐
|
|
│ ▼
|
|
└──────────────────────────────────────────► CFKVStore (wraps env.KV)
|
|
│
|
|
▼
|
|
env.KV (binding)
|
|
```
|
|
|
|
## Related Code Files
|
|
### Create
|
|
- `src/db/kv-store-interface.js` — JSDoc `@typedef` only, no runtime code
|
|
- `src/db/cf-kv-store.js` — `CFKVStore` class wrapping `KVNamespace`
|
|
- `src/db/create-store.js` — `createStore(moduleName, env)` prefixing factory
|
|
|
|
### Modify
|
|
- none
|
|
|
|
### Delete
|
|
- none
|
|
|
|
## Implementation Steps
|
|
1. `src/db/kv-store-interface.js`:
|
|
```js
|
|
/**
|
|
* @typedef {Object} KVStorePutOptions
|
|
* @property {number} [expirationTtl] seconds
|
|
*
|
|
* @typedef {Object} KVStoreListOptions
|
|
* @property {string} [prefix]
|
|
* @property {number} [limit]
|
|
* @property {string} [cursor]
|
|
*
|
|
* @typedef {Object} KVStoreListResult
|
|
* @property {string[]} keys
|
|
* @property {string} [cursor]
|
|
* @property {boolean} done
|
|
*
|
|
* @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
|
|
* @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise<void>} putJSON
|
|
*/
|
|
export {};
|
|
```
|
|
2. `src/db/cf-kv-store.js`:
|
|
- `export class CFKVStore` with `constructor(kvNamespace)` stashing binding.
|
|
- `get(key)` → `this.kv.get(key, { type: "text" })`.
|
|
- `put(key, value, opts)` → `this.kv.put(key, value, opts?.expirationTtl ? { expirationTtl: opts.expirationTtl } : undefined)`.
|
|
- `delete(key)` → `this.kv.delete(key)`.
|
|
- `list({ prefix, limit, cursor } = {})` → call `this.kv.list({ prefix, limit, cursor })`, map to normalized shape `{ keys: result.keys.map(k => k.name), cursor: result.cursor, done: result.list_complete }`.
|
|
- `getJSON(key)` → `const raw = await this.get(key); if (raw == null) return null; try { return JSON.parse(raw); } catch (e) { console.warn("getJSON parse failed", { key, err: String(e) }); return null; }`.
|
|
- `putJSON(key, value, opts)` → `if (value === undefined) throw new Error("putJSON: value is undefined"); return this.put(key, JSON.stringify(value), opts);`.
|
|
3. `src/db/create-store.js`:
|
|
- `export function createStore(moduleName, env)`:
|
|
- Validate `moduleName` is non-empty `[a-z0-9_-]+`.
|
|
- `const base = new CFKVStore(env.KV)`.
|
|
- `const prefix = \`${moduleName}:\``.
|
|
- Return object:
|
|
- `get(key)` → `base.get(prefix + key)`
|
|
- `put(key, value, opts)` → `base.put(prefix + key, value, opts)`
|
|
- `delete(key)` → `base.delete(prefix + key)`
|
|
- `list(opts)` → call `base.list({ ...opts, prefix: prefix + (opts?.prefix ?? "") })`, then strip the `<module>:` prefix from returned keys before returning.
|
|
- `getJSON(key)` → `base.getJSON(prefix + key)`
|
|
- `putJSON(key, value, opts)` → `base.putJSON(prefix + key, value, opts)`
|
|
4. JSDoc every exported function. Types on params + return.
|
|
5. `npm run lint`.
|
|
|
|
## Todo List
|
|
- [ ] `src/db/kv-store-interface.js` JSDoc types (incl. `getJSON` / `putJSON`)
|
|
- [ ] `src/db/cf-kv-store.js` CFKVStore class (incl. `getJSON` / `putJSON`)
|
|
- [ ] `src/db/create-store.js` prefixing factory (all six methods)
|
|
- [ ] Validate module name in factory
|
|
- [ ] JSDoc on every exported symbol
|
|
- [ ] Lint clean
|
|
|
|
## Success Criteria
|
|
- All four interface methods round-trip via `wrangler dev` + preview KV namespace (manual sanity check OK; unit tests land in phase-08).
|
|
- Prefix stripping verified: `createStore("wordle").put("k","v")` → raw KV key is `wordle:k`; `createStore("wordle").list()` returns `["k"]`.
|
|
- Swapping `CFKVStore` for a future backend requires changes ONLY inside `src/db/`.
|
|
|
|
## Risk Assessment
|
|
| Risk | Likelihood | Impact | Mitigation |
|
|
|---|---|---|---|
|
|
| KV hot-key limit breaks a module (1 w/s per key) | Med | Med | Document in JSDoc; phase-08 adds a throttle util if needed |
|
|
| Eventual consistency confuses testers | High | Low | README note in phase-09 |
|
|
| Corrupt JSON crashes a module handler | Low | Med | `getJSON` swallows parse errors → `null`; logs warning |
|
|
| Module name collision with prefix characters | Low | Med | Validate regex `^[a-z0-9_-]+$` in factory |
|
|
|
|
## Security Considerations
|
|
- Colon in module names would let a malicious module escape its namespace — regex validation blocks this.
|
|
- Never expose raw `env.KV` outside `src/db/` — enforce by code review (module registry only passes the prefixed store, never the bare binding).
|
|
|
|
## Next Steps
|
|
- Phase 04 consumes `createStore` from the module registry's `init` hook.
|