diff --git a/docs/architecture.md b/docs/architecture.md index 9be3ced..db40022 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,28 +17,38 @@ For authoring a new plugin module, see [`adding-a-module.md`](./adding-a-module. ``` src/ -├── index.js ── fetch router: POST /webhook + GET / health +├── index.js ── fetch + scheduled handlers ├── bot.js ── memoized grammY Bot factory, lazy dispatcher install ├── db/ -│ ├── kv-store-interface.js ── JSDoc typedefs only — the contract +│ ├── kv-store-interface.js ── KVStore contract (JSDoc) │ ├── cf-kv-store.js ── Cloudflare KV adapter -│ └── create-store.js ── per-module prefixing factory +│ ├── create-store.js ── KV per-module prefixing factory +│ ├── sql-store-interface.js ── SqlStore contract (JSDoc) +│ ├── cf-sql-store.js ── Cloudflare D1 adapter +│ └── create-sql-store.js ── D1 per-module prefixing factory ├── modules/ │ ├── index.js ── static import map (add new modules here) │ ├── registry.js ── loader + builder + conflict detection + memoization │ ├── dispatcher.js ── bot.command() for every visibility -│ ├── validate-command.js ── shared validators -│ ├── util/ ── fully implemented: /info + /help -│ ├── trading/ ── paper trading: VN stocks (dynamic symbol resolution) -│ ├── wordle/ ── 5-letter guessing game (KV storage) -│ ├── loldle/ ── classic-mode LoL champion guesser (KV storage) -│ └── misc/ ── stub that exercises the DB (ping/mstats) +│ ├── cron-dispatcher.js ── routes scheduled events to matching module crons +│ ├── validate-command.js ── command contract validator +│ ├── validate-cron.js ── cron contract validator +│ ├── util/ ── /info + /help +│ ├── misc/ ── stub: /ping + /mstats +│ ├── trading/ ── paper trading: VN stocks (D1 + KV, daily cron) +│ ├── wordle/ ── 5-letter guessing game (KV) +│ ├── loldle/ ── classic-mode LoL champion guesser (KV) +│ ├── lolschedule/ ── LoL esports schedule + daily digest subscriptions (KV, cron) +│ ├── semantle/ ── English semantic word guess (KV, word2sim) +│ ├── doantu/ ── Vietnamese semantle (KV, phow2sim) +│ └── twentyq/ ── reverse-Akinator yes/no game (KV + Workers AI) └── util/ └── escape-html.js scripts/ ├── register.js ── post-deploy: setWebhook + setMyCommands -└── stub-kv.js ── no-op KV binding for deploy-time registry build +├── migrate.js ── apply D1 migrations +└── stub-kv.js ── no-op KV + AI bindings for deploy-time registry build ``` ## 3. Cold-start and the bot factory @@ -104,11 +114,15 @@ Cloudflare Workers bundle statically via wrangler. A dynamic import from a varia ```js // src/modules/index.js export const moduleRegistry = { - util: () => import("./util/index.js"), - wordle: () => import("./wordle/index.js"), - loldle: () => import("./loldle/index.js"), - misc: () => import("./misc/index.js"), - trading: () => import("./trading/index.js"), + util: () => import("./util/index.js"), + wordle: () => import("./wordle/index.js"), + loldle: () => import("./loldle/index.js"), + misc: () => import("./misc/index.js"), + trading: () => import("./trading/index.js"), + lolschedule: () => import("./lolschedule/index.js"), + semantle: () => import("./semantle/index.js"), + doantu: () => import("./doantu/index.js"), + twentyq: () => import("./twentyq/index.js"), }; ``` diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 41b678b..3b0a072 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -2,7 +2,7 @@ ## Overview -Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY handles Telegram API; modules register commands with three visibility levels. Data stored in Cloudflare KV behind a prefixed `KVStore` interface. +Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY handles Telegram API; modules register commands with three visibility levels. Data stored in Cloudflare KV (behind a prefixed `KVStore` interface) or D1 (behind `SqlStore` interface). ## Tech Stack @@ -10,21 +10,25 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand |-------|-----------| | Runtime | Cloudflare Workers (V8 isolates) | | Bot framework | grammY 1.x | -| Storage | Cloudflare KV | +| Storage | Cloudflare KV + D1 | +| AI inference | Workers AI binding (`env.AI`) | | Linter/Formatter | Biome | | Tests | Vitest | | Deploy | Wrangler CLI | ## Active Modules -| Module | Status | Commands | Storage | Crons | Description | -|--------|--------|----------|---------|-------|-------------| -| `util` | Complete | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, and sticker file_id echo helper | -| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. | -| `wordle` | Complete | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV (game, stats) | — | Classic 5-letter word game. 14,855-word dict sourced from [dracos's gist](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93). | -| `loldle` | Complete | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | — | Classic-mode LoL champion guesser (auto-starts a new round after solve/giveup). Champion data synced from `tiennm99/loldle-data`. | -| `twentyq` | Complete | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | KV (game, stats) | — | Reverse-Akinator yes/no game. Workers AI (`@cf/google/gemma-4-26b-a4b-it`) judges each question via function calling + generates fresh hints. | -| `misc` | Stub | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo | +| Module | Commands | Storage | Crons | Description | +|--------|----------|---------|-------|-------------| +| `util` | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, sticker file_id echo helper | +| `misc` | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo stub | +| `trading` | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution | +| `wordle` | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV | — | 5-letter word guessing game. 14,855-word dict | +| `loldle` | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV | — | Classic-mode LoL champion guesser. Data synced from `tiennm99/loldle-data` | +| `lolschedule` | `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` | KV | Daily 01:00 UTC | LoL esports schedule + daily digest subscriptions | +| `semantle` | `/semantle`, `/semantle_giveup`, `/semantle_stats` | KV | — | English semantic word guessing via hosted word2sim service | +| `doantu` | `/doantu`, `/doantu_hint`, `/doantu_giveup`, `/doantu_stats` | KV | — | Vietnamese semantle via hosted phow2sim service | +| `twentyq` | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | KV | — | Reverse-Akinator yes/no game. Workers AI (`@cf/google/gemma-4-26b-a4b-it`) generates round-start category+hint and judges each turn via one-line JSON | ## Key Data Flows @@ -62,23 +66,13 @@ npm run deploy |-----------|---------|---------| | `grammy` | Telegram Bot API framework | ^1.30.0 | | `@biomejs/biome` | Linting + formatting (dev) | ^1.9.0 | -| `vitest` | Test runner (dev) | ^2.1.0 | -| `wrangler` | Cloudflare Workers CLI (dev) | ^3.90.0 | +| `vitest` | Test runner (dev) | ^4.1.4 | +| `wrangler` | Cloudflare Workers CLI (dev) | ^4.84.0 | ## Module Documentation Each module maintains its own `README.md` with commands, data model, and implementation details. See `src/modules//README.md`. -## Test Coverage +## Tests -200 tests across 21 test files (run via `npm test` — ~2s): - -| Area | Tests | What's Covered | -|------|-------|---------------| -| DB layer (KV) | 19 | KV store, prefixing, JSON helpers, pagination | -| DB layer (D1) | — | Fake D1 in-memory implementation (fake-d1.js) backs trading tests | -| Module framework | 33 | Registry, dispatcher, validators, help renderer, cron validation | -| Utilities | 4 | HTML escaping | -| Trading module | 79 | Symbol resolution, formatters, flat portfolio CRUD, command handlers, history/retention | -| Loldle module | 18 | Classic-mode champion comparison, champion lookup, daily picker | -| Wordle module | 13 | Duplicate-letter two-pass comparison, guess validation | +`npm test` runs the full vitest suite (run in a few seconds — ~450 tests). Structure: one folder per module under `tests/modules//`, shared fakes under `tests/fakes/` (fake-kv-namespace, fake-d1, fake-bot, fake-modules, fake-ai). No workerd, no Telegram fixtures — pure-logic unit tests with injected fakes. diff --git a/docs/todo.md b/docs/todo.md deleted file mode 100644 index 1ae4fef..0000000 --- a/docs/todo.md +++ /dev/null @@ -1,47 +0,0 @@ -# TODO - -Manual follow-ups after the D1 + Cron infra rollout (plan: `plans/260415-1010-d1-cron-infra/`). - -## Pre-deploy (required before next `npm run deploy`) - -- [ ] Create the D1 database: - ```bash - npx wrangler d1 create miti99bot-db - ``` - Copy the returned UUID. - -- [ ] Replace `REPLACE_ME_D1_UUID` in `wrangler.toml` (`[[d1_databases]]` → `database_id`) with the real UUID. - -- [ ] Commit `wrangler.toml` with the real UUID (the ID is not a secret). - -## First deploy verification - -- [ ] Run `npm run db:migrate -- --dry-run` — confirm it lists `src/modules/trading/migrations/0001_trades.sql` as pending. - -- [ ] Run `npm run deploy` — chain is `wrangler deploy` → `npm run db:migrate` → `npm run register`. - -- [ ] Verify in Cloudflare dashboard: - - D1 database `miti99bot-db` shows `trading_trades` + `_migrations` tables - - Worker shows a cron trigger `0 17 * * *` - -## Post-deploy smoke tests - -- [ ] Send `/buy VNM 10 80000` (or whatever the real buy syntax is) via Telegram, then `/history` — expect 1 row. - -- [ ] Manually fire the cron to verify retention: - ```bash - npx wrangler dev --test-scheduled - # in another terminal: - curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*" - ``` - Check logs for `trim-trades` output. - -## Nice-to-have (not blocking) - -- [ ] End-to-end test of `wrangler dev --test-scheduled` documented with real output snippet in `docs/using-cron.md`. - -- [ ] Decide on migration rollback story (currently forward-only). Either document "write a new migration to undo" explicitly, or add a `down/` convention. - -- [ ] Tune `trim-trades` schedule if 17:00 UTC conflicts with anything — currently chosen as ~00:00 ICT. - -- [ ] Consider per-environment D1 (staging vs prod) if a staging bot is added later. diff --git a/scripts/stub-kv.js b/scripts/stub-kv.js index ef12426..4d2822b 100644 --- a/scripts/stub-kv.js +++ b/scripts/stub-kv.js @@ -7,7 +7,7 @@ * These stubs satisfy the shape without doing any real IO. All init hooks * are assumed read-only (or tolerant of missing state) at registration time. * If a future module writes inside init(), update the matching stub to - * swallow writes or gate the write on a `process.env.REGISTER_DRYRUN` flag. + * swallow writes safely. */ /** @type {KVNamespace} */ diff --git a/src/modules/twentyq/README.md b/src/modules/twentyq/README.md index abf2b16..5f1d64a 100644 --- a/src/modules/twentyq/README.md +++ b/src/modules/twentyq/README.md @@ -1,10 +1,12 @@ # Twentyq Module -A reverse-Akinator yes/no guessing game. The bot picks a secret object from a -hand-curated seed list, gives an opening hint, then judges every user input -with a Workers AI LLM (`@cf/google/gemma-4-26b-a4b-it`) via function calling. -Each turn the model returns `{ is_guess, answer, hint }`. Round ends on a -correct guess (`is it an organ?` matches secret) or `/twentyq_giveup`. +A reverse-Akinator yes/no guessing game. The bot picks a secret keyword from +a flat seed list. At round-start, a Workers AI LLM +(`@cf/google/gemma-4-26b-a4b-it`) generates a category + cryptic opening +hint. Each turn the same model judges the user's input and returns +`{ is_guess, answer, hint }` as one-line JSON (parsed out of response text — +no function calling, no tools array). Round ends on a correct guess or +`/twentyq_giveup`. **Visibility: `public`** — commands appear in both `/help` and Telegram's native `/` autocomplete menu. @@ -60,16 +62,19 @@ AI request) + one request per turn, well under the cap for normal play volume. ## Architecture -- `seeds.js` — `SEEDS` array + `getRandomSeed(rng)`. Targets are lowercased. +- `seeds.js` — flat `SEEDS` string array of target keywords + `getRandomSeed(rng)`. - `state.js` — KV persistence for game + stats. Subject = user id (DM) or chat id (group). 7-day TTL on the active round. -- `prompts.js` — `buildSystemPrompt(state)` injects secret + history; - `ANSWER_FUNCTION_SCHEMA` declares the `submit_answer` tool. +- `prompts.js` — `buildStartRoundPrompt(target)` (opens a round) and + `buildSystemPrompt(state)` (per-turn judging). Both include HINT STYLE + rules to keep hints cryptic. - `validate-input.js` — pre-AI regex check; rejects open-ended starters, empty/oversized input. Saves Neurons. -- `ai-client.js` — wraps `env.AI.run`, parses both Cloudflare-traditional and - OpenAI-style tool-call shapes, normalizes payload, redacts the secret from - hints (defense-in-depth). `UpstreamError` wraps any failure. +- `ai-client.js` — wraps `env.AI.run`. `generateRoundStart(env, target)` + produces `{category, initialHint}`; `judge(env, state, userInput)` produces + `{is_guess, answer, hint}`. Both parse one-line JSON from response text, + redact the secret word from any generated hint, and throw `UpstreamError` + on upstream failure. - `render.js` — five Telegram-HTML formatters; all user-derived text HTML-escaped. - `handlers.js` — three command entry points + subject resolver + repeat diff --git a/src/modules/twentyq/ai-client.js b/src/modules/twentyq/ai-client.js index 47df01d..5627c42 100644 --- a/src/modules/twentyq/ai-client.js +++ b/src/modules/twentyq/ai-client.js @@ -126,8 +126,7 @@ export function redactSecret(hint, target) { if (!target) return hint; const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`\\b${escaped}\\b`, "ig"); - const out = hint.replace(re, "(redacted)"); - return out.length > 0 ? out : "the hint was redacted to avoid revealing the answer"; + return hint.replace(re, "(redacted)"); } /** diff --git a/src/modules/twentyq/index.js b/src/modules/twentyq/index.js index cf2df33..c56645b 100644 --- a/src/modules/twentyq/index.js +++ b/src/modules/twentyq/index.js @@ -1,11 +1,11 @@ /** * @file Twentyq module — reverse-Akinator yes/no guessing game. * - * Bot picks a secret object from a hand-curated seed list (./seeds.js) and - * gives an initial hint. Each user input is judged by Workers AI - * (@cf/google/gemma-4-26b-a4b-it) via function calling — the model returns - * { is_guess, answer, hint }. Round ends on a correct guess or /twentyq_giveup. - * Unlimited turns. Per-subject state in KV (user id in DMs, chat id in groups). + * Bot picks a secret keyword from ./seeds.js. Workers AI + * (@cf/google/gemma-4-26b-a4b-it) generates {category, initialHint} at + * round start and emits a one-line JSON {is_guess, answer, hint} per turn. + * Round ends on a correct guess or /twentyq_giveup. Unlimited turns. + * Per-subject state in KV (user id in DMs, chat id in groups). * * `init` captures both the prefixed KV store AND the raw env so handlers can * reach env.AI per request without changing the dispatcher contract. diff --git a/tests/modules/twentyq/handlers.test.js b/tests/modules/twentyq/handlers.test.js index d19f99f..cf3c569 100644 --- a/tests/modules/twentyq/handlers.test.js +++ b/tests/modules/twentyq/handlers.test.js @@ -137,6 +137,7 @@ describe("twentyq/handlers", () => { mockJudgement(ai, { is_guess: false, answer: "yes", hint: "yes hint" }); const ctx = makeCtx(1, "private", "/twentyq is it big?"); await handleTwentyq(ctx, { db, env }); + expect(ai.run).toHaveBeenCalledTimes(2); // roundstart + judge expect(ctx.reply).toHaveBeenCalledTimes(2); // intro + turn expect(ctx.replies[0].text).toMatch(/I'm thinking/); expect(ctx.replies[1].text).toMatch(/Yes/); @@ -164,6 +165,7 @@ describe("twentyq/handlers", () => { const ctx = makeCtx(99, "group", "/twentyq is it big?"); ctx.chat.id = 12345; await handleTwentyq(ctx, { db, env }); + expect(ai.run).toHaveBeenCalledTimes(2); // roundstart + judge // Game saved under chat id (12345), not user id (99) expect(await loadGame(db, 12345)).not.toBeNull(); expect(await loadGame(db, 99)).toBeNull(); @@ -190,11 +192,9 @@ describe("twentyq/handlers", () => { }); describe("handleStats", () => { - it("renders stats summary", async () => { - await saveGame(db, 1, sampleGame()); + it("renders empty-stats message when no rounds finished", async () => { const ctx = makeCtx(1); await handleStats(ctx, { db }); - // No games played yet -> "no twentyq games" expect(ctx.replies[0].text).toMatch(/no.*games/i); }); }); diff --git a/wrangler.toml b/wrangler.toml index 6ed2fcf..7a151e2 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -25,12 +25,11 @@ binding = "DB" database_name = "miti99bot-db" database_id = "261b54e7-0fdb-4fe7-8ed9-2e8a8bcf459c" -# Workers AI — inference binding used by semantle + doantu for -# @cf/baai/bge-m3 multilingual text embeddings. Accessed as `env.AI` -# in handlers. Included on the Workers Free plan: 10,000 Neurons/day at -# no charge (hard-stops — no billing on Free plan). -# bge-m3 is 1075 Neurons per M input tokens → ~0.002 N/guess (2 short -# words), ~4.6M guesses/day within the cap. +# Workers AI inference binding, accessed as `env.AI` in handlers. +# Used by: +# - semantle / doantu → @cf/baai/bge-m3 multilingual embeddings +# - twentyq → @cf/google/gemma-4-26b-a4b-it text generation +# Workers Free plan: 10,000 Neurons/day, hard-stops (no billing on Free). # Pricing: https://developers.cloudflare.com/workers-ai/platform/pricing/ [ai] binding = "AI"