mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
d84bc3d1fb
commit
f60b661444
7 files changed
+44
-47
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user