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.
This commit is contained in:
tiennm99 committed 2026-10-09 16:42:31 +07:00
1 parent d84bc3d1fb
commit f60b661444
7 files changed
+44 -47

No files matched your search

+1 -1
View File
@@ -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 | | `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 | | `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` | | `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) | | `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 | | `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` | | `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` |
+12 -9
View File
@@ -6,8 +6,8 @@ three commands:
- `/noitu` sends the game into a group as a room card: the members who press - `/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). 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). 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 - `/noitubot` sends the separate `noitubot` game for a solo game against the
private chat. bot, in a group or a private chat.
- `/noitutop` shows the group's leaderboard across its room games. See - `/noitutop` shows the group's leaderboard across its room games. See
[Leaderboard](#leaderboard-noitutop). [Leaderboard](#leaderboard-noitutop).
@@ -24,8 +24,9 @@ and its dictionary is embedded in the binary.
## Setup ## Setup
1. **BotFather:** create a game for the bot with `/newgame` and the short name 1. **BotFather:** create two games for the bot with `/newgame`, short names
`noitu`. The share link is then `t.me/<bot username>?game=noitu`. `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 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`. Coolify, set the `bot` service's domain to `https://noitu.example.com:8080`.
The proxy terminates TLS and forwards to the container. 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 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 bot answers with a short text instead, because Telegram does not allow games
in channels. 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: `<GAME_BASE_URL>/games/noitu/?t=<token>`. The token answers it with a URL: `<GAME_BASE_URL>/games/noitu/?t=<token>`. The token
is signed, expires after 6 hours, and names the player and the game message. 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 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 `/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. 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 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 BotFather game `noitu`, and records the sent message as a **card** in the
module's storage collection (`noitu`, key `pvp:<chat_id>:<message_id>`, with module's storage collection (`noitu`, key `pvp:<chat_id>:<message_id>`, with
the forum topic). If the card cannot be recorded, the bot deletes it again and 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é." 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 start screen. The server checks the flag again: `api/start` refuses a PvP
token and `api/room/join` refuses any other. token and `api/room/join` refuses any other.
- A forwarded copy of a card is a different message, and a `?game=` share is - 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 - 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 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 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: Players share a game in three ways:
- by forwarding the game message; - by forwarding the game message;
- with the `t.me/<bot>?game=noitu` link; - with the `t.me/<bot>?game=noitubot` or `?game=noitu` link (both play solo);
- with the share button Telegram shows on the game page. - 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", When `telegram.org/js/games.js` loads, the page also shows "Chia sẻ điểm",
+4 -3
View File
@@ -28,9 +28,10 @@ func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Upd
if form == "" { if form == "" {
return answerAlert(ctx, b, q.ID, msgNoGameTarget) return answerAlert(ctx, b, q.ID, msgNoGameTarget)
} }
// Cards live only in groups, whose chat IDs are negative; a private chat // Cards are noitu games and live only in groups, whose chat IDs are
// never needs the lookup, so a storage fault cannot block its solo game. // negative; a noitubot game or a private chat never needs the lookup, so a
if c.InlineID == "" && c.ChatID < 0 { // 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) card, ok, err := s.lookupCard(ctx, c.ChatID, c.MessageID)
if err != nil { if err != nil {
_ = answerAlert(ctx, b, q.ID, msgCardLookup) _ = answerAlert(ctx, b, q.ID, msgCardLookup)
+5 -4
View File
@@ -10,14 +10,15 @@ import (
) )
const ( const (
// soloCommand sends the game played against the bot. // soloCommand sends the game played against the bot. It shares its name
soloCommand = "noitubot" // 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." 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é." 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 Play button itself, which is the button the game requires first.
// The command works even when the game is disabled; Play explains why. // 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 { 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{ _, err := b.SendGame(ctx, &bot.SendGameParams{
ChatID: msg.Chat.ID, ChatID: msg.Chat.ID,
MessageThreadID: msg.MessageThreadID, MessageThreadID: msg.MessageThreadID,
GameShorName: ShortName, // the library's field name is misspelled GameShorName: SoloShortName, // the library's field name is misspelled
}) })
if err != nil { if err != nil {
_ = chathelper.Reply(ctx, b, msg, msgSendGameFail) _ = chathelper.Reply(ctx, b, msg, msgSendGameFail)
+16 -28
View File
@@ -1,9 +1,9 @@
// Package noitu is the "nối từ" Telegram HTML5 game: the /noitubot command // 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 // sends the noitubot BotFather game, the Play button opens a page this bot
// the player chains Vietnamese words against the bot opponent there. /noitu // serves, and the player chains Vietnamese words against the bot opponent
// sends a card to a group whose Play button opens a room where the chat's // there. /noitu sends the noitu game to a group as a card whose Play button
// members chain words against each other, and /noitutop shows the group's // opens a room where the chat's members chain words against each other, and
// leaderboard across those room games. // /noitutop shows the group's leaderboard across those room games.
// //
// The server owns the game. The page only sends words; validation, the turn // 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 // timer, the bot's replies and the score all happen here, and the score is
@@ -12,25 +12,25 @@
package noitu package noitu
import ( import (
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/binary" "encoding/binary"
mrand "math/rand/v2" mrand "math/rand/v2"
"net/url"
"os" "os"
"strings"
"time" "time"
"github.com/tiennm99/tiennm99bot/internal/log" "github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules" "github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict" "github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/storage" "github.com/tiennm99/tiennm99bot/internal/storage"
) )
const ( const (
// ShortName is the game's BotFather short name. // ShortName is the BotFather short name of the group game /noitu sends.
ShortName = "noitu" 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" baseURLEnv = "GAME_BASE_URL"
secretEnv = "NOITU_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential 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, ShortName: ShortName,
Visibility: modules.VisibilityPublic, Visibility: modules.VisibilityPublic,
Handler: svc.handlePlay, Handler: svc.handlePlay,
}, {
ShortName: SoloShortName,
Visibility: modules.VisibilityPublic,
Handler: svc.handlePlay,
}}, }},
} }
if svc.enabled() { if svc.enabled() {
@@ -139,18 +143,7 @@ func (svc *service) module() modules.Module {
// parseBaseURL accepts https://host[/path] and returns it without a trailing // parseBaseURL accepts https://host[/path] and returns it without a trailing
// slash. Anything else is logged and disables the game. // slash. Anything else is logged and disables the game.
func parseBaseURL(raw string) string { func parseBaseURL(raw string) string { return htmlgame.ParseBaseURL(raw, ShortName) }
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(), "/")
}
// tokenKey returns NOITU_GAME_SECRET when set, otherwise a key derived from // 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 // 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 // deriveKey is HMAC-SHA256(key=bot token, tokenKeyLabel). Rotating the bot
// token therefore invalidates every open game link. // token therefore invalidates every open game link.
func deriveKey(botToken string) []byte { func deriveKey(botToken string) []byte {
if botToken == "" { return htmlgame.DeriveKey([]byte(botToken), tokenKeyLabel)
return nil
}
mac := hmac.New(sha256.New, []byte(botToken))
mac.Write([]byte(tokenKeyLabel))
return mac.Sum(nil)
} }
// newSessionRNG seeds a per-game generator for the opening word and the bot's // newSessionRNG seeds a per-game generator for the opening word and the bot's
+2 -2
View File
@@ -121,7 +121,7 @@ func TestNew_DisabledWithoutBaseURL(t *testing.T) {
t.Fatalf("command %+v is not a described public command", c) 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) t.Fatalf("games = %+v", mod.Games)
} }
// /noitu registers cards even while the game is disabled, so their // /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 update.Message.MessageThreadID = 12
rb.Bot.ProcessUpdate(context.Background(), update) rb.Bot.ProcessUpdate(context.Background(), update)
last := rb.LastSent() 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()) t.Fatalf("sent %+v", rb.Sent())
} }
if last.Form["reply_markup"] != "" { if last.Form["reply_markup"] != "" {
+4
View File
@@ -108,6 +108,10 @@ func TestPlay_OnACardIssuesAPvPToken(t *testing.T) {
if c := h.playToken(testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName)); c.PvP { if c := h.playToken(testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName)); c.PvP {
t.Fatalf("inline message gave a pvp token: %+v", c) 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) { func TestPlay_CardLookupFailureAnswersAlert(t *testing.T) {