mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 19:22:09 +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/
7.7 KiB
7.7 KiB
Phase 03 — DB abstraction
Context Links
- Plan: plan.md
- Reports: Cloudflare KV basics
Overview
- Priority: P1
- Status: pending
- Description: define minimal KV-like interface (
get/put/delete/list+getJSON/putJSONconvenience) via JSDoc, implementCFKVStoreagainstKVNamespace, exposecreateStore(moduleName)factory that auto-prefixes keys with<module>:.
Key Insights
- Interface is deliberately small. YAGNI: no metadata, no bulk ops, no transactions.
expirationTtlIS exposed onput()— useful for cooldowns / easter-egg throttling.getJSON/putJSONare thin wrappers (JSON.parse/JSON.stringify). Included because every planned module stores structured state — DRY, not speculative.getJSONreturnsnullwhen 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→D1StoreorRedisStorelater 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?). optionsonput/putJSON:{ expirationTtl?: number }(seconds).optionsonlist:{ prefix?: string, limit?: number, cursor?: string }.createStore(moduleName, env)returns a prefixed store bound toenv.KV.getreturnsstring | null.putacceptsstringonly — structured data goes throughputJSON.getJSON(key)→any | null. On missing key:null. On malformed JSON: log a warning and returnnull(do NOT throw — a single corrupt record must not crash the bot).putJSON(key, value, opts?)→ serializes withJSON.stringifythen delegates toput. Throws ifvalueisundefinedor contains a cycle.listreturns{ keys: string[], cursor?: string, done: boolean }, with module prefix stripped.cursoris passed through from KV so callers can paginate.
Non-functional
- JSDoc
@typedefforKVStoreinterface 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@typedefonly, no runtime codesrc/db/cf-kv-store.js—CFKVStoreclass wrappingKVNamespacesrc/db/create-store.js—createStore(moduleName, env)prefixing factory
Modify
- none
Delete
- none
Implementation Steps
src/db/kv-store-interface.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 {};src/db/cf-kv-store.js:export class CFKVStorewithconstructor(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 } = {})→ callthis.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);.
src/db/create-store.js:export function createStore(moduleName, env):- Validate
moduleNameis 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)→ callbase.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)
- Validate
- JSDoc every exported function. Types on params + return.
npm run lint.
Todo List
src/db/kv-store-interface.jsJSDoc types (incl.getJSON/putJSON)src/db/cf-kv-store.jsCFKVStore class (incl.getJSON/putJSON)src/db/create-store.jsprefixing 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 iswordle:k;createStore("wordle").list()returns["k"]. - Swapping
CFKVStorefor a future backend requires changes ONLY insidesrc/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.KVoutsidesrc/db/— enforce by code review (module registry only passes the prefixed store, never the bare binding).
Next Steps
- Phase 04 consumes
createStorefrom the module registry'sinithook.