mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
- seeded 54 objects across 6 categories (instrument, animal, food, vehicle, sport, household)
- @cf/google/gemma-4-26b-a4b-it judges via function calling; returns {is_guess, answer, hint}
- pre-AI validator rejects open-ended questions; handler dedups exact repeats
- secret-redacting hint filter as defense-in-depth
- 86 new vitest tests (seeds, state, validator, ai-client, handlers, render)
7.8 KiB
7.8 KiB
Phase 03 — Gameplay Handlers + Render
Context links
- Plan overview:
./plan.md - Foundation pieces:
./phase-01-foundation.md - AI client:
./phase-02-ai-client.md - Handler pattern reference:
src/modules/doantu/handlers.js - Render reference:
src/modules/doantu/render.js - Subject resolution + grammY ctx:
src/modules/loldle/handlers.js
Overview
- Priority: P1
- Status: planned
- Description: Wire all four commands (
/twentyq,/twentyq_giveup,/twentyq_stats, plus the implicit ask/guess flow via/twentyq <text>) to the seeds + state + AI client. Build the renderer for board snapshots and per-turn replies. Manage round lifecycle: start → answer turns → solve/giveup.
Key insights
- grammY
ctx.matchholds the slash-command argument string (everything after/twentyq). Emptyctx.match→ board view OR start fresh round. - Subject = user id in DMs (
ctx.chat.type === "private"), chat id otherwise. Mirrordoantu/handlers.jsresolver. - Auto-start rule:
/twentyqwith no args AND no active game → start a round. With args → submit input (start a round first if none). - After a
solvedround: next/twentyq(any form) clears + starts fresh. - Use Telegram HTML mode for output (matches loldle/doantu).
Requirements
Functional
/twentyq(no args) — show board if active, else start a round and show intro line + initial hint./twentyq <text>— validate input → if invalid, reply with rephrase hint (no state mutation, no AI call). If valid, calljudge, append turn, reply withyes/no + hint. Ifis_guess && answer==="yes", mark solved, record stats, reveal secret, congratulate./twentyq_giveup— if active round, reveal secret + record loss; clear game key. Idempotent if no active round (replies "no active round")./twentyq_stats— render{played, solved, totalTurns, bestTurnCount}.- Repeat-question detection: simple lowercased exact-text dedup against prior
turns. If repeat → reply
🔁 already askedand skip AI call (no count).
Non-functional
- Each handler ≤80 LOC.
- HTML escape all user-rendered text via existing
src/util/escape-html.js. - Surface
UpstreamErroras a friendly "AI service hiccup, try again" reply. - Each file ≤200 LOC.
Architecture
src/modules/twentyq/
├── handlers.js # handleTwentyq, handleGiveup, handleStats — the entry points
├── render.js # formatBoard, formatTurnReply, formatGiveup, formatStats, formatIntro
└── index.js # full module export with all four commands wired
ctx ──► handleTwentyq ──► loadGame
│ │
├── empty arg ─┴── present? show board : start round (intro)
│
└── arg present ─► validateQuestion → judge → save turn → reply
Related code files
Create
src/modules/twentyq/handlers.jssrc/modules/twentyq/render.js
Edit
src/modules/twentyq/index.js— replace phase-1 stub with real commands array.
Implementation steps
- Create
src/modules/twentyq/render.js:formatIntro(state)→"🎯 I'm thinking of a <category>.\nHint: <initialHint>".formatTurnReply({ answer, hint, isGuess, solved, target, turnCount }):- solve win →
"🎉 Correct! It was <b>{target}</b>. Solved in {turnCount} questions." - guess miss →
"❌ No. Hint: {hint}" - regular yes →
"✅ Yes. Hint: {hint}" - regular no →
"❌ No. Hint: {hint}"
- solve win →
formatBoard(state)— initial hint + numbered list of past Q/A in<pre>.formatGiveup(state)—"🏳️ Gave up. The answer was <b>{target}</b>."formatStats(stats)— terse multi-line summary.- All target/text values HTML-escaped.
- Create
src/modules/twentyq/handlers.js:resolveSubject(ctx)— same as doantu (private → user id, else chat id).handleTwentyq(ctx, { db, env }):- Subject = resolveSubject.
state = await loadGame(db, subject).- If state and
state.solved→ clearGame + treat as no game. - If no state and no
ctx.match→ start a round (callgetRandomSeed, build state, save, replyformatIntro). - If no state and
ctx.match→ start round THEN process input as turn. - If state and no
ctx.match→ replyformatBoard(state). - If state and
ctx.match→ process turn (see below).
- Process-turn block:
validateQuestion(text)→ on fail reply with reason.- Repeat-text check against
state.turns[].text(lowercased) → reply🔁 already asked. await judge(env, state, text). CatchUpstreamError→ friendly reply.- Append turn to
state.turns. Ifresult.is_guess && result.answer === "yes": setstate.solved = true; recordResult({solved:true, turnCount: turns.length}); clearGame. - Else save updated state.
- Reply with
formatTurnReply(...).
handleGiveup(ctx, { db }):- Load game; if none → "no active round".
- Else → recordResult({solved:false, turnCount}); reveal target;
clearGame; reply
formatGiveup(state).
handleStats(ctx, { db })— load + render.
- Replace
src/modules/twentyq/index.js:- Mirror doantu shape: closure-scoped
dbset ininit, plusenvpassed through to handlers (because we needenv.AI). Two options:- Option A (cleaner): capture
envininitalongsidedband hand both to handlers. - Option B: pass
envasctx.env(grammY already exposes it via the worker handler binding). Confirm at impl time; if not exposed, use Option A.
- Option A (cleaner): capture
- Register 4 commands:
twentyq,twentyq_giveup,twentyq_stats, plus a hidden alias if useful (skip for now per YAGNI).
- Mirror doantu shape: closure-scoped
- Manual smoke test in
wrangler dev(use ngrok / cloudflared tunnel + a throwaway test bot) — verify start, ask, guess-correct, giveup paths.
Todo list
render.js— all five formatters with HTML escapehandlers.js— three handlers, subject resolver, repeat dedupindex.js— full module export withinit({ db, env })capture- Confirm
envpropagation pattern (capture-in-init vs ctx.env) - Manual smoke test happy path + giveup + repeat input
npm run lintclean
Success criteria
- Manual flow works end-to-end through Telegram.
is_guess && yesends round and records solve./twentyq_giveupends round, reveals, records loss.- Repeat input does NOT increment turn count and does NOT call AI.
- Open-ended question ("what is it?") gets the validator's rephrase reply.
- KV state persists across cold starts (verified by waiting >30s between turns).
Risk assessment
envpropagation — modules currently capture onlydbininit. Doantu/semantle captureenvonly enough to read URL config at init time, not for per-request AI calls. Need to capture the fullenvref or change the dispatcher contract. Decision: capture ininitclosure — least invasive, no framework change.- Race condition on rapid double-send — two near-simultaneous
/twentyqquestions could both load state, both write — last writer wins. Acceptable for v1 (KV is eventually consistent anyway; users notice nothing in normal pacing). - AI hint may leak the secret despite system-prompt instructions.
Mitigation: post-process hint to redact case-insensitive substring of
target. Add as a defensive filter informatTurnReply(cheap, ~3 lines).
Security considerations
- All user-controlled strings (input text, target, hint) HTML-escaped before rendering.
- No KV keys derived from raw user text — only subject id + literal prefix.
- Secret-leak filter on hints (see Risk above).
Next steps
→ Phase 04 — vitest coverage, README, help-command verification.