diff --git a/.env.example b/.env.example index 2f289da..2a98c85 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,13 @@ GAME_BASE_URL= # game links. A value shorter than 32 bytes disables the game. Secret. NOITU_GAME_SECRET= +# Optional secret (at least 32 bytes) for the wordledaily game: it signs game +# links and keys the daily answer order. Unset = derived from +# TELEGRAM_BOT_TOKEN. A value shorter than 32 bytes disables the game. The game +# is also served under /games/wordledaily/. See +# docs/wordledaily.md. Secret. +WORDLEDAILY_GAME_SECRET= + # ====================== Leave UNSET on self-host ================== # Defaults are correct for self-host: # KV_PROVIDER — auto-selects mongodb because MONGO_URL is set diff --git a/README.md b/README.md index 121a580..767a837 100644 --- a/README.md +++ b/README.md @@ -12,6 +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` | +| `wordledaily` | Wordle Daily HTML5 game: one shared puzzle a day for everyone, new at 07:00 ICT. `/wordledaily` sends the card; Play opens a board page this bot serves, progress and stats are kept per player, and a win goes to Telegram's high-score table. In a group the bot keeps one live results message per day with spoiler-free colour grids. `/wordledaily_subscribe` / `/wordledaily_unsubscribe` toggle a 07:00 ICT push of the new card plus yesterday's group results. Needs `GAME_BASE_URL`. See [docs/wordledaily.md](docs/wordledaily.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 | @@ -233,7 +234,7 @@ all. ``` cmd/server/ entrypoint (long polling + in-process cron + HTTP server) -internal/server/ HTTP routes (/ health, plus module routes such as /games/noitu/; cron has no HTTP route) +internal/server/ HTTP routes (/ health, plus module routes such as /games/noitu/ and /games/wordledaily/; cron has no HTTP route) internal/telegram/ Telegram long-polling bot wrapper internal/cron/ in-process cron scheduler (UTC) internal/deploynotify/ startup DM to the owner with the deployed commit @@ -253,6 +254,7 @@ compose.yml Coolify self-host stack (bot + renderer services) - [Blacklist](docs/blacklist.md) - [Sticker pack](docs/sticker-packs.md) - [Nối từ game](docs/noitu-game.md) +- [Wordle Daily](docs/wordledaily.md) - [Lunar calendar algorithm and known issues](docs/amlich-known-issues.md) - [Agent and contributor rules](AGENTS.md) diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 9270173..8bf820d 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -107,6 +107,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "tth": "[target...]", "wheelofnames": "", "wordle": "[word]", + + "wordledaily": "", + "wordledaily_subscribe": "", + "wordledaily_unsubscribe": "", } menu := botCommandMenu(reg) diff --git a/cmd/server/main.go b/cmd/server/main.go index cc86172..26ad3a3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -42,6 +42,7 @@ import ( "github.com/tiennm99/tiennm99bot/internal/modules/util" "github.com/tiennm99/tiennm99bot/internal/modules/weather" "github.com/tiennm99/tiennm99bot/internal/modules/wordle" + "github.com/tiennm99/tiennm99bot/internal/modules/wordledaily" "github.com/tiennm99/tiennm99bot/internal/server" "github.com/tiennm99/tiennm99bot/internal/storage" "github.com/tiennm99/tiennm99bot/internal/systemstate" @@ -101,6 +102,7 @@ func factories() map[string]modules.Factory { "amlich": amlich.New, "monkeyd": monkeyd.New, "wordle": wordle.New, + wordledaily.ShortName: wordledaily.New, noitu.ShortName: noitu.New, "loldle": loldle.New, lol.CollectionName: lol.New, diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 8560ef2..0f4039a 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -122,6 +122,30 @@ func TestFactoriesRegistersNoituDisabledByDefault(t *testing.T) { } } +// wordledaily is in the catalog and, with GAME_BASE_URL unset, registers its +// commands and game but no public HTTP route and no daily push. +func TestFactoriesRegistersWordleDailyDisabledByDefault(t *testing.T) { + t.Setenv("GAME_BASE_URL", "") + t.Setenv("WORDLEDAILY_GAME_SECRET", "") + reg, err := modules.Build([]string{"wordledaily"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build wordledaily: %v", err) + } + for _, name := range []string{"wordledaily", "wordledaily_subscribe", "wordledaily_unsubscribe"} { + if _, ok := reg.AllCommands[name]; !ok { + t.Fatalf("missing command %s", name) + } + } + if routes := serverRoutes(reg); len(routes) != 0 { + t.Fatalf("disabled game exposes routes: %+v", routes) + } + for _, m := range reg.Modules { + if len(m.Crons) != 0 { + t.Fatalf("disabled game registers crons: %+v", m.Crons) + } + } +} + // An empty MODULES loads every module, so a command name that collides with an // existing module surfaces here as a test failure rather than as a startup // crash on deploy. diff --git a/compose.yml b/compose.yml index 2b93334..50e29a0 100644 --- a/compose.yml +++ b/compose.yml @@ -25,8 +25,9 @@ services: # GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY:-} # VNAppMob key — SECRET; empty = fetched and cached in Mongo # BOT_USERNAME: ${BOT_USERNAME:-} # bot username without "@"; empty = getMe at startup # STICKER_PACK_NAME: ${STICKER_PACK_NAME:-} # /addsticker set; empty = stickers_by_ - # GAME_BASE_URL: ${GAME_BASE_URL:-} # public https base routed to this service's :8080; empty = noitu game disabled + # GAME_BASE_URL: ${GAME_BASE_URL:-} # public https base routed to this service's :8080; empty = noitu and wordledaily games disabled # NOITU_GAME_SECRET: ${NOITU_GAME_SECRET:-} # >= 32 bytes, signs game links — SECRET; empty = derived from the bot token + # WORDLEDAILY_GAME_SECRET: ${WORDLEDAILY_GAME_SECRET:-} # >= 32 bytes, signs wordledaily links and keys its answers — SECRET; empty = derived from the bot token # LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error # --- Fixed by this stack (not Coolify settings) --- diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 5ff969f..4dd5c23 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -45,8 +45,9 @@ 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_`. 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) | +| `GAME_BASE_URL` | optional | public `https://` base routed to the bot's `:8080`, e.g. `https://noitu.example.com`; unset or invalid = the `noitu` and `wordledaily` games are disabled. See [the noitu game](noitu-game.md) and [Wordle Daily](wordledaily.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 | +| `WORDLEDAILY_GAME_SECRET` | optional | at least 32 bytes; signs `wordledaily` game links and keys its daily answer order — secret. Unset = derived from `TELEGRAM_BOT_TOKEN`. Puzzles already started keep their stored answer when it changes. See [Wordle Daily](wordledaily.md) | | `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 | @@ -175,7 +176,7 @@ MP4, with the same text fallback. 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. + container's 8080; only `/games/noitu/`, `/games/wordledaily/` 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 diff --git a/docs/wordledaily.md b/docs/wordledaily.md new file mode 100644 index 0000000..fe996a4 --- /dev/null +++ b/docs/wordledaily.md @@ -0,0 +1,312 @@ +# Wordle Daily + +`wordledaily` is a Telegram HTML5 game modelled on NYT Wordle and its Discord +Activity. Everyone plays the same English five-letter word each day. A new +puzzle starts at 07:00 ICT (00:00 UTC), and the puzzle number goes up by one +each day. + +It has three commands: + +- `/wordledaily` sends the game card in any chat, private or group, and in a + forum topic it stays in that topic. Pressing **Play** opens a board page that + this bot serves. +- `/wordledaily_subscribe` opts the chat, or the forum topic, into a daily push + at 07:00 ICT. +- `/wordledaily_unsubscribe` opts it out. + +A topic means a real forum topic (`is_topic_message`). Telegram also gives a +reply chain in an ordinary supergroup a thread ID; a command sent as such a +reply counts for the whole chat, the same scope its plays and group results +are stored under. + +Anyone in a chat may subscribe or unsubscribe, the same as `/lol_subscribe` and +`/thuyvan_subscribe`. Channels are refused, because Telegram does not allow +games in channels. + +The classic `/wordle` text game is a separate module. The two share their word +data and scoring through `internal/modules/wordle/wordlist`. + +## Setup + +1. **BotFather:** create a game for the bot with `/newgame` and the short name + `wordledaily`. The share link is then `t.me/?game=wordledaily`. +2. **Public URL:** this uses the same `GAME_BASE_URL` as the + [noitu game](noitu-game.md#setup). The page is served at + `/games/wordledaily/`. +3. **Environment:** `WORDLEDAILY_GAME_SECRET` is optional and must be at least + 32 bytes. Two keys are derived from it, each with its own label: + - `tiennm99bot/wordledaily/token/v1` signs the Play links. + - `tiennm99bot/wordledaily/answer/v1` orders the daily answers. + + When the secret is unset, the bot token is the root key. Because the labels + are separate, a noitu link never opens this game. +4. Keep `wordledaily` in `MODULES`, or leave `MODULES` empty. + +The game is disabled when any of these holds: + +- `GAME_BASE_URL` is unset or invalid. +- The secret is shorter than 32 bytes. +- There is no storage. + +A disabled game behaves like this: + +- `/wordledaily` and `/wordledaily_subscribe` answer "Wordle Daily isn't + configured on this server." `/wordledaily_unsubscribe` still removes a + subscription. +- Play shows the same text as an alert. +- No `/games/wordledaily/` route exists and the daily push is not registered. + +## The puzzle + +- **Day boundary:** puzzle #1 started at 07:00 ICT on 9 October 2026. Puzzle `n` + runs from `epoch + (n−1) days` until one day later. Because 07:00 ICT is + 00:00 UTC, the 00:00 UTC cron always pushes exactly the puzzle a player then + gets. +- **Word lists:** + - `wordlist/data/answers.txt` holds about 2,230 common English words that a + puzzle can be. It was curated for this bot; it is not NYT's list. + - `wordlist/data/words.txt` (14,855 words) is what a guess may be. + - A test checks that the answers are sorted, unique, and all in the + dictionary. +- **Choosing the answer:** the days are split into cycles as long as the answer + list. Each cycle walks one permutation of the list: a Fisher–Yates shuffle + seeded with `HMAC(answer key, "cycle:")` through ChaCha8. + - No answer repeats within a cycle (about six years). + - The order can't be guessed without the key. +- **Pinning:** the first time a puzzle is needed (a request, or the push at + 07:00), its answer is stored as `puzzle:`. Every later read uses that + document, so a new secret or an edited answer list never changes a puzzle + that has already started. +- **Rollover mid-game:** a guess names the puzzle number the page shows. A + guess for a puzzle that is no longer current is refused with `new_puzzle`, + and the page reloads the new board. + +## Playing + +- **Validation:** the server checks every guess and colours it. Two-pass marking + handles duplicate letters the NYT way. The page never receives a word list, + and it never receives the answer until that player's game is over. +- **Progress:** each player's board is saved after every guess, per puzzle. A + restart, a reload, or opening any other card shows the same board. +- **One game a day:** each player gets one game per day across all chats. A + finished puzzle can't be replayed. +- **Stats:** each player's stats cover every chat. They are played, win %, + current and max streak, and the guess distribution, shown on the end screen. + - The current streak counts consecutive puzzle wins. + - A loss or a missed day ends it. + - A streak whose last win is older than yesterday shows as 0. +- **End screen:** it shows the answer, the stats, a countdown to the next + puzzle and two buttons: + - **Share** uses Telegram's game share. + - **Copy result** copies the spoiler-free text, such as + `Wordle Daily #12 3/6` followed by the emoji grid. +- **Page features:** physical and on-screen keyboards, a dark scheme that follows + the system, and a high-contrast (orange/blue) toggle that is remembered in + `localStorage`. The board is sized from the height the header and keyboard + leave, so the page fits a short phone screen without scrolling. Each tile + turns to its colour in turn, and the keyboard colours once the row is + revealed. Tiles and keys carry their result as an accessible label, and + each guess's result is announced to screen readers. +- **Score:** a win reports `7 − guesses` with `setGameScore` (`force=false`). A + 1-guess win scores 6 and a 6-guess win scores 1. A loss is not reported. + - The score is set on the card the player played from. + - When they open another card the same day, it is set there too, once per + card. + - Each day's card therefore carries that day's leaderboard. + - A card is recorded as reported only once Telegram accepts the score. + `BOT_SCORE_NOT_MODIFIED` counts as accepted. Any other failure is logged, + and the next time the player opens that card the score is set again. + +## Groups: mapping the Discord Activity to Telegram + +NYT's Discord Activity works like this: + +- Everyone plays the same daily word. +- A live embed in the channel shows each player's progress. +- Results are spoiler-free colour grids. +- A daily message recaps yesterday's scores, crowns the best, and shows the + group streak. +- Reminders of a new puzzle can be muted. + +Telegram has different limits: + +- A game message has no caption, so any recap has to be a separate text message. +- Bots can edit their own text messages. +- `setGameScore` in a group also posts Telegram's own "X scored N" service + message, which cannot be avoided. +- A message holds at most 4,096 characters, and groups allow about 20 messages + a minute. + +The bot maps the Activity onto those limits as follows. + +| Discord Activity | Here | +|---|---| +| `/wordle` launches the activity | `/wordledaily` sends the card; Play opens the board | +| Live channel embed of everyone's progress | One **live results** message per group, topic and day (below) | +| `/share` and the Share button | **Share** and **Copy result** on the end screen | +| Daily recap of yesterday, crown, group streak | The 07:00 ICT push to subscribed chats (below) | +| Mute daily reminders | `/wordledaily_unsubscribe` | +| Personal streaks | Per-user stats, the same for every chat | + +### Live results message + +A live results message exists only in groups and supergroups, never in a +private chat or for an inline card. It works like this: + +- **Who appears:** a player is listed in a group topic's day once they have + made a guess from a card in that topic. A player who finished elsewhere and + then opens the group's card is listed at once, with their finished grid. +- **Creating it:** the first time, the bot sends one silent message as a reply + to the card, with `AllowSendingWithoutReply`. After that it only edits that + message. +- **Rate of updates:** edits are coalesced, at most one every 5 seconds per + message, and the text is always rendered from the store. +- **Errors:** + - A "message is not modified" answer is ignored. + - If the message was deleted, a new one is sent at most once every 10 + minutes. An update that comes sooner is held until then, so the final + results still arrive. + - A failure that may pass (429, a timeout, a 5xx, a store error) is retried + up to 5 times, waiting Telegram's `retry_after` or 5, 10, 20, 40 and 80 + seconds. A refused request (bad request, forbidden) is not retried. +- **Content:** colour grids only, never letters. The rows are ordered like + this: + 1. Winners, by fewer guesses and then earlier finish. + 2. Losses (`X/6`). + 3. Players still playing, with their partial grid, most guesses first. + + The message stops before Telegram's limit and ends with `+N more`. + +``` +Wordle Daily #12 · live results +🔥 Group streak: 5 days + +Alice 3/6 +⬜🟨⬜⬜⬜ +🟨🟩⬜🟩⬜ +🟩🟩🟩🟩🟩 +Bob X/6 +… +Carol playing 2/6 +⬜⬜🟨⬜⬜ +⬜🟩🟩⬜⬜ +``` + +### The 07:00 ICT push + +The cron `wordledaily_daily_push` (`0 0 * * *` UTC) does the following: + +1. Pins today's answer. +2. Deletes the `puzzle:`, `play:` and `cday:` documents of puzzles older than + yesterday's (see [Storage](#storage)). +3. Claims the day under `daily_push:last_date`, so a second trigger on the same + day sends nothing. +4. For each subscriber: + 1. In a group, it sends yesterday's recap. The recap opens with yesterday's + answer, then the group streak, then the players who finished. + 2. It sends today's card in the same topic. + +If the recap fails to send, the card is skipped. The shared subscription fan-out +then prunes chats that blocked or removed the bot. Sends are throttled above 30 +subscribers. + +``` +Wordle Daily #11 — yesterday's results +Answer: CRANE +🔥 Your group is on a 5 day streak! +👑 3/6: Alice, Dan +4/6: Eve +X/6: Bob +``` + +What else the push sends depends on the day before: + +- **Group streak:** it counts consecutive puzzles that at least one member of + the group or topic **solved**; a loss does not keep it going. If nobody + solved yesterday's word and a streak of at least 2 days was alive, the + streak line reads `Nobody solved it. Group streak reset.` +- **Nobody played:** the recap is just the title and the answer. +- **Private chats:** a private chat always gets just the card. + +**Group streak:** the number of consecutive puzzles on which at least one +player listed in that group topic finished, win or lose. Streak lines appear +only from 2 days up. + +Nothing else is posted. There is no separate "X is playing" message and no +message per guess; the live results message covers both. + +## HTTP API + +Every route lives under `/games/wordledaily/`: + +- `GET /games/wordledaily/` serves the page, and `GET app.js` and `GET app.css` + serve its assets. +- The API routes are `POST` with a JSON body. + +Headers, CSP, caching and the 4 KB request limit are the same as noitu's, +through `internal/modules/util/htmlgame`. + +| Route | Body | Answer | +|---|---|---| +| `api/state` | `{"token"}` | view | +| `api/guess` | `{"token","num","word"}` | view | + +The view has these fields: + +- `num`, `date`, `player`, `max` (6), `len` (5) +- `guesses`: a list of `{word, marks}`. `marks` has one letter per position: + `c` correct, `p` present elsewhere, `w` wrong. +- `status`: `playing`, `won` or `lost` +- `next_at`: the unix second the next puzzle starts +- `answer`, `stats` (`played`, `win_pct`, `cur`, `max`, `dist[6]`) and `share`: + only once the game is over + +Errors are `{"error","message"}`: + +| Status | Codes | +|---|---| +| 400 | `bad_request` | +| 401 | `bad_token`, `expired` | +| 409 | `new_puzzle` | +| 422 | `length`, `unknown`, `finished` | +| 429 | `rate_limited`: 60 requests a minute per player, both routes together | + +A Play token expires after 6 hours and is not tied to a day. + +## Storage + +Everything is in the module's collection, under separate key prefixes. + +| Key | Holds | +|---|---| +| `puzzle:` | the pinned answer of puzzle `n` | +| `play::` | one player's board on puzzle `n`: guesses with marks, status, finish time, cards reported on, group topics joined | +| `stats:` | played, wins, current and max streak, guess distribution, last puzzle finished and won | +| `cday::::` | the players of a group topic's day, the card the summary replies to, and the summary message ID | +| `cstreak:::` | the group topic's streak and the last puzzle it counted | +| `subscribers` | the subscribed chats and topics | +| `daily_push:last_date` | the last puzzle number pushed | + +Each player's board and stats are written under a per-player lock. The board +is the record and is written first. A stats update is skipped for a puzzle +already counted, and when the stats write after a finished board fails, the +next state load or guess folds that board into the stats. So the stats always +follow the board and never count a game twice. Group documents use versioned +writes with retries. + +**Retention:** the 07:00 push deletes `puzzle:`, `play::*` and +`cday::*` for every `n` older than yesterday's puzzle; nothing reads them +after the recap. `stats:`, `cstreak:` and the subscription documents are +kept. With the memory store this keeps the process's memory bounded. + +## Choices made + +These follow NYT's behaviour where it is known. Change them here if wanted: + +- The recap shows yesterday's answer first. +- The group streak counts wins only. +- The live results message shows partial grids of players still playing, as + NYT's live embed does. This reveals some colour hints to members who have + not played yet. +- The answer list was curated for this bot and can be replaced. A puzzle that + has already started keeps its stored answer. diff --git a/internal/keylock/keylock.go b/internal/keylock/keylock.go index 203295c..7681015 100644 --- a/internal/keylock/keylock.go +++ b/internal/keylock/keylock.go @@ -31,3 +31,14 @@ func (m *Map) Acquire(key string) func() { mu.Lock() return mu.Unlock } + +// Len counts the keys that have a mutex, which is the map's memory +// footprint: keys are never freed, so callers keep their key set bounded. +func (m *Map) Len() int { + n := 0 + m.m.Range(func(_, _ any) bool { + n++ + return true + }) + return n +} diff --git a/internal/keylock/keylock_test.go b/internal/keylock/keylock_test.go index b9ef68a..d3c429e 100644 --- a/internal/keylock/keylock_test.go +++ b/internal/keylock/keylock_test.go @@ -71,3 +71,16 @@ func TestMap_SameKeySerialises(t *testing.T) { t.Errorf("counter = %d, want %d (lost updates → mutex didn't serialise)", got, goroutines*itersEach) } } + +func TestMap_LenCountsDistinctKeys(t *testing.T) { + var m Map + if m.Len() != 0 { + t.Fatalf("empty Len = %d", m.Len()) + } + m.Acquire("a")() + m.Acquire("a")() + m.Acquire("b")() + if m.Len() != 2 { + t.Fatalf("Len = %d, want 2", m.Len()) + } +} diff --git a/internal/modules/noitu/http_api.go b/internal/modules/noitu/http_api.go index f4c5be6..401cd2c 100644 --- a/internal/modules/noitu/http_api.go +++ b/internal/modules/noitu/http_api.go @@ -1,9 +1,7 @@ package noitu import ( - "encoding/json" "errors" - "io" "net/http" "strings" "time" @@ -13,10 +11,11 @@ import ( "github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict" "github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine" "github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" ) const ( - maxBodyBytes = 4 << 10 + maxBodyBytes = htmlgame.MaxBodyBytes maxWordRunes = 64 maxWordTokens = 8 ) @@ -101,37 +100,17 @@ func (s *service) handler() http.Handler { // travels in the page URL, so no referrer may leak it. Telegram Web embeds // games in an iframe, so framing is deliberately not restricted. func securityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h := w.Header() - h.Set("Referrer-Policy", "no-referrer") - h.Set("X-Content-Type-Options", "nosniff") - h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'") - if strings.HasPrefix(r.URL.Path, routePrefix+"api/") { - h.Set("Cache-Control", "no-store") - } - next.ServeHTTP(w, r) - }) + return htmlgame.SecurityHeaders(routePrefix, next) } -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} +func writeJSON(w http.ResponseWriter, status int, v any) { htmlgame.WriteJSON(w, status, v) } func writeError(w http.ResponseWriter, e apiError) { writeJSON(w, e.status, e) } // decode reads one JSON object of at most maxBodyBytes with no unknown fields // and nothing after it. func decode(w http.ResponseWriter, r *http.Request, dst any) bool { - r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(dst); err != nil { - writeError(w, errBadRequest) - return false - } - if _, err := dec.Token(); !errors.Is(err, io.EOF) { + if err := htmlgame.DecodeJSON(w, r, dst); err != nil { writeError(w, errBadRequest) return false } diff --git a/internal/modules/noitu/http_static.go b/internal/modules/noitu/http_static.go index 56ae916..e6718f0 100644 --- a/internal/modules/noitu/http_static.go +++ b/internal/modules/noitu/http_static.go @@ -2,29 +2,11 @@ package noitu import ( "net/http" - "path" "github.com/tiennm99/tiennm99bot/internal/modules/noitu/web" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" ) -var assetTypes = map[string]string{ - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", -} - -// serveAsset serves one embedded page file. The files are small and fixed -// for a binary's lifetime, so a short public cache is safe; the page's token -// lives in the query string, which does not change the content. -func serveAsset(name string) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - body, err := web.FS.ReadFile(name) - if err != nil { - http.NotFound(w, nil) - return - } - w.Header().Set("Content-Type", assetTypes[path.Ext(name)]) - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = w.Write(body) - } -} +// serveAsset serves one embedded page file with a short public cache. See +// htmlgame.ServeAsset. +func serveAsset(name string) http.HandlerFunc { return htmlgame.ServeAsset(web.FS, name) } diff --git a/internal/modules/noitu/noitu.go b/internal/modules/noitu/noitu.go index 39ab907..3edc219 100644 --- a/internal/modules/noitu/noitu.go +++ b/internal/modules/noitu/noitu.go @@ -12,19 +12,16 @@ 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" ) @@ -146,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 @@ -179,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/score_reporter.go b/internal/modules/noitu/score_reporter.go index fa623d2..3adbe30 100644 --- a/internal/modules/noitu/score_reporter.go +++ b/internal/modules/noitu/score_reporter.go @@ -2,12 +2,11 @@ package noitu import ( "context" - "strings" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" - "github.com/tiennm99/tiennm99bot/internal/telegram" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" ) // scoreReporter records a finished game's score on the game message. @@ -23,16 +22,7 @@ type botReporter struct { } func (r botReporter) Report(ctx context.Context, c claims, score int) error { - if c.InlineID != "" { - return telegram.SetInlineGameScore(ctx, r.b.Token(), c.InlineID, c.UserID, score) - } - _, err := r.b.SetGameScore(ctx, &bot.SetGameScoreParams{ - UserID: c.UserID, - Score: score, - ChatID: c.ChatID, - MessageID: c.MessageID, - }) - return err + return htmlgame.ReportScore(ctx, r.b, htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID}, c.UserID, score) } // announcer posts a finished room game's result into the card's chat. @@ -54,6 +44,4 @@ func (r botReporter) Announce(ctx context.Context, card claims, text string) err // scoreNotModified reports Telegram's answer to a score that does not beat the // player's best. With force=false that is the expected outcome, not a failure. -func scoreNotModified(err error) bool { - return err != nil && strings.Contains(err.Error(), "BOT_SCORE_NOT_MODIFIED") -} +func scoreNotModified(err error) bool { return htmlgame.ScoreNotModified(err) } diff --git a/internal/modules/noitu/token.go b/internal/modules/noitu/token.go index d8738e6..3a1f31b 100644 --- a/internal/modules/noitu/token.go +++ b/internal/modules/noitu/token.go @@ -1,15 +1,11 @@ package noitu import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" "strconv" - "strings" "time" "unicode/utf8" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" ) // tokenTTL bounds how long a Play link stays usable. @@ -19,8 +15,8 @@ const tokenTTL = 6 * time.Hour const maxNameRunes = 64 var ( - errBadToken = errors.New("noitu: bad token") - errTokenExpired = errors.New("noitu: token expired") + errBadToken = htmlgame.ErrBadToken + errTokenExpired = htmlgame.ErrTokenExpired ) // claims is what a game token asserts: who pressed Play, on which game @@ -41,6 +37,12 @@ type claims struct { Expiry int64 `json:"e"` } +// Valid and ExpiresAt let htmlgame.Verify check a decoded token. +func (c claims) Valid() bool { return c.valid() } + +// ExpiresAt is the unix second the token stops working. +func (c claims) ExpiresAt() int64 { return c.Expiry } + func (c claims) valid() bool { if c.UserID <= 0 || c.Expiry <= 0 { return false @@ -71,44 +73,13 @@ func truncateName(name string) string { } // signToken encodes claims as base64url(json) "." base64url(HMAC-SHA256). -func signToken(key []byte, c claims) (string, error) { - payload, err := json.Marshal(c) - if err != nil { - return "", err - } - body := base64.RawURLEncoding.EncodeToString(payload) - return body + "." + base64.RawURLEncoding.EncodeToString(tokenMAC(key, body)), nil -} - -func tokenMAC(key []byte, body string) []byte { - mac := hmac.New(sha256.New, key) - mac.Write([]byte(body)) - return mac.Sum(nil) -} +func signToken(key []byte, c claims) (string, error) { return htmlgame.Sign(key, c) } // verifyToken checks the signature first, then the claims and the expiry. func verifyToken(key []byte, token string, now time.Time) (claims, error) { - if len(key) == 0 || len(token) > 1024 { - return claims{}, errBadToken - } - body, sig, ok := strings.Cut(token, ".") - if !ok { - return claims{}, errBadToken - } - gotMAC, err := base64.RawURLEncoding.DecodeString(sig) - if err != nil || !hmac.Equal(gotMAC, tokenMAC(key, body)) { - return claims{}, errBadToken - } - payload, err := base64.RawURLEncoding.DecodeString(body) - if err != nil { - return claims{}, errBadToken - } var c claims - if err := json.Unmarshal(payload, &c); err != nil || !c.valid() { - return claims{}, errBadToken - } - if now.Unix() >= c.Expiry { - return claims{}, errTokenExpired + if err := htmlgame.Verify(key, token, now, &c); err != nil { + return claims{}, err } return c, nil } diff --git a/internal/modules/util/htmlgame/htmlgame_test.go b/internal/modules/util/htmlgame/htmlgame_test.go new file mode 100644 index 0000000..1ca8960 --- /dev/null +++ b/internal/modules/util/htmlgame/htmlgame_test.go @@ -0,0 +1,219 @@ +package htmlgame + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +var testKey = []byte("0123456789abcdef0123456789abcdef") + +type testClaims struct { + User int64 `json:"u"` + Expiry int64 `json:"e"` +} + +func (c testClaims) Valid() bool { return c.User > 0 && c.Expiry > 0 } +func (c testClaims) ExpiresAt() int64 { return c.Expiry } + +func TestToken_RoundTripTamperOversizeExpiry(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + in := testClaims{User: 7, Expiry: now.Add(time.Hour).Unix()} + tok, err := Sign(testKey, in) + if err != nil { + t.Fatal(err) + } + var got testClaims + if err := Verify(testKey, tok, now, &got); err != nil || got != in { + t.Fatalf("round trip = %+v, %v", got, err) + } + body, sig, _ := strings.Cut(tok, ".") + invalid, _ := Sign(testKey, testClaims{Expiry: in.Expiry}) + for name, bad := range map[string]string{ + "tampered body": strings.ToUpper(body[:4]) + body[4:] + "." + sig, + "bad sig": body + "." + sig[:len(sig)-2] + "AA", + "no dot": body, + "empty": "", + "oversize": strings.Repeat("a", MaxTokenBytes+1), + "invalid": invalid, + } { + if err := Verify(testKey, bad, now, &testClaims{}); !errors.Is(err, ErrBadToken) { + t.Errorf("%s: err = %v, want ErrBadToken", name, err) + } + } + if err := Verify([]byte("other-key-other-key-other-key-xx"), tok, now, &testClaims{}); !errors.Is(err, ErrBadToken) { + t.Errorf("wrong key: %v", err) + } + if err := Verify(nil, tok, now, &testClaims{}); !errors.Is(err, ErrBadToken) { + t.Errorf("no key: %v", err) + } + if err := Verify(testKey, tok, now.Add(time.Hour), &testClaims{}); !errors.Is(err, ErrTokenExpired) { + t.Errorf("expired: %v", err) + } +} + +func TestDeriveKey_LabelsSeparateKeys(t *testing.T) { + a := DeriveKey([]byte("root"), "game/a") + b := DeriveKey([]byte("root"), "game/b") + if len(a) != 32 || string(a) == string(b) { + t.Fatalf("keys a=%x b=%x", a, b) + } + if string(a) != string(DeriveKey([]byte("root"), "game/a")) { + t.Fatal("not deterministic") + } + if DeriveKey(nil, "x") != nil { + t.Fatal("key from an empty root") + } +} + +func TestParseBaseURL(t *testing.T) { + cases := map[string]string{ + "": "", + "https://game.example": "https://game.example", + " https://game.example/ ": "https://game.example", + "https://game.example/bot//": "https://game.example/bot", + "http://game.example": "", + "game.example": "", + "https://u:p@game.example": "", + "https://game.example/?x=1": "", + "https://game.example/#frag": "", + "https:///no-host": "", + } + for in, want := range cases { + if got := ParseBaseURL(in, "test"); got != want { + t.Errorf("ParseBaseURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSecurityHeadersAndAssets(t *testing.T) { + fsys := fstest.MapFS{ + "index.html": {Data: []byte("")}, + "app.js": {Data: []byte("1")}, + "app.css": {Data: []byte("a{}")}, + } + mux := http.NewServeMux() + for _, name := range []string{"index.html", "app.js", "app.css"} { + mux.HandleFunc("GET /games/x/"+name, ServeAsset(fsys, name)) + } + mux.HandleFunc("GET /games/x/missing", ServeAsset(fsys, "missing.js")) + mux.HandleFunc("POST /games/x/api/ping", func(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]bool{"ok": true}) + }) + h := SecurityHeaders("/games/x/", mux) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/games/x/api/ping", nil)) + hdr := rec.Header() + if hdr.Get("Cache-Control") != "no-store" || hdr.Get("Referrer-Policy") != "no-referrer" || + hdr.Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(hdr.Get("Content-Security-Policy"), "default-src 'self'") || + hdr.Get("Content-Type") != "application/json; charset=utf-8" || strings.TrimSpace(rec.Body.String()) != `{"ok":true}` { + t.Fatalf("API response %v %q", hdr, rec.Body.String()) + } + if strings.Contains(hdr.Get("Content-Security-Policy"), "frame-ancestors") { + t.Fatal("framing restricted; Telegram Web embeds games in an iframe") + } + for name, ctype := range map[string]string{"index.html": "text/html", "app.js": "text/javascript", "app.css": "text/css"} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/games/x/"+name, nil)) + if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) || + rec.Header().Get("Cache-Control") != "public, max-age=300" { + t.Errorf("GET %s: %d %v", name, rec.Code, rec.Header()) + } + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/games/x/missing", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("missing asset: %d", rec.Code) + } +} + +func TestDecodeJSON(t *testing.T) { + type req struct { + Token string `json:"token"` + } + for body, ok := range map[string]bool{ + `{"token":"x"}`: true, + `{"token":"x","score":1}`: false, + `{"token":"x"}{}`: false, + `not json`: false, + `{"token":"` + strings.Repeat("a", MaxBodyBytes) + `"}`: false, + } { + var dst req + r := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(bytes.NewBufferString(body))) + err := DecodeJSON(httptest.NewRecorder(), r, &dst) + if (err == nil) != ok { + t.Errorf("DecodeJSON(%.40q) err = %v, want ok=%v", body, err, ok) + } + } +} + +func TestLimiter_SlidingWindow(t *testing.T) { + l := &Limiter{Per: 3, Window: time.Minute} + t0 := time.Unix(1_800_000_000, 0) + for i := range 3 { + if !l.Allow(1, t0.Add(time.Duration(i)*time.Second)) { + t.Fatalf("request %d refused", i) + } + } + if l.Allow(1, t0.Add(10*time.Second)) { + t.Fatal("fourth request in the window allowed") + } + if !l.Allow(2, t0.Add(10*time.Second)) { + t.Fatal("another key is limited") + } + // The first request leaves the window after a minute. + if !l.Allow(1, t0.Add(time.Minute+time.Second/2)) { + t.Fatal("window did not slide") + } + if l.Allow(1, t0.Add(time.Minute+time.Second/2)) { + t.Fatal("window slid too far") + } + // Idle keys are pruned. + l.Allow(3, t0.Add(10*time.Minute)) + l.mu.Lock() + n := len(l.hits) + l.mu.Unlock() + if n != 1 { + t.Fatalf("idle keys kept: %d", n) + } +} + +func TestReportScore_ChatInlineAndNotModified(t *testing.T) { + rb := testutil.NewRecordingBot(t) + ctx := context.Background() + if err := ReportScore(ctx, rb.Bot, Address{ChatID: -1005, MessageID: 8}, 42, 5); err != nil { + t.Fatalf("ReportScore: %v", err) + } + f := rb.LastSent().Form + if rb.LastSent().Method != "setGameScore" || f["user_id"] != "42" || f["score"] != "5" || f["chat_id"] != "-1005" || f["message_id"] != "8" || f["force"] != "" { + t.Fatalf("setGameScore form = %v", f) + } + rb.FailMethodCode("setGameScore", 400, "Bad Request: BOT_SCORE_NOT_MODIFIED") + err := ReportScore(ctx, rb.Bot, Address{ChatID: -1005, MessageID: 8}, 42, 1) + if !ScoreNotModified(err) { + t.Fatalf("not-modified err = %v", err) + } + if ScoreNotModified(nil) || ScoreNotModified(errors.New("other")) { + t.Fatal("ScoreNotModified matched a non-match") + } + // An inline address takes the raw call, which goes to the live API; with + // a cancelled context it fails before leaving the process. + cctx, cancel := context.WithCancel(ctx) + cancel() + if err := ReportScore(cctx, rb.Bot, Address{InlineID: "BAAA"}, 42, 5); err == nil { + t.Fatal("inline report with a cancelled context succeeded") + } + if rb.LastSent().Form["inline_message_id"] != "" { + t.Fatal("inline report went through the library") + } +} diff --git a/internal/modules/util/htmlgame/http.go b/internal/modules/util/htmlgame/http.go new file mode 100644 index 0000000..cd32441 --- /dev/null +++ b/internal/modules/util/htmlgame/http.go @@ -0,0 +1,101 @@ +package htmlgame + +import ( + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "net/url" + "path" + "strings" + + "github.com/tiennm99/tiennm99bot/internal/log" +) + +// BaseURLEnv names the public https base every game page is served under. +const BaseURLEnv = "GAME_BASE_URL" + +// MaxBodyBytes caps one API request body. +const MaxBodyBytes = 4 << 10 + +// ErrBadRequest is a body that is too big, not one JSON object, or carries +// unknown fields. +var ErrBadRequest = errors.New("htmlgame: bad request") + +// ParseBaseURL accepts https://host[/path] and returns it without a trailing +// slash. Anything else is logged, naming game, and disables that game. +func ParseBaseURL(raw, game 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]; " + game + " game disabled") + return "" + } + return strings.TrimRight(u.String(), "/") +} + +// SecurityHeaders applies to every response under a game's route prefix. The +// token travels in the page URL, so no referrer may leak it. Telegram Web +// embeds games in an iframe, so framing is deliberately not restricted. API +// answers under prefix+"api/" are never cached. +func SecurityHeaders(prefix string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'") + if strings.HasPrefix(r.URL.Path, prefix+"api/") { + h.Set("Cache-Control", "no-store") + } + next.ServeHTTP(w, r) + }) +} + +// WriteJSON writes v as the JSON response body with status. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// DecodeJSON reads one JSON object of at most MaxBodyBytes with no unknown +// fields and nothing after it. It returns ErrBadRequest otherwise; the caller +// writes its own error body. +func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return ErrBadRequest + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return ErrBadRequest + } + return nil +} + +var assetTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", +} + +// ServeAsset serves one embedded page file from fsys. The files are small +// and fixed for a binary's lifetime, so a short public cache is safe; the +// page's token lives in the query string, which does not change the content. +func ServeAsset(fsys fs.FS, name string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + body, err := fs.ReadFile(fsys, name) + if err != nil { + http.NotFound(w, nil) + return + } + w.Header().Set("Content-Type", assetTypes[path.Ext(name)]) + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(body) + } +} diff --git a/internal/modules/util/htmlgame/ratelimit.go b/internal/modules/util/htmlgame/ratelimit.go new file mode 100644 index 0000000..3af4426 --- /dev/null +++ b/internal/modules/util/htmlgame/ratelimit.go @@ -0,0 +1,49 @@ +package htmlgame + +import ( + "sync" + "time" +) + +// Limiter allows at most Per requests per key within any Window (a sliding +// window). It is in-memory: a restart forgets it, which only resets the +// window. Keys with no request in the last Window are pruned as it runs. +type Limiter struct { + Per int + Window time.Duration + + mu sync.Mutex + hits map[int64][]time.Time + pruned time.Time +} + +// Allow records a request for key at now and reports whether it is within +// the limit. A refused request is not recorded. +func (l *Limiter) Allow(key int64, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.hits == nil { + l.hits = map[int64][]time.Time{} + } + cutoff := now.Add(-l.Window) + if now.Sub(l.pruned) >= l.Window { + for k, ts := range l.hits { + if len(ts) == 0 || !ts[len(ts)-1].After(cutoff) { + delete(l.hits, k) + } + } + l.pruned = now + } + recent := l.hits[key] + i := 0 + for i < len(recent) && !recent[i].After(cutoff) { + i++ + } + recent = recent[i:] + if len(recent) >= l.Per { + l.hits[key] = recent + return false + } + l.hits[key] = append(recent, now) + return true +} diff --git a/internal/modules/util/htmlgame/score.go b/internal/modules/util/htmlgame/score.go new file mode 100644 index 0000000..4d55b8f --- /dev/null +++ b/internal/modules/util/htmlgame/score.go @@ -0,0 +1,43 @@ +package htmlgame + +import ( + "context" + "strings" + + "github.com/go-telegram/bot" + + "github.com/tiennm99/tiennm99bot/internal/telegram" +) + +// Address names one game message: ChatID+MessageID for a message the bot +// sent into a chat, or InlineID for one sent via the bot. +type Address struct { + ChatID int64 + MessageID int + InlineID string +} + +// ReportScore records score for userID on the game message with +// setGameScore (force=false, so Telegram keeps each player's best). A chat +// message goes through the library; an inline message needs the raw call, +// because the library types inline_message_id as an int and cannot decode +// Telegram's `true` result. +func ReportScore(ctx context.Context, b *bot.Bot, a Address, userID int64, score int) error { + if a.InlineID != "" { + return telegram.SetInlineGameScore(ctx, b.Token(), a.InlineID, userID, score) + } + _, err := b.SetGameScore(ctx, &bot.SetGameScoreParams{ + UserID: userID, + Score: score, + ChatID: a.ChatID, + MessageID: a.MessageID, + }) + return err +} + +// ScoreNotModified reports Telegram's answer to a score that does not beat +// the player's best. With force=false that is the expected outcome, not a +// failure. +func ScoreNotModified(err error) bool { + return err != nil && strings.Contains(err.Error(), "BOT_SCORE_NOT_MODIFIED") +} diff --git a/internal/modules/util/htmlgame/token.go b/internal/modules/util/htmlgame/token.go new file mode 100644 index 0000000..11f26cc --- /dev/null +++ b/internal/modules/util/htmlgame/token.go @@ -0,0 +1,93 @@ +// Package htmlgame holds the plumbing shared by the bot's Telegram HTML5 +// games: the public base URL, signed Play-link tokens, the HTTP security +// headers and JSON helpers of a game's API, embedded page assets, score +// reporting with setGameScore, and a per-user request limiter. +// +// Each game keeps its own claims type, token key label, messages and routes; +// this package only owns the parts that must behave the same everywhere. +package htmlgame + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +// MaxTokenBytes bounds a token before any work is spent on it. +const MaxTokenBytes = 1024 + +var ( + // ErrBadToken is a token that is malformed, forged or names no valid + // game message. + ErrBadToken = errors.New("htmlgame: bad token") + // ErrTokenExpired is a correctly signed token past its expiry. + ErrTokenExpired = errors.New("htmlgame: token expired") +) + +// Claims is what a game decodes a token into. Valid checks the game's own +// rules (for example exactly one message address); ExpiresAt is the unix +// second the token stops working. +type Claims interface { + Valid() bool + ExpiresAt() int64 +} + +// DeriveKey is HMAC-SHA256(key=root, label). Each game and purpose uses its +// own label, so a key derived for one never verifies for another. An empty +// root yields nil. +func DeriveKey(root []byte, label string) []byte { + if len(root) == 0 { + return nil + } + mac := hmac.New(sha256.New, root) + mac.Write([]byte(label)) + return mac.Sum(nil) +} + +// Sign encodes payload as base64url(json) "." base64url(HMAC-SHA256). +func Sign(key []byte, payload any) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(raw) + return body + "." + base64.RawURLEncoding.EncodeToString(tokenMAC(key, body)), nil +} + +func tokenMAC(key []byte, body string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(body)) + return mac.Sum(nil) +} + +// Verify checks the signature first, then decodes into dst (a pointer) and +// checks the claims and the expiry. It returns ErrBadToken or +// ErrTokenExpired. +func Verify(key []byte, token string, now time.Time, dst Claims) error { + if len(key) == 0 || len(token) > MaxTokenBytes { + return ErrBadToken + } + body, sig, ok := strings.Cut(token, ".") + if !ok { + return ErrBadToken + } + gotMAC, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil || !hmac.Equal(gotMAC, tokenMAC(key, body)) { + return ErrBadToken + } + payload, err := base64.RawURLEncoding.DecodeString(body) + if err != nil { + return ErrBadToken + } + if err := json.Unmarshal(payload, dst); err != nil || !dst.Valid() { + return ErrBadToken + } + if now.Unix() >= dst.ExpiresAt() { + return ErrTokenExpired + } + return nil +} diff --git a/internal/modules/util/subscription/fanout_func_test.go b/internal/modules/util/subscription/fanout_func_test.go new file mode 100644 index 0000000..77fb8d3 --- /dev/null +++ b/internal/modules/util/subscription/fanout_func_test.go @@ -0,0 +1,80 @@ +package subscription + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +func newTestStore() Store { + return storage.Typed[Doc](storage.NewMemoryProvider().Collection("sub")) +} + +func TestFanoutFunc_SendsPrunesChatWideAndTopicOnly(t *testing.T) { + ctx := context.Background() + store := newTestStore() + var mu sync.Mutex + for _, s := range []Subscriber{{ChatID: 1}, {ChatID: -2, ThreadID: 5}, {ChatID: -2, ThreadID: 6}, {ChatID: -3, ThreadID: 7}, {ChatID: -3}, {ChatID: 4}} { + if _, err := Add(ctx, store, s.ChatID, s.ThreadID); err != nil { + t.Fatal(err) + } + } + subs, _ := List(ctx, store) + var got []Subscriber + res, err := FanoutFunc(ctx, "test", store, &mu, subs, func(_ context.Context, sub Subscriber) error { + got = append(got, sub) + switch { + case sub.ChatID == -2 && sub.ThreadID == 5: + return errors.New("Forbidden: bot was blocked by the user") + case sub.ChatID == -3 && sub.ThreadID == 7: + return errors.New("Bad Request: have no rights to send a message") + case sub.ChatID == 4: + return errors.New("Too Many Requests: retry after 3") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 6 || res.Sent != 3 || res.Failed != 3 || res.Pruned != 3 || res.Throttled { + t.Fatalf("calls %d, result %+v", len(got), res) + } + left, _ := List(ctx, store) + want := []Subscriber{{ChatID: 1}, {ChatID: -3}, {ChatID: 4}} + if len(left) != len(want) { + t.Fatalf("left = %+v, want %+v", left, want) + } + for i := range want { + if left[i] != want[i] { + t.Fatalf("left = %+v, want %+v", left, want) + } + } +} + +func TestFanoutFunc_ThrottlesAndStopsOnCancel(t *testing.T) { + subs := make([]Subscriber, rateLimitThreshold+5) + for i := range subs { + subs[i] = Subscriber{ChatID: int64(i + 1)} + } + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + var mu sync.Mutex + start := time.Now() + res, err := FanoutFunc(ctx, "test", newTestStore(), &mu, subs, func(context.Context, Subscriber) error { + calls++ + if calls == 3 { + cancel() + } + return nil + }) + if !errors.Is(err, context.Canceled) || !res.Throttled || calls != 3 || res.Sent != 3 { + t.Fatalf("calls %d, result %+v, err %v", calls, res, err) + } + if elapsed := time.Since(start); elapsed < 2*rateLimitDelay { + t.Fatalf("not throttled: %v", elapsed) + } +} diff --git a/internal/modules/util/subscription/push.go b/internal/modules/util/subscription/push.go index a184430..d1bea69 100644 --- a/internal/modules/util/subscription/push.go +++ b/internal/modules/util/subscription/push.go @@ -134,16 +134,33 @@ type Result struct { Throttled bool } -// Fanout sends params(sub) to every subscriber. Per-chat send failures are +// Fanout sends params to every subscriber. ChatID and MessageThreadID are +// filled in from each subscriber so forum subscribers receive the push in +// their topic, not in General. Throttling, failure handling and pruning are +// FanoutFunc's. +func Fanout(ctx context.Context, name string, store Store, mu *sync.Mutex, subs []Subscriber, sender Sender, params bot.SendMessageParams) (Result, error) { + return FanoutFunc(ctx, name, store, mu, subs, func(ctx context.Context, sub Subscriber) error { + p := params + p.ChatID = sub.ChatID + p.MessageThreadID = sub.ThreadID + _, err := sender.SendMessage(ctx, &p) + return err + }) +} + +// SendFunc delivers one subscriber's push. It may send several messages; the +// error of the first one that failed is what FanoutFunc classifies. +type SendFunc func(ctx context.Context, sub Subscriber) error + +// FanoutFunc calls send for every subscriber. Per-chat send failures are // logged but do not abort the batch — one bad chat does not deny the rest. -// ChatID and MessageThreadID are filled in from each subscriber so forum -// subscribers receive the push in their topic, not in General. +// Above rateLimitThreshold subscribers, calls are spaced by rateLimitDelay. // // Unreachable subscribers are pruned afterwards, best-effort, while holding // mu (the same mutex that guards Add and Remove). A failed prune just leaves // the dead chats listed, and the next push fails on them and retries. // The only returned error is ctx cancellation while throttling. -func Fanout(ctx context.Context, name string, store Store, mu *sync.Mutex, subs []Subscriber, sender Sender, params bot.SendMessageParams) (Result, error) { +func FanoutFunc(ctx context.Context, name string, store Store, mu *sync.Mutex, subs []Subscriber, send SendFunc) (Result, error) { res := Result{Throttled: len(subs) > rateLimitThreshold} deadChats := map[int64]struct{}{} var deadTopics []Subscriber @@ -155,10 +172,7 @@ func Fanout(ctx context.Context, name string, store Store, mu *sync.Mutex, subs case <-time.After(rateLimitDelay): } } - p := params - p.ChatID = sub.ChatID - p.MessageThreadID = sub.ThreadID - if _, err := sender.SendMessage(ctx, &p); err != nil { + if err := send(ctx, sub); err != nil { log.Warn(name+" push send failed", "chat", sub.ChatID, "thread", sub.ThreadID, "err", err) res.Failed++ switch ClassifyTerminal(err) { diff --git a/internal/modules/wordle/compare.go b/internal/modules/wordle/compare.go index de2f3bd..11db32e 100644 --- a/internal/modules/wordle/compare.go +++ b/internal/modules/wordle/compare.go @@ -1,77 +1,24 @@ // Package wordle implements the classic 5-letter word-guess game, scored -// letter-by-letter green/yellow/grey. +// letter-by-letter green/yellow/grey. The word data and scoring live in the +// wordlist subpackage, shared with wordledaily. package wordle -// WordLength is wordle's fixed 5. Exposed so render.go and tests can reuse it -// without magic numbers. -const WordLength = 5 +import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" + +// WordLength is wordle's fixed 5. +const WordLength = wordlist.WordLength // Letter result categories stored in LetterScore.Result. The values are part // of the stored game document's shape, so changing one orphans saved rounds. const ( - ResultCorrect = "correct" - ResultPartial = "partial" - ResultWrong = "wrong" + ResultCorrect = wordlist.ResultCorrect + ResultPartial = wordlist.ResultPartial + ResultWrong = wordlist.ResultWrong ) // LetterScore is one scored letter, stored per guess (nested in GameState). -// bson tags mirror the json names so the document's field names are the same -// in either encoding. -type LetterScore struct { - Letter string `json:"letter" bson:"letter"` - Result string `json:"result" bson:"result"` -} +type LetterScore = wordlist.LetterScore -// CompareWords scores guess against target letter-by-letter. Both are assumed -// lowercase a-z and exactly WordLength long; callers validate via -// validateGuess before reaching here. -// -// Two-pass marking is required to handle duplicate letters correctly: -// - pass 1: positional matches → "correct"; consume those slots from the -// target's available pool. -// - pass 2: remaining guess letters → "partial" if still in the pool (and -// consume), else "wrong". -// -// Example: target "abbey", guess "babes" → -// -// b@0 partial, a@1 partial, b@2 correct, e@3 correct, s@4 wrong. -func CompareWords(guess, target string) []LetterScore { - out := make([]LetterScore, WordLength) - pool := make([]byte, 0, WordLength) - - // Pass 1 — positional matches. - for i := 0; i < WordLength; i++ { - if guess[i] == target[i] { - out[i] = LetterScore{Letter: string(guess[i]), Result: ResultCorrect} - } else { - pool = append(pool, target[i]) - } - } - - // Pass 2 — partial matches against the remaining-pool, with consumption. - for i := 0; i < WordLength; i++ { - if out[i].Result == ResultCorrect { - continue - } - idx := indexOfByte(pool, guess[i]) - if idx >= 0 { - pool = append(pool[:idx], pool[idx+1:]...) - out[i] = LetterScore{Letter: string(guess[i]), Result: ResultPartial} - } else { - out[i] = LetterScore{Letter: string(guess[i]), Result: ResultWrong} - } - } - return out -} - -// indexOfByte returns the first index of c in s, or -1. -// (bytes.IndexByte gives the same answer; inlined to keep this file -// dependency-free since the scoring algorithm is the whole point.) -func indexOfByte(s []byte, c byte) int { - for i, b := range s { - if b == c { - return i - } - } - return -1 -} +// CompareWords scores guess against the answer letter-by-letter, handling +// duplicate letters the NYT way. See wordlist.Compare. +func CompareWords(guess, answer string) []LetterScore { return wordlist.Compare(guess, answer) } diff --git a/internal/modules/wordle/lookup.go b/internal/modules/wordle/lookup.go index e1daad1..8f4c121 100644 --- a/internal/modules/wordle/lookup.go +++ b/internal/modules/wordle/lookup.go @@ -1,52 +1,26 @@ package wordle -import "strings" +import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" // normalizeWord lowercases input and strips anything outside a-z. -func normalizeWord(input string) string { - lower := strings.ToLower(input) - out := make([]byte, 0, len(lower)) - for i := 0; i < len(lower); i++ { - c := lower[i] - if c >= 'a' && c <= 'z' { - out = append(out, c) - } - } - return string(out) -} +func normalizeWord(input string) string { return wordlist.Normalize(input) } // rejectReason classifies why validateGuess returned not-ok. rejectMessage in // handlers.go maps each reason to its user-facing reply. -type rejectReason string +type rejectReason = wordlist.Reason const ( - reasonEmpty rejectReason = "empty" - reasonLength rejectReason = "length" - reasonUnknown rejectReason = "unknown" + reasonEmpty = wordlist.ReasonEmpty + reasonLength = wordlist.ReasonLength + reasonUnknown = wordlist.ReasonUnknown ) -// guessResult is the validateGuess outcome. Word is always populated with the -// normalized form, even on failure, so callers can include it in error -// messages without re-normalizing. -type guessResult struct { - OK bool - Word string - Reason rejectReason -} +// guessResult is the validateGuess outcome; Word is always the normalized +// input. +type guessResult = wordlist.Result // validateGuess normalizes input then checks length + dictionary membership. -// -// Reasons in priority order: empty (post-normalize blank) > length > unknown. +// Reasons in priority order: empty > length > unknown. func validateGuess(dict map[string]struct{}, input string) guessResult { - w := normalizeWord(input) - if w == "" { - return guessResult{OK: false, Word: w, Reason: reasonEmpty} - } - if len(w) != WordLength { - return guessResult{OK: false, Word: w, Reason: reasonLength} - } - if _, ok := dict[w]; !ok { - return guessResult{OK: false, Word: w, Reason: reasonUnknown} - } - return guessResult{OK: true, Word: w} + return wordlist.Validate(dict, input) } diff --git a/internal/modules/wordle/render.go b/internal/modules/wordle/render.go index 84db6be..c1bd215 100644 --- a/internal/modules/wordle/render.go +++ b/internal/modules/wordle/render.go @@ -2,19 +2,9 @@ package wordle import ( "strings" -) -// markerFor maps a LetterScore.Result to the NYT-Wordle share emoji. -func markerFor(result string) string { - switch result { - case ResultCorrect: - return "🟩" - case ResultPartial: - return "🟨" - default: - return "⬜" - } -} + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" +) // renderGuess formats one guess as the NYT share-pattern: word on one line, // emoji marker row below. @@ -22,11 +12,7 @@ func markerFor(result string) string { // CRANE // 🟩🟨⬜🟩🟩 func renderGuess(word string, results []LetterScore) string { - var markers strings.Builder - for _, r := range results { - markers.WriteString(markerFor(r.Result)) - } - return strings.ToUpper(word) + "\n" + markers.String() + return strings.ToUpper(word) + "\n" + wordlist.EmojiRow(results) } // renderBoard joins all prior guesses, blank-line separated. Used when a diff --git a/internal/modules/wordle/wordlist/compare.go b/internal/modules/wordle/wordlist/compare.go new file mode 100644 index 0000000..c4f0b85 --- /dev/null +++ b/internal/modules/wordle/wordlist/compare.go @@ -0,0 +1,77 @@ +// Package wordlist holds the English 5-letter word data and the pure scoring +// logic shared by the wordle and wordledaily games. +package wordlist + +// WordLength is the fixed word length, exposed so renderers and tests reuse it +// without magic numbers. +const WordLength = 5 + +// Letter result categories stored in LetterScore.Result. The values are part +// of the stored game document's shape, so changing one orphans saved rounds. +const ( + ResultCorrect = "correct" + ResultPartial = "partial" + ResultWrong = "wrong" +) + +// LetterScore is one scored letter, stored per guess (nested in GameState). +// bson tags mirror the json names so the document's field names are the same +// in either encoding. +type LetterScore struct { + Letter string `json:"letter" bson:"letter"` + Result string `json:"result" bson:"result"` +} + +// Compare scores guess against target letter-by-letter. Both are assumed +// lowercase a-z and exactly WordLength long; callers validate via +// Validate before reaching here. +// +// Two-pass marking is required to handle duplicate letters correctly: +// - pass 1: positional matches → "correct"; consume those slots from the +// target's available pool. +// - pass 2: remaining guess letters → "partial" if still in the pool (and +// consume), else "wrong". +// +// Example: target "abbey", guess "babes" → +// +// b@0 partial, a@1 partial, b@2 correct, e@3 correct, s@4 wrong. +func Compare(guess, target string) []LetterScore { + out := make([]LetterScore, WordLength) + pool := make([]byte, 0, WordLength) + + // Pass 1 — positional matches. + for i := 0; i < WordLength; i++ { + if guess[i] == target[i] { + out[i] = LetterScore{Letter: string(guess[i]), Result: ResultCorrect} + } else { + pool = append(pool, target[i]) + } + } + + // Pass 2 — partial matches against the remaining-pool, with consumption. + for i := 0; i < WordLength; i++ { + if out[i].Result == ResultCorrect { + continue + } + idx := indexOfByte(pool, guess[i]) + if idx >= 0 { + pool = append(pool[:idx], pool[idx+1:]...) + out[i] = LetterScore{Letter: string(guess[i]), Result: ResultPartial} + } else { + out[i] = LetterScore{Letter: string(guess[i]), Result: ResultWrong} + } + } + return out +} + +// indexOfByte returns the first index of c in s, or -1. +// (bytes.IndexByte gives the same answer; inlined to keep this file +// dependency-free since the scoring algorithm is the whole point.) +func indexOfByte(s []byte, c byte) int { + for i, b := range s { + if b == c { + return i + } + } + return -1 +} diff --git a/internal/modules/wordle/wordlist/data/answers.txt b/internal/modules/wordle/wordlist/data/answers.txt new file mode 100644 index 0000000..bbcecd1 --- /dev/null +++ b/internal/modules/wordle/wordlist/data/answers.txt @@ -0,0 +1,2230 @@ +aback +abase +abate +abbey +abbot +abhor +abide +abode +abort +about +above +abuse +abyss +acorn +acrid +actor +acute +adage +adapt +adept +admin +admit +adobe +adopt +adore +adorn +adult +affix +afire +afoot +afoul +after +again +agape +agate +agent +agile +aging +aglow +agony +agree +ahead +aisle +alarm +album +alert +algae +alibi +alien +align +alike +alive +allay +alley +allot +allow +alloy +aloft +alone +along +aloof +aloud +alpha +altar +alter +amass +amaze +amber +amble +amend +amigo +amiss +amity +among +ample +amply +amuse +angel +anger +angle +angry +angst +anime +ankle +annex +annoy +annul +anode +antic +anvil +aorta +apart +aphid +apnea +apple +apply +apron +aptly +arbor +ardor +arena +argue +arise +armor +aroma +arose +array +arrow +arson +artsy +ascot +ashen +aside +askew +assay +asset +atoll +atone +attic +audio +audit +augur +aunty +avail +avert +avian +avoid +await +awake +award +aware +awash +awful +awoke +axial +axiom +azure +bacon +badge +badly +bagel +baggy +baker +balmy +banal +banjo +barge +baron +basal +basic +basil +basin +basis +baste +batch +bathe +baton +batty +bawdy +bayou +beach +beady +beard +beast +beech +beefy +befit +began +beget +begin +begun +beige +being +belch +belie +belle +belly +below +bench +beret +berry +berth +beset +bevel +bezel +bible +bicep +bigot +biker +bilge +binge +bingo +biome +birch +birth +bison +bitty +black +blade +blame +bland +blank +blare +blast +blaze +bleak +bleat +bleed +bleep +blend +bless +blimp +blind +blink +bliss +blitz +bloat +block +bloke +blond +blood +bloom +blown +bluer +bluff +blunt +blurb +blurt +blush +board +boast +bobby +bongo +bonus +boost +booth +booze +boozy +borax +borne +bosom +bossy +botch +bough +bound +bowel +boxer +brace +braid +brain +brake +brand +brash +brass +brave +bravo +brawl +brawn +bread +break +breed +briar +bribe +brick +bride +brief +brine +bring +brink +briny +brisk +broad +broil +broke +brood +brook +broom +broth +brown +brunt +brush +brute +buddy +budge +buggy +bugle +build +built +bulge +bulky +bully +bunch +bunny +burly +burnt +burro +burst +bushy +butte +buxom +buyer +bylaw +cabal +cabby +cabin +cable +cacao +cache +cacti +caddy +cadet +cagey +cairn +camel +cameo +canal +candy +canny +canoe +canon +caper +carat +cargo +carol +carry +carve +caste +catch +cater +catty +caulk +cause +cease +cedar +cello +chafe +chaff +chain +chair +chalk +champ +chant +chaos +chard +charm +chart +chase +chasm +cheap +cheat +check +cheek +cheer +chess +chest +chick +chide +chief +child +chili +chill +chime +chimp +china +chirp +chock +choir +choke +chord +chore +chose +chuck +chump +chunk +churn +chute +cider +cigar +cinch +circa +civic +civil +clack +claim +clamp +clang +clank +clash +clasp +class +clean +clear +cleat +cleft +clerk +click +cliff +climb +cling +clink +cloak +clock +clone +close +cloth +cloud +clout +clove +clown +cluck +clued +clump +clung +coach +coast +cobra +cocoa +colon +color +combo +comet +comfy +comic +comma +conch +condo +conic +copse +coral +couch +cough +could +count +coupe +court +coven +cover +covet +cower +coyly +crack +craft +cramp +crane +crank +crash +crass +crate +crave +crawl +craze +crazy +creak +cream +credo +creed +creek +creep +crepe +crept +cress +crest +crick +cried +crier +crime +crimp +crisp +croak +crock +crone +crony +crook +cross +croup +crowd +crown +crude +cruel +crumb +crump +crush +crust +crypt +cubic +cumin +curio +curly +curry +curse +curve +curvy +cutie +cyber +cycle +cynic +daddy +daily +dairy +daisy +dally +dance +dandy +datum +daunt +dealt +death +debit +debug +debut +decal +decay +decor +decoy +decry +defer +deign +deity +delay +delta +delve +demon +demur +denim +dense +depot +depth +derby +deter +detox +deuce +devil +diary +dicey +digit +dimly +diner +dingo +dingy +diode +dirge +dirty +disco +ditch +ditto +ditty +diver +dizzy +dodge +dodgy +dogma +doing +dolly +donor +donut +dopey +doubt +dough +dowdy +dowel +downy +dowry +dozen +draft +drain +drake +drama +drank +drape +drawl +drawn +dread +dream +dress +dried +drier +drift +drill +drink +drive +droll +drone +drool +droop +dross +drove +drown +druid +drunk +dryer +dryly +duchy +dully +dummy +dumpy +dunce +dusky +dusty +duvet +dwarf +dwell +dwelt +dying +eager +eagle +early +earth +easel +eaten +eater +ebony +edict +edify +eerie +egret +eight +eject +elate +elbow +elder +elect +elegy +elfin +elide +elite +elope +elude +email +embed +ember +emcee +emoji +empty +enact +endow +enemy +enjoy +ennui +ensue +enter +entry +envoy +epoch +epoxy +equal +equip +erase +erect +erode +error +erupt +essay +ester +ether +ethic +ethos +etude +evade +event +every +evict +evoke +exact +exalt +excel +exert +exile +exist +expel +extol +extra +exult +fable +facet +faint +fairy +faith +false +fancy +farce +fatal +fatty +fault +fauna +favor +feast +feign +fella +felon +femur +fence +feral +ferry +fetal +fetch +fetid +fetus +fever +fewer +fiber +ficus +field +fiend +fiery +fifth +fifty +fight +filer +filet +filly +filmy +filth +final +finch +finer +first +fishy +fixer +fizzy +fjord +flack +flail +flair +flake +flaky +flame +flank +flare +flash +flask +fleck +fleet +flesh +flick +flier +fling +flint +flirt +float +flock +flood +floor +flora +floss +flour +flout +flown +fluff +fluid +fluke +flume +flung +flunk +flush +flute +flyer +foamy +focal +focus +foggy +foist +folio +folly +foray +force +forge +forgo +forte +forth +forty +forum +found +foyer +frail +frame +frank +fraud +freak +freed +freer +fresh +friar +fried +frill +frisk +frock +frond +front +frost +froth +frown +froze +fruit +fudge +fugue +fully +fungi +funky +funny +furor +furry +fussy +fuzzy +gaffe +gaily +gamer +gamma +gamut +gassy +gator +gaudy +gauge +gaunt +gauze +gavel +gawky +gazer +gecko +geeky +geese +genie +genre +ghost +ghoul +giant +giddy +girth +given +giver +glade +gland +glare +glass +glaze +gleam +glean +glide +glint +gloat +globe +gloom +glory +gloss +glove +glyph +gnash +gnome +godly +going +golem +golly +goner +goody +gooey +goofy +goose +gorge +gouge +gourd +grace +grade +graft +grail +grain +grand +grant +grape +graph +grasp +grass +grate +grave +gravy +graze +great +greed +green +greet +grief +grill +grime +grimy +grind +gripe +groan +groin +groom +grope +gross +group +grout +grove +growl +grown +gruel +gruff +grunt +guard +guava +guess +guest +guide +guild +guile +guilt +guise +gulch +gully +gumbo +gummy +guppy +gusto +gusty +habit +hairy +halve +handy +happy +hardy +harpy +harsh +haste +hasty +hatch +hater +haunt +haven +havoc +hazel +heady +heard +heart +heath +heave +heavy +hedge +hefty +heist +helix +hello +hence +heron +hiker +hilly +hinge +hippo +hitch +hoard +hobby +hoist +holly +homer +honey +honor +horde +horse +hotel +hotly +hound +house +hovel +hover +howdy +human +humid +humor +humph +humus +hunch +hunky +hurry +husky +hutch +hydro +hyena +hyper +icily +icing +ideal +idiom +idiot +idler +idyll +igloo +image +imbue +impel +imply +inane +inbox +incur +index +inept +inert +infer +ingot +inlay +inlet +inner +input +inter +intro +ionic +irate +irony +islet +issue +itchy +ivory +jaunt +jazzy +jelly +jerky +jetty +jewel +jiffy +joint +joist +joker +jolly +joust +judge +juice +juicy +jumbo +jumpy +junta +juror +karma +kayak +kebab +khaki +kiosk +kitty +knack +knave +knead +kneed +kneel +knelt +knife +knock +knoll +known +koala +krill +label +labor +laden +ladle +lager +lance +lanky +lapel +lapse +large +larva +laser +lasso +latch +later +lathe +latte +laugh +layer +leach +leafy +leaky +leant +leapt +learn +lease +leash +least +leave +ledge +leech +leery +lefty +legal +leggy +lemon +lemur +leper +level +lever +libel +liege +light +liken +lilac +limbo +limit +linen +liner +lingo +lipid +lithe +liver +livid +llama +loamy +loath +lobby +local +locus +lodge +lofty +logic +login +loopy +loose +lorry +loser +lotus +louse +lousy +lover +lower +lowly +loyal +lucid +lucky +lumen +lumpy +lunar +lunch +lunge +lupus +lurch +lurid +lying +lymph +lyric +macaw +macho +macro +madam +madly +mafia +magic +magma +maize +major +maker +mambo +manga +mange +mango +mangy +mania +manic +manly +manor +maple +march +marry +marsh +mason +match +mauve +maxim +maybe +mayor +mealy +meant +meaty +medal +media +medic +melee +melon +mercy +merge +merit +merry +messy +metal +meter +metro +micro +midge +midst +might +milky +mimic +mince +miner +minor +minty +minus +mirth +miser +mixer +mocha +modal +model +modem +mogul +moist +molar +moldy +money +month +moody +moose +moral +morph +mossy +motel +motif +motor +motto +moult +mound +mount +mourn +mouse +mouth +mover +movie +mower +mucky +mucus +muddy +mulch +mummy +munch +mural +murky +mushy +music +musky +musty +myrrh +nacho +nadir +naive +nanny +nasal +nasty +natal +naval +navel +needy +neigh +nerdy +nerve +never +newer +newly +nicer +niche +niece +night +ninja +ninth +noble +nobly +noise +noisy +nomad +noose +north +nosey +notch +novel +nudge +nurse +nutty +nylon +nymph +oaken +oasis +obese +occur +ocean +octet +odder +oddly +offal +offer +often +olden +older +olive +omega +onion +onset +opera +opine +opium +optic +orbit +order +organ +other +otter +ought +ounce +outdo +outer +outgo +ovary +overt +owing +owner +oxide +ozone +paddy +pagan +paint +paler +palsy +panda +panel +panic +pansy +papal +paper +parka +parry +parse +party +pasta +paste +pasty +patch +patio +patsy +patty +pause +payee +payer +peace +peach +pearl +pecan +pedal +penal +pence +penne +penny +perch +peril +perky +pesky +pesto +petal +petty +phase +phone +phony +photo +piano +picky +piece +piety +piggy +pilot +pinch +pinky +pinto +piper +pique +pitch +pithy +pivot +pixel +pixie +pizza +place +plaid +plain +plait +plane +plank +plant +plate +plaza +plead +pleat +plied +plier +pluck +plumb +plume +plump +plunk +plush +poesy +point +poise +poker +polar +polka +polyp +pooch +poppy +porch +poser +posit +posse +pouch +pound +pouty +power +prank +prawn +preen +press +price +prick +pride +pried +prime +primo +print +prior +prism +privy +prize +probe +promo +prone +prong +proof +prose +proud +prove +prowl +proxy +prude +prune +psalm +pudgy +puffy +pulpy +pulse +punch +pupil +puppy +puree +purer +purge +purse +pushy +putty +quack +quail +quake +qualm +quark +quart +quash +quasi +queen +queer +quell +query +quest +queue +quick +quiet +quill +quilt +quirk +quite +quota +quote +rabbi +rabid +racer +radar +radii +radio +rainy +raise +rally +ramen +ranch +randy +range +rapid +rarer +raspy +ratio +ratty +raven +rayon +razor +reach +react +ready +realm +rebar +rebel +rebus +rebut +recap +recur +reedy +refer +refit +regal +rehab +reign +relax +relay +relic +remit +renal +renew +repay +repel +reply +rerun +reset +resin +retch +retro +retry +reuse +revel +revue +rhino +rhyme +rider +ridge +rifle +right +rigid +rigor +rinse +ripen +riper +risen +riser +risky +rival +river +rivet +roach +roast +robin +robot +rocky +rodeo +rogue +roomy +roost +rotor +rouge +rough +round +rouse +route +rover +rowdy +rower +royal +ruddy +ruder +rugby +ruler +rumba +rumor +rupee +rural +rusty +sadly +safer +saint +salad +sally +salon +salsa +salty +salve +salvo +sandy +saner +sappy +sassy +satin +sauce +saucy +sauna +saute +savor +savvy +scald +scale +scalp +scaly +scamp +scant +scare +scarf +scary +scene +scent +scion +scoff +scold +scone +scoop +scope +score +scorn +scour +scout +scowl +scram +scrap +scree +screw +scrub +scrum +scuba +sedan +seedy +segue +seize +sense +sepia +serif +serum +serve +setup +seven +sever +sewer +shack +shade +shady +shaft +shake +shaky +shale +shall +shalt +shame +shank +shape +shard +share +shark +sharp +shave +shawl +shear +sheen +sheep +sheer +sheet +sheik +shelf +shell +shied +shift +shine +shiny +shire +shirk +shirt +shoal +shock +shone +shook +shoot +shore +shorn +short +shout +shove +shown +showy +shrew +shrub +shrug +shuck +shunt +shush +shyly +siege +sieve +sight +sigma +silky +silly +since +sinew +singe +siren +sitar +sixth +sixty +skate +skier +skiff +skill +skimp +skirt +skulk +skull +skunk +slack +slain +slang +slant +slash +slate +sleek +sleep +sleet +slept +slice +slick +slide +slime +slimy +sling +slink +sloop +slope +slosh +sloth +slump +slung +slunk +slurp +slush +slyly +smack +small +smart +smash +smear +smell +smelt +smile +smirk +smite +smith +smock +smoke +smoky +smote +snack +snail +snake +snaky +snare +snarl +sneak +sneer +snide +sniff +snipe +snoop +snore +snort +snout +snowy +snuck +snuff +soapy +sober +soggy +solar +solid +solve +sonar +sonic +sooty +sorry +sound +south +sower +space +spade +spank +spare +spark +spasm +spawn +speak +spear +speck +speed +spell +spelt +spend +spent +spice +spicy +spied +spiel +spike +spiky +spill +spilt +spine +spiny +spire +spite +splat +split +spoil +spoke +spoof +spook +spool +spoon +spore +sport +spout +spray +spree +sprig +spunk +spurn +spurt +squad +squat +squib +squid +stack +staff +stage +staid +stain +stair +stake +stale +stalk +stall +stamp +stand +stank +stare +stark +start +stash +state +stave +stead +steak +steal +steam +steed +steel +steep +steer +stein +stern +stick +stiff +still +stilt +sting +stink +stint +stock +stoic +stoke +stole +stomp +stone +stony +stood +stool +stoop +store +stork +storm +story +stout +stove +strap +straw +stray +strip +strut +stuck +study +stuff +stump +stung +stunk +stunt +style +suave +sugar +suing +suite +sulky +sully +sumac +sunny +super +surer +surge +surly +sushi +swamp +swarm +swash +swath +swear +sweat +sweep +sweet +swell +swept +swift +swill +swine +swing +swirl +swish +swoon +swoop +sword +swore +sworn +swung +syrup +tabby +table +taboo +tacit +tacky +taffy +taint +taken +taker +tally +talon +tamer +tango +tangy +taper +tapir +tardy +tarot +taste +tasty +taunt +tawny +teach +teary +tease +teddy +teeth +tempo +tenet +tenor +tense +tenth +tepee +tepid +terse +testy +thank +theft +their +theme +there +these +thick +thief +thigh +thing +think +third +thong +thorn +those +three +threw +throb +throw +thrum +thumb +thump +thyme +tiara +tibia +tidal +tiger +tight +tilde +timer +timid +tipsy +titan +tithe +title +toast +today +token +tonal +tonic +tooth +topaz +topic +torch +torso +total +totem +touch +tough +towel +tower +toxic +toxin +trace +track +tract +trade +trail +train +trait +tramp +trash +trawl +tread +treat +trend +triad +trial +tribe +trice +trick +tried +tripe +trite +troll +troop +trope +trout +trove +truce +truck +truer +truly +trump +trunk +truss +trust +truth +tryst +tuber +tulip +tulle +tumor +tuner +tunic +turbo +tutor +twang +tweak +tweed +tweet +twice +twine +twirl +twist +tying +udder +ulcer +ultra +uncle +uncut +under +undid +undue +unfit +unify +union +unite +unity +unlit +unmet +untie +until +unzip +upper +upset +urban +usage +usher +using +usual +usurp +utter +vague +valet +valid +valor +value +valve +vapid +vapor +vault +vaunt +vegan +venom +venue +verge +verse +verve +vicar +video +vigil +vigor +villa +vinyl +viola +viper +viral +virus +visit +visor +vista +vital +vivid +vixen +vocal +vodka +vogue +voice +voila +vomit +voter +vouch +vowel +vying +wacky +wafer +wager +wagon +waist +waive +waltz +warty +waste +watch +water +waver +waxen +weary +weave +wedge +weedy +weigh +weird +whack +whale +wharf +wheat +wheel +whelp +where +which +whiff +while +whine +whiny +whirl +whisk +white +whole +whoop +whose +widen +wider +widow +width +wield +wight +wimpy +wince +winch +windy +wiper +wiser +wispy +witch +witty +woken +woman +women +woody +wooer +woozy +wordy +world +worry +worse +worst +worth +would +wound +woven +wrack +wrath +wreak +wreck +wrest +wring +wrist +write +wrong +wrote +wrung +wryly +yacht +yearn +yeast +yield +yodel +young +youth +yummy +zebra +zesty +zonal diff --git a/internal/modules/wordle/data/words.txt b/internal/modules/wordle/wordlist/data/words.txt similarity index 100% rename from internal/modules/wordle/data/words.txt rename to internal/modules/wordle/wordlist/data/words.txt diff --git a/internal/modules/wordle/wordlist/emoji.go b/internal/modules/wordle/wordlist/emoji.go new file mode 100644 index 0000000..b379ad5 --- /dev/null +++ b/internal/modules/wordle/wordlist/emoji.go @@ -0,0 +1,24 @@ +package wordlist + +import "strings" + +// Marker maps a LetterScore.Result to the NYT-Wordle share emoji. +func Marker(result string) string { + switch result { + case ResultCorrect: + return "🟩" + case ResultPartial: + return "🟨" + default: + return "⬜" + } +} + +// EmojiRow renders one scored guess as its spoiler-free emoji row. +func EmojiRow(scores []LetterScore) string { + var b strings.Builder + for _, s := range scores { + b.WriteString(Marker(s.Result)) + } + return b.String() +} diff --git a/internal/modules/wordle/wordlist/lookup.go b/internal/modules/wordle/wordlist/lookup.go new file mode 100644 index 0000000..5e41ff4 --- /dev/null +++ b/internal/modules/wordle/wordlist/lookup.go @@ -0,0 +1,53 @@ +package wordlist + +import "strings" + +// Normalize lowercases input and strips anything outside a-z. +func Normalize(input string) string { + lower := strings.ToLower(input) + out := make([]byte, 0, len(lower)) + for i := 0; i < len(lower); i++ { + c := lower[i] + if c >= 'a' && c <= 'z' { + out = append(out, c) + } + } + return string(out) +} + +// Reason classifies why Validate returned not-ok. Each game maps it to its +// own user-facing reply. +type Reason string + +// Validation failure reasons. +const ( + ReasonEmpty Reason = "empty" + ReasonLength Reason = "length" + ReasonUnknown Reason = "unknown" +) + +// Result is the Validate outcome. Word is always populated with the +// normalized form, even on failure, so callers can include it in error +// messages without re-normalizing. +type Result struct { + OK bool + Word string + Reason Reason +} + +// Validate normalizes input then checks length + dictionary membership. +// +// Reasons in priority order: empty (post-normalize blank) > length > unknown. +func Validate(dict map[string]struct{}, input string) Result { + w := Normalize(input) + if w == "" { + return Result{OK: false, Word: w, Reason: ReasonEmpty} + } + if len(w) != WordLength { + return Result{OK: false, Word: w, Reason: ReasonLength} + } + if _, ok := dict[w]; !ok { + return Result{OK: false, Word: w, Reason: ReasonUnknown} + } + return Result{OK: true, Word: w} +} diff --git a/internal/modules/wordle/wordlist/wordlist_test.go b/internal/modules/wordle/wordlist/wordlist_test.go new file mode 100644 index 0000000..c8f7bb9 --- /dev/null +++ b/internal/modules/wordle/wordlist/wordlist_test.go @@ -0,0 +1,83 @@ +package wordlist + +import ( + "slices" + "strings" + "testing" +) + +func TestLoad_EmbeddedDictIsValid(t *testing.T) { + words, set := Load() + if len(words) < 14000 || len(words) > 15000 { + t.Errorf("word count = %d, want ~14855", len(words)) + } + if len(words) != len(set) { + t.Errorf("words/set length mismatch: %d vs %d (duplicates?)", len(words), len(set)) + } +} + +// The answer list must stay a sorted, duplicate-free subset of the guess +// dictionary, or a daily answer could be a word nobody may guess. +func TestAnswers_SortedUniqueSubsetOfDictionary(t *testing.T) { + answers := Answers() + if len(answers) < 2000 || len(answers) > 2600 { + t.Fatalf("answer count = %d, want 2000-2600", len(answers)) + } + if !slices.IsSorted(answers) { + t.Error("answers.txt is not sorted") + } + if len(slices.Compact(slices.Clone(answers))) != len(answers) { + t.Error("answers.txt has duplicates") + } + _, set := Load() + for _, a := range answers { + if _, ok := set[a]; !ok { + t.Errorf("answer %q is not in words.txt", a) + } + } +} + +func marks(rs []LetterScore) string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.Result[:1] + } + return strings.Join(out, "") +} + +func TestCompare_DuplicateLetters(t *testing.T) { + cases := []struct{ guess, target, want string }{ + {"crane", "crane", "ccccc"}, + {"slate", "shale", "cpcwc"}, + {"babes", "abbey", "ppccw"}, + {"aahed", "abide", "cwwpp"}, + {"ebbed", "lever", "pwwcw"}, + {"aabbb", "aaaaa", "ccwww"}, + {"speed", "abide", "wwpwp"}, + } + for _, c := range cases { + if got := marks(Compare(c.guess, c.target)); got != c.want { + t.Errorf("Compare(%q, %q) = %s, want %s", c.guess, c.target, got, c.want) + } + } +} + +func TestValidate(t *testing.T) { + dict := map[string]struct{}{"crane": {}} + for in, want := range map[string]Result{ + "": {Reason: ReasonEmpty}, + "cat": {Word: "cat", Reason: ReasonLength}, + "there": {Word: "there", Reason: ReasonUnknown}, + " CRANE!": {OK: true, Word: "crane"}, + } { + if got := Validate(dict, in); got != want { + t.Errorf("Validate(%q) = %+v, want %+v", in, got, want) + } + } +} + +func TestEmojiRow(t *testing.T) { + if got := EmojiRow(Compare("slate", "shale")); got != "🟩🟨🟩⬜🟩" { + t.Errorf("EmojiRow = %s", got) + } +} diff --git a/internal/modules/wordle/wordlist/words.go b/internal/modules/wordle/wordlist/words.go new file mode 100644 index 0000000..023982c --- /dev/null +++ b/internal/modules/wordle/wordlist/words.go @@ -0,0 +1,67 @@ +package wordlist + +import ( + _ "embed" + "strings" +) + +// rawWords is the allowed-guess dictionary: one lowercase WordLength a-z word +// per line. /wordle also draws its random answers from it. +// +//go:embed data/words.txt +var rawWords string + +// rawAnswers is the curated list of common words a daily puzzle can be. Every +// entry is also in words.txt; a test enforces that. +// +//go:embed data/answers.txt +var rawAnswers string + +// Load parses the embedded dictionary into a slice plus a membership set. Both +// outputs share the same backing strings, so the word bytes (≈90 KiB) are +// held once rather than duplicated. +// +// Words are validated to be exactly WordLength a-z; any malformed line panics +// at startup so a bad regen of the data file is caught immediately, not on +// the first guess. +func Load() ([]string, map[string]struct{}) { + words := parse(rawWords, "dictionary") + set := make(map[string]struct{}, len(words)) + for _, w := range words { + set[w] = struct{}{} + } + return words, set +} + +// Answers returns the curated daily-answer list in file order (sorted). It +// panics on a malformed line, like Load. +func Answers() []string { return parse(rawAnswers, "answer list") } + +func parse(raw, what string) []string { + lines := strings.Split(strings.TrimSpace(raw), "\n") + words := make([]string, 0, len(lines)) + for _, w := range lines { + w = strings.TrimSpace(w) + if w == "" { + continue + } + if !validWord(w) { + panic("wordlist: invalid word in embedded " + what + ": " + w) + } + words = append(words, w) + } + return words +} + +func validWord(w string) bool { + if len(w) != WordLength { + return false + } + for i := 0; i < len(w); i++ { + c := w[i] + if c < 'a' || c > 'z' { + return false + } + } + return true +} diff --git a/internal/modules/wordle/words.go b/internal/modules/wordle/words.go index 9a710ac..4939078 100644 --- a/internal/modules/wordle/words.go +++ b/internal/modules/wordle/words.go @@ -1,50 +1,7 @@ package wordle -import ( - _ "embed" - "strings" -) +import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" -// rawWords holds the raw words.txt bytes embedded at compile time. One word -// per line, lowercase, exactly WordLength a-z; see loadWords for validation. -// -//go:embed data/words.txt -var rawWords string - -// loadWords parses the embedded list into a slice plus a membership set. Both -// outputs share the same backing strings, so the word bytes (≈90 KiB) are -// held once rather than duplicated. -// -// Words are validated to be exactly WordLength a-z; any malformed line panics -// at startup so a bad regen of the data file is caught immediately, not on -// the first /wordle. -func loadWords() ([]string, map[string]struct{}) { - lines := strings.Split(strings.TrimSpace(rawWords), "\n") - words := make([]string, 0, len(lines)) - set := make(map[string]struct{}, len(lines)) - for _, w := range lines { - w = strings.TrimSpace(w) - if w == "" { - continue - } - if !validWord(w) { - panic("wordle: invalid word in embedded list: " + w) - } - words = append(words, w) - set[w] = struct{}{} - } - return words, set -} - -func validWord(w string) bool { - if len(w) != WordLength { - return false - } - for i := 0; i < len(w); i++ { - c := w[i] - if c < 'a' || c > 'z' { - return false - } - } - return true -} +// loadWords parses the embedded dictionary into a slice plus a membership +// set; a malformed line panics at startup. See wordlist.Load. +func loadWords() ([]string, map[string]struct{}) { return wordlist.Load() } diff --git a/internal/modules/wordledaily/callback.go b/internal/modules/wordledaily/callback.go new file mode 100644 index 0000000..2fef078 --- /dev/null +++ b/internal/modules/wordledaily/callback.go @@ -0,0 +1,71 @@ +package wordledaily + +import ( + "context" + "net/url" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/log" +) + +const ( + msgDisabled = "Wordle Daily isn't configured on this server." + msgNoGameTarget = "Couldn't find the game message. Send /wordledaily to play." +) + +// handlePlay answers a Play press with the game URL carrying a signed token, +// or with an alert when the game cannot open. Every path answers the query, +// so the client never keeps spinning. +func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error { + q := update.CallbackQuery + if !s.enabled() { + return answerAlert(ctx, b, q.ID, msgDisabled) + } + c, form := playClaims(q) + if form == "" { + return answerAlert(ctx, b, q.ID, msgNoGameTarget) + } + c.Expiry = s.cfg.now().Add(tokenTTL).Unix() + token, err := s.signToken(c) + if err != nil { + _ = answerAlert(ctx, b, q.ID, msgDisabled) + return err + } + log.Debug("wordledaily play", "address", form) + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: q.ID, + URL: s.cfg.baseURL + routePrefix + "?t=" + url.QueryEscape(token), + }) + return err +} + +// playClaims addresses the card: the message itself when the bot can see +// it (with its forum topic), the inaccessible message's chat and id +// otherwise, and the inline message id for a card sent via the bot. form is +// "" when none is present. +func playClaims(q *models.CallbackQuery) (claims, string) { + c := claims{UserID: q.From.ID, Name: displayName(q.From.FirstName, q.From.LastName)} + switch { + case q.Message.Message != nil: + m := q.Message.Message + c.ChatID, c.MessageID = m.Chat.ID, m.ID + if m.IsTopicMessage { + c.ThreadID = m.MessageThreadID + } + return c, "message" + case q.Message.InaccessibleMessage != nil: + c.ChatID, c.MessageID = q.Message.InaccessibleMessage.Chat.ID, q.Message.InaccessibleMessage.MessageID + return c, "inaccessible_message" + case q.InlineMessageID != "": + c.InlineID = q.InlineMessageID + return c, "inline_message" + } + return c, "" +} + +func answerAlert(ctx context.Context, b *bot.Bot, id, text string) error { + _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: id, Text: text, ShowAlert: true}) + return err +} diff --git a/internal/modules/wordledaily/callback_test.go b/internal/modules/wordledaily/callback_test.go new file mode 100644 index 0000000..8aa0166 --- /dev/null +++ b/internal/modules/wordledaily/callback_test.go @@ -0,0 +1,303 @@ +package wordledaily + +import ( + "context" + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +// install registers mod through the real registry and dispatcher. +func install(t *testing.T, mod modules.Module) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + reg, err := modules.Build([]string{ShortName}, map[string]modules.Factory{ + ShortName: func(modules.Deps) modules.Module { return mod }, + }, storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + modules.Install(rb.Bot, reg, modules.Auth{}) + return rb +} + +func answerForm(t *testing.T, rb *testutil.RecordingBot) map[string]string { + t.Helper() + calls := sentMethod(rb, "answerCallbackQuery") + if len(calls) == 0 { + t.Fatalf("no answerCallbackQuery; sent %+v", rb.Sent()) + } + return calls[len(calls)-1].Form +} + +func TestModule_Registration(t *testing.T) { + mod := newService(config{}).module() + var names []string + for _, c := range mod.Commands { + names = append(names, c.Name) + if c.Visibility != modules.VisibilityPublic || c.Description == "" || c.Parameters != "" { + t.Errorf("command %+v is not a described public command", c) + } + } + if strings.Join(names, " ") != "wordledaily wordledaily_subscribe wordledaily_unsubscribe" { + t.Fatalf("commands = %v", names) + } + if len(mod.Games) != 1 || mod.Games[0].ShortName != "wordledaily" { + t.Fatalf("games = %+v", mod.Games) + } + if len(mod.HTTP) != 0 || len(mod.Crons) != 0 { + t.Fatalf("disabled module exposes routes %v / crons %v", mod.HTTP, mod.Crons) + } + h := newHarness(t) + if mod := h.svc.module(); mod.HTTP[0].Pattern != "/games/wordledaily/" || mod.Crons[0].Name != "wordledaily_daily_push" || mod.Crons[0].Schedule != "0 0 * * *" { + t.Fatalf("enabled module: %+v %+v", mod.HTTP, mod.Crons) + } +} + +func TestNew_FromEnv(t *testing.T) { + t.Setenv("GAME_BASE_URL", "https://game.example/") + t.Setenv(secretEnv, "") + rb := testutil.NewRecordingBot(t) + mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}) + if len(mod.HTTP) != 1 { + t.Fatalf("routes = %+v", mod.HTTP) + } + if mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 { + t.Fatal("enabled without a bot or secret") + } + t.Setenv(secretEnv, "short") + if mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 { + t.Fatal("enabled with a short secret") + } + if string(rootKey(strings.Repeat("s", 32), modules.Deps{Bot: rb.Bot})) != strings.Repeat("s", 32) { + t.Fatal("secret not preferred over the bot token") + } +} + +func TestPlay_DisabledAnswersAlert(t *testing.T) { + rb := install(t, newService(config{}).module()) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(1, 1, 5, ShortName)) + f := answerForm(t, rb) + if f["text"] != msgDisabled || f["show_alert"] != "true" || f["url"] != "" { + t.Fatalf("answer = %v", f) + } +} + +func decodeURLToken(t *testing.T, h *harness, raw string) claims { + t.Helper() + u, err := url.Parse(raw) + if err != nil || !strings.HasPrefix(raw, testBase+routePrefix+"?t=") { + t.Fatalf("url = %q", raw) + } + c, err := h.svc.verifyToken(u.Query().Get("t"), h.clock.now()) + if err != nil { + t.Fatalf("token: %v", err) + } + return c +} + +func TestPlay_AnswersSignedURLForEveryCardForm(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + + topic := testutil.NewGameCallback(42, groupChat, 9, ShortName) + topic.CallbackQuery.From.LastName = "Nguyen" + topic.CallbackQuery.Message.Message.IsTopicMessage = true + topic.CallbackQuery.Message.Message.MessageThreadID = 77 + rb.Bot.ProcessUpdate(context.Background(), topic) + c := decodeURLToken(t, h, answerForm(t, rb)["url"]) + if c.UserID != 42 || c.ChatID != groupChat || c.MessageID != 9 || c.ThreadID != 77 || c.Name != "Test Nguyen" || c.InlineID != "" { + t.Fatalf("topic claims = %+v", c) + } + if c.Expiry != h.clock.now().Add(tokenTTL).Unix() { + t.Fatalf("expiry = %d", c.Expiry) + } + + inaccessible := &models.Update{CallbackQuery: &models.CallbackQuery{ + ID: "q", From: models.User{ID: 42, FirstName: "A"}, GameShortName: ShortName, + Message: models.MaybeInaccessibleMessage{ + Type: models.MaybeInaccessibleMessageTypeInaccessibleMessage, + InaccessibleMessage: &models.InaccessibleMessage{Chat: models.Chat{ID: -5}, MessageID: 3}, + }, + }} + rb.Bot.ProcessUpdate(context.Background(), inaccessible) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); c.ChatID != -5 || c.MessageID != 3 { + t.Fatalf("inaccessible claims = %+v", c) + } + + rb.Bot.ProcessUpdate(context.Background(), testutil.NewInlineGameCallback(42, "BAAAInline", ShortName)) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); c.InlineID != "BAAAInline" || c.ChatID != 0 { + t.Fatalf("inline claims = %+v", c) + } + + none := testutil.NewInlineGameCallback(42, "", ShortName) + rb.Bot.ProcessUpdate(context.Background(), none) + if f := answerForm(t, rb); f["text"] != msgNoGameTarget || f["show_alert"] != "true" { + t.Fatalf("no target = %v", f) + } +} + +func TestClaims_Valid(t *testing.T) { + for name, c := range map[string]claims{ + "no user": {ChatID: 1, MessageID: 1, Expiry: 1}, + "no expiry": {UserID: 1, ChatID: 1, MessageID: 1}, + "no address": {UserID: 1, Expiry: 1}, + "both addresses": {UserID: 1, ChatID: 1, MessageID: 1, InlineID: "x", Expiry: 1}, + "inline + thread": {UserID: 1, InlineID: "x", ThreadID: 3, Expiry: 1}, + } { + if c.Valid() { + t.Errorf("%s: valid", name) + } + } + if !(claims{UserID: 1, InlineID: "x", Expiry: 1}).Valid() || !(claims{UserID: 1, ChatID: -1, MessageID: 2, ThreadID: 3, Expiry: 1}).Valid() { + t.Fatal("good claims refused") + } + if got := displayName(strings.Repeat("a", 70), "b"); len([]rune(got)) != maxNameRunes { + t.Fatalf("name not truncated: %d", len(got)) + } +} + +func TestCommand_SendsGameKeepingTopic(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + u := testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily") + u.Message.MessageThreadID, u.Message.IsTopicMessage = 77, true + rb.Bot.ProcessUpdate(context.Background(), u) + games := sentMethod(rb, "sendGame") + if len(games) != 1 || games[0].Form["game_short_name"] != ShortName || games[0].Form["message_thread_id"] != "77" || games[0].ChatID() != "-100" { + t.Fatalf("sent %+v", rb.Sent()) + } + // A reply chain in an ordinary supergroup carries a thread id too, but + // it is not a topic: the card goes to the chat. + rb.Reset() + u = testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily") + u.Message.MessageThreadID = 55 + rb.Bot.ProcessUpdate(context.Background(), u) + if games := sentMethod(rb, "sendGame"); len(games) != 1 || games[0].Form["message_thread_id"] != "" { + t.Fatalf("reply-chain card = %+v", rb.Sent()) + } +} + +func TestCommand_ChannelDisabledAndFailure(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, "/wordledaily")) + if last := rb.LastSent(); last.Text() != msgChannel { + t.Fatalf("channel: %+v", rb.Sent()) + } + rb.FailMethodCode("sendGame", 400, "Bad Request: wrong game short name") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordledaily")) + if last := rb.LastSent(); last.Text() != msgSendGameFail { + t.Fatalf("failure: %+v", rb.Sent()) + } + + rb = install(t, newService(config{}).module()) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordledaily")) + if last := rb.LastSent(); last.Text() != msgDisabled || len(sentMethod(rb, "sendGame")) != 0 { + t.Fatalf("disabled: %+v", rb.Sent()) + } +} + +func TestSubscribeAndUnsubscribe(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + ctx := context.Background() + send := func(text string, thread int) string { + u := testutil.NewSupergroupMessage(groupChat, 1, text) + u.Message.MessageThreadID, u.Message.IsTopicMessage = thread, thread != 0 + rb.Bot.ProcessUpdate(ctx, u) + return rb.LastSent().Text() + } + if got := send("/wordledaily_subscribe", 77); !strings.HasPrefix(got, "✅ Subscribed this topic to Wordle Daily: a new puzzle and yesterday's group results every day at 07:00 ICT.") { + t.Fatalf("subscribe = %q", got) + } + if got := send("/wordledaily_subscribe", 77); got != "Already subscribed in this topic." { + t.Fatalf("again = %q", got) + } + if got := send("/wordledaily_unsubscribe", 0); got != "This chat wasn't subscribed." { + t.Fatalf("unsubscribe general = %q", got) + } + if got := send("/wordledaily_unsubscribe", 77); got != "Unsubscribed this topic." { + t.Fatalf("unsubscribe topic = %q", got) + } + rb.Bot.ProcessUpdate(ctx, testutil.NewChannelMessage(-300, "/wordledaily_subscribe")) + if got := rb.LastSent().Text(); got != msgChannel { + t.Fatalf("channel subscribe = %q", got) + } + // A private chat may subscribe too; it gets the card without a recap. + rb.Bot.ProcessUpdate(ctx, testutil.NewPrivateMessage(5, "/wordledaily_subscribe")) + if got := rb.LastSent().Text(); !strings.HasPrefix(got, "✅ Subscribed this chat") { + t.Fatalf("dm subscribe = %q", got) + } + raw, _ := json.Marshal(mustList(t, h)) + if string(raw) != `[{"chat_id":5}]` { + t.Fatalf("subscribers = %s", raw) + } +} + +// A subscription made by replying in an ordinary supergroup is the whole +// chat's, the scope its plays and group results are stored under, so the +// 07:00 push carries that chat's recap. +func TestSubscribe_ReplyChainIsTheWholeChat(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + ctx := context.Background() + reply := func(text string) string { + u := testutil.NewSupergroupMessage(groupChat, 1, text) + u.Message.MessageThreadID = 55 // a reply chain, not a forum topic + rb.Bot.ProcessUpdate(ctx, u) + return rb.LastSent().Text() + } + if got := reply("/wordledaily_subscribe"); !strings.HasPrefix(got, "✅ Subscribed this chat") { + t.Fatalf("subscribe = %q", got) + } + if raw, _ := json.Marshal(mustList(t, h)); string(raw) != `[{"chat_id":-100}]` { + t.Fatalf("subscribers = %s", raw) + } + playGroupDay(t, h, 0) + h.nextDay() + if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + t.Fatal(err) + } + sends := sentMethod(h.rb, "sendMessage") + if len(sends) != 1 || !strings.Contains(sends[0].Text(), "yesterday's results") || sends[0].Form["message_thread_id"] != "" { + t.Fatalf("push = %+v", h.rb.Sent()) + } + if got := reply("/wordledaily_unsubscribe"); got != "Unsubscribed this chat." { + t.Fatalf("unsubscribe = %q", got) + } +} + +// Without a base URL the push cron is not registered, so subscribing must +// not promise a push; leaving still works. +func TestSubscribe_DisabledGameRefuses(t *testing.T) { + coll := storage.NewMemoryProvider().Collection(ShortName) + svc := newService(config{store: coll}) + if svc.enabled() || len(svc.module().Crons) != 0 { + t.Fatal("game unexpectedly enabled") + } + rb := install(t, svc.module()) + ctx := context.Background() + rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily_subscribe")) + if got := rb.LastSent().Text(); got != msgDisabled { + t.Fatalf("subscribe = %q", got) + } + if subs, err := subscription.List(ctx, svc.subscribers); err != nil || len(subs) != 0 { + t.Fatalf("subscribers = %v, %v", subs, err) + } + if _, err := subscription.Add(ctx, svc.subscribers, groupChat, 0); err != nil { + t.Fatal(err) + } + rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily_unsubscribe")) + if got := rb.LastSent().Text(); got != "Unsubscribed this chat." { + t.Fatalf("unsubscribe = %q", got) + } +} diff --git a/internal/modules/wordledaily/command.go b/internal/modules/wordledaily/command.go new file mode 100644 index 0000000..38a775d --- /dev/null +++ b/internal/modules/wordledaily/command.go @@ -0,0 +1,117 @@ +package wordledaily + +import ( + "context" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper" + "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" +) + +const ( + msgChannel = "Wordle Daily can't be played in channels." + msgSendGameFail = "Couldn't send Wordle Daily. Try again later." +) + +// handleCommand (/wordledaily) sends the BotFather game in the same chat and +// topic. No reply markup: Telegram then adds the Play button itself, which +// is the button a game message requires first. +func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + if msg.Chat.Type == models.ChatTypeChannel { + return chathelper.Reply(ctx, b, msg, msgChannel) + } + if !s.enabled() { + return chathelper.Reply(ctx, b, msg, msgDisabled) + } + if _, err := b.SendGame(ctx, &bot.SendGameParams{ + ChatID: msg.Chat.ID, + MessageThreadID: topicOf(msg), + GameShorName: ShortName, // the library's field name is misspelled + }); err != nil { + _ = chathelper.Reply(ctx, b, msg, msgSendGameFail) + return err + } + return nil +} + +// handleSubscribe (/wordledaily_subscribe) opts the chat topic into the +// 07:00 ICT push. Anyone in the chat may subscribe, like the other daily +// pushes. +func (s *service) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + if msg.Chat.Type == models.ChatTypeChannel { + return chathelper.Reply(ctx, b, msg, msgChannel) + } + // The push cron exists only when the game is enabled, so a + // subscription would never be served. + if !s.enabled() { + return chathelper.Reply(ctx, b, msg, msgDisabled) + } + thread := topicOf(msg) + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + added, err := subscription.Add(ctx, s.subscribers, msg.Chat.ID, thread) + if err != nil { + return err + } + scope := scopeOf(thread) + if added { + return chathelper.Reply(ctx, b, msg, + "✅ Subscribed "+scope+" to Wordle Daily: a new puzzle and yesterday's group results every day at 07:00 ICT.\n"+ + "If you block the bot, you'll be auto-unsubscribed on the next push.") + } + return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".") +} + +// handleUnsubscribe (/wordledaily_unsubscribe) opts the chat topic out. It +// works while the game is disabled too, so a chat can always leave. +func (s *service) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + if s.subscribers == nil { + return chathelper.Reply(ctx, b, msg, msgDisabled) + } + thread := topicOf(msg) + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + removed, err := subscription.Remove(ctx, s.subscribers, msg.Chat.ID, thread) + if err != nil { + return err + } + scope := scopeOf(thread) + if removed { + return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".") + } + return chathelper.Reply(ctx, b, msg, strings.ToUpper(scope[:1])+scope[1:]+" wasn't subscribed.") +} + +// topicOf is the message's forum topic, or 0 for the whole chat. Telegram +// also gives a reply chain in an ordinary supergroup a thread id; +// IsTopicMessage marks a real topic. Plays key the group results the same +// way (playClaims), so a subscription made by replying still gets its +// chat's recap and streak. +func topicOf(msg *models.Message) int { + if !msg.IsTopicMessage { + return 0 + } + return msg.MessageThreadID +} + +func scopeOf(thread int) string { + if thread != 0 { + return "this topic" + } + return "this chat" +} diff --git a/internal/modules/wordledaily/helpers_test.go b/internal/modules/wordledaily/helpers_test.go new file mode 100644 index 0000000..2291579 --- /dev/null +++ b/internal/modules/wordledaily/helpers_test.go @@ -0,0 +1,275 @@ +package wordledaily + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +var ( + testRoot = []byte("0123456789abcdef0123456789abcdef") + testAnswers = wordlist.Answers() + _, testDict = wordlist.Load() + testStart = epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day + testBase = "https://game.example" + editStub = `{"message_id":1,"date":0,"chat":{"id":-100,"type":"supergroup"}}` + groupChat int64 = -100 +) + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +type reportCall struct { + addr htmlgame.Address + user int64 + score int + result error +} + +type fakeReporter struct { + mu sync.Mutex + calls []reportCall + err error +} + +func (r *fakeReporter) Report(_ context.Context, a htmlgame.Address, userID int64, score int) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, reportCall{a, userID, score, r.err}) + return r.err +} + +func (r *fakeReporter) snapshot() []reportCall { + r.mu.Lock() + defer r.mu.Unlock() + return append([]reportCall(nil), r.calls...) +} + +// fakeScheduler holds summary flushes until the test runs them. +type fakeScheduler struct { + mu sync.Mutex + fns []func() + delays []time.Duration +} + +func (f *fakeScheduler) schedule(d time.Duration, fn func()) { + f.mu.Lock() + defer f.mu.Unlock() + f.fns = append(f.fns, fn) + f.delays = append(f.delays, d) +} + +func (f *fakeScheduler) pending() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.fns) +} + +// run1 runs the oldest pending flush only. +func (f *fakeScheduler) run1() { + f.mu.Lock() + if len(f.fns) == 0 { + f.mu.Unlock() + return + } + fn := f.fns[0] + f.fns, f.delays = f.fns[1:], f.delays[1:] + f.mu.Unlock() + fn() +} + +// run runs every pending flush, including ones they schedule. +func (f *fakeScheduler) run() { + for { + f.mu.Lock() + if len(f.fns) == 0 { + f.mu.Unlock() + return + } + fn := f.fns[0] + f.fns, f.delays = f.fns[1:], f.delays[1:] + f.mu.Unlock() + fn() + } +} + +type harness struct { + t *testing.T + svc *service + clock *fakeClock + rep *fakeReporter + rb *testutil.RecordingBot + sched *fakeScheduler + coll storage.Collection + handler http.Handler +} + +func testConfig(clock *fakeClock, coll storage.Collection, rep scoreReporter, api telegramAPI, sched *fakeScheduler) config { + return config{ + baseURL: testBase, + rootKey: testRoot, + now: clock.now, + store: coll, + reporter: rep, + api: api, + schedule: sched.schedule, + async: func(f func()) { f() }, + answers: testAnswers, + dict: testDict, + } +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{ + t: t, + clock: &fakeClock{t: testStart}, + rep: &fakeReporter{}, + rb: testutil.NewRecordingBot(t), + sched: &fakeScheduler{}, + coll: storage.NewMemoryProvider().Collection(ShortName), + } + h.rb.StubMethod("editMessageText", editStub) + h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched)) + mod := h.svc.module() + if len(mod.HTTP) != 1 || len(mod.Crons) != 1 { + t.Fatalf("enabled module: routes %d crons %d", len(mod.HTTP), len(mod.Crons)) + } + h.handler = mod.HTTP[0].Handler + return h +} + +// restart builds a fresh service over the same store, as after a deploy. +func (h *harness) restart() { + h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched)) + h.handler = h.svc.handler() +} + +// tokenFor signs a token for user on card msg in chat (thread is its topic). +func (h *harness) tokenFor(user, chat int64, msg, thread int, name string) string { + h.t.Helper() + tok, err := h.svc.signToken(claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: thread, Expiry: h.clock.now().Add(tokenTTL).Unix()}) + if err != nil { + h.t.Fatal(err) + } + return tok +} + +// dmToken is a token for user's card in their private chat. +func (h *harness) dmToken(user int64) string { return h.tokenFor(user, user, 5, 0, "Alice") } + +func (h *harness) answer() string { + h.t.Helper() + a, err := h.svc.resolvePuzzle(context.Background(), h.svc.puzzleNum(h.clock.now())) + if err != nil { + h.t.Fatal(err) + } + return a +} + +// wrong returns n distinct dictionary words that are not today's answer. +func (h *harness) wrong(n int) []string { + ans := h.answer() + var out []string + for _, w := range testAnswers { + if w != ans && len(out) < n { + out = append(out, w) + } + } + return out +} + +func (h *harness) post(path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, req) + return rec +} + +func (h *harness) call(path string, body any, wantStatus int) view { + h.t.Helper() + raw, _ := json.Marshal(body) + rec := h.post(path, string(raw)) + if rec.Code != wantStatus { + h.t.Fatalf("%s: status %d, want %d; body %s", path, rec.Code, wantStatus, rec.Body.String()) + } + var v view + if wantStatus == http.StatusOK { + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + h.t.Fatalf("%s: decode %q: %v", path, rec.Body.String(), err) + } + } + return v +} + +func (h *harness) state(tok string) view { + h.t.Helper() + return h.call("state", map[string]string{"token": tok}, http.StatusOK) +} + +func (h *harness) guess(tok string, num int, word string) view { + h.t.Helper() + return h.call("guess", map[string]any{"token": tok, "num": num, "word": word}, http.StatusOK) +} + +// guessErr posts a guess expected to fail and returns its error code. +func (h *harness) guessErr(tok string, num int, word string, wantStatus int) string { + h.t.Helper() + raw, _ := json.Marshal(map[string]any{"token": tok, "num": num, "word": word}) + rec := h.post("guess", string(raw)) + if rec.Code != wantStatus { + h.t.Fatalf("guess %q: status %d, want %d; body %s", word, rec.Code, wantStatus, rec.Body.String()) + } + return errorCode(h.t, rec) +} + +func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var e struct { + Code string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + if e.Message == "" { + t.Errorf("error %q has no message", e.Code) + } + return e.Code +} + +// sentMethod returns the recorded calls of one API method. +func sentMethod(rb *testutil.RecordingBot, method string) []testutil.SentCall { + var out []testutil.SentCall + for _, c := range rb.Sent() { + if c.Method == method { + out = append(out, c) + } + } + return out +} diff --git a/internal/modules/wordledaily/http_api.go b/internal/modules/wordledaily/http_api.go new file mode 100644 index 0000000..077433f --- /dev/null +++ b/internal/modules/wordledaily/http_api.go @@ -0,0 +1,199 @@ +package wordledaily + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" + "github.com/tiennm99/tiennm99bot/internal/modules/wordledaily/web" +) + +// requestsPerMinute caps one player's API calls, both endpoints together. +const requestsPerMinute = 60 + +// apiError is the JSON error body every API failure returns. +type apiError struct { + status int + Code string `json:"error"` + Message string `json:"message"` +} + +var ( + errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."} + errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."} + errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."} + errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new puzzle has started."} + errLengthAPI = apiError{http.StatusUnprocessableEntity, "length", "Word must be exactly 5 letters."} + errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Not in the word list."} + errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's puzzle."} + errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."} + errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."} +) + +// guessView is one row of the board: the word and its c/p/w marks. +type guessView struct { + Word string `json:"word"` + Marks string `json:"marks"` +} + +// statsView is the player's record, shown once today's game is over. +type statsView struct { + Played int `json:"played"` + WinPct int `json:"win_pct"` + Cur int `json:"cur"` + Max int `json:"max"` + Dist []int `json:"dist"` +} + +// view is the state the page renders. The answer, the stats and the share +// text are present only once the game is over. +type view struct { + Num int `json:"num"` + Date string `json:"date"` + Player string `json:"player"` + Max int `json:"max"` + Len int `json:"len"` + Guesses []guessView `json:"guesses"` + Status string `json:"status"` + Answer string `json:"answer,omitempty"` + Stats *statsView `json:"stats,omitempty"` + NextAt int64 `json:"next_at"` + Share string `json:"share,omitempty"` +} + +type stateRequest struct { + Token string `json:"token"` +} + +type guessRequest struct { + Token string `json:"token"` + Num int `json:"num"` + Word string `json:"word"` +} + +// handler serves the page, its assets and the JSON API under routePrefix. +func (s *service) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET "+routePrefix+"{$}", htmlgame.ServeAsset(web.FS, "index.html")) + mux.HandleFunc("GET "+routePrefix+"app.js", htmlgame.ServeAsset(web.FS, "app.js")) + mux.HandleFunc("GET "+routePrefix+"app.css", htmlgame.ServeAsset(web.FS, "app.css")) + mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState) + mux.HandleFunc("POST "+routePrefix+"api/guess", s.apiGuess) + return htmlgame.SecurityHeaders(routePrefix, mux) +} + +func writeError(w http.ResponseWriter, e apiError) { htmlgame.WriteJSON(w, e.status, e) } + +// authorize verifies the token and the player's request budget, writing +// the error itself when either fails. +func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (claims, bool) { + c, err := s.verifyToken(token, now) + switch { + case errors.Is(err, htmlgame.ErrTokenExpired): + writeError(w, errExpiredAPI) + return claims{}, false + case err != nil: + writeError(w, errBadTokenAPI) + return claims{}, false + } + if !s.limiter.Allow(c.UserID, now) { + writeError(w, errTooFastAPI) + return claims{}, false + } + return c, true +} + +func (s *service) apiState(w http.ResponseWriter, r *http.Request) { + var req stateRequest + if htmlgame.DecodeJSON(w, r, &req) != nil { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + o, err := s.loadState(r.Context(), c) + if err != nil { + log.Error("wordledaily state failed", "err", err) + writeError(w, errInternalAPI) + return + } + htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o)) +} + +func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) { + var req guessRequest + if htmlgame.DecodeJSON(w, r, &req) != nil || len(req.Word) > 32 { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + o, err := s.submitGuess(r.Context(), c, req.Num, req.Word) + switch { + case errors.Is(err, errNewPuzzle): + writeError(w, errNewPuzzleAPI) + case errors.Is(err, errWordLength): + writeError(w, errLengthAPI) + case errors.Is(err, errWordUnknown): + writeError(w, errUnknownAPI) + case errors.Is(err, errFinished): + writeError(w, errFinishedAPI) + case err != nil: + log.Error("wordledaily guess failed", "err", err) + writeError(w, errInternalAPI) + default: + htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o)) + } +} + +// view renders a game for the page. The answer leaves the server only once +// the game is over. +func (s *service) view(c claims, o outcome) view { + v := view{ + Num: o.num, + Date: s.puzzleDate(o.num), + Player: c.Name, + Max: maxGuesses, + Len: wordlist.WordLength, + Guesses: make([]guessView, len(o.p.Guesses)), + Status: o.p.Status, + NextAt: s.puzzleStart(o.num + 1).Unix(), + } + if v.Status == "" { + v.Status = statusPlaying + } + for i, g := range o.p.Guesses { + v.Guesses[i] = guessView{Word: strings.ToUpper(g.Word), Marks: g.Marks} + } + if !o.p.finished() { + return v + } + v.Answer = strings.ToUpper(o.answer) + st := o.stats + pct := 0 + if st.Played > 0 { + pct = (st.Wins*100 + st.Played/2) / st.Played + } + v.Stats = &statsView{Played: st.Played, WinPct: pct, Cur: displayStreak(st, o.num), Max: st.MaxStreak, Dist: st.Dist} + v.Share = shareText(o.p) + return v +} + +// shareText is the NYT-style spoiler-free result. +func shareText(p progress) string { + var b strings.Builder + b.WriteString("Wordle Daily #" + strconv.Itoa(p.Num) + " " + resultLine(p) + "\n") + for _, g := range p.Guesses { + b.WriteString("\n" + emojiRow(g.Marks)) + } + return b.String() +} diff --git a/internal/modules/wordledaily/http_api_test.go b/internal/modules/wordledaily/http_api_test.go new file mode 100644 index 0000000..4b8c31c --- /dev/null +++ b/internal/modules/wordledaily/http_api_test.go @@ -0,0 +1,83 @@ +package wordledaily + +import ( + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/tiennm99/tiennm99bot/internal/modules/wordledaily/web" +) + +func TestAPI_HeadersAndRoutes(t *testing.T) { + h := newHarness(t) + rec := h.post("state", `{}`) + hdr := rec.Header() + if !strings.Contains(hdr.Get("Content-Security-Policy"), "default-src 'self'") || + hdr.Get("Referrer-Policy") != "no-referrer" || + hdr.Get("Cache-Control") != "no-store" || + hdr.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("API headers = %v", hdr) + } + for path, ctype := range map[string]string{"": "text/html", "app.js": "text/javascript", "app.css": "text/css"} { + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path+"?t=abc", nil)) + if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) || rec.Body.Len() == 0 || + rec.Header().Get("Cache-Control") != "public, max-age=300" { + t.Errorf("GET %q: %d %v", path, rec.Code, rec.Header()) + } + } + for _, path := range []string{"web.go", "api/state", "api/guess", "../noitu/"} { + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil)) + if rec.Code == http.StatusOK { + t.Errorf("GET %q served", path) + } + } +} + +var inlineScriptRe = regexp.MustCompile(`(?is)]*)>(.*?)`) + +// The page must run under the strict CSP: no inline script or style, and no +// word list shipped to the client. +func TestWebAssets_FitTheCSP(t *testing.T) { + index, err := web.FS.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + for _, m := range inlineScriptRe.FindAllStringSubmatch(string(index), -1) { + if strings.TrimSpace(m[2]) != "" || !strings.Contains(m[1], "src=") { + t.Errorf("inline script breaks the CSP: %q", m[0]) + } + } + if strings.Contains(string(index), " style=") || strings.Contains(string(index), "]*class="icon[^"]*"[^>]*>(.*?)`) + +// Icon-only buttons are named by aria-label: their glyph would otherwise be +// the accessible name, and title only a description. +func TestWebAssets_IconButtonsHaveNames(t *testing.T) { + index, err := web.FS.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + buttons := iconButtonRe.FindAllStringSubmatch(string(index), -1) + if len(buttons) != 3 { + t.Fatalf("icon buttons = %d", len(buttons)) + } + for _, b := range buttons { + if !strings.Contains(b[0], `aria-label="`) || !strings.HasPrefix(b[1], `