refactor(modules): remove twentyq genai integration

This commit is contained in:
tiennm99 committed 2026-06-29 10:07:21 +07:00
1 parent 20dc03c6d2
commit 9c998a0e2f
24 files changed
+2 -1413

No files matched your search

-4
View File
@@ -19,10 +19,6 @@ OWNER_ID=
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
ADMIN_IDS=
# ============================ Optional ============================
# Only the twentyq module needs this. Leave blank to disable that command.
GEMINI_API_KEY=
# The wc module uses football-data.org for World Cup schedule/live-score data.
# Leave blank to keep /wc commands loaded but replying with a not-configured
# message.
-2
View File
@@ -13,7 +13,6 @@ Atlas via long polling and an in-process cron scheduler.
| `loldle` | League-of-Legends "guess the champion" |
| `lolschedule` | Pro-match schedule + daily push |
| `wc` | World Cup schedule + daily push |
| `twentyq` | 20-questions game (requires Gemini API key) |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; primary VNAppMob SJC buy/sell VND/luong, fallback spot XAU) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
@@ -30,7 +29,6 @@ internal/telegram/ Telegram long-polling bot wrapper
internal/cron/ in-process cron scheduler
internal/modules/ Module framework, registry, dispatchers, modules
internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents
internal/ai/ Gemini client (used by twentyq)
compose.yml Coolify self-host stack (single bot service)
telegram-commands.json Manual Telegram command menu source
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
+1 -20
View File
@@ -12,7 +12,6 @@ import (
"syscall"
"time"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/cron"
"github.com/tiennm99/miti99bot/internal/deploynotify"
"github.com/tiennm99/miti99bot/internal/log"
@@ -25,7 +24,6 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/modules/twentyq"
"github.com/tiennm99/miti99bot/internal/modules/util"
"github.com/tiennm99/miti99bot/internal/modules/wc"
"github.com/tiennm99/miti99bot/internal/modules/wordle"
@@ -67,7 +65,6 @@ func factories() map[string]modules.Factory {
"wc": wc.New,
"coin": coin.New,
"gold": gold.New,
"twentyq": twentyq.New,
"stock": stock.New,
"stats": stats.New,
}
@@ -102,22 +99,8 @@ func main() {
log.Fatal("telegram bot init failed", "err", err)
}
// Gemini is optional: twentyq checks for nil and refuses the command at
// handler time. A blank GEMINI_API_KEY is therefore not fatal — the rest
// of the bot still runs.
aiClient, err := ai.NewClient(rootCtx, cfg.GeminiAPIKey)
if err != nil && !errors.Is(err, ai.ErrNotConfigured) {
log.Fatal("gemini init failed", "err", err)
}
if aiClient == nil {
log.Warn("GEMINI_API_KEY unset; AI-backed modules will refuse commands")
} else {
log.Info("gemini client initialised")
}
reg, err := modules.Build(cfg.Modules, factories(), provider, modules.BuildOptions{
Chatter: aiClient,
Bot: b,
Bot: b,
})
if err != nil {
log.Fatal("module registry build failed", "err", err)
@@ -260,7 +243,6 @@ type config struct {
Port string
TelegramBotToken string
SourceCommit string // Coolify-injected commit SHA (runtime env) for deploynotify
GeminiAPIKey string
Modules []string
BotOwnerID int64
AdminUserIDs map[int64]bool
@@ -290,7 +272,6 @@ func loadConfig() config {
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
SourceCommit: envMap["SOURCE_COMMIT"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
Modules: splitCSV(envMap["MODULES"]),
BotOwnerID: parseInt64(envMap["OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]),
-2
View File
@@ -14,8 +14,6 @@ services:
MODULES: ${MODULES} # CSV; empty = all modules
OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids
# --- Optional ---
GEMINI_API_KEY: ${GEMINI_API_KEY} # only the twentyq module needs it
# SOURCE_COMMIT is intentionally not declared here. Coolify provides it
# at runtime via its generated env file; declaring it here with Compose
# interpolation can override the runtime value with an empty string.
-1
View File
@@ -33,7 +33,6 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `MODULES` | optional | CSV; empty = all modules |
| `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) |
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
| `GEMINI_API_KEY` | optional | only the `twentyq` module needs it |
| `WC_FOOTBALL_DATA_TOKEN` | optional | football-data.org token for the `wc` module |
**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`,
-25
View File
@@ -6,41 +6,16 @@ require (
github.com/go-telegram/bot v1.20.0
github.com/robfig/cron/v3 v3.0.1
go.mongodb.org/mongo-driver/v2 v2.7.0
golang.org/x/time v0.15.0
google.golang.org/genai v1.56.0
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
-63
View File
@@ -1,44 +1,13 @@
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -50,20 +19,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
@@ -72,8 +27,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -83,8 +36,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -93,21 +44,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE=
google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-98
View File
@@ -1,98 +0,0 @@
package ai
import (
"context"
"errors"
"fmt"
"strings"
"google.golang.org/genai"
)
// chatModel is pinned here rather than exposed to callers — modules should
// not pick their own model. If we ever need to A/B test, a higher-level
// config wins, not a per-module override.
const chatModel = "gemini-2.5-flash" // newest flash; 15 RPM / 1500 RPD free
// ErrRateLimited is returned when the upstream rejected with 429 (or our
// in-process per-user bucket dropped the call). Modules show a friendly
// "AI is rate-limited, try again in N minutes" message on this sentinel.
var ErrRateLimited = errors.New("ai: rate limited")
// ErrNotConfigured is returned when GEMINI_API_KEY was empty at startup.
// The Client is nil in that case; modules using AI must check before
// invoking and refuse the command with a config-error reply.
var ErrNotConfigured = errors.New("ai: GEMINI_API_KEY not set")
// Client wraps a *genai.Client with the small surface the bot needs. The
// underlying gRPC connection is reused across requests so each command does not
// pay a fresh handshake cost.
//
// Safe for concurrent use; *genai.Client is itself goroutine-safe.
type Client struct {
g *genai.Client
}
// NewClient constructs a *Client backed by the Gemini API. A blank apiKey
// returns ErrNotConfigured so callers can decide whether to skip AI-dependent
// module loading.
func NewClient(ctx context.Context, apiKey string) (*Client, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, ErrNotConfigured
}
g, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: apiKey,
Backend: genai.BackendGeminiAPI,
})
if err != nil {
return nil, fmt.Errorf("ai: genai.NewClient: %w", err)
}
return &Client{g: g}, nil
}
// Generate runs a single-turn chat with `system` as the system instruction
// and `user` as the user message. Returns the model's text reply.
//
// The output is capped to keep the twentyq prompt's response budget tight
// (≤200 tokens, single-line JSON). Temperature 0.7 trades a little
// variability for hint freshness.
func (c *Client) Generate(ctx context.Context, system, user string) (string, error) {
if c == nil || c.g == nil {
return "", ErrNotConfigured
}
cfg := &genai.GenerateContentConfig{
Temperature: ptrFloat32(0.7),
MaxOutputTokens: 256,
}
if system != "" {
cfg.SystemInstruction = genai.NewContentFromText(system, genai.RoleUser)
}
resp, err := c.g.Models.GenerateContent(ctx, chatModel, genai.Text(user), cfg)
if err != nil {
if isRateLimit(err) {
return "", ErrRateLimited
}
return "", fmt.Errorf("ai: GenerateContent: %w", err)
}
if resp == nil {
return "", fmt.Errorf("ai: GenerateContent: nil response")
}
return resp.Text(), nil
}
func ptrFloat32(v float32) *float32 { return &v }
// isRateLimit returns true if err looks like a Gemini 429. The genai SDK
// surfaces 429 as a typed error with Code=429 in some paths and as a wrapped
// HTTP status string in others — we sniff both.
func isRateLimit(err error) bool {
if err == nil {
return false
}
var apiErr genai.APIError
if errors.As(err, &apiErr) && apiErr.Code == 429 {
return true
}
msg := err.Error()
return strings.Contains(msg, "429") || strings.Contains(strings.ToLower(msg), "resource_exhausted")
}
-58
View File
@@ -1,58 +0,0 @@
package ai
import (
"sync"
"golang.org/x/time/rate"
)
// PerUserLimiter is a tiny wrapper around x/time/rate.Limiter that gives each
// subject (user-id or chat-id, formatted as a string) its own token bucket.
//
// Why per-user: the Gemini free tier is 15 RPM / 1500 RPD shared across the
// entire bot. A single chatty user could exhaust the daily quota in minutes;
// a soft per-user cap (default 5/min) cushions everyone else.
//
// Why we don't enforce daily caps here: x/time/rate is a token bucket, not
// a fixed-window counter. Per-day caps need a different abstraction; if we
// hit RPD limits in practice we'll add a persistent counter.
//
// Memory bound: the buckets map is never evicted. Each entry costs ~120 B.
// The bot's expected audience is small enough that the distinct-subject working
// set does not justify an LRU. Same rationale as keylock.Map; add eviction if
// either map starts growing materially in production.
type PerUserLimiter struct {
mu sync.Mutex
buckets map[string]*rate.Limiter
r rate.Limit // refill rate per second
burst int // bucket capacity
}
// NewPerUserLimiter returns a limiter that allows `burst` requests
// instantaneously and refills at `perSec` requests per second.
//
// Defaults for the bot: burst=5, perSec=5/60 → 5 reqs in any 60s window.
func NewPerUserLimiter(perSec float64, burst int) *PerUserLimiter {
if burst < 1 {
burst = 1
}
return &PerUserLimiter{
buckets: map[string]*rate.Limiter{},
r: rate.Limit(perSec),
burst: burst,
}
}
// Allow consumes one token from the subject's bucket. Returns false if the
// bucket is empty — caller should reply with a "slow down" message and NOT
// invoke the upstream model.
func (p *PerUserLimiter) Allow(subject string) bool {
p.mu.Lock()
b, ok := p.buckets[subject]
if !ok {
b = rate.NewLimiter(p.r, p.burst)
p.buckets[subject] = b
}
p.mu.Unlock()
return b.Allow()
}
-38
View File
@@ -1,38 +0,0 @@
package ai
import "testing"
func TestPerUserLimiter_BurstThenDrop(t *testing.T) {
// 5 burst, 0 refill — second batch must drop.
l := NewPerUserLimiter(0, 5)
for i := 0; i < 5; i++ {
if !l.Allow("user-1") {
t.Fatalf("burst[%d]: want allow, got drop", i)
}
}
if l.Allow("user-1") {
t.Errorf("post-burst: want drop, got allow")
}
}
func TestPerUserLimiter_PerSubjectIsolated(t *testing.T) {
l := NewPerUserLimiter(0, 1)
if !l.Allow("a") {
t.Fatalf("user a first call dropped")
}
if l.Allow("a") {
t.Errorf("user a second call: want drop")
}
// Different subject must have its own bucket.
if !l.Allow("b") {
t.Errorf("user b first call dropped — buckets not isolated")
}
}
func TestPerUserLimiter_BurstFloor(t *testing.T) {
// burst=0 → floored to 1 so the limiter never permanently blocks.
l := NewPerUserLimiter(0, 0)
if !l.Allow("x") {
t.Errorf("burst=0 floored: want first call allowed")
}
}
-22
View File
@@ -1,22 +0,0 @@
// Package ai wraps Google's genai SDK with a small, mockable surface for the
// twentyq module. The package owns:
//
// - Chatter interface — what modules consume
// - Client struct — production implementation backed by genai
// - per-user rate limiter — defends the shared 1500-RPD Gemini free tier
//
// Modules accept the interface (not *Client) so unit tests can pass fakes
// without spinning up a real Gemini client. Production wiring in cmd/server
// passes the *Client (which satisfies the interface) into Deps.
package ai
import "context"
// Chatter produces a single text completion from a system + user prompt
// pair. Used by the twentyq module's judge + round-start calls.
//
// On rate-limit (HTTP 429) the implementation should return ErrRateLimited
// so callers can show a user-friendly retry message.
type Chatter interface {
Generate(ctx context.Context, system, user string) (string, error)
}
-2
View File
@@ -6,7 +6,6 @@ import (
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/storage"
)
@@ -73,7 +72,6 @@ type Module struct {
type Deps struct {
Store storage.Collection // the module's own collection; build typed views with storage.Typed[T]
Registry *Registry // populated by Build; safe to capture but read-only at module use
Chatter ai.Chatter // nil if GEMINI_API_KEY unset; twentyq must check
Bot *bot.Bot // nil-safe: only crons that fan-out (lolschedule daily push) need it
}
+1 -4
View File
@@ -9,7 +9,6 @@ import (
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/storage"
)
@@ -95,8 +94,7 @@ func (r *Registry) Crons() []Cron {
// Factory's Deps. Adding new optional deps here keeps Build's signature
// stable as the dep list grows.
type BuildOptions struct {
Chatter ai.Chatter
Bot *bot.Bot
Bot *bot.Bot
}
func Build(enabled []string, factories map[string]Factory, provider storage.Provider, opts BuildOptions) (*Registry, error) {
@@ -148,7 +146,6 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
moduleDeps := Deps{
Store: provider.Collection(name),
Registry: reg,
Chatter: opts.Chatter,
Bot: opts.Bot,
}
mod := factory(moduleDeps)
-249
View File
@@ -1,249 +0,0 @@
package twentyq
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"strings"
"sync"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/keylock"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
const (
upstreamFail = "⚠️ AI service hiccup — try again in a few seconds."
notConfig = "⚠️ Twentyq is not configured (missing GEMINI_API_KEY)."
rateLimited = "⚠️ AI is rate-limited. Try again in a minute."
noRound = "No active round. Send <code>/twentyq</code> to start one."
)
type state struct {
games GameStore
stats StatsStore
chatter ai.Chatter
limiter *ai.PerUserLimiter
rngMu sync.Mutex
rng *rand.Rand
locks keylock.Map
}
func (s *state) randomSeed() string {
s.rngMu.Lock()
defer s.rngMu.Unlock()
return seeds[s.rng.IntN(len(seeds))]
}
// fallbackRoundStart returns a generic category + hint pair the round can
// start with when the LLM call fails or returns unparseable output.
func fallbackRoundStart() (string, string) {
return "object", "it is something you might encounter in everyday life"
}
func (s *state) startFreshGame(ctx context.Context) (*GameState, error) {
target := s.randomSeed()
prompt := buildStartRoundPrompt(target)
resp, err := s.chatter.Generate(ctx, prompt, "begin")
if err != nil {
return nil, err
}
cat, hint, ok := parseRoundStart(parseJSON(resp))
if !ok {
log.Warn("twentyq roundstart unparseable", "preview", truncate(resp, 200))
cat, hint = fallbackRoundStart()
}
hint = redactSecret(hint, target)
now := chathelper.NowMillis()
return &GameState{
Category: cat,
Target: target,
InitialHint: hint,
StartedAt: &now,
Solved: false,
Turns: []Turn{},
}, nil
}
func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
if s.chatter == nil {
return chathelper.Reply(ctx, b, msg, notConfig)
}
defer s.locks.Acquire(subject)()
arg := chathelper.ArgAfterCommand(msg.Text)
game, err := loadGame(ctx, s.games, subject)
if err != nil {
return err
}
// Solved-but-lingering rounds → start fresh transparently rather than
// reject the user with "round already solved".
if game != nil && game.Solved {
// Best-effort delete: a hard failure here means the solved-state
// branch re-enters every call and the user is stuck. Log but keep
// going — the fresh-round write below will overwrite the slot
// regardless.
if err := clearGame(ctx, s.games, subject); err != nil {
log.Warn("twentyq clearGame on solved-relaunch failed", "subject", subject, "err", err)
}
game = nil
}
if game == nil {
if s.limiter != nil && !s.limiter.Allow(subject) {
return chathelper.Reply(ctx, b, msg, "⏳ Slow down — too many requests in a short window.")
}
fresh, err := s.startFreshGame(ctx)
if err != nil {
if errors.Is(err, ai.ErrRateLimited) {
return chathelper.Reply(ctx, b, msg, rateLimited)
}
log.Warn("twentyq roundstart failed", "err", err)
return chathelper.Reply(ctx, b, msg, upstreamFail)
}
if err := saveGame(ctx, s.games, subject, fresh); err != nil {
return err
}
if arg == "" {
return chathelper.ReplyHTML(ctx, b, msg, formatIntro(*fresh))
}
// Fresh round + immediate question — show intro then process turn.
if err := chathelper.ReplyHTML(ctx, b, msg, formatIntro(*fresh)); err != nil {
return err
}
return s.submitTurn(ctx, b, msg, subject, fresh, arg)
}
if arg == "" {
return chathelper.ReplyHTML(ctx, b, msg, formatBoard(*game))
}
return s.submitTurn(ctx, b, msg, subject, game, arg)
}
func (s *state) submitTurn(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, raw string) error {
v := validateQuestion(raw)
if !v.OK {
return chathelper.ReplyHTML(ctx, b, msg, v.Reason)
}
lower := strings.ToLower(v.Normalized)
for _, t := range game.Turns {
if strings.ToLower(t.Text) == lower {
return chathelper.Reply(ctx, b, msg,
"🔁 You already asked that exact question — try a new angle.")
}
}
if s.limiter != nil && !s.limiter.Allow(subject) {
return chathelper.Reply(ctx, b, msg, "⏳ Slow down — too many turns in a short window.")
}
system := buildSystemPrompt(*game)
resp, err := s.chatter.Generate(ctx, system, v.Normalized)
if err != nil {
if errors.Is(err, ai.ErrRateLimited) {
return chathelper.Reply(ctx, b, msg, rateLimited)
}
log.Warn("twentyq judge failed", "err", err)
return chathelper.Reply(ctx, b, msg, upstreamFail)
}
payload := parseJSON(resp)
if payload == nil {
log.Warn("twentyq judge unparseable", "preview", truncate(resp, 200))
}
jud := normalizeJudgement(payload)
jud.Hint = redactSecret(jud.Hint, game.Target)
turn := Turn{
Text: v.Normalized,
IsGuess: jud.IsGuess,
Answer: jud.Answer,
Hint: jud.Hint,
TS: chathelper.NowMillis(),
}
game.Turns = append(game.Turns, turn)
won := turn.IsGuess && turn.Answer == "yes"
if won {
game.Solved = true
count := len(game.Turns)
if _, err := recordResult(ctx, s.stats, subject, true, count, chathelper.NowMillis()); err != nil {
return err
}
if err := clearGame(ctx, s.games, subject); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg,
formatTurnReply(turn, true, game.Target, count))
}
if err := saveGame(ctx, s.games, subject, game); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg,
formatTurnReply(turn, false, game.Target, len(game.Turns)))
}
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
game, err := loadGame(ctx, s.games, subject)
if err != nil {
return err
}
if game == nil {
return chathelper.ReplyHTML(ctx, b, msg, noRound)
}
if _, err := recordResult(ctx, s.stats, subject, false, len(game.Turns), chathelper.NowMillis()); err != nil {
return err
}
if err := clearGame(ctx, s.games, subject); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg, formatGiveup(*game))
}
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
st, err := loadStats(ctx, s.stats, subject)
if err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg, formatStats(*st))
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return fmt.Sprintf("%s…", s[:n])
}
-108
View File
@@ -1,108 +0,0 @@
package twentyq
import (
"context"
"strings"
"testing"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// scriptedChatter returns canned responses by call index. Tests script the
// exact sequence the handler will see (round-start then judge per turn).
type scriptedChatter struct {
responses []string
err error
calls int
}
func (s *scriptedChatter) Generate(_ context.Context, _, _ string) (string, error) {
if s.err != nil {
return "", s.err
}
if s.calls >= len(s.responses) {
return "{}", nil
}
r := s.responses[s.calls]
s.calls++
return r, nil
}
func install(t *testing.T, c ai.Chatter) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"twentyq"},
map[string]modules.Factory{"twentyq": New},
storage.NewMemoryProvider(),
modules.BuildOptions{Chatter: c})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
func TestTwentyq_NoChatterRefuses(t *testing.T) {
rb := install(t, nil)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
last := rb.LastSent().Text()
if !strings.Contains(last, "GEMINI_API_KEY") {
t.Errorf("missing-key warning: got %q", last)
}
}
func TestTwentyq_FreshRoundShowsIntro(t *testing.T) {
c := &scriptedChatter{responses: []string{
`{"category":"animal","initialHint":"a cryptic clue"}`,
}}
rb := install(t, c)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
last := rb.LastSent().Text()
if !strings.Contains(last, "animal") || !strings.Contains(last, "cryptic clue") {
t.Errorf("intro: got %q", last)
}
}
func TestTwentyq_RoundStartFallback(t *testing.T) {
c := &scriptedChatter{responses: []string{"unparseable garbage"}}
rb := install(t, c)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
last := rb.LastSent().Text()
// Fallback category=object, initialHint=everyday-life.
if !strings.Contains(last, "object") || !strings.Contains(last, "everyday life") {
t.Errorf("fallback intro: got %q", last)
}
}
func TestTwentyq_GiveupNoActiveRound(t *testing.T) {
c := &scriptedChatter{}
rb := install(t, c)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq_giveup"))
last := rb.LastSent().Text()
if !strings.Contains(last, "No active round") {
t.Errorf("giveup-no-round: got %q", last)
}
}
func TestTwentyq_StatsEmpty(t *testing.T) {
c := &scriptedChatter{}
rb := install(t, c)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq_stats"))
last := rb.LastSent().Text()
if !strings.Contains(last, "No twentyq games") {
t.Errorf("empty stats: got %q", last)
}
}
func TestTwentyq_RateLimitedRoundStart(t *testing.T) {
c := &scriptedChatter{err: ai.ErrRateLimited}
rb := install(t, c)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/twentyq"))
last := rb.LastSent().Text()
if !strings.Contains(last, "rate-limited") {
t.Errorf("rate-limited reply: got %q", last)
}
}
-128
View File
@@ -1,128 +0,0 @@
package twentyq
import (
"encoding/json"
"regexp"
"strings"
)
// Judgement is the canonical shape after parse + normalize.
// Answer ∈ {"yes","no"}.
type Judgement struct {
IsGuess bool `json:"is_guess"`
Answer string `json:"answer"`
Hint string `json:"hint"`
}
// RoundStart is the LLM's reply for the round-start prompt.
type RoundStart struct {
Category string `json:"category"`
InitialHint string `json:"initialHint"`
}
const defaultHint = "I couldn't fully parse that — try a clear yes/no question."
// fenceRe strips ```json / ``` code fences if the model disobeys the prompt.
var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
// parseJSON returns the first balanced {...} JSON object found in `text`.
// Returns nil on parse failure — tolerant by design since LLM output is
// untrusted and we'd rather fall back to defaults than 500.
func parseJSON(text string) map[string]any {
if text == "" {
return nil
}
unfenced := strings.ReplaceAll(fenceRe.ReplaceAllString(text, ""), "```", "")
start := strings.IndexByte(unfenced, '{')
if start < 0 {
return nil
}
depth := 0
inString := false
escaped := false
for i := start; i < len(unfenced); i++ {
ch := unfenced[i]
if inString {
switch {
case escaped:
escaped = false
case ch == '\\':
escaped = true
case ch == '"':
inString = false
}
continue
}
switch ch {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
slice := unfenced[start : i+1]
var out map[string]any
if err := json.Unmarshal([]byte(slice), &out); err != nil {
return nil
}
return out
}
}
}
return nil
}
// normalizeJudgement coerces a parsed payload to the canonical Judgement
// shape with safe defaults (answer="no", hint=defaultHint, is_guess=false).
func normalizeJudgement(payload map[string]any) Judgement {
out := Judgement{Answer: "no", Hint: defaultHint}
if payload == nil {
return out
}
if v, ok := payload["is_guess"].(bool); ok {
out.IsGuess = v
}
if v, ok := payload["answer"].(string); ok {
if strings.EqualFold(v, "yes") {
out.Answer = "yes"
}
}
if v, ok := payload["hint"].(string); ok {
if t := strings.TrimSpace(v); t != "" {
out.Hint = t
}
}
return out
}
// redactSecret blanks any whole-word case-insensitive match of `target` in
// `hint`. Defense-in-depth — the prompt forbids it, but never trust the
// model. Word boundaries: ASCII-letter neighbours.
func redactSecret(hint, target string) string {
if target == "" {
return hint
}
pattern := `(?i)\b` + regexp.QuoteMeta(target) + `\b`
re, err := regexp.Compile(pattern)
if err != nil {
return hint
}
return re.ReplaceAllString(hint, "(redacted)")
}
// parseRoundStart returns (category, initialHint, ok). ok=false on any
// failure so the caller can substitute fallbacks rather than serve garbage.
func parseRoundStart(payload map[string]any) (string, string, bool) {
if payload == nil {
return "", "", false
}
cat, _ := payload["category"].(string)
hint, _ := payload["initialHint"].(string)
cat = strings.TrimSpace(cat)
hint = strings.TrimSpace(hint)
if cat == "" || hint == "" {
return "", "", false
}
return cat, hint, true
}
-122
View File
@@ -1,122 +0,0 @@
package twentyq
import "testing"
func TestParseJSON_Plain(t *testing.T) {
got := parseJSON(`{"is_guess": true, "answer": "yes", "hint": "ok"}`)
if got == nil {
t.Fatal("parseJSON returned nil")
}
if got["is_guess"] != true || got["answer"] != "yes" {
t.Errorf("got %+v", got)
}
}
func TestParseJSON_WithFences(t *testing.T) {
in := "```json\n{\"is_guess\": false, \"answer\": \"no\", \"hint\": \"x\"}\n```"
got := parseJSON(in)
if got == nil {
t.Fatalf("parseJSON returned nil for fenced input")
}
if got["answer"] != "no" {
t.Errorf("got %+v", got)
}
}
func TestParseJSON_PreNoise(t *testing.T) {
in := "Sure! Here's the json: {\"is_guess\": false, \"answer\": \"yes\", \"hint\": \"clue\"}"
got := parseJSON(in)
if got == nil || got["answer"] != "yes" {
t.Errorf("parseJSON pre-noise: got %+v", got)
}
}
func TestParseJSON_Malformed(t *testing.T) {
if got := parseJSON("not json at all"); got != nil {
t.Errorf("malformed input returned %+v, want nil", got)
}
if got := parseJSON(""); got != nil {
t.Errorf("empty input returned %+v, want nil", got)
}
if got := parseJSON("{ unclosed"); got != nil {
t.Errorf("unclosed brace returned %+v", got)
}
}
func TestNormalizeJudgement_Defaults(t *testing.T) {
j := normalizeJudgement(nil)
if j.Answer != "no" || j.IsGuess || j.Hint == "" {
t.Errorf("nil payload: got %+v", j)
}
// "YES" lowercased; missing hint → default; bad is_guess type → false.
j = normalizeJudgement(map[string]any{
"is_guess": "true", // wrong type intentionally
"answer": "YES",
"hint": " ",
})
if j.IsGuess {
t.Errorf("string is_guess should not coerce to true: %+v", j)
}
if j.Answer != "yes" {
t.Errorf("answer YES: got %q, want yes", j.Answer)
}
if j.Hint == "" {
t.Errorf("blank hint should fall back to default")
}
}
func TestRedactSecret(t *testing.T) {
cases := []struct {
hint, target, want string
}{
{"this is a guitar", "guitar", "this is a (redacted)"},
{"GUITAR is a thing", "guitar", "(redacted) is a thing"},
{"guitarist works here", "guitar", "guitarist works here"}, // word boundary
{"clean hint", "guitar", "clean hint"},
{"empty target", "", "empty target"},
}
for _, c := range cases {
got := redactSecret(c.hint, c.target)
if got != c.want {
t.Errorf("redactSecret(%q,%q) = %q, want %q", c.hint, c.target, got, c.want)
}
}
}
func TestParseRoundStart(t *testing.T) {
cat, hint, ok := parseRoundStart(map[string]any{
"category": " instrument ",
"initialHint": " a clue ",
})
if !ok || cat != "instrument" || hint != "a clue" {
t.Errorf("got cat=%q hint=%q ok=%v", cat, hint, ok)
}
if _, _, ok := parseRoundStart(nil); ok {
t.Errorf("nil should not parse")
}
if _, _, ok := parseRoundStart(map[string]any{"category": ""}); ok {
t.Errorf("empty category should not parse")
}
}
func TestValidateQuestion(t *testing.T) {
cases := []struct {
raw string
wantOK bool
}{
{"is it big?", true},
{" is it round ?", true},
{" ab", false}, // too short
{string(make([]byte, 250)), false}, // too long
{"What color is it?", false}, // open-ended
{"why is it heavy?", false}, // open-ended
{"", false},
}
for _, c := range cases {
v := validateQuestion(c.raw)
if v.OK != c.wantOK {
t.Errorf("validate(%q) ok=%v, want %v (reason=%s)", c.raw, v.OK, c.wantOK, v.Reason)
}
}
}
-85
View File
@@ -1,85 +0,0 @@
package twentyq
import (
"fmt"
"strings"
)
const historyWindow = 5
// buildSystemPrompt: per-turn judge prompt. The LLM is instructed to emit
// one-line JSON; parser.go does the parse.
func buildSystemPrompt(g GameState) string {
recent := g.Turns
if len(recent) > historyWindow {
recent = recent[len(recent)-historyWindow:]
}
var historyText string
if len(recent) == 0 {
historyText = "(no questions yet)"
} else {
var lines []string
for i, t := range recent {
lines = append(lines, fmt.Sprintf("%d. Q: %s\n A: %s. Hint: %s", i+1, t.Text, t.Answer, t.Hint))
}
historyText = strings.Join(lines, "\n")
}
return fmt.Sprintf(`You are the judge for a "20 questions" reverse-Akinator game.
The user is trying to guess a secret object. You must answer truthfully based on what the secret actually is.
Secret object: "%s"
Category: %s
Initial hint already given: %s
Question history so far:
%s
The user will send a single message — either a yes/no question (e.g. "is it big?", "does it have wheels?") or a final guess of a specific noun (e.g. "is it an organ?", "is it a piano?").
You MUST reply with exactly ONE line of JSON and NOTHING else — no prose, no backticks, no code fences, no explanation.
Schema:
{"is_guess": boolean, "answer": "yes" | "no", "hint": string}
Field meanings:
- is_guess: true ONLY when the user is naming a specific concrete object equal to, a synonym of, or extremely close to the secret. Vague descriptors ("is it big?", "is it round?") are NOT guesses. Saying "is it a string instrument?" when the secret is "guitar" is NOT a guess (too broad). Saying "is it a guitar?" IS a guess.
- answer: truthful "yes" or "no" about the secret.
* If is_guess is true: "yes" only if the named object matches the secret (allowing for synonyms / minor wording). Otherwise "no".
* If is_guess is false: "yes" or "no" based on whether the property holds for the secret.
- hint: a cryptic clue in plain text, max 120 characters. Must be TRUE about the secret but phrased indirectly. Vary from prior hints. Never include the secret word, its plural, its base form, or any obvious category word.
HINT STYLE — the point of a good hint:
- Be INDIRECT. Think riddle, metaphor, oblique association — not a definition.
- Use partial, lateral, or sideways facts. Hint at ONE small property at a time.
- Prefer "it is often found near X", "people tend to associate it with Y", "a famous one lives in Z" over "it is used for X".
- Avoid giving a second clear category word. If the user has narrowed it down with questions, DO NOT hand them the final word.
- Aim for: player thinks "interesting, but I still need another question." NOT: "oh it's obviously X."
Rules:
- Output ONLY the JSON line. No markdown fences. No prose before or after.
- If the user input is not a valid yes/no question and not a guess, still return JSON with answer="no", is_guess=false, and a cryptic hint nudging them to rephrase as yes/no.`,
g.Target, g.Category, g.InitialHint, historyText)
}
// buildStartRoundPrompt: round-start prompt. Expects {"category", "initialHint"}.
func buildStartRoundPrompt(target string) string {
return fmt.Sprintf(`You are opening a new round of the "20 questions" reverse-Akinator game.
Secret object: "%s"
Your job: emit ONE line of JSON with these two fields:
{"category": "<short broad category>", "initialHint": "<cryptic opening clue>"}
Category rules:
- A SHORT, COMMON category word a player can start narrowing from (e.g. "instrument", "animal", "food", "vehicle", "sport", "household item", "tool", "clothing", "plant"). Prefer single words.
- Broad enough that many objects fall under it — NOT a narrow sub-category (don't say "brass instrument", say "instrument").
- Do not include the secret word in the category.
Initial hint rules (same HINT STYLE as the main game):
- Max 120 characters. TRUE about the secret. Indirect, oblique, riddle-like.
- Never include the secret word, its plural, its base form, or an obvious category synonym.
- Hint at ONE lateral property — a cultural association, a habitat, a use context, a historical fact.
- Player should think "ok that narrows it a bit" NOT "oh that's obviously X".
Output ONLY the JSON line. No fences, no prose.`, target)
}
-111
View File
@@ -1,111 +0,0 @@
package twentyq
import (
"fmt"
"html"
"strings"
)
const maxTurnRows = 10
func emojiFor(answer string) string {
switch answer {
case "yes":
return "✅"
case "no":
return "❌"
default:
return "❓"
}
}
func formatIntro(g GameState) string {
return strings.Join([]string{
fmt.Sprintf("🎯 I'm thinking of <b>a %s</b>.", html.EscapeString(g.Category)),
"Hint: " + html.EscapeString(g.InitialHint),
"",
"Ask yes/no questions with <code>/twentyq is it ...?</code>",
}, "\n")
}
func formatTurnReply(t Turn, solved bool, target string, turnCount int) string {
if solved {
return fmt.Sprintf("🎉 Correct! It was <b>%s</b>.\nSolved in %d question%s.",
html.EscapeString(target), turnCount, plural(turnCount))
}
emoji := emojiFor(t.Answer)
if t.IsGuess {
return fmt.Sprintf("%s Not quite. Hint: %s", emoji, html.EscapeString(t.Hint))
}
yn := "No"
if t.Answer == "yes" {
yn = "Yes"
}
return fmt.Sprintf("%s %s. Hint: %s", emoji, yn, html.EscapeString(t.Hint))
}
func formatBoard(g GameState) string {
header := fmt.Sprintf("🎯 Category: <b>%s</b>", html.EscapeString(g.Category))
intro := "Initial hint: " + html.EscapeString(g.InitialHint)
if len(g.Turns) == 0 {
return strings.Join([]string{header, intro, "", "<i>No questions yet — go ahead and ask one.</i>"}, "\n")
}
recent := g.Turns
startNo := 1
if len(recent) > maxTurnRows {
startNo = len(g.Turns) - maxTurnRows + 1
recent = recent[len(recent)-maxTurnRows:]
}
var lines []string
for i, t := range recent {
num := fmt.Sprintf("%2d", startNo+i)
lines = append(lines, fmt.Sprintf("%s. %s <b>%s</b>\n %s",
num, emojiFor(t.Answer), html.EscapeString(t.Text), html.EscapeString(t.Hint)))
}
body := strings.Join([]string{header, intro, "", strings.Join(lines, "\n")}, "\n")
if hidden := len(g.Turns) - len(recent); hidden > 0 {
body += fmt.Sprintf("\n…%d earlier turn%s hidden.", hidden, pluralS(hidden))
}
return body
}
func formatGiveup(g GameState) string {
return strings.Join([]string{
fmt.Sprintf("🏳️ Gave up. The answer was <b>%s</b>.", html.EscapeString(g.Target)),
"Send <code>/twentyq</code> to start a fresh round.",
}, "\n")
}
func formatStats(s Stats) string {
if s.Played == 0 {
return "No twentyq games played yet."
}
solveRate := 0
if s.Played > 0 {
solveRate = (s.Solved*200 + s.Played) / (2 * s.Played)
}
avg := "—"
if s.Played > 0 {
avg = fmt.Sprintf("%d", (s.TotalTurns*2+s.Played)/(2*s.Played))
}
best := "—"
if s.BestTurnCount != nil {
best = fmt.Sprintf("%d", *s.BestTurnCount)
}
return fmt.Sprintf("🎯 <b>Twentyq stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal questions: %d\nFewest to solve: %s\nAvg per round: %s",
s.Played, s.Solved, solveRate, s.TotalTurns, best, avg)
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}
-57
View File
@@ -1,57 +0,0 @@
package twentyq
// seeds is the seed pool. The LLM derives category + initial hint per round
// so no metadata table is needed. Keep entries:
// - single concrete nouns (no abstractions, no proper nouns, no plurals)
// - distinguishable to a player with general knowledge
// - ASCII-only so prompt-engineered locale traps don't bias the model
//
// Pool size matters because random selection is uniform without exclusion —
// at N=50 a power user sees a repeat within ~9 plays (birthday paradox); at
// N=200 the same threshold moves to ~17. This list is deliberately wide
// across categories to reduce thematic clustering when the model picks hints.
var seeds = []string{
// instruments
"guitar", "piano", "drum", "violin", "flute", "trumpet", "organ", "harmonica",
"saxophone", "cello", "accordion", "banjo", "xylophone", "clarinet", "tambourine",
"harp", "ukulele", "bagpipes",
// animals — land
"elephant", "kangaroo", "tiger", "horse", "snake", "owl", "wolf", "rabbit",
"giraffe", "hedgehog", "rhinoceros", "panda", "cheetah", "gorilla", "raccoon",
"squirrel", "platypus", "bat", "armadillo", "sloth",
// animals — sea + air
"dolphin", "eagle", "octopus", "penguin", "shark", "whale", "starfish", "crab",
"jellyfish", "lobster", "parrot", "hummingbird", "flamingo", "ostrich", "seahorse",
// food
"pizza", "sushi", "burger", "ramen", "taco", "pho", "curry", "salad",
"chocolate", "cheese", "pancake", "waffle", "donut", "croissant", "dumpling",
"sandwich", "lasagna", "popcorn", "kimchi", "biryani",
// fruits + vegetables
"banana", "pineapple", "watermelon", "strawberry", "mango", "avocado", "coconut",
"pomegranate", "broccoli", "carrot", "cucumber", "pumpkin", "garlic", "onion",
// vehicles
"bicycle", "car", "airplane", "boat", "train", "motorcycle", "helicopter",
"submarine", "tractor", "skateboard", "scooter", "rocket", "ambulance", "bulldozer",
// sports + games
"soccer", "basketball", "tennis", "swimming", "boxing", "golf", "chess", "skiing",
"badminton", "cricket", "rugby", "bowling", "surfing", "archery", "fencing",
// household items
"refrigerator", "microwave", "vacuum", "toaster", "kettle", "blender", "lamp",
"sofa", "mirror", "broom", "umbrella", "scissors", "telephone", "clock",
"hammer", "screwdriver", "ladder", "candle", "flashlight",
// nature + places
"mountain", "volcano", "desert", "glacier", "waterfall", "island", "forest",
"beach", "cave", "river",
// space
"telescope", "asteroid", "comet", "satellite", "galaxy",
// occupations
"firefighter", "astronaut", "chef", "carpenter", "librarian", "dentist",
"photographer", "scientist", "lifeguard", "magician",
// tools + tech
"camera", "compass", "binoculars", "keyboard", "headphones",
"microscope", "printer", "stapler",
// clothing
"sweater", "sneakers", "scarf", "helmet", "gloves", "raincoat",
// drinks
"coffee", "tea", "lemonade", "smoothie",
}
-104
View File
@@ -1,104 +0,0 @@
package twentyq
import (
"context"
"errors"
"fmt"
"github.com/tiennm99/miti99bot/internal/storage"
)
// Turn is one Q&A entry stored in the game's history.
type Turn struct {
Text string `json:"text" bson:"text"`
IsGuess bool `json:"isGuess" bson:"isGuess"`
Answer string `json:"answer" bson:"answer"` // "yes" | "no"
Hint string `json:"hint" bson:"hint"`
TS int64 `json:"ts" bson:"ts"`
}
type GameState struct {
Category string `json:"category" bson:"category"`
Target string `json:"target" bson:"target"`
InitialHint string `json:"initialHint" bson:"initialHint"`
StartedAt *int64 `json:"startedAt" bson:"startedAt"`
Solved bool `json:"solved" bson:"solved"`
Turns []Turn `json:"turns" bson:"turns"`
}
type Stats struct {
Played int `json:"played" bson:"played"`
Solved int `json:"solved" bson:"solved"`
TotalTurns int `json:"totalTurns" bson:"totalTurns"`
BestTurnCount *int `json:"bestTurnCount" bson:"bestTurnCount"`
LastResultAt *int64 `json:"lastResultAt" bson:"lastResultAt"`
}
// GameStore is the twentyq module's typed game store.
type GameStore = storage.DocStore[GameState]
// StatsStore is the twentyq module's typed stats store.
type StatsStore = storage.DocStore[Stats]
func gameKey(subject string) string { return "game:" + subject }
func statsKey(subject string) string { return "stats:" + subject }
func loadGame(ctx context.Context, games GameStore, subject string) (*GameState, error) {
g, _, err := games.Get(ctx, gameKey(subject))
switch {
case err == nil:
return &g, nil
case errors.Is(err, storage.ErrNotFound):
return nil, nil
default:
return nil, fmt.Errorf("twentyq loadGame: %w", err)
}
}
func saveGame(ctx context.Context, games GameStore, subject string, g *GameState) error {
if err := games.Put(ctx, gameKey(subject), *g); err != nil {
return fmt.Errorf("twentyq saveGame: %w", err)
}
return nil
}
func clearGame(ctx context.Context, games GameStore, subject string) error {
if err := games.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("twentyq clearGame: %w", err)
}
return nil
}
func loadStats(ctx context.Context, st StatsStore, subject string) (*Stats, error) {
s, _, err := st.Get(ctx, statsKey(subject))
switch {
case err == nil:
return &s, nil
case errors.Is(err, storage.ErrNotFound):
return &Stats{}, nil
default:
return nil, fmt.Errorf("twentyq loadStats: %w", err)
}
}
func recordResult(ctx context.Context, st StatsStore, subject string, solved bool, turnCount int, nowMillis int64) (*Stats, error) {
s, err := loadStats(ctx, st, subject)
if err != nil {
return nil, err
}
s.Played++
s.TotalTurns += turnCount
if solved {
s.Solved++
if s.BestTurnCount == nil || turnCount < *s.BestTurnCount {
tc := turnCount
s.BestTurnCount = &tc
}
}
now := nowMillis
s.LastResultAt = &now
if err := st.Put(ctx, statsKey(subject), *s); err != nil {
return nil, fmt.Errorf("twentyq recordResult: %w", err)
}
return s, nil
}
-54
View File
@@ -1,54 +0,0 @@
package twentyq
import (
"crypto/rand"
"encoding/binary"
mrand "math/rand/v2"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
// New is the twentyq module Factory. If Deps.Chatter is nil
// (GEMINI_API_KEY unset) the module loads but every command replies with a
// config-error message — keeps the rest of the bot functional.
func New(deps modules.Deps) modules.Module {
s := &state{
games: storage.Typed[GameState](deps.Store),
stats: storage.Typed[Stats](deps.Store),
chatter: deps.Chatter,
limiter: ai.NewPerUserLimiter(5.0/60.0, 5),
rng: newRNG(),
}
return modules.Module{
Commands: []modules.Command{
{
Name: "twentyq",
Visibility: modules.VisibilityPublic,
Description: "20 questions — bot picks an object, you ask yes/no questions",
Handler: s.handleTwentyq,
},
{
Name: "twentyq_giveup",
Visibility: modules.VisibilityPublic,
Description: "Reveal the current twentyq answer (auto-starts a fresh round)",
Handler: s.handleGiveup,
},
{
Name: "twentyq_stats",
Visibility: modules.VisibilityPublic,
Description: "Show your twentyq stats (played, solved, best round)",
Handler: s.handleStats,
},
},
}
}
func newRNG() *mrand.Rand {
var seed [16]byte
_, _ = rand.Read(seed[:])
s1 := binary.LittleEndian.Uint64(seed[0:8])
s2 := binary.LittleEndian.Uint64(seed[8:16])
return mrand.New(mrand.NewPCG(s1, s2))
}
-44
View File
@@ -1,44 +0,0 @@
package twentyq
import (
"regexp"
"strings"
)
const (
minLen = 3
maxLen = 200
)
// openEndedRe rejects open-ended questions before spending a Gemini call.
// The prefix list is intentionally narrow (canonical interrogatives only)
// to avoid blocking borderline yes/no phrasings.
var openEndedRe = regexp.MustCompile(`(?i)^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b`)
// ValidateResult is the public outcome of validateQuestion. Either OK + the
// normalized form, or !OK + a user-visible reason (HTML allowed).
type ValidateResult struct {
OK bool
Normalized string
Reason string
}
// validateQuestion strips/collapses whitespace and rejects too-short,
// too-long, or open-ended inputs. Reason strings carry HTML <code> markup
// so the dispatcher's ReplyHTML wrapping is required for those branches.
func validateQuestion(raw string) ValidateResult {
if raw == "" {
return ValidateResult{Reason: "Please send a yes/no question after the command."}
}
collapsed := strings.Join(strings.Fields(raw), " ")
if len(collapsed) < minLen {
return ValidateResult{Reason: "Question too short — try something like <code>is it big?</code>."}
}
if len(collapsed) > maxLen {
return ValidateResult{Reason: "Question too long — keep it under 200 characters."}
}
if openEndedRe.MatchString(collapsed) {
return ValidateResult{Reason: "Yes/no questions only — try <code>is it ...?</code> or <code>does it ...?</code>."}
}
return ValidateResult{OK: true, Normalized: collapsed}
}
-12
View File
@@ -80,18 +80,6 @@
"command": "wc_unsubscribe",
"description": "Stop the daily World Cup schedule digest"
},
{
"command": "twentyq",
"description": "20 questions; ask yes/no questions"
},
{
"command": "twentyq_giveup",
"description": "Reveal the current twentyq answer"
},
{
"command": "twentyq_stats",
"description": "Show your twentyq stats"
},
{
"command": "stock_topup",
"description": "Top up VND to your stock account"