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 |
| `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` |
+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
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/<bot username>?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: `<GAME_BASE_URL>/games/noitu/?t=<token>`. 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:<chat_id>:<message_id>`, 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/<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.
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 == "" {
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)
+5 -4
View File
@@ -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)
+16 -28
View File
@@ -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
+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)
}
}
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"] != "" {
+4
View File
@@ -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) {