feat(loldle): tunable max guesses per subject + retuned defaults

Drop classic loldle from 8 → 6 (7-axis grid leaks too much per guess for 8
to feel earned) and emoji from 5 → 4 (3 emojis are usually unmistakable).
Add a hidden /<module>_setmax <n> command per loldle module so a chat can
override its own round length (1-10). Override stored at config:<subject>
in each module's KV; getMaxGuesses() falls back to the default when unset.
This commit is contained in:
tiennm99 committed 2026-05-07 14:10:01 +07:00
1 parent 5782e4779e
commit b52fa7094e
95 files changed
+396 -7708

No files matched your search

+27 -19
View File
@@ -173,11 +173,9 @@ Every command — public, protected, **and private** — is registered via `bot.
There is no custom text-match middleware, no `bot.on("message:text", ...)` handler, no private-command-specific path. One routing path for all three visibilities. This is what reduced the original two-path design (slash + text-match) to one during the revision pass.
## 8. Storage: MongoDB + Dual-Write Migration Era
## 8. Storage: KVStore and SqlStore
**Current state (Phases 01–08):** MongoDB Atlas is the primary store. During migration, a dual-write layer persists to both MongoDB and Cloudflare KV/D1 for safety. Modules NEVER touch `env.KV`, `env.DB`, or `env.MONGODB_URI` directly — they receive prefixed stores from the module context via `createStore()` and `createSqlStore()` factories.
**Post-Phase-07 cutover:** The dual-write layer collapses, Cloudflare KV/D1 are deleted, and `createStore()` returns pure MongoDB stores.
Modules NEVER touch `env.KV` or `env.DB` directly. They receive prefixed stores from the module context.
### KVStore (key-value, fast reads/writes)
@@ -205,20 +203,20 @@ getJSON(key) // → any | null (swallows corrupt JSON)
putJSON(key, value, { expirationTtl? })
```
**Current implementation:** `createStore("wordle", env)` returns a `MongoKVStore` directly (or `DualKVStore` if `DUAL_WRITE=1` is set). During migration, dual-write sends to both MongoDB and Cloudflare KV. TTL expirations are enforced server-side by MongoDB (via `expiresAt` field) and at read-time by the `MongoKVStore` layer.
#### Prefix mechanics
All keys are prefixed with `<moduleName>:` before storage:
`createStore("wordle", env)` returns a wrapped store where every key is rewritten:
```
module calls: store prefixes: MongoDB collection doc:
───────────────────────── ────────────────────── ────────────────────────
put("games:42", v) ──► put("wordle:games:42") ──► { _id: "wordle:games:42", … }
get("games:42") ──► get("wordle:games:42") ──► (find by _id, return value)
list({prefix:"games:"})──► (scan, filter prefix) ──► (keys matching "wordle:games:")
module calls: wrapper sends to CFKVStore: raw KV key:
───────────────────────── ───────────────────────────── ─────────────
put("games:42", v) ──► put("wordle:games:42", v) ──► wordle:games:42
get("games:42") ──► get("wordle:games:42") ──► wordle:games:42
list({prefix:"games:"})──► list({prefix:"wordle:games:"}) (then strips "wordle:" from returned keys)
```
Two stores for different modules cannot read each other's data unless they reconstruct prefixes by hand — a code-review boundary, not a cryptographic one.
### SqlStore (relational, scans, append-only history)
For complex queries, aggregates, or audit logs, use `sql` (a `SqlStore`):
@@ -226,7 +224,7 @@ For complex queries, aggregates, or audit logs, use `sql` (a `SqlStore`):
```js
// In a module's init:
init: async ({ sql }) => {
sqlStore = sql; // null if not bound
sqlStore = sql; // null if env.DB not bound
},
// In a handler or cron:
@@ -242,19 +240,29 @@ The interface (full JSDoc in `src/db/sql-store-interface.js`):
run(query, ...binds) // INSERT/UPDATE/DELETE — returns { changes, last_row_id }
all(query, ...binds) // SELECT all rows → array of objects
first(query, ...binds) // SELECT first row → object | null
prepare(query, ...binds) // Prepared statement for batch operations
batch(statements) // Execute multiple statements in one round-trip
```
**Current implementation:** `createSqlStore("trading", env)` returns a `MongoTradesStore` (native MongoDB inserts / queries on `trading_trades` collection). D1 is read-only during migration. Post-cutover, D1 is deleted.
All tables must follow the naming convention `{moduleName}_{table}` (e.g., `trading_trades`).
### Swapping the backends (post-Phase-07)
Tables are created via migrations in `src/modules/<name>/migrations/*.sql`. The migration runner (`scripts/migrate.js`) applies them on deploy and tracks them in `_migrations` table.
After cutover, the backend is locked to MongoDB. To replace it:
### Swapping the backends
To replace Cloudflare KV with a different store (e.g. Upstash Redis, Postgres):
1. Create a new `src/db/<name>-store.js` that implements the `KVStore` interface.
2. Change the one `new MongoKVStore(...)` line in `src/db/create-store.js` to construct your new adapter.
3. Update `wrangler.toml` bindings if needed.
2. Change the one `new CFKVStore(env.KV)` line in `src/db/create-store.js` to construct your new adapter.
3. Update `wrangler.toml` bindings.
Similarly for SQL: create `<name>-trades-store.js` implementing the trades interface, update `create-sql-store.js`.
That's the full change. No module code moves.
To replace D1 with a different SQL backend:
1. Create a new `src/db/<name>-sql-store.js` that implements the `SqlStore` interface.
2. Change the one `new CFSqlStore(env.DB)` line in `src/db/create-sql-store.js` to construct your new adapter.
3. Update `wrangler.toml` bindings.
## 9. HTTP and Scheduled Entry Points
+2 -12
View File
@@ -69,22 +69,12 @@ export default {
```
- Store module-level `db` and `sql` references in closure variables, set during `init`
- Never access `env.KV`, `env.DB`, or `env.MONGODB_URI` directly — always use the prefixed `db` (KVStore) or `sql` (SqlStore) from `init`
- `sql` is `null` when no relational store is bound — always guard with `if (!sql) return`
- Never access `env.KV` or `env.DB` directly — always use the prefixed `db` (KV) or `sql` (D1) from `init`
- `sql` is `null` when `env.DB` is not bound — always guard with `if (!sql) return`
- Command handlers receive grammY `ctx` — use `ctx.match` for command arguments, `ctx.from.id` for user identity
- Reply with `ctx.reply(text)` — plain text or Telegram HTML
- Cron handlers receive `(event, { db, sql, env })` — same context as `init`
## Persistence Layer
All data persistence flows through storage factories:
- **`createStore(moduleName, env)`** — returns a `KVStore` interface for key-value data (simple state, settings, JSON blobs). Implementation: `MongoKVStore` (primary) with optional dual-write to Cloudflare KV during migration.
- **`createSqlStore(moduleName, env)`** — returns a `SqlStore` interface for relational data (trading ledger, aggregates, scans). Implementation: `MongoTradesStore` (MongoDB native queries and inserts). D1 is read-only during migration.
- **Modules NEVER instantiate `MongoClient` directly.** All MongoDB access goes through `MongoKVStore` or `MongoTradesStore` factories.
Post-migration (after Phase 07 cutover), the dual-write layer collapses and Cloudflare KV/D1 are deleted; `createStore` returns `MongoKVStore` directly.
## Error Handling
- **Load-time failures** (bad module, command conflicts, missing env): throw immediately — fail loud at deploy, not at runtime.
+11 -11
View File
@@ -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. **During migration (Phase 08):** Data is stored in MongoDB Atlas M0 (behind a prefixed `KVStore` interface for KV data, `MongoTradesStore` for trading ledger). Dual-write to Cloudflare KV/D1 is active for safety; post-Phase-07 cutover, MongoDB becomes sole backend.
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,7 +10,7 @@ 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 | MongoDB Atlas M0 (primary, via official driver). Cloudflare KV + D1 (secondary, for dual-write safety during migration). |
| Storage | Cloudflare KV + D1 |
| AI inference | Workers AI binding (`env.AI`) |
| Linter/Formatter | Biome |
| Tests | Vitest |
@@ -21,14 +21,14 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
| Module | Commands | Storage | Crons | Description |
|--------|----------|---------|-------|-------------|
| `util` | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, sticker file_id echo helper |
| `misc` | `/ping`, `/mstats`, `/fortytwo` | MongoDB KVStore | — | Health check + DB demo stub |
| `trading` | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | MongoDB (trades + portfolio + symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution |
| `wordle` | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | MongoDB KVStore | — | 5-letter word guessing game. 14,855-word dict |
| `loldle` | `/loldle`, `/loldle_giveup`, `/loldle_stats` | MongoDB KVStore | — | Classic-mode LoL champion guesser. Data synced from `tiennm99/loldle-data` |
| `lolschedule` | `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` | MongoDB KVStore | Daily 01:00 UTC | LoL esports schedule + daily digest subscriptions |
| `semantle` | `/semantle`, `/semantle_giveup`, `/semantle_stats` | MongoDB KVStore | — | English semantic word guessing via hosted word2sim service |
| `doantu` | `/doantu`, `/doantu_hint`, `/doantu_giveup`, `/doantu_stats` | MongoDB KVStore | — | Vietnamese semantle via hosted phow2sim service |
| `twentyq` | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | MongoDB KVStore | — | 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 |
| `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
@@ -75,4 +75,4 @@ Each module maintains its own `README.md` with commands, data model, and impleme
## Tests
`npm test` runs the full vitest suite (run in ~10 seconds — 733 tests). Structure: one folder per module under `tests/modules/<name>/`, shared fakes under `tests/fakes/` (fake-mongo, fake-kv-namespace, fake-d1, fake-bot, fake-modules, fake-ai). No workerd, no Telegram fixtures — pure-logic unit tests with injected fakes. See `tests/fakes/fake-mongo.js` for the frozen surface implemented during Phase 02–08.
`npm test` runs the full vitest suite (run in a few seconds — ~450 tests). Structure: one folder per module under `tests/modules/<name>/`, 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.
-65
View File
@@ -1,65 +0,0 @@
# MongoDB Atlas Cost Tracking
Operational runbook for monitoring and managing MongoDB Atlas billing during and post-migration.
## Free Tier Limits (M0)
| Resource | Limit | Warning |
|----------|-------|---------|
| Storage | 512 MB | Reached 400 MB: prepare upgrade plan |
| Connections | 500 | Reached 400: review cron/handler concurrency |
| Throughput | ~100 ops/sec sustained | Degradation: check dashboard Charts |
| Backups | None | No PITR; only daily snapshots (read-only) |
## Cost Ladder
| Tier | Monthly | When | Ops/sec | Storage |
|------|---------|------|---------|---------|
| **M0** | Free | Initial, dev | ~100 | 512 MB |
| **Flex** | $8–$30 | Sustained >400 MB or >400 conn | 400–1000 | 10–256 GB |
| **M10** | $57 | Production high-throughput | 1000+ | Unlimited |
Flex tier auto-scales cost based on data volume and throughput. Start at M2 ($9) and scale to M5 ($70+) as needed.
## Monitoring & Upgrade Triggers
### Monthly Review Checklist
1. **Storage:** Open Atlas Dashboard → Metrics → check `Database Storage` chart
- If > 400 MB: escalate to Flex within 2 weeks
- Projection: (current MB / days since epoch) * 30 → projected month-end
2. **Connections:** Metrics → check `Current Connections` peak
- If > 400: cron jobs or handlers running concurrently too often
- Review `src/modules/*/index.js` cron frequency; stagger if possible
3. **Throughput:** Metrics → `Network Egress` + `Database Operations`
- Spike during trading/wordle command storms: expected
- Sustained >100 ops/sec: assess Flex upgrade
4. **Cluster Status:** Alerts → check if "Cluster unavailable" triggered
- M0 never pauses if any cron writes data (bot has 6+ crons, any write prevents pause)
### Upgrade Decision
Plan upgrade **before** hitting limits:
| Condition | Action | Timeline |
|-----------|--------|----------|
| Storage trend → 512 MB in 3 months | Upgrade to Flex M2 | 2 weeks notice |
| Peak connections > 400 regularly | Upgrade to Flex M2 | Immediate if sustained |
| Ops/sec spikes > 2000 | Upgrade to Flex M5 or M10 | 1 week notice |
## Rotation & Maintenance
- **Password rotation:** Every 90 days, owner = repo maintainer. See `docs/using-mongodb.md` "Rotation" section.
- **Alert config review:** Monthly, ensure Atlas + CF Observability alerts are wired.
- **Backups:** M0 has none; data is live-only. Backups start at Flex tier.
## Post-Cutover Simplification
Once Phase 07 cutover completes and Cloudflare KV/D1 are deleted, MongoDB becomes the sole data store. Cost is dominated by storage growth (KV reads/writes are gone). Rebaseline this doc:
- Remove dual-write cost considerations
- Focus on pure Mongo spend
- Extend storage projections based on new single-source data
-44
View File
@@ -1,44 +0,0 @@
# Project Changelog
All significant changes, features, and fixes to miti99bot.
## [2026-04-25] MongoDB Atlas Migration Complete
**Status:** Phases 01–08 committed on `dev` branch. Dual-write ready; cutover pending operator execution (Phase 07 binding deletion).
**Summary:**
Research, planning, and 7 phases of implementation + documentation to migrate from Cloudflare KV/D1 to MongoDB Atlas M0 free tier. Achieved zero-downtime dual-write architecture with automated drift detection, backfill/reverse-backfill scripts, and comprehensive telemetry. All 733 unit tests passing.
**Plan:** `plans/260425-1945-mongodb-atlas-migration/`
**Phases:**
- Phase 01: Wrangler config, secret-leak lint, bundle-size gate
- Phase 02: MongoKVStore + memoized client + fake-mongo + tests
- Phase 03: MongoTradesStore + trading refactor + MongoSqlStore shim
- Phase 04: Dual-write wrappers + factories + retry queue + drift verifier + e2e
- Phase 05: Backfill scripts (local node, CF KV REST + wrangler d1 export)
- Phase 06: Telemetry instrumentation + soak runbook
- Phase 07: Cutover scripts + reverse-backfill + decommission helpers
- Phase 08: Final docs pass + alternatives section update
**Key artifacts:**
- `src/db/mongo-kv-store.js` — MongoDB implementation of KVStore interface
- `src/db/mongo-trades-store.js` — MongoDB trading ledger store
- `src/db/dual-kv-store.js` — dual-write wrapper for safety during migration
- `src/db/mongo-client.js` — shared memoized connection with retry logic
- `docs/using-mongodb.md` — operational runbook
- `docs/cost-tracking.md` — cost monitoring and upgrade triggers
**Reviewers noted** (not adopted):
- Maintenance-window cutover instead of dual-write (saves ~6h engineering)
- Defer trading migration (D1 free handles ~100 writes/day indefinitely)
- Single shared `kv` collection vs 12 per-module collections
- Pivot to Upstash before starting (smaller bundle, HTTP-native)
User chose full cold-start Atlas validation. If bundle size trips, `phase-07-alt-pivot.md` is ready.
**Post-cutover simplification** (Phase 07 Stage 3 — operator-driven):
- Delete dual-write layer
- Delete `cf-kv-store.js`, `cf-sql-store.js`, and D1 bindings from `wrangler.toml`
- `createStore()` returns pure `MongoKVStore`
- Cost tracking shifts to Mongo-only projections
-142
View File
@@ -1,142 +0,0 @@
# Using MongoDB Atlas
Operational runbook for the MongoDB Atlas backend introduced by `plans/260425-1945-mongodb-atlas-migration/`.
## Cluster
| Field | Value |
|---|---|
| Provider | MongoDB Atlas |
| Tier | M0 Free |
| Region | `aws-ap-southeast-1` (Singapore) |
| Cluster name | `miti99bot-prod` (operator confirms) |
| Database | `miti99bot` |
| DB user | `miti99bot-worker` (`readWrite@miti99bot`) |
Connection string format:
```
mongodb+srv://miti99bot-worker:<pass>@<host>/miti99bot?retryWrites=true&w=majority
```
Stored in two places (must match):
1. CF Worker secret: `wrangler secret put MONGODB_URI`
2. `.env.deploy` (gitignored, used by local backfill / verify scripts)
Same secret-mirror protocol as `TELEGRAM_BOT_TOKEN`.
## Free-tier ceiling
- 512 MB storage (data + indexes)
- 500 max concurrent connections
- ~100 ops/sec sustained (no daily cap)
- No backups, single region, no PITR
- Auto-pauses after 30 days of zero ops
Upgrade path: **Flex Tier $8–$30/month** (M2/M5 deprecated as of 2026).
## Auto-pause
After 30 days idle the cluster pauses. First request after pause:
- Driver throws `MongoServerSelectionError` after `serverSelectionTimeoutMS` (5s).
- Worker code (see `src/db/mongo-client.js`, lands Phase 02) catches and returns 503 with `Retry-After: 30`.
- Cluster auto-wakes within 30–60s on attempted connection.
The bot has 6+ daily crons; any cron that writes Mongo prevents pause. Phase 08 confirms.
## Network access
`0.0.0.0/0` — Cloudflare Workers do NOT have static egress IPs on the Free or basic Paid plans. Only auth (SCRAM-SHA-256) + TLS gate connections.
**Permanent risk** unless upgrading to CF Workers paid static-egress IP add-on (~$10/mo).
Mitigations:
- DB user has `readWrite` on one db only (NOT `dbAdmin` / `clusterAdmin`).
- Password ≥32 chars random.
- Rotate quarterly.
- Atlas free-tier email alerts configured for cluster unavailability + connections > 400.
## Bundle gate (Phase 01 result)
Measured `npx wrangler deploy --dry-run` with a minimal probe importing `MongoClient`:
| Metric | Value | Cap (Free) | Cap (Paid) |
|---|---|---|---|
| Compressed (gzip) | **226 KiB** | 3 MiB | 10 MiB |
| Raw (minified) | 1.74 MiB | — | — |
| On-disk (uncompressed) | 3.9 MiB | — | — |
Pass on both plans with **>92% headroom**. nodejs_compat_v2 provides `node:net`/`node:tls`/`node:crypto` from the runtime, so the driver's transitive deps are not bundled.
## CPU-time gate (Phase 01 — operator-run)
Requires real Atlas + `wrangler dev`. Procedure:
1. Add a temporary `/__mongo-ping` route that connects + runs `db.runCommand({ping:1})` + returns `{wall_ms}`.
2. Run 5+ cold cycles (10-min spaced).
3. Inspect CF dashboard CPU column for each invocation.
4. **Hard gate**: if any cold-start CPU time approaches 50ms (Free plan limit), abort migration. Escalate to paid plan or pivot via `phase-07-alt-pivot.md`.
5. Record cold-ping P95 wall-clock as `BASELINE_COLD_PING_MS` here:
```
BASELINE_COLD_PING_MS = <fill after measurement>
```
Phase 06 derives the abort threshold from this value: `2.5 × BASELINE_COLD_PING_MS`.
## Auto-pause behavior gate (Phase 01 — operator-run)
In Atlas UI, manually pause the cluster, then hit `/__mongo-ping`. Confirm:
- Driver throws within 5s (does NOT hang indefinitely).
- Error class is `MongoServerSelectionError` (or driver subclass).
- Phase 02 `getDb()` catches this and surfaces a 503.
## Node API surface
`src/` (the Worker) imports zero `node:*` modules today. `nodejs_compat_v2` is enabled solely for the `mongodb` driver:
| Module | Used by Worker? | Used by scripts/? |
|---|---|---|
| `node:fs` | no | yes (build/scrape/migrate) |
| `node:path` | no | yes |
| `node:child_process` | no | yes (migrate.js) |
| `node:net` | indirectly (via mongodb) | no |
| `node:tls` | indirectly (via mongodb) | no |
| `node:crypto` | indirectly (via mongodb) | no |
| `process.env` | no | yes (register.js) |
| `Buffer` | no | no |
Risk: minimal. No existing module relies on the absence of these globals.
## Rollback
If migration is abandoned at any phase before cutover:
1. `wrangler secret delete MONGODB_URI`
2. Revert `wrangler.toml`: remove `compatibility_flags = ["nodejs_compat_v2"]`.
3. `npm uninstall mongodb`.
4. `npm run deploy` — bot continues on KV/D1 unchanged.
5. (Optional) Delete Atlas cluster from UI.
`scripts/check-secret-leaks.js` should stay — it covers other secrets too.
## Rotation
`MONGODB_URI` rotation cadence: every 90 days, owner = repo maintainer.
Procedure:
1. In Atlas UI → Database Access → edit `miti99bot-worker` → reset password.
2. Update `.env.deploy` with new URI.
3. `wrangler secret put MONGODB_URI` (paste new URI).
4. `npm run deploy` (re-runs register; no Worker restart needed since secret reads at request time via `env.MONGODB_URI`).
Mismatch between `.env.deploy` and CF secret causes register-script failure on next deploy — same fail-loud pattern as `TELEGRAM_WEBHOOK_SECRET`.
## Alerts
Configured in Atlas free-tier UI:
- **Cluster unavailable** → email maintainer.
- **Current connections > 400** (80% of cap) → email maintainer.
Plus CF Observability rule (Phase 06): >10 errors per 1 min window → email.