mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
refactor(modules): remove twentyq genai integration
This commit is contained in:
1 parent
20dc03c6d2
commit
9c998a0e2f
24 files changed
+2
-1413
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user