From f60b6614441dc65abbddcc9f83d6c1bb0d005980 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 9 Oct 2026 16:42:31 +0700 Subject: [PATCH] feat(noitu): send the separate noitubot game from /noitubot /noitubot now sends its own BotFather game, noitubot, so players can tell a solo game from a /noitu room card. Both games share the Play handler and page. A noitubot Play press skips the card lookup and always plays solo. --- README.md | 2 +- docs/noitu-game.md | 21 +++++++----- internal/modules/noitu/callback.go | 7 ++-- internal/modules/noitu/command.go | 9 ++--- internal/modules/noitu/noitu.go | 44 +++++++++---------------- internal/modules/noitu/noitu_test.go | 4 +-- internal/modules/noitu/pvp_card_test.go | 4 +++ 7 files changed, 44 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 54abfcf..121a580 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Atlas via long polling and an in-process cron scheduler. | `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`), `/genshin` (unlisted; the same wish as a Genshin-style meteor). The animations use the bundled [renderer](renderer/README.md) service | | `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only | | `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | -| `noitu` | Nối từ HTML5 game against the other members of a group (`/noitu`, groups only: everyone who presses Play on that card joins one room, last player standing wins; in a private chat it points to `/noitubot`), or vs the bot (`/noitubot`); `/noitutop` shows the group's leaderboard of room games (wins, best game, games played). Needs `GAME_BASE_URL`. Play opens a page this bot serves; words are checked against a Vietnamese dictionary derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) (CC BY-SA 4.0, see [its attribution](internal/modules/noitu/dict/data/ATTRIBUTION.md)), and scores go to Telegram's high-score table. See [docs/noitu-game.md](docs/noitu-game.md) | +| `noitu` | Nối từ HTML5 game against the other members of a group (`/noitu`, groups only: everyone who presses Play on that card joins one room, last player standing wins; in a private chat it points to `/noitubot`), or vs the bot (`/noitubot`, its own `noitubot` BotFather game); `/noitutop` shows the group's leaderboard of room games (wins, best game, games played). Needs `GAME_BASE_URL`. Play opens a page this bot serves; words are checked against a Vietnamese dictionary derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) (CC BY-SA 4.0, see [its attribution](internal/modules/noitu/dict/data/ATTRIBUTION.md)), and scores go to Telegram's high-score table. See [docs/noitu-game.md](docs/noitu-game.md) | | `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) | | `lol` | Pro-match schedule (`/lol [date]`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`), per-chat digest opt-in (`/lol_subscribe`, `/lol_unsubscribe`) + daily push at 08:00 ICT | | `stock` | VN-stocks paper trading: `/stock_price`, `/stock_info`, `/stock_events`, `/stock_topup`, `/stock_buy`, `/stock_sell`, `/stock_cash_dividend`, `/stock_share_dividend`, `/stock_portfolio` | diff --git a/docs/noitu-game.md b/docs/noitu-game.md index 25d44ef..a5efbf0 100644 --- a/docs/noitu-game.md +++ b/docs/noitu-game.md @@ -6,8 +6,8 @@ three commands: - `/noitu` sends the game into a group as a room card: the members who press Play on it play each other. See [Playing together](#playing-together-noitu). It works only in groups; elsewhere it answers with a hint (see below). -- `/noitubot` sends the game for a solo game against the bot, in a group or a - private chat. +- `/noitubot` sends the separate `noitubot` game for a solo game against the + bot, in a group or a private chat. - `/noitutop` shows the group's leaderboard across its room games. See [Leaderboard](#leaderboard-noitutop). @@ -24,8 +24,9 @@ and its dictionary is embedded in the binary. ## Setup -1. **BotFather:** create a game for the bot with `/newgame` and the short name - `noitu`. The share link is then `t.me/?game=noitu`. +1. **BotFather:** create two games for the bot with `/newgame`, short names + `noitu` (the group card) and `noitubot` (the solo game). Both open the same + page; separate games let players tell a room card from a solo game. 2. **Public URL:** attach an HTTPS domain to the bot container's port 8080. In Coolify, set the `bot` service's domain to `https://noitu.example.com:8080`. The proxy terminates TLS and forwards to the container. @@ -54,7 +55,7 @@ also disables the game. So is a `NOITU_GAME_SECRET` shorter than 32 bytes. button itself. In a forum topic the game stays in the topic. In a channel the bot answers with a short text instead, because Telegram does not allow games in channels. -2. Play delivers a callback query with `game_short_name=noitu`. The module +2. Play delivers a callback query with `game_short_name=noitubot`. The module answers it with a URL: `/games/noitu/?t=`. The token is signed, expires after 6 hours, and names the player and the game message. The game message is either a chat message (`chat_id` + `message_id`) or, for @@ -211,8 +212,8 @@ Errors are `{"error","message"}`: `/noitu` works in groups and supergroups only. In a private chat or a channel it does not send a game; it answers "Gửi /noitu trong nhóm để chơi cùng nhau. -Muốn chơi với bot thì dùng /noitubot nhé." It sends the same -BotFather game, `noitu`, and records the sent message as a **card** in the +Muốn chơi với bot thì dùng /noitubot nhé." It sends the +BotFather game `noitu`, and records the sent message as a **card** in the module's storage collection (`noitu`, key `pvp::`, with the forum topic). If the card cannot be recorded, the bot deletes it again and answers "Không tạo được phòng nối từ. Thử lại sau nhé." @@ -222,7 +223,9 @@ answers "Không tạo được phòng nối từ. Thử lại sau nhé." start screen. The server checks the flag again: `api/start` refuses a PvP token and `api/room/join` refuses any other. - A forwarded copy of a card is a different message, and a `?game=` share is - an inline message; neither is a card, so both play against the bot. + an inline message; neither is a card, so both play against the bot. A + `noitubot` game is never looked up as a card and always plays solo, as do + `noitu` games sent before the split. - A card stays a room for 30 days after its last Play press. Play refreshes that date at most once a day, and a daily cron (`noitu_pvp_cards`, 03:30 ICT) forgets older cards, which then play solo. The cron runs whenever the @@ -367,7 +370,7 @@ each card is separate: it keeps each member's best score on that card only. Players share a game in three ways: - by forwarding the game message; -- with the `t.me/?game=noitu` link; +- with the `t.me/?game=noitubot` or `?game=noitu` link (both play solo); - with the share button Telegram shows on the game page. When `telegram.org/js/games.js` loads, the page also shows "Chia sẻ điểm", diff --git a/internal/modules/noitu/callback.go b/internal/modules/noitu/callback.go index 087d97b..3065707 100644 --- a/internal/modules/noitu/callback.go +++ b/internal/modules/noitu/callback.go @@ -28,9 +28,10 @@ func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Upd if form == "" { return answerAlert(ctx, b, q.ID, msgNoGameTarget) } - // Cards live only in groups, whose chat IDs are negative; a private chat - // never needs the lookup, so a storage fault cannot block its solo game. - if c.InlineID == "" && c.ChatID < 0 { + // Cards are noitu games and live only in groups, whose chat IDs are + // negative; a noitubot game or a private chat never needs the lookup, so a + // storage fault cannot block a solo game. + if q.GameShortName == ShortName && c.InlineID == "" && c.ChatID < 0 { card, ok, err := s.lookupCard(ctx, c.ChatID, c.MessageID) if err != nil { _ = answerAlert(ctx, b, q.ID, msgCardLookup) diff --git a/internal/modules/noitu/command.go b/internal/modules/noitu/command.go index 9f77b5f..1167e3c 100644 --- a/internal/modules/noitu/command.go +++ b/internal/modules/noitu/command.go @@ -10,14 +10,15 @@ import ( ) const ( - // soloCommand sends the game played against the bot. - soloCommand = "noitubot" + // soloCommand sends the game played against the bot. It shares its name + // with that BotFather game. + soloCommand = SoloShortName msgChannel = "Không thể chơi nối từ trong kênh. Hãy dùng /noitubot trong nhóm hoặc chat riêng với bot." msgSendGameFail = "Không gửi được trò chơi nối từ. Thử lại sau nhé." ) -// handleCommand (/noitubot) sends the BotFather game for a solo game. No reply markup: Telegram then adds +// handleCommand (/noitubot) sends the noitubot BotFather game for a solo game. No reply markup: Telegram then adds // the Play button itself, which is the button the game requires first. // The command works even when the game is disabled; Play explains why. func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -28,7 +29,7 @@ func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models. _, err := b.SendGame(ctx, &bot.SendGameParams{ ChatID: msg.Chat.ID, MessageThreadID: msg.MessageThreadID, - GameShorName: ShortName, // the library's field name is misspelled + GameShorName: SoloShortName, // the library's field name is misspelled }) if err != nil { _ = chathelper.Reply(ctx, b, msg, msgSendGameFail) diff --git a/internal/modules/noitu/noitu.go b/internal/modules/noitu/noitu.go index 5f9c55b..3edc219 100644 --- a/internal/modules/noitu/noitu.go +++ b/internal/modules/noitu/noitu.go @@ -1,9 +1,9 @@ // Package noitu is the "nối từ" Telegram HTML5 game: the /noitubot command -// sends the BotFather game, the Play button opens a page this bot serves, and -// the player chains Vietnamese words against the bot opponent there. /noitu -// sends a card to a group whose Play button opens a room where the chat's -// members chain words against each other, and /noitutop shows the group's -// leaderboard across those room games. +// sends the noitubot BotFather game, the Play button opens a page this bot +// serves, and the player chains Vietnamese words against the bot opponent +// there. /noitu sends the noitu game to a group as a card whose Play button +// opens a room where the chat's members chain words against each other, and +// /noitutop shows the group's leaderboard across those room games. // // The server owns the game. The page only sends words; validation, the turn // timer, the bot's replies and the score all happen here, and the score is @@ -12,25 +12,25 @@ package noitu import ( - "crypto/hmac" "crypto/rand" - "crypto/sha256" "encoding/binary" mrand "math/rand/v2" - "net/url" "os" - "strings" "time" "github.com/tiennm99/tiennm99bot/internal/log" "github.com/tiennm99/tiennm99bot/internal/modules" "github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/storage" ) const ( - // ShortName is the game's BotFather short name. + // ShortName is the BotFather short name of the group game /noitu sends. ShortName = "noitu" + // SoloShortName is the BotFather short name of the game /noitubot sends. + // It always plays against the bot. + SoloShortName = "noitubot" baseURLEnv = "GAME_BASE_URL" secretEnv = "NOITU_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential @@ -125,6 +125,10 @@ func (svc *service) module() modules.Module { ShortName: ShortName, Visibility: modules.VisibilityPublic, Handler: svc.handlePlay, + }, { + ShortName: SoloShortName, + Visibility: modules.VisibilityPublic, + Handler: svc.handlePlay, }}, } if svc.enabled() { @@ -139,18 +143,7 @@ func (svc *service) module() modules.Module { // parseBaseURL accepts https://host[/path] and returns it without a trailing // slash. Anything else is logged and disables the game. -func parseBaseURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - u, err := url.Parse(raw) - if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - log.Warn("GAME_BASE_URL must be https://host[/path]; noitu game disabled") - return "" - } - return strings.TrimRight(u.String(), "/") -} +func parseBaseURL(raw string) string { return htmlgame.ParseBaseURL(raw, ShortName) } // tokenKey returns NOITU_GAME_SECRET when set, otherwise a key derived from // the bot token. A secret that is set but too short disables the game rather @@ -172,12 +165,7 @@ func tokenKey(secret string, deps modules.Deps) []byte { // deriveKey is HMAC-SHA256(key=bot token, tokenKeyLabel). Rotating the bot // token therefore invalidates every open game link. func deriveKey(botToken string) []byte { - if botToken == "" { - return nil - } - mac := hmac.New(sha256.New, []byte(botToken)) - mac.Write([]byte(tokenKeyLabel)) - return mac.Sum(nil) + return htmlgame.DeriveKey([]byte(botToken), tokenKeyLabel) } // newSessionRNG seeds a per-game generator for the opening word and the bot's diff --git a/internal/modules/noitu/noitu_test.go b/internal/modules/noitu/noitu_test.go index d584ba6..d377a99 100644 --- a/internal/modules/noitu/noitu_test.go +++ b/internal/modules/noitu/noitu_test.go @@ -121,7 +121,7 @@ func TestNew_DisabledWithoutBaseURL(t *testing.T) { t.Fatalf("command %+v is not a described public command", c) } } - if len(mod.Games) != 1 || mod.Games[0].ShortName != "noitu" { + if len(mod.Games) != 2 || mod.Games[0].ShortName != "noitu" || mod.Games[1].ShortName != "noitubot" { t.Fatalf("games = %+v", mod.Games) } // /noitu registers cards even while the game is disabled, so their @@ -240,7 +240,7 @@ func TestCommand_SendsGameKeepingTopic(t *testing.T) { update.Message.MessageThreadID = 12 rb.Bot.ProcessUpdate(context.Background(), update) last := rb.LastSent() - if last.Method != "sendGame" || last.Form["game_short_name"] != "noitu" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" { + if last.Method != "sendGame" || last.Form["game_short_name"] != "noitubot" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" { t.Fatalf("sent %+v", rb.Sent()) } if last.Form["reply_markup"] != "" { diff --git a/internal/modules/noitu/pvp_card_test.go b/internal/modules/noitu/pvp_card_test.go index 43e845a..2ae799c 100644 --- a/internal/modules/noitu/pvp_card_test.go +++ b/internal/modules/noitu/pvp_card_test.go @@ -108,6 +108,10 @@ func TestPlay_OnACardIssuesAPvPToken(t *testing.T) { if c := h.playToken(testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName)); c.PvP { t.Fatalf("inline message gave a pvp token: %+v", c) } + // The noitubot game always plays solo, whatever is stored for its message. + if c := h.playToken(testutil.NewGameCallback(42, -100500, 7, SoloShortName)); c.PvP { + t.Fatalf("noitubot game gave a pvp token: %+v", c) + } } func TestPlay_CardLookupFailureAnswersAlert(t *testing.T) {