mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under /games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play answers with a signed, expiring link, and the server validates every word against the embedded noitu dictionary (CC BY-SA 4.0), runs the bot opponent and turn timer, and reports the score with setGameScore. Modules can now register game-short-name callbacks and HTTP routes.
This commit is contained in:
1 parent
fe552f43e4
commit
6f30d13b2d
52 files changed
+42452
-35
No files matched your search
@@ -10,17 +10,25 @@ Run `tiennm99bot` as a long-lived container on [Coolify](https://coolify.io) wit
|
||||
in-process scheduler ───────────────────> module crons
|
||||
MongoDB Atlas (db / one collection per module + system metadata)
|
||||
Coolify env vars (plain secrets)
|
||||
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
|
||||
no ingress for Telegram (polling = outbound only; no /webhook)
|
||||
optional HTTPS ingress ──> container :8080 /games/noitu/ (only when GAME_BASE_URL is set)
|
||||
```
|
||||
|
||||
- **Storage** — `mongodb` auto-selected when `MONGO_URL` is set (no `KV_PROVIDER`).
|
||||
- **Cron** — an in-process scheduler (`internal/cron`) runs unconditionally and
|
||||
fires each module cron on its `Schedule`, evaluated in UTC. The only cron
|
||||
today is the `lol` daily digest at `0 1 * * *` (08:00 ICT).
|
||||
fires each module cron on its `Schedule`, evaluated in UTC. Examples: the
|
||||
`lol` daily digest at `0 1 * * *` (08:00 ICT), and the `noitu` game's
|
||||
minutely session sweep when the game is enabled.
|
||||
- **Transport** — long polling (`b.Start`) is the **only** transport. The bot
|
||||
opens an outbound connection to Telegram and pulls updates, so there is no
|
||||
public domain, no `/webhook`, and no webhook secret. The container clears any
|
||||
leftover webhook on startup (`deleteWebhook`) before polling.
|
||||
- **Game page** — the `noitu` HTML5 game is the one thing served to the
|
||||
public. With `GAME_BASE_URL` set, the bot's HTTP server on `:8080` serves the
|
||||
game page and its JSON API under `/games/noitu/`, and that path must be
|
||||
reachable over HTTPS from players' phones. With it unset, those routes do not
|
||||
exist and the bot needs no public ingress at all. See
|
||||
[the noitu game](noitu-game.md).
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -37,11 +45,13 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
|
||||
| `BOT_USERNAME` | optional | the bot's Telegram username, without `@`; unset = asked from Telegram (`getMe`) once at startup. Used for the default sticker pack name and the lol User-Agent |
|
||||
| `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; default `stickers_by_<bot username>`. See [sticker packs](sticker-packs.md) |
|
||||
| `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
|
||||
| `GAME_BASE_URL` | optional | public `https://` base routed to the bot's `:8080`, e.g. `https://noitu.example.com`; unset or invalid = the `noitu` game is disabled. See [the noitu game](noitu-game.md) |
|
||||
| `NOITU_GAME_SECRET` | optional | at least 32 bytes; signs game links — secret. Unset = derived from `TELEGRAM_BOT_TOKEN`, so rotating the token invalidates open game links |
|
||||
| `RENDERER_URL` | leave unset | base URL of the animation renderer; fixed by `compose.yml` to the bundled renderer (`http://renderer:3000`), so a Coolify value is ignored |
|
||||
| `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; logs are JSON on stdout |
|
||||
| `GOLD_VNAPP_API_KEY` | optional | VNAppMob key — secret; empty = the gold module fetches one and caches it in MongoDB |
|
||||
| `KV_PROVIDER` | leave unset | `memory` or `mongodb`; unset = `mongodb` when `MONGO_URL` is set, otherwise `memory` |
|
||||
| `PORT` | leave unset | health server port; default `8080` |
|
||||
| `PORT` | leave unset | health server (and game page) port; default `8080` |
|
||||
| `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 5 below) |
|
||||
|
||||
`compose.yml` references only the required settings; every optional one is a
|
||||
@@ -159,9 +169,14 @@ MP4, with the same text fallback.
|
||||
The committed [`compose.yml`](../compose.yml) defines the `bot` service
|
||||
and the internal `renderer` service.
|
||||
2. Set the env vars above in Coolify.
|
||||
3. **No public domain / port** is needed — polling is outbound-only. Do not
|
||||
publish a port or attach a domain. `expose: 8080` keeps the health endpoint
|
||||
reachable only inside Coolify's network.
|
||||
3. **No public domain / port** is needed for the bot itself — polling is
|
||||
outbound-only. Never publish a host port. `expose: 8080` keeps the health
|
||||
endpoint reachable only inside Coolify's network. **Only if the `noitu` game
|
||||
is enabled:** attach a domain to the `bot` service with port 8080 (in Coolify,
|
||||
`https://noitu.example.com:8080`), and set `GAME_BASE_URL` to that domain
|
||||
without the port. Coolify's proxy terminates TLS and forwards to the
|
||||
container's 8080; only `/games/noitu/` and the health text are served there.
|
||||
Never attach a domain to the `renderer` service.
|
||||
4. **Exactly one replica.** Telegram permits only one `getUpdates` consumer per
|
||||
bot token; a second poller gets HTTP 409, and a second in-process scheduler
|
||||
double-fires crons. Prefer **stop-first redeploys** so two containers never
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Nối từ game
|
||||
|
||||
`noitu` is a Telegram HTML5 game: Vietnamese word chaining ("nối từ") against
|
||||
the bot. `/noitu` sends the game into the chat. Pressing **Play** opens a page
|
||||
that this bot serves itself, where the player chains words against the bot.
|
||||
The final score goes to Telegram's in-chat high-score table.
|
||||
|
||||
The rules, the dictionary and the bot opponent come from `tiennm99/noitu`. Its
|
||||
Go code is ported in-tree under `internal/modules/noitu/{dict,engine,opponent}`,
|
||||
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`.
|
||||
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.
|
||||
3. **Environment:**
|
||||
- `GAME_BASE_URL`: set it to that domain, with no port and no trailing slash,
|
||||
for example `https://noitu.example.com`. The page is served at
|
||||
`<GAME_BASE_URL>/games/noitu/`.
|
||||
- `NOITU_GAME_SECRET` (optional): at least 32 bytes. It signs the game
|
||||
links. When it is unset, a key is derived from `TELEGRAM_BOT_TOKEN`, so
|
||||
rotating the bot token invalidates game links that are already open.
|
||||
4. Keep `noitu` in `MODULES`, or leave `MODULES` empty.
|
||||
|
||||
With `GAME_BASE_URL` unset or invalid, the game is disabled:
|
||||
|
||||
- `/noitu` still sends the game.
|
||||
- Pressing Play shows the alert "Trò chơi nối từ chưa được cấu hình trên máy
|
||||
chủ này."
|
||||
- No `/games/` route exists, so the bot needs no public ingress.
|
||||
|
||||
A `GAME_BASE_URL` that is not `https://host[/path]` is logged as a warning and
|
||||
also disables the game. So is a `NOITU_GAME_SECRET` shorter than 32 bytes.
|
||||
|
||||
## How a game flows
|
||||
|
||||
1. `/noitu` calls `sendGame` with no keyboard, so Telegram adds the Play
|
||||
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
|
||||
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
|
||||
a game sent through a `?game=` share, an `inline_message_id`.
|
||||
3. The page sends the token to `api/start` and gets back a session. From then
|
||||
on the session ID is what identifies the game.
|
||||
4. Each word goes to `api/move`. The server validates it, plays the bot's reply
|
||||
in the same request, and returns the new state.
|
||||
5. When the game ends, the server reports the score with `setGameScore`
|
||||
(`force=false`), once, and only if the score is above 0.
|
||||
|
||||
The client never sends a score. The server owns the chain, the clock and the
|
||||
points.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Opening:** the bot opens with a random word whose last syllable starts at
|
||||
least 20 words. The player moves first.
|
||||
- **Valid word:** at least 2 syllables, present in the dictionary, starting with
|
||||
the previous word's last syllable, and not already played in this game.
|
||||
- **Order of checks:** syllables, dictionary, link, reuse. A rejected word keeps
|
||||
the turn and the clock.
|
||||
- **Accepted spellings:**
|
||||
- Input is normalized: Unicode NFC, lowercase, and collapsed spaces.
|
||||
- Alternative tone placement in `oa`/`oe`/`uy` is accepted (`hoà` and `hòa`).
|
||||
- An `i`/`y` swap after `h k l m t qu` is accepted, as are the `sĩ`/`sỹ` and
|
||||
`vĩ`/`vỹ` pairs.
|
||||
- A variant counts only when exactly one dictionary word matches it.
|
||||
- **Turn timer:**
|
||||
- Each turn lasts 30 seconds.
|
||||
- A move that arrives within 2 more seconds still counts, which absorbs
|
||||
network latency, but it earns no speed points.
|
||||
- After that the player loses on time.
|
||||
- A player handed a dead end keeps the turn. When the clock runs out the
|
||||
game ends as `no_legal_move`, not as a timeout. Pressing "Chịu thua" in a
|
||||
dead end ends it as `no_legal_move` at once; in a playable position it
|
||||
ends as `gave_up`.
|
||||
- **Bot:**
|
||||
- The player picks the bot's difficulty: easy (a random legal word), medium
|
||||
(one-move lookahead; the default), or hard (a 4-ply negamax, capped at
|
||||
20,000 nodes).
|
||||
- The bot answers instantly, and the page shows a short pause.
|
||||
- If the bot has no legal word, the player wins.
|
||||
- **Chain cap:** a chain that reaches 300 words also ends as a player win.
|
||||
- **After a loss:** the page shows up to 3 words the player could have played.
|
||||
An empty list means the position was a true dead end.
|
||||
|
||||
## Scoring
|
||||
|
||||
Each of the player's accepted words scores the sum of five parts, capped at 100
|
||||
points per word:
|
||||
|
||||
| Part | Points |
|
||||
|---|---|
|
||||
| Base | 10 |
|
||||
| Chain | 2 × min(words already played, 15) |
|
||||
| Length | 5 × (syllables − 2) |
|
||||
| Speed | 10 × time left ÷ 30 s (measured on the server) |
|
||||
| Rarity | max(15 − 3 × ⌊log₂(words that start with the answered syllable)⌋, 0) |
|
||||
|
||||
The game score is the sum of the player's words. The bot's words are shown but
|
||||
do not count.
|
||||
|
||||
`setGameScore` uses `force=false`, so Telegram keeps each player's best. A
|
||||
score that is not higher returns `BOT_SCORE_NOT_MODIFIED`, which the page
|
||||
treats as saved.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Every route lives under `/games/noitu/`:
|
||||
|
||||
- `GET /games/noitu/` serves the page, and `GET /games/noitu/app.js` and
|
||||
`GET /games/noitu/app.css` serve its assets.
|
||||
- Every API route is a `POST` with a JSON body.
|
||||
|
||||
| Route | Body | Answer |
|
||||
|---|---|---|
|
||||
| `api/start` | `{"token","difficulty":"easy\|medium\|hard"}` | session view |
|
||||
| `api/move` | `{"session","word"}` | `{"result":{accepted,reason,message,player_word,bot_word},"state":view}` |
|
||||
| `api/state` | `{"session"}` | session view; also settles an expired turn |
|
||||
| `api/give-up` | `{"session"}` | session view; `no_legal_move` in a dead end, otherwise `gave_up` |
|
||||
|
||||
The session view has these fields:
|
||||
|
||||
- `session`, `player`, `difficulty`
|
||||
- `status`: `playing`, `won` or `lost`
|
||||
- `end_reason`: `timeout`, `no_legal_move`, `gave_up`, `bot_stuck` or
|
||||
`max_moves`
|
||||
- `current`: the syllable the next word must start with
|
||||
- `chain`: a list of `{word, by: bot|player, points, meanings[{pos, gloss}]}`
|
||||
- `score`
|
||||
- `turn_limit_ms`, `deadline_ms` and `server_now_ms`: the page counts down
|
||||
from `deadline_ms − server_now_ms`, so it never depends on the phone's clock
|
||||
- `suggestions`
|
||||
- `score_reported`: `pending`, `ok`, `failed` or `skipped`
|
||||
|
||||
A move the rules reject answers `accepted:false` with one of these reasons:
|
||||
`too_few_syllables`, `not_in_dictionary`, `wrong_link`, `already_used` or
|
||||
`timeout`. Each comes with a Vietnamese message.
|
||||
|
||||
Errors are `{"error","message"}`:
|
||||
|
||||
| Code | Status |
|
||||
|---|---|
|
||||
| `bad_request` | 400 |
|
||||
| `bad_token` | 401 |
|
||||
| `token_expired` | 401 |
|
||||
| `no_session` | 404 |
|
||||
| `game_over` | 409 |
|
||||
| `too_fast` | 429 |
|
||||
| `busy` | 503 |
|
||||
|
||||
## Limits and security
|
||||
|
||||
- **Token:**
|
||||
- Format: `base64url(json) "." base64url(HMAC-SHA256)`, checked with
|
||||
`hmac.Equal`.
|
||||
- It expires after 6 hours and is checked only at `api/start`.
|
||||
- It travels in the query string, never in the path. The request log records
|
||||
only the path. The page removes it from the address bar on load and keeps
|
||||
it in memory and `sessionStorage`, so copying or sharing the page link
|
||||
does not leak it.
|
||||
- Every response sends `Referrer-Policy: no-referrer`, so the token cannot
|
||||
leak through the referrer.
|
||||
- **Request limits:**
|
||||
- Bodies are capped at 4 KiB, and unknown JSON fields are rejected.
|
||||
- A word is at most 64 characters and 8 syllables.
|
||||
- At most one move per 300 ms per session.
|
||||
- At most 10 starts per user per minute.
|
||||
- **Sessions:**
|
||||
- At most 2,000 live sessions; beyond that, `api/start` answers `busy`.
|
||||
- At most 3 live sessions per user. A fourth start ends the user's oldest
|
||||
session.
|
||||
- A new start replaces the player's own session on the same game message.
|
||||
- A session that a start replaces or ends is settled first: an unfinished
|
||||
game ends as given up (or `no_legal_move` in a dead end), and a positive
|
||||
score is still reported.
|
||||
- Sessions live in memory only, so a restart ends live games. The page then
|
||||
returns to the start screen with "Ván chơi không còn nữa. Hãy bắt đầu ván
|
||||
mới."
|
||||
- An idle game is dropped after 15 minutes, and a finished one after 2
|
||||
minutes.
|
||||
- A minutely cron settles turns that ran out with the page closed, so those
|
||||
scores are still reported.
|
||||
- **Headers:**
|
||||
- A strict CSP: the page has no inline script or style, and only
|
||||
`https://telegram.org` is allowed as an external script source.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
- API responses are `no-store`.
|
||||
- Framing is not restricted, because Telegram Web shows games in an iframe.
|
||||
|
||||
## Sharing, and why there is no inline mode
|
||||
|
||||
Players share a game in three ways:
|
||||
|
||||
- by forwarding the game message;
|
||||
- with the `t.me/<bot>?game=noitu` link;
|
||||
- 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",
|
||||
which calls `TelegramGameProxy.shareScore()`. The page works without that
|
||||
script.
|
||||
|
||||
Inline-mode game results (`@bot noitu`) are not offered. Inline queries have a
|
||||
single owner in the registry, the `alias` module, which answers them with saved
|
||||
aliases. Mixing game results into that handler would couple two unrelated
|
||||
modules for a sharing path the three above already cover.
|
||||
|
||||
A game sent through a `?game=` link is expected to come back as an inline
|
||||
message (`inline_message_id`). Its score is reported with a raw Bot API call,
|
||||
`internal/telegram/game_score.go`, because `go-telegram/bot` v1.20.0 types
|
||||
`inline_message_id` as an int and cannot decode Telegram's `true` answer.
|
||||
|
||||
## Attribution
|
||||
|
||||
The dictionary is
|
||||
[`internal/modules/noitu/dict/data/dictionary.txt`](../internal/modules/noitu/dict/data/dictionary.txt).
|
||||
It is derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) and
|
||||
licensed under [CC BY-SA 4.0](../internal/modules/noitu/dict/data/LICENSE).
|
||||
|
||||
- It is embedded unmodified with `go:embed`.
|
||||
- Its [ATTRIBUTION.md](../internal/modules/noitu/dict/data/ATTRIBUTION.md)
|
||||
records the source and the modifications.
|
||||
- The page footer credits it.
|
||||
- CC BY-SA applies to the data only. The bot's code stays Apache-2.0.
|
||||
Reference in new issue
Block a user