feat(wordledaily): add the daily Wordle HTML5 game

/wordledaily sends a BotFather game card whose page plays one shared word
per day, changing at 07:00 ICT. The server checks guesses, saves each
player's board after every guess and keeps NYT-style stats. A win is
reported with setGameScore, and in a group one live results message shows
everyone's colour grids.

/wordledaily_subscribe and /wordledaily_unsubscribe opt a chat or topic in
to the 07:00 push: yesterday's recap, opening with the answer and the
group's win streak, then today's card.

The HTML5 game plumbing that noitu used is now shared in util/htmlgame, and
the wordle word list and scoring live in wordle/wordlist.
This commit is contained in:
tiennm99 committed 2026-10-09 17:20:02 +07:00
1 parent 871b4a1042
commit c0231ceadf
56 files changed
+8013 -306

No files matched your search

+7
View File
@@ -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 <GAME_BASE_URL>/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
+3 -1
View File
@@ -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)
+4
View File
@@ -107,6 +107,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"tth": "[target...]",
"wheelofnames": "<option,...>",
"wordle": "[word]",
"wordledaily": "",
"wordledaily_subscribe": "",
"wordledaily_unsubscribe": "",
}
menu := botCommandMenu(reg)
+2
View File
@@ -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,
+24
View File
@@ -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.
+2 -1
View File
@@ -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_<bot username>
# 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) ---
+3 -2
View File
@@ -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_<bot username>`. See [sticker packs](sticker-packs.md) |
| `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
| `GAME_BASE_URL` | optional | public `https://` base routed to the bot's `:8080`, e.g. `https://noitu.example.com`; unset or invalid = the `noitu` game is disabled. See [the noitu game](noitu-game.md) |
| `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
+312
View File
@@ -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/<bot username>?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
`<GAME_BASE_URL>/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:<n>")` 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:<n>`. 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:<n>` | the pinned answer of puzzle `n` |
| `play:<n>:<user>` | one player's board on puzzle `n`: guesses with marks, status, finish time, cards reported on, group topics joined |
| `stats:<user>` | played, wins, current and max streak, guess distribution, last puzzle finished and won |
| `cday:<n>:<chat>:<thread>:` | the players of a group topic's day, the card the summary replies to, and the summary message ID |
| `cstreak:<chat>:<thread>:` | 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:<n>`, `play:<n>:*` and
`cday:<n>:*` 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.
+11
View File
@@ -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
}
+13
View File
@@ -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())
}
}
+5 -26
View File
@@ -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
}
+4 -22
View File
@@ -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) }
+3 -22
View File
@@ -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
+3 -15
View File
@@ -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) }
+13 -42
View File
@@ -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
}
@@ -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("<!doctype html>")},
"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")
}
}
+101
View File
@@ -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)
}
}
@@ -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
}
+43
View File
@@ -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")
}
+93
View File
@@ -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
}
@@ -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)
}
}
+22 -8
View File
@@ -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) {
+13 -66
View File
@@ -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) }
+11 -37
View File
@@ -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)
}
+3 -17
View File
@@ -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
@@ -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
}
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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()
}
@@ -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}
}
@@ -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)
}
}
+67
View File
@@ -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
}
+4 -47
View File
@@ -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() }
+71
View File
@@ -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
}
@@ -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)
}
}
+117
View File
@@ -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"
}
@@ -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
}
+199
View File
@@ -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()
}
@@ -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)<script\b([^>]*)>(.*?)</script>`)
// 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), "<style") {
t.Error("inline style breaks the CSP")
}
js, _ := web.FS.ReadFile("app.js")
for _, w := range []string{"crane", "abbey", "zebra"} {
if strings.Contains(string(js), w) {
t.Errorf("app.js carries the word %q", w)
}
}
}
var iconButtonRe = regexp.MustCompile(`<button[^>]*class="icon[^"]*"[^>]*>(.*?)</button>`)
// 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], `<span aria-hidden="true">`) {
t.Errorf("unnamed icon button: %s", b[0])
}
}
}
+369
View File
@@ -0,0 +1,369 @@
package wordledaily
import (
"context"
"errors"
"slices"
"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"
)
const reportTimeout = 10 * time.Second
// Rejections of a guess, mapped to API errors in http_api.go.
var (
errNewPuzzle = errors.New("wordledaily: the puzzle changed")
errFinished = errors.New("wordledaily: today's puzzle is finished")
errWordLength = errors.New("wordledaily: word must be 5 letters")
errWordUnknown = errors.New("wordledaily: not in the word list")
)
// outcome is a player's game after a request, with what the request changed.
type outcome struct {
num int
answer string
p progress
stats userStats // set once the game is over
guessed bool // this request added a guess
ended bool // this request finished the game
joined bool // this request added the card's chat to the player's chats
report bool // this request must report the win on the card
}
// cardKey names the card a token was issued for, so a win is reported on
// each card once.
func cardKey(c claims) string {
if c.InlineID != "" {
return "i:" + c.InlineID
}
return "c:" + strconv.FormatInt(c.ChatID, 10) + ":" + strconv.Itoa(c.MessageID)
}
func userLockKey(userID int64) string { return "u:" + strconv.FormatInt(userID, 10) }
// marksOf encodes scored letters as c/p/w, the stored and served form.
func marksOf(scores []wordlist.LetterScore) string {
var b strings.Builder
for _, sc := range scores {
switch sc.Result {
case wordlist.ResultCorrect:
b.WriteByte('c')
case wordlist.ResultPartial:
b.WriteByte('p')
default:
b.WriteByte('w')
}
}
return b.String()
}
// emojiRow renders stored marks as the spoiler-free share row.
func emojiRow(marks string) string {
var b strings.Builder
for i := 0; i < len(marks); i++ {
switch marks[i] {
case 'c':
b.WriteString(wordlist.Marker(wordlist.ResultCorrect))
case 'p':
b.WriteString(wordlist.Marker(wordlist.ResultPartial))
default:
b.WriteString(wordlist.Marker(wordlist.ResultWrong))
}
}
return b.String()
}
// score is what a win reports with setGameScore: fewer guesses rank higher.
func score(guesses int) int { return maxGuesses + 1 - guesses }
// submitGuess plays word on today's puzzle for the token's player. num is
// the puzzle the page shows; a stale one is refused so a board never mixes
// two days.
func (s *service) submitGuess(ctx context.Context, c claims, num int, word string) (outcome, error) {
now := s.cfg.now()
today := s.puzzleNum(now)
if num != today {
return outcome{}, errNewPuzzle
}
answer, err := s.resolvePuzzle(ctx, today)
if err != nil {
return outcome{}, err
}
o, err := s.guessLocked(ctx, c, today, answer, word, now)
if err != nil {
if o.ended {
// The refused guess repaired a finished board's stats; its
// group bookkeeping still has to run.
s.afterPlay(c, o)
}
return o, err
}
s.afterPlay(c, o)
return o, nil
}
func (s *service) guessLocked(ctx context.Context, c claims, num int, answer, word string, now time.Time) (outcome, error) {
defer s.locks.Acquire(userLockKey(c.UserID))()
p, err := s.loadProgress(ctx, num, c.UserID)
if err != nil {
return outcome{}, err
}
if p.finished() {
// The board is the record: a finish whose stats write failed is
// folded into the stats now, before the guess is refused.
o := outcome{num: num, answer: answer, p: p}
if o.stats, o.ended, err = s.finishStats(ctx, &p); err != nil {
return outcome{}, err
}
return o, errFinished
}
v := wordlist.Validate(s.cfg.dict, word)
switch {
case v.Reason == wordlist.ReasonUnknown:
return outcome{}, errWordUnknown
case !v.OK:
return outcome{}, errWordLength
}
o := outcome{num: num, answer: answer, guessed: true}
p.Name = c.Name
p.Guesses = append(p.Guesses, guess{Word: v.Word, Marks: marksOf(wordlist.Compare(v.Word, answer))})
o.joined = s.joinLocally(&p, c)
if v.Word == answer || len(p.Guesses) >= maxGuesses {
p.Status, p.FinishedAt, o.ended = statusLost, now.UnixMilli(), true
if v.Word == answer {
p.Status = statusWon
o.report = needsReport(p, c)
}
}
if err := s.plays.Put(ctx, playKey(num, c.UserID), p); err != nil {
return outcome{}, err
}
if o.ended {
// The board is saved first and is the record. If this stats write
// fails, the next state load or guess folds the finished board in;
// the LastNum guard keeps it from counting twice.
if o.stats, _, err = s.finishStats(ctx, &p); err != nil {
return outcome{}, err
}
}
o.p = p
return o, nil
}
// loadState returns today's game for the token's player. Opening a card
// also puts a player who already guessed into that card's group results,
// and reports a win on a card it was not reported on yet.
func (s *service) loadState(ctx context.Context, c claims) (outcome, error) {
today := s.puzzleNum(s.cfg.now())
answer, err := s.resolvePuzzle(ctx, today)
if err != nil {
return outcome{}, err
}
o, err := s.stateLocked(ctx, c, today, answer)
if err != nil {
return o, err
}
s.afterPlay(c, o)
return o, nil
}
func (s *service) stateLocked(ctx context.Context, c claims, num int, answer string) (outcome, error) {
defer s.locks.Acquire(userLockKey(c.UserID))()
p, err := s.loadProgress(ctx, num, c.UserID)
if err != nil {
return outcome{}, err
}
o := outcome{num: num, answer: answer}
if len(p.Guesses) > 0 {
o.joined = s.joinLocally(&p, c)
}
if p.Status == statusWon {
o.report = needsReport(p, c)
}
if o.joined {
if c.Name != "" {
p.Name = c.Name
}
if err := s.plays.Put(ctx, playKey(num, c.UserID), p); err != nil {
return outcome{}, err
}
}
if p.finished() {
// Folds in a finished board whose stats write failed; a no-op
// otherwise.
if o.stats, o.ended, err = s.finishStats(ctx, &p); err != nil {
return outcome{}, err
}
}
o.p = p
return o, nil
}
// joinLocally adds the card's group topic to the player's chats. Only group
// chats (negative IDs) have a results message; a private chat or an inline
// card has none.
func (s *service) joinLocally(p *progress, c claims) bool {
if c.InlineID != "" || c.ChatID >= 0 || p.joinedChat(c.ChatID, c.ThreadID) || len(p.Chats) >= maxChats {
return false
}
p.Chats = append(p.Chats, chatRef{ChatID: c.ChatID, ThreadID: c.ThreadID, CardID: c.MessageID})
return true
}
// needsReport reports whether the win still has to be reported on the
// token's card. A card is recorded in Reported only once Telegram accepted
// the score, so a failed report is retried on the next state load.
func needsReport(p progress, c claims) bool {
return !slices.Contains(p.Reported, cardKey(c)) && len(p.Reported) < maxReported
}
// recordReported notes that the win of puzzle num is on the token's card.
func (s *service) recordReported(ctx context.Context, c claims, num int) error {
defer s.locks.Acquire(userLockKey(c.UserID))()
p, err := s.loadProgress(ctx, num, c.UserID)
if err != nil || p.Status != statusWon || !needsReport(p, c) {
return err
}
p.Reported = append(p.Reported, cardKey(c))
return s.plays.Put(ctx, playKey(num, c.UserID), p)
}
// finishStats folds the finished game into the player's stats, once per
// puzzle, and reports whether this call folded it. Caller holds the
// player's lock.
func (s *service) finishStats(ctx context.Context, p *progress) (userStats, bool, error) {
st, err := s.loadStats(ctx, p.UserID)
if err != nil {
return userStats{}, false, err
}
if st.LastNum >= p.Num {
return st, false, nil
}
st.Played++
if p.Status == statusWon {
st.Wins++
st.Dist[len(p.Guesses)-1]++
if st.LastWinNum == p.Num-1 {
st.CurStreak++
} else {
st.CurStreak = 1
}
st.MaxStreak = max(st.MaxStreak, st.CurStreak)
st.LastWinNum = p.Num
} else {
st.CurStreak = 0
}
st.LastNum = p.Num
if err := s.stats.Put(ctx, statsKey(p.UserID), st); err != nil {
return userStats{}, false, err
}
return st, true, nil
}
// afterPlay runs a request's side effects once the player's lock is
// released: the group results bookkeeping, the live summary and the score
// report. Failures are logged; the player's game is already saved.
func (s *service) afterPlay(c claims, o outcome) {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
if o.joined {
ref := o.p.Chats[len(o.p.Chats)-1]
if err := s.addChatPlayer(ctx, o.num, ref, c.UserID, o.p.Name); err != nil {
log.Warn("wordledaily chat join failed", "err", err)
}
if o.p.Status == statusWon && !o.ended {
s.bumpStreak(ctx, o.num, ref)
}
}
if o.ended && o.p.Status == statusWon {
for _, ref := range o.p.Chats {
s.bumpStreak(ctx, o.num, ref)
}
}
if o.guessed || o.joined || o.ended {
for _, ref := range o.p.Chats {
s.touchSummary(o.num, ref.ChatID, ref.ThreadID)
}
}
if o.report {
s.reportWin(c, o.num, len(o.p.Guesses))
}
}
// reportWin sets the score on the card in the background, then records the
// card as reported. BOT_SCORE_NOT_MODIFIED (the score does not beat the
// player's best there) counts as reported; any other failure leaves the
// card unrecorded, so the next state load from it tries again.
func (s *service) reportWin(c claims, num, guesses int) {
addr := htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID}
s.async(func() {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
err := s.cfg.reporter.Report(ctx, addr, c.UserID, score(guesses))
if err != nil && !htmlgame.ScoreNotModified(err) {
log.Warn("wordledaily score report failed", "err", err)
return
}
if err := s.recordReported(ctx, c, num); err != nil {
log.Warn("wordledaily score report record failed", "err", err)
}
})
}
func (s *service) async(f func()) {
if s.cfg.async != nil {
s.cfg.async(f)
return
}
go f()
}
// addChatPlayer lists the player in the chat topic's day.
func (s *service) addChatPlayer(ctx context.Context, num int, ref chatRef, userID int64, name string) error {
return updateVersioned(ctx, s.chatDays, chatDayKey(num, ref.ChatID, ref.ThreadID), func(cd *chatDay, found bool) bool {
changed := !found
if !found {
*cd = chatDay{Num: num, ChatID: ref.ChatID, ThreadID: ref.ThreadID, Players: []chatPlayer{}}
}
if cd.CardID == 0 && ref.CardID != 0 {
cd.CardID, changed = ref.CardID, true
}
if !slices.ContainsFunc(cd.Players, func(p chatPlayer) bool { return p.UserID == userID }) && len(cd.Players) < maxChatPlayers {
cd.Players, changed = append(cd.Players, chatPlayer{UserID: userID, Name: name}), true
}
return changed
})
}
// bumpStreak counts puzzle num toward the chat topic's streak, once.
func (s *service) bumpStreak(ctx context.Context, num int, ref chatRef) {
err := updateVersioned(ctx, s.streaks, chatStreakKey(ref.ChatID, ref.ThreadID), func(st *chatStreak, _ bool) bool {
switch {
case st.LastNum >= num:
return false
case st.LastNum == num-1:
st.Streak++
default:
st.Streak = 1
}
st.LastNum = num
return true
})
if err != nil {
log.Warn("wordledaily group streak update failed", "err", err)
}
}
// displayStreak is the current streak as shown on puzzle today: a streak
// whose last win is older than yesterday is over.
func displayStreak(st userStats, today int) int {
if st.LastWinNum < today-1 {
return 0
}
return st.CurStreak
}
+312
View File
@@ -0,0 +1,312 @@
package wordledaily
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
func TestAPI_WinUpdatesStatsAndHidesTheAnswerUntilTheEnd(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
ans := h.answer()
v := h.state(tok)
if v.Num != 1 || v.Date != "2026-10-09" || v.Status != statusPlaying || len(v.Guesses) != 0 || v.Max != 6 || v.Len != 5 || v.Player != "Alice" {
t.Fatalf("fresh state = %+v", v)
}
if v.NextAt != epoch.Add(24*time.Hour).Unix() {
t.Fatalf("next_at = %d", v.NextAt)
}
for _, w := range h.wrong(2) {
rec := h.post("guess", `{"token":"`+tok+`","num":1,"word":"`+w+`"}`)
body := strings.ToLower(rec.Body.String())
if rec.Code != http.StatusOK || strings.Contains(body, `"answer"`) || strings.Contains(body, `"stats"`) || strings.Contains(body, `"share"`) {
t.Fatalf("mid-game answer leaked or failed: %d %s", rec.Code, body)
}
}
v = h.guess(tok, 1, strings.ToUpper(ans))
if v.Status != statusWon || v.Answer != strings.ToUpper(ans) || len(v.Guesses) != 3 || v.Guesses[2].Marks != "ccccc" {
t.Fatalf("win = %+v", v)
}
if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 100 || st.Cur != 1 || st.Max != 1 || st.Dist[2] != 1 {
t.Fatalf("stats = %+v", v.Stats)
}
if !strings.HasPrefix(v.Share, "Wordle Daily #1 3/6\n\n") || !strings.HasSuffix(v.Share, "🟩🟩🟩🟩🟩") || strings.ContainsAny(strings.SplitN(v.Share, "\n\n", 2)[1], "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") {
t.Fatalf("share = %q", v.Share)
}
// No replay: a finished puzzle refuses another guess and keeps its board.
if code := h.guessErr(tok, 1, h.wrong(1)[0], http.StatusUnprocessableEntity); code != "finished" {
t.Fatalf("guess after win = %s", code)
}
if v := h.state(tok); v.Status != statusWon || len(v.Guesses) != 3 || v.Answer == "" {
t.Fatalf("reopened = %+v", v)
}
}
func TestAPI_LossAfterSixGuesses(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
var v view
for _, w := range h.wrong(6) {
v = h.guess(tok, 1, w)
}
if v.Status != statusLost || v.Answer != strings.ToUpper(h.answer()) || len(v.Guesses) != 6 {
t.Fatalf("loss = %+v", v)
}
if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 0 || st.Cur != 0 || st.Dist[5] != 0 {
t.Fatalf("stats = %+v", v.Stats)
}
if !strings.HasPrefix(v.Share, "Wordle Daily #1 X/6") {
t.Fatalf("share = %q", v.Share)
}
if len(h.rep.snapshot()) != 0 {
t.Fatal("a loss was reported")
}
}
func TestAPI_RejectedWordsAreNotRecorded(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
for word, code := range map[string]string{"abc": "length", "abcdef": "length", "": "length", "zzzzz": "unknown"} {
if got := h.guessErr(tok, 1, word, http.StatusUnprocessableEntity); got != code {
t.Errorf("guess %q = %s, want %s", word, got, code)
}
}
if v := h.state(tok); len(v.Guesses) != 0 {
t.Fatalf("rejected words recorded: %+v", v.Guesses)
}
}
func TestAPI_DuplicateLetterMarks(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
// Pin puzzle #1 to a word with a doubled letter.
if err := storage.Typed[puzzleDoc](h.coll).Put(ctx, puzzleKey(1), puzzleDoc{Num: 1, Answer: "abbey"}); err != nil {
t.Fatal(err)
}
v := h.guess(h.dmToken(1), 1, "babes")
if v.Guesses[0].Word != "BABES" || v.Guesses[0].Marks != "ppccw" {
t.Fatalf("marks = %+v", v.Guesses)
}
}
func TestAPI_StaleBoardGetsNewPuzzleAfterRollover(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
h.guess(tok, 1, h.wrong(1)[0])
h.clock.advance(12 * time.Hour) // 07:00 ICT next day
tok = h.dmToken(1) // the old link expired meanwhile
if code := h.guessErr(tok, 1, h.wrong(1)[0], http.StatusConflict); code != "new_puzzle" {
t.Fatalf("stale guess = %s", code)
}
v := h.state(tok)
if v.Num != 2 || len(v.Guesses) != 0 || v.Status != statusPlaying || v.Date != "2026-10-10" {
t.Fatalf("new day = %+v", v)
}
}
// win plays today's answer for user in their private chat.
func (h *harness) win(user int64) view {
h.t.Helper()
v := h.state(h.dmToken(user))
return h.guess(h.dmToken(user), v.Num, h.answer())
}
func TestStats_StreakContinuesBreaksAndDisplaysZero(t *testing.T) {
h := newHarness(t)
h.win(1)
h.clock.advance(24 * time.Hour)
v := h.win(1)
if v.Stats.Cur != 2 || v.Stats.Max != 2 || v.Stats.Played != 2 {
t.Fatalf("day 2 = %+v", v.Stats)
}
h.clock.advance(48 * time.Hour) // day 3 missed
st, err := h.svc.loadStats(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if got := displayStreak(st, h.svc.puzzleNum(h.clock.now())); got != 0 {
t.Fatalf("streak after a missed day shows %d", got)
}
v = h.win(1)
if v.Stats.Cur != 1 || v.Stats.Max != 2 || v.Stats.Played != 3 || v.Stats.Dist[0] != 3 {
t.Fatalf("day 4 = %+v", v.Stats)
}
h.clock.advance(24 * time.Hour)
tok := h.dmToken(1)
for _, w := range h.wrong(6) {
v = h.guess(tok, 5, w)
}
if v.Stats.Cur != 0 || v.Stats.Max != 2 || v.Stats.Played != 4 || v.Stats.WinPct != 75 {
t.Fatalf("loss = %+v", v.Stats)
}
}
// flakyPlays fails the next Put.
type flakyPlays struct {
storage.DocStore[progress]
fail bool
}
func (f *flakyPlays) Put(ctx context.Context, id string, v progress) error {
if f.fail {
f.fail = false
return errors.New("store down")
}
return f.DocStore.Put(ctx, id, v)
}
func TestFinish_FailedProgressWriteDoesNotDoubleCount(t *testing.T) {
h := newHarness(t)
flaky := &flakyPlays{DocStore: h.svc.plays}
h.svc.plays = flaky
tok := h.dmToken(1)
h.guess(tok, 1, h.wrong(1)[0])
flaky.fail = true
rec := h.post("guess", `{"token":"`+tok+`","num":1,"word":"`+h.answer()+`"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("failed write = %d", rec.Code)
}
v := h.guess(tok, 1, h.answer())
if v.Status != statusWon || v.Stats.Played != 1 || v.Stats.Dist[1] != 1 {
t.Fatalf("retried finish = %+v %+v", v, v.Stats)
}
}
// A finishing guess whose board write failed counts nowhere, so the player
// can play on with other words and the stats follow the board.
func TestFinish_FailedBoardWriteThenOtherWordsKeepsStatsInStep(t *testing.T) {
h := newHarness(t)
flaky := &flakyPlays{DocStore: h.svc.plays}
h.svc.plays = flaky
tok := h.dmToken(1)
words := h.wrong(6)
h.guess(tok, 1, words[0])
flaky.fail = true
if rec := h.post("guess", `{"token":"`+tok+`","num":1,"word":"`+h.answer()+`"}`); rec.Code != http.StatusInternalServerError {
t.Fatalf("failed write = %d", rec.Code)
}
var v view
for _, w := range words[1:] {
v = h.guess(tok, 1, w)
}
if v.Status != statusLost || len(v.Guesses) != 6 {
t.Fatalf("board = %+v", v)
}
if st := v.Stats; st.Played != 1 || st.WinPct != 0 || st.Cur != 0 || st.Max != 0 || slices.Max(st.Dist) != 0 {
t.Fatalf("stats = %+v", st)
}
}
// flakyStats fails the next Put.
type flakyStats struct {
storage.DocStore[userStats]
fail bool
}
func (f *flakyStats) Put(ctx context.Context, id string, v userStats) error {
if f.fail {
f.fail = false
return errors.New("store down")
}
return f.DocStore.Put(ctx, id, v)
}
// A board saved as won whose stats write failed is folded into the stats on
// the next load, once, with the group bookkeeping that finishing runs.
func TestFinish_FailedStatsWriteIsRepairedFromTheBoard(t *testing.T) {
h := newHarness(t)
flaky := &flakyStats{DocStore: h.svc.stats}
h.svc.stats = flaky
tok := h.tokenFor(1, groupChat, 7, 0, "Alice")
h.guess(tok, 1, h.wrong(1)[0])
flaky.fail = true
if rec := h.post("guess", `{"token":"`+tok+`","num":1,"word":"`+h.answer()+`"}`); rec.Code != http.StatusInternalServerError {
t.Fatalf("failed write = %d", rec.Code)
}
// The board is finished: another word is refused, and that repairs
// the stats and the group streak.
if code := h.guessErr(tok, 1, h.wrong(2)[1], http.StatusUnprocessableEntity); code != "finished" {
t.Fatalf("guess after finish = %s", code)
}
v := h.state(tok)
if v.Status != statusWon || len(v.Guesses) != 2 {
t.Fatalf("board = %+v", v)
}
if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 100 || st.Cur != 1 || st.Dist[1] != 1 {
t.Fatalf("stats = %+v", v.Stats)
}
if v = h.state(tok); v.Stats.Played != 1 {
t.Fatalf("counted twice: %+v", v.Stats)
}
if st, _ := h.svc.chatStreakAt(context.Background(), groupChat, 0); st.Streak != 1 || st.LastNum != 1 {
t.Fatalf("group streak = %+v", st)
}
if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 5 {
t.Fatalf("reports = %+v", calls)
}
}
func TestRestart_KeepsTheBoard(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
w := h.wrong(1)[0]
h.guess(tok, 1, w)
h.restart()
v := h.state(tok)
if len(v.Guesses) != 1 || v.Guesses[0].Word != strings.ToUpper(w) {
t.Fatalf("after restart = %+v", v)
}
}
func TestAPI_TokenErrors(t *testing.T) {
h := newHarness(t)
// A token signed with noitu's key derivation never verifies here.
noituKey := htmlgame.DeriveKey(testRoot, "tiennm99bot/noitu/token/v1")
noituTok, _ := htmlgame.Sign(noituKey, claims{UserID: 1, ChatID: 1, MessageID: 1, Expiry: h.clock.now().Add(time.Hour).Unix()})
for name, tok := range map[string]string{"noitu key": noituTok, "garbage": "abc.def", "empty": ""} {
rec := h.post("state", `{"token":"`+tok+`"}`)
if rec.Code != http.StatusUnauthorized || errorCode(t, rec) != "bad_token" {
t.Errorf("%s: %d %s", name, rec.Code, rec.Body.String())
}
}
tok := h.dmToken(1)
h.clock.advance(tokenTTL)
rec := h.post("state", `{"token":"`+tok+`"}`)
if rec.Code != http.StatusUnauthorized || errorCode(t, rec) != "expired" {
t.Fatalf("expired: %d %s", rec.Code, rec.Body.String())
}
}
func TestAPI_RateLimitAndRequestShape(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
for range requestsPerMinute {
h.state(tok)
}
rec := h.post("state", `{"token":"`+tok+`"}`)
if rec.Code != http.StatusTooManyRequests || errorCode(t, rec) != "rate_limited" {
t.Fatalf("flood: %d", rec.Code)
}
// Another player is not limited, and the window slides.
h.state(h.dmToken(2))
h.clock.advance(time.Minute)
h.state(tok)
for _, body := range []string{`{"token":"x","score":7}`, `{"token":"x"}{}`, `[]`, `{"token":"x","num":1,"word":"` + strings.Repeat("a", 40) + `"}`} {
path := "state"
if strings.Contains(body, "word") {
path = "guess"
}
if rec := h.post(path, body); rec.Code != http.StatusBadRequest || errorCode(t, rec) != "bad_request" {
t.Errorf("%s %s: %d", path, body, rec.Code)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
package wordledaily
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"math/rand/v2"
"strconv"
"sync"
"time"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
dayLength = 24 * time.Hour
// maxCachedPuzzles bounds the in-memory answer cache: today and
// yesterday are all a request or the push ever needs.
maxCachedPuzzles = 4
)
// epoch is the start of puzzle #1: 07:00 ICT on 9 October 2026, which is
// 00:00 UTC, so every puzzle day runs 07:00 to 07:00 ICT and the cron at
// 00:00 UTC pushes exactly the puzzle a player then gets.
var epoch = time.Date(2026, time.October, 9, 0, 0, 0, 0, time.UTC)
// ict is Vietnam time (UTC+7, no DST), used only to print a puzzle's date.
var ict = time.FixedZone("ICT", 7*60*60)
// puzzleNum is the number of the puzzle playing at now: 1 on the epoch day,
// then one more each day. Times before the epoch play #1.
func (s *service) puzzleNum(now time.Time) int {
d := now.Sub(s.epoch)
if d < 0 {
return 1
}
return int(d/dayLength) + 1
}
// puzzleStart is when puzzle num starts; the next one starts a day later.
func (s *service) puzzleStart(num int) time.Time {
return s.epoch.Add(time.Duration(num-1) * dayLength)
}
// puzzleDate is the ICT calendar date puzzle num belongs to.
func (s *service) puzzleDate(num int) string {
return s.puzzleStart(num).In(ict).Format(time.DateOnly)
}
// answerFor computes puzzle num's answer from the answer key: each cycle of
// len(answers) days walks one keyed permutation of the list, so no answer
// repeats within a cycle (about six years) and the order cannot be guessed
// without the key. Persisted puzzles take precedence; see resolvePuzzle.
func (s *service) answerFor(num int) string {
n := len(s.cfg.answers)
idx := num - 1
cycle, pos := idx/n, idx%n
return s.cfg.answers[s.perms.get(s.answerKey, cycle, n)[pos]]
}
// permCache keeps the permutation of the current cycle.
type permCache struct {
mu sync.Mutex
cycle int
perm []int
}
func (c *permCache) get(key []byte, cycle, n int) []int {
c.mu.Lock()
defer c.mu.Unlock()
if c.perm != nil && c.cycle == cycle && len(c.perm) == n {
return c.perm
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte("cycle:" + strconv.Itoa(cycle)))
var seed [32]byte
copy(seed[:], mac.Sum(nil))
rng := rand.New(rand.NewChaCha8(seed))
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
rng.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
c.cycle, c.perm = cycle, perm
return perm
}
// puzzleCache remembers resolved answers so a guess does not read the store
// for the puzzle every time.
type puzzleCache struct {
mu sync.Mutex
answers map[int]string
}
func (c *puzzleCache) get(num int) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
a, ok := c.answers[num]
return a, ok
}
func (c *puzzleCache) put(num int, answer string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.answers == nil {
c.answers = map[int]string{}
}
c.answers[num] = answer
for len(c.answers) > maxCachedPuzzles {
oldest := num
for k := range c.answers {
oldest = min(oldest, k)
}
delete(c.answers, oldest)
}
}
// resolvePuzzle returns puzzle num's answer. The first resolution persists
// it, and every later one reads it back, so changing the secret or the
// answer list never changes a puzzle that has already started.
func (s *service) resolvePuzzle(ctx context.Context, num int) (string, error) {
if a, ok := s.cache.get(num); ok {
return a, nil
}
key := puzzleKey(num)
for range writeRetries {
doc, _, err := s.puzzles.Get(ctx, key)
if err == nil {
s.cache.put(num, doc.Answer)
return doc.Answer, nil
}
if !errors.Is(err, storage.ErrNotFound) {
return "", err
}
doc = puzzleDoc{Num: num, Answer: s.answerFor(num), CreatedAt: s.cfg.now().UnixMilli()}
err = s.puzzles.PutVersioned(ctx, key, 0, doc)
if err == nil {
s.cache.put(num, doc.Answer)
return doc.Answer, nil
}
if !errors.Is(err, storage.ErrConflict) {
return "", err
}
}
return "", storage.ErrConflict
}
+129
View File
@@ -0,0 +1,129 @@
package wordledaily
import (
"context"
"slices"
"sync"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
func TestPuzzleNum_RollsOverAt0700ICT(t *testing.T) {
h := newHarness(t)
s := h.svc
at := func(y int, m time.Month, d, hh, mm, ss int) time.Time { return time.Date(y, m, d, hh, mm, ss, 0, ict) }
cases := []struct {
at time.Time
want int
}{
{at(2026, 10, 9, 7, 0, 0), 1},
{at(2026, 10, 10, 6, 59, 59), 1},
{at(2026, 10, 10, 7, 0, 0), 2},
{at(2026, 10, 11, 0, 30, 0), 2},
{at(2026, 10, 19, 7, 0, 0), 11},
{at(2026, 1, 1, 0, 0, 0), 1}, // before the epoch
}
for _, c := range cases {
if got := s.puzzleNum(c.at); got != c.want {
t.Errorf("puzzleNum(%s) = %d, want %d", c.at, got, c.want)
}
}
if got := s.puzzleDate(2); got != "2026-10-10" {
t.Errorf("puzzleDate(2) = %s", got)
}
if got := s.puzzleStart(2); !got.Equal(at(2026, 10, 10, 7, 0, 0)) {
t.Errorf("puzzleStart(2) = %s", got)
}
}
func TestAnswerFor_KeyedPermutationWithoutRepeats(t *testing.T) {
h := newHarness(t)
n := len(testAnswers)
seen := make(map[string]bool, n)
var first []string
for num := 1; num <= n; num++ {
a := h.svc.answerFor(num)
if seen[a] {
t.Fatalf("answer %q repeats within the first cycle (puzzle %d)", a, num)
}
seen[a] = true
if num <= 10 {
first = append(first, a)
}
}
if len(seen) != n {
t.Fatalf("cycle covers %d answers, want %d", len(seen), n)
}
if slices.Equal(first, testAnswers[:10]) {
t.Fatal("answers follow the sorted list order")
}
// The same key gives the same order; another key another order.
same := newService(testConfig(h.clock, h.coll, h.rep, nil, h.sched))
other := testConfig(h.clock, h.coll, h.rep, nil, h.sched)
other.rootKey = []byte("another-root-key-another-root-key")
diff := newService(other)
var sameSeq, diffSeq []string
for num := 1; num <= 10; num++ {
sameSeq = append(sameSeq, same.answerFor(num))
diffSeq = append(diffSeq, diff.answerFor(num))
}
if !slices.Equal(sameSeq, first) {
t.Fatal("same key, different sequence")
}
if slices.Equal(diffSeq, first) {
t.Fatal("different key, same sequence")
}
// The next cycle is another permutation.
if h.svc.answerFor(n+1) == h.svc.answerFor(1) && h.svc.answerFor(n+2) == h.svc.answerFor(2) {
t.Fatal("second cycle repeats the first")
}
}
func TestResolvePuzzle_StoredAnswerWinsOverKeyChange(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
first, err := h.svc.resolvePuzzle(ctx, 1)
if err != nil || first != h.svc.answerFor(1) {
t.Fatalf("resolve = %q, %v", first, err)
}
cfg := testConfig(h.clock, h.coll, h.rep, nil, h.sched)
cfg.rootKey = []byte("another-root-key-another-root-key")
rotated := newService(cfg)
if rotated.answerFor(1) == first {
t.Skip("rotated key happens to pick the same answer")
}
if got, err := rotated.resolvePuzzle(ctx, 1); err != nil || got != first {
t.Fatalf("after key change = %q, %v; want stored %q", got, err, first)
}
doc, _, err := storage.Typed[puzzleDoc](h.coll).Get(ctx, puzzleKey(1))
if err != nil || doc.Num != 1 || doc.Answer != first || doc.CreatedAt == 0 {
t.Fatalf("stored puzzle = %+v, %v", doc, err)
}
}
func TestResolvePuzzle_ConcurrentFirstResolutionAgrees(t *testing.T) {
h := newHarness(t)
var wg sync.WaitGroup
got := make([]string, 16)
for i := range got {
// A service per goroutine, so no in-memory cache hides the race.
svc := newService(testConfig(h.clock, h.coll, h.rep, nil, h.sched))
wg.Add(1)
go func() {
defer wg.Done()
a, err := svc.resolvePuzzle(context.Background(), 3)
if err != nil {
t.Error(err)
}
got[i] = a
}()
}
wg.Wait()
for _, a := range got {
if a != got[0] || a == "" {
t.Fatalf("answers disagree: %v", got)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
package wordledaily
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/go-telegram/bot"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/util/subscription"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// dailyPushCronName must be unique across all modules' crons.
dailyPushCronName = "wordledaily_daily_push"
// dailyPushSchedule is UTC; 00:00 UTC is 07:00 ICT, when a puzzle starts.
dailyPushSchedule = "0 0 * * *"
// lastPushDateKey records the number of the last puzzle pushed, so a
// second trigger on the same day sends nothing.
lastPushDateKey = "daily_push:last_date"
)
// dailyPushHandler is the cron entry point.
func (s *service) dailyPushHandler(ctx context.Context, deps modules.Deps) error {
if deps.Bot == nil {
return errors.New("wordledaily daily push: deps.Bot is nil (BuildOptions.Bot not wired)")
}
return s.runDailyPush(ctx, deps.Bot)
}
// runDailyPush sends each subscriber yesterday's group results (when there
// is something to say) and then today's card, once per puzzle.
func (s *service) runDailyPush(ctx context.Context, api telegramAPI) error {
num := s.puzzleNum(s.cfg.now())
// Pin today's answer now, before anyone plays.
if _, err := s.resolvePuzzle(ctx, num); err != nil {
return fmt.Errorf("wordledaily daily push: resolve puzzle: %w", err)
}
s.pruneDays(ctx, num)
subs, err := subscription.List(ctx, s.subscribers)
if err != nil {
return fmt.Errorf("wordledaily daily push: list subscribers: %w", err)
}
if len(subs) == 0 {
log.Info("wordledaily daily push: no subscribers, skipping")
return nil
}
won, err := subscription.ClaimDay(ctx, s.pushDate, lastPushDateKey, strconv.Itoa(num))
if err != nil {
return fmt.Errorf("wordledaily daily push: claim day: %w", err)
}
if !won {
log.Info("wordledaily daily push: already pushed, skipping", "puzzle", num)
return nil
}
res, err := subscription.FanoutFunc(ctx, "wordledaily daily", s.subscribers, &s.subscribersMu, subs, func(ctx context.Context, sub subscription.Subscriber) error {
return s.pushTo(ctx, api, num, sub)
})
if err != nil {
return err
}
log.Info("wordledaily daily push complete", "puzzle", num, "subscribers", len(subs),
"sent", res.Sent, "failed", res.Failed, "pruned", res.Pruned, "throttled", res.Throttled)
return nil
}
// pushTo sends one subscriber its recap and card. A recap that fails to send
// skips the card, so FanoutFunc classifies that error and prunes a dead chat.
func (s *service) pushTo(ctx context.Context, api telegramAPI, num int, sub subscription.Subscriber) error {
if num > 1 {
text, err := s.renderRecap(ctx, num-1, sub.ChatID, sub.ThreadID)
if err != nil {
log.Warn("wordledaily recap render failed", "err", err)
}
if text != "" {
if _, err := api.SendMessage(ctx, &bot.SendMessageParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, Text: text}); err != nil {
return err
}
}
}
_, err := api.SendGame(ctx, &bot.SendGameParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, GameShorName: ShortName})
return err
}
// renderRecap is puzzle num's results in a group chat topic: the answer
// first, then the finishers grouped by guess count with the best line
// crowned, and the group streak, which only a win keeps going. It is "" in a
// private chat.
func (s *service) renderRecap(ctx context.Context, num int, chatID int64, threadID int) (string, error) {
if chatID >= 0 {
return "", nil
}
answer, err := s.resolvePuzzle(ctx, num)
if err != nil {
return "", err
}
cd, _, err := s.chatDays.Get(ctx, chatDayKey(num, chatID, threadID))
if err != nil && !errors.Is(err, storage.ErrNotFound) {
return "", err
}
results, err := s.chatResults(ctx, cd)
if err != nil {
return "", err
}
finished := results[:0]
for _, r := range results {
if r.p.finished() {
finished = append(finished, r)
}
}
st, err := s.chatStreakAt(ctx, chatID, threadID)
if err != nil {
return "", err
}
label := "Wordle Daily #" + strconv.Itoa(num)
head := label + " — yesterday's results\nAnswer: " + strings.ToUpper(answer) + "\n"
// The recap runs after puzzle num closed, so the streak survives only if
// someone in the group solved num itself.
switch {
case st.LastNum >= num && st.Streak >= minStreakShown:
head += "🔥 Your group is on a " + strconv.Itoa(st.Streak) + " day streak!\n"
case st.LastNum == num-1 && st.Streak >= minStreakShown:
head += "Nobody solved it. Group streak reset.\n"
}
if len(finished) == 0 {
return strings.TrimSuffix(head, "\n"), nil
}
sortResults(finished)
var lines []string
for i := 0; i < len(finished); {
j := i
line := resultLine(finished[i].p)
for j < len(finished) && resultLine(finished[j].p) == line {
j++
}
prefix := line + ": "
if i == 0 && finished[i].p.Status == statusWon {
prefix = "👑 " + prefix
}
lines = append(lines, prefix+nameList(finished[i:j]))
i = j
}
return joinLimited(head, lines, "\n"), nil
}
// maxLineUnits caps one recap line, so even all seven result lines fit in
// one message.
const maxLineUnits = 500
// nameList joins the players' names, ending with "+N more" past
// maxLineUnits.
func nameList(rs []chatResult) string {
var b strings.Builder
for i, r := range rs {
if i > 0 && utf16Len(b.String())+utf16Len(r.name) > maxLineUnits {
return b.String() + ", +" + strconv.Itoa(len(rs)-i) + " more"
}
if i > 0 {
b.WriteString(", ")
}
b.WriteString(r.name)
}
return b.String()
}
+215
View File
@@ -0,0 +1,215 @@
package wordledaily
import (
"context"
"strings"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/util/subscription"
)
func mustList(t *testing.T, h *harness) []subscription.Subscriber {
t.Helper()
subs, err := subscription.List(context.Background(), h.svc.subscribers)
if err != nil {
t.Fatal(err)
}
return subs
}
func subscribe(t *testing.T, h *harness, chat int64, thread int) {
t.Helper()
if _, err := subscription.Add(context.Background(), h.svc.subscribers, chat, thread); err != nil {
t.Fatal(err)
}
}
// playGroupDay has Alice and Dan win in 2, Eve win in 3 and Bob lose on
// puzzle #1 in the group, from the card in topic thread.
func playGroupDay(t *testing.T, h *harness, thread int) {
t.Helper()
w := h.wrong(6)
ans := h.answer()
play := func(user int64, name string, words ...string) {
tok := h.tokenFor(user, groupChat, 7, thread, name)
for _, word := range words {
h.guess(tok, 1, word)
}
}
play(1, "Alice", w[0], ans)
h.clock.advance(time.Minute)
play(2, "Bob", w...)
play(3, "Dan", w[1], ans)
play(4, "Eve", w[2], w[3], ans)
h.sched.run()
h.rb.Reset()
}
func (h *harness) nextDay() { h.clock.advance(24 * time.Hour) }
func TestPush_RecapThenCardWithCrownTiesAndLoss(t *testing.T) {
h := newHarness(t)
playGroupDay(t, h, 77)
subscribe(t, h, groupChat, 77)
h.nextDay()
if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil {
t.Fatal(err)
}
sent := h.rb.Sent()
if len(sent) != 2 || sent[0].Method != "sendMessage" || sent[1].Method != "sendGame" {
t.Fatalf("sent %+v", sent)
}
want := "Wordle Daily #1 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.answerFor(1)) + "\n👑 2/6: Alice, Dan\n3/6: Eve\nX/6: Bob"
if got := sent[0].Text(); got != want {
t.Fatalf("recap:\n%s\nwant:\n%s", got, want)
}
for _, c := range sent {
if c.Form["message_thread_id"] != "77" || c.ChatID() != "-100" {
t.Fatalf("not sent to the topic: %+v", c)
}
}
if sent[1].Form["game_short_name"] != ShortName {
t.Fatalf("card = %v", sent[1].Form)
}
// The same puzzle is never pushed twice.
h.rb.Reset()
if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 {
t.Fatalf("second run sent %+v, %v", h.rb.Sent(), err)
}
}
func TestPush_StreakLineAndReset(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
subscribe(t, h, groupChat, 0)
// Someone in the group finishes on #1 and #2: a two-day streak.
h.state(h.dmToken(1))
h.guess(h.tokenFor(1, groupChat, 7, 0, "Alice"), 1, h.answer())
h.nextDay()
h.guess(h.tokenFor(1, groupChat, 8, 0, "Alice"), 2, h.answer())
h.nextDay()
h.rb.Reset()
if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
if got := h.rb.Sent()[0].Text(); got != "Wordle Daily #2 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(2))+"\n🔥 Your group is on a 2 day streak!\n👑 1/6: Alice" {
t.Fatalf("streak recap = %q", got)
}
// Nobody plays #3: the next push says the streak ended, then sends the card.
h.nextDay()
h.rb.Reset()
if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
sent := h.rb.Sent()
if len(sent) != 2 || sent[0].Text() != "Wordle Daily #3 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(3))+"\nNobody solved it. Group streak reset." || sent[1].Method != "sendGame" {
t.Fatalf("reset push = %+v", sent)
}
// The day after, with no streak left, the recap is just the answer.
h.nextDay()
h.rb.Reset()
if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
if sent := h.rb.Sent(); len(sent) != 2 || sent[0].Text() != "Wordle Daily #4 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(4)) || sent[1].Method != "sendGame" {
t.Fatalf("quiet push = %+v", sent)
}
}
// A loss keeps nobody's streak going: the group streak counts wins only.
func TestPush_LossBreaksTheGroupStreak(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
subscribe(t, h, groupChat, 0)
h.guess(h.tokenFor(1, groupChat, 7, 0, "Alice"), 1, h.answer())
h.nextDay()
h.guess(h.tokenFor(1, groupChat, 8, 0, "Alice"), 2, h.answer())
h.nextDay()
tok := h.tokenFor(1, groupChat, 9, 0, "Alice")
for _, w := range h.wrong(6) {
h.guess(tok, 3, w)
}
h.nextDay()
h.rb.Reset()
if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
want := "Wordle Daily #3 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.answerFor(3)) + "\nNobody solved it. Group streak reset.\nX/6: Alice"
if got := h.rb.Sent()[0].Text(); got != want {
t.Fatalf("loss recap = %q, want %q", got, want)
}
}
func TestPush_PrivateSubscriberGetsCardOnly(t *testing.T) {
h := newHarness(t)
h.win(5)
subscribe(t, h, 5, 0)
h.nextDay()
h.rb.Reset()
if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil {
t.Fatal(err)
}
if sent := h.rb.Sent(); len(sent) != 1 || sent[0].Method != "sendGame" || sent[0].ChatID() != "5" {
t.Fatalf("sent %+v", sent)
}
}
func TestPush_BlockedRecapPrunesAndSkipsTheCard(t *testing.T) {
h := newHarness(t)
playGroupDay(t, h, 0)
subscribe(t, h, groupChat, 0)
subscribe(t, h, 9, 0)
h.nextDay()
h.rb.FailMethodCode("sendMessage", 403, "Forbidden: bot was kicked from the supergroup chat; bot is not a member of the supergroup chat")
if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil {
t.Fatal(err)
}
games := sentMethod(h.rb, "sendGame")
if len(games) != 1 || games[0].ChatID() != "9" {
t.Fatalf("cards = %+v", games)
}
if subs := mustList(t, h); len(subs) != 1 || subs[0].ChatID != 9 {
t.Fatalf("subscribers = %+v", subs)
}
}
func TestPush_NoSubscribersAndNoBot(t *testing.T) {
h := newHarness(t)
if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 {
t.Fatalf("no subscribers: %+v, %v", h.rb.Sent(), err)
}
if err := h.svc.dailyPushHandler(context.Background(), modules.Deps{}); err == nil {
t.Fatal("push without a bot succeeded")
}
// The push pins the day's answer even with nobody subscribed.
if _, _, err := h.svc.puzzles.Get(context.Background(), puzzleKey(1)); err != nil {
t.Fatalf("puzzle not pinned: %v", err)
}
}
// The daily push deletes the boards, chat days and pinned answers older
// than yesterday, even without subscribers, and keeps stats and streaks.
func TestPush_PrunesOldPuzzleDays(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
for day := 1; day <= 3; day++ {
h.guess(h.tokenFor(1, groupChat, 7, 0, "Alice"), day, h.answer())
h.sched.run()
if day < 3 {
h.nextDay()
}
}
if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
keys, err := h.svc.plays.List(ctx, "")
if err != nil {
t.Fatal(err)
}
want := "cday:2:-100:0: cday:3:-100:0: cstreak:-100:0: play:2:1 play:3:1 puzzle:2 puzzle:3 stats:1"
if got := strings.Join(keys, " "); got != want {
t.Fatalf("keys after prune:\n%s\nwant:\n%s", got, want)
}
}
@@ -0,0 +1,76 @@
package wordledaily
import (
"errors"
"testing"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
func TestScore_WinReportsSevenMinusGuessesOncePerCard(t *testing.T) {
h := newHarness(t)
card := h.tokenFor(1, groupChat, 7, 0, "Alice")
h.guess(card, 1, h.wrong(1)[0])
h.guess(card, 1, h.wrong(2)[1])
h.guess(card, 1, h.answer())
calls := h.rep.snapshot()
if len(calls) != 1 || calls[0].score != 4 || calls[0].user != 1 || calls[0].addr != (htmlgame.Address{ChatID: groupChat, MessageID: 7}) {
t.Fatalf("reports = %+v", calls)
}
// Reopening the same card does not report again.
h.state(card)
if n := len(h.rep.snapshot()); n != 1 {
t.Fatalf("reopen reported again: %d", n)
}
// Opening another card reports the day's result there, once.
other := h.tokenFor(1, -200, 9, 0, "Alice")
h.state(other)
h.state(other)
inline, _ := h.svc.signToken(claims{UserID: 1, InlineID: "BAAAInline", Expiry: h.clock.now().Add(tokenTTL).Unix()})
h.state(inline)
calls = h.rep.snapshot()
if len(calls) != 3 || calls[1].addr != (htmlgame.Address{ChatID: -200, MessageID: 9}) || calls[1].score != 4 ||
calls[2].addr != (htmlgame.Address{InlineID: "BAAAInline"}) {
t.Fatalf("reports = %+v", calls)
}
}
func TestScore_LossIsNotReported(t *testing.T) {
h := newHarness(t)
tok := h.dmToken(1)
for _, w := range h.wrong(6) {
h.guess(tok, 1, w)
}
h.state(h.tokenFor(1, groupChat, 7, 0, "Alice"))
if n := len(h.rep.snapshot()); n != 0 {
t.Fatalf("loss reported %d times", n)
}
}
// A score that does not beat the player's best on the card is Telegram's
// expected answer: the card counts as reported and is not tried again.
func TestScore_NotModifiedCountsAsReported(t *testing.T) {
h := newHarness(t)
h.rep.err = errors.New("Bad Request: BOT_SCORE_NOT_MODIFIED")
h.win(2)
h.state(h.dmToken(2))
if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 6 {
t.Fatalf("reports = %+v", calls)
}
}
// A report that failed (a timeout, a 5xx) leaves the card unrecorded, so
// opening it again reports the win until Telegram accepts it.
func TestScore_FailedReportIsRetriedOnTheNextLoad(t *testing.T) {
h := newHarness(t)
card := h.tokenFor(1, groupChat, 7, 0, "Alice")
h.rep.err = errors.New("context deadline exceeded")
h.guess(card, 1, h.answer())
h.rep.err = nil
h.state(card)
h.state(card)
calls := h.rep.snapshot()
if len(calls) != 2 || calls[1].score != 6 || calls[1].addr != (htmlgame.Address{ChatID: groupChat, MessageID: 7}) {
t.Fatalf("reports = %+v", calls)
}
}
+241
View File
@@ -0,0 +1,241 @@
package wordledaily
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
// Every document lives in the module's one collection under its own key
// prefix. JSON and BSON names match because the memory store round-trips
// through JSON and MongoDB through BSON.
const (
statusPlaying = "playing"
statusWon = "won"
statusLost = "lost"
maxGuesses = 6
// maxReported bounds the cards a player's win is reported on per day.
maxReported = 32
// maxChats bounds the chats one player's daily result appears in.
maxChats = 20
// maxChatPlayers bounds one chat's daily player list.
maxChatPlayers = 200
// writeRetries bounds a versioned read-modify-write.
writeRetries = 5
)
// puzzleDoc pins one day's answer once it has been resolved.
type puzzleDoc struct {
Num int `json:"num" bson:"num"`
Answer string `json:"answer" bson:"answer"`
CreatedAt int64 `json:"created_at" bson:"created_at"`
}
// guess is one submitted word and its marks: one letter per position, c
// (correct), p (present elsewhere) or w (wrong).
type guess struct {
Word string `json:"word" bson:"word"`
Marks string `json:"marks" bson:"marks"`
}
// chatRef is a group chat (and forum topic) a player's result appears in,
// with the card they last played from there, which the live summary replies
// to.
type chatRef struct {
ChatID int64 `json:"chat_id" bson:"chat_id"`
ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"`
CardID int `json:"card_id,omitempty" bson:"card_id,omitempty"`
}
// progress is one player's game on one puzzle.
type progress struct {
Num int `json:"num" bson:"num"`
UserID int64 `json:"user_id" bson:"user_id"`
Name string `json:"name" bson:"name"`
Guesses []guess `json:"guesses" bson:"guesses"`
Status string `json:"status" bson:"status"`
FinishedAt int64 `json:"finished_at,omitempty" bson:"finished_at,omitempty"` // unix ms
Reported []string `json:"reported,omitempty" bson:"reported,omitempty"` // card keys the win was reported on
Chats []chatRef `json:"chats,omitempty" bson:"chats,omitempty"`
}
func (p *progress) finished() bool { return p.Status == statusWon || p.Status == statusLost }
// joinedChat reports whether the player's result already appears in the
// chat's topic.
func (p *progress) joinedChat(chatID int64, threadID int) bool {
return slices.ContainsFunc(p.Chats, func(c chatRef) bool { return c.ChatID == chatID && c.ThreadID == threadID })
}
// userStats is a player's record across every puzzle.
type userStats struct {
Played int `json:"played" bson:"played"`
Wins int `json:"wins" bson:"wins"`
CurStreak int `json:"cur_streak" bson:"cur_streak"`
MaxStreak int `json:"max_streak" bson:"max_streak"`
Dist []int `json:"dist" bson:"dist"` // wins by guess count, index 0 = 1 guess
LastNum int `json:"last_num" bson:"last_num"`
LastWinNum int `json:"last_win_num" bson:"last_win_num"`
}
// chatPlayer is one player listed in a chat's day.
type chatPlayer struct {
UserID int64 `json:"user_id" bson:"user_id"`
Name string `json:"name" bson:"name"`
}
// chatDay is who played one puzzle in one chat topic, plus the live
// summary message the bot keeps there.
type chatDay struct {
Num int `json:"num" bson:"num"`
ChatID int64 `json:"chat_id" bson:"chat_id"`
ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"`
Players []chatPlayer `json:"players" bson:"players"`
CardID int `json:"card_id,omitempty" bson:"card_id,omitempty"` // the card the summary replies to
MsgID int `json:"msg_id,omitempty" bson:"msg_id,omitempty"`
MsgSentAt int64 `json:"msg_sent_at,omitempty" bson:"msg_sent_at,omitempty"` // unix ms
}
// chatStreak counts the consecutive puzzles on which at least one player in
// a chat topic solved the word; a loss does not count. LastNum is the latest
// such puzzle.
type chatStreak struct {
Streak int `json:"streak" bson:"streak"`
LastNum int `json:"last_num" bson:"last_num"`
}
// liveAt is the streak as of puzzle num: a streak whose last puzzle is
// older than yesterday has been broken.
func (c chatStreak) liveAt(num int) int {
if c.LastNum >= num-1 {
return c.Streak
}
return 0
}
func puzzleKey(num int) string { return "puzzle:" + strconv.Itoa(num) }
func playKey(num int, userID int64) string {
return "play:" + strconv.Itoa(num) + ":" + strconv.FormatInt(userID, 10)
}
func statsKey(userID int64) string { return "stats:" + strconv.FormatInt(userID, 10) }
// chatTopic is "<chat>:<thread>:"; the trailing colon keeps chat -1 from
// matching chat -100 in a prefix.
func chatTopic(chatID int64, threadID int) string {
return strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(threadID) + ":"
}
func chatDayKey(num int, chatID int64, threadID int) string {
return "cday:" + strconv.Itoa(num) + ":" + chatTopic(chatID, threadID)
}
func chatStreakKey(chatID int64, threadID int) string {
return "cstreak:" + chatTopic(chatID, threadID)
}
// loadProgress returns the player's game on puzzle num, or a fresh one.
func (s *service) loadProgress(ctx context.Context, num int, userID int64) (progress, error) {
p, _, err := s.plays.Get(ctx, playKey(num, userID))
switch {
case err == nil:
return p, nil
case errors.Is(err, storage.ErrNotFound):
return progress{Num: num, UserID: userID, Guesses: []guess{}, Status: statusPlaying}, nil
default:
return progress{}, err
}
}
func (s *service) loadStats(ctx context.Context, userID int64) (userStats, error) {
st, _, err := s.stats.Get(ctx, statsKey(userID))
if errors.Is(err, storage.ErrNotFound) {
return userStats{Dist: make([]int, maxGuesses)}, nil
}
if len(st.Dist) != maxGuesses {
st.Dist = append(st.Dist, make([]int, maxGuesses)...)[:maxGuesses]
}
return st, err
}
// updateVersioned applies mutate to the document at key with a versioned
// write, retrying when another writer got in first. mutate gets the current
// value (zero and found=false when absent) and reports whether to write.
func updateVersioned[T any](ctx context.Context, store storage.DocStore[T], key string, mutate func(v *T, found bool) bool) error {
for range writeRetries {
v, version, err := store.Get(ctx, key)
found := err == nil
switch {
case errors.Is(err, storage.ErrNotFound):
version = 0
case err != nil:
return err
}
if !mutate(&v, found) {
return nil
}
err = store.PutVersioned(ctx, key, version, v)
if !errors.Is(err, storage.ErrConflict) {
return err
}
}
return storage.ErrConflict
}
// dayStore is the part of a DocStore that pruneDays needs.
type dayStore interface {
List(ctx context.Context, prefix string) ([]string, error)
Delete(ctx context.Context, id string) error
}
// pruneDays deletes the per-puzzle documents (pinned answers, boards and
// chat days) older than yesterday's puzzle. Nothing reads them once the
// 07:00 recap of the day after has run, and without this they would pile up
// forever, in RAM with the memory store. Stats and group streaks are kept.
// Failures are logged; the next day's run retries them.
func (s *service) pruneDays(ctx context.Context, today int) {
stores := []struct {
prefix string
store dayStore
}{{"puzzle:", s.puzzles}, {"play:", s.plays}, {"cday:", s.chatDays}}
deleted := 0
for _, d := range stores {
keys, err := d.store.List(ctx, d.prefix)
if err != nil {
log.Warn("wordledaily prune list failed", "prefix", d.prefix, "err", err)
continue
}
for _, key := range keys {
num, ok := keyNum(key, d.prefix)
if !ok || num >= today-1 {
continue
}
if err := d.store.Delete(ctx, key); err != nil {
log.Warn("wordledaily prune delete failed", "key", key, "err", err)
continue
}
deleted++
}
}
if deleted > 0 {
log.Info("wordledaily pruned old puzzle days", "deleted", deleted, "today", today)
}
}
// keyNum reads the puzzle number that follows prefix in a per-puzzle key.
func keyNum(key, prefix string) (int, bool) {
rest := strings.TrimPrefix(key, prefix)
if i := strings.IndexByte(rest, ':'); i >= 0 {
rest = rest[:i]
}
n, err := strconv.Atoi(rest)
return n, err == nil
}
+357
View File
@@ -0,0 +1,357 @@
package wordledaily
import (
"cmp"
"context"
"errors"
"slices"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// summaryInterval is the shortest gap between two updates of one
// chat's live results message.
summaryInterval = 5 * time.Second
// resendInterval is the shortest gap between two new results messages
// for one chat's day, when the old one was deleted.
resendInterval = 10 * time.Minute
// maxTextUnits keeps a message under Telegram's 4096 UTF-16 unit cap,
// leaving room for the "+N more" line.
maxTextUnits = 4000
// minStreakShown hides a one-day "streak".
minStreakShown = 2
// maxSummaryRetries bounds the retries of one failed summary update.
maxSummaryRetries = 5
)
// flusher coalesces live summary updates: at most one is pending per chat
// day, and two never run closer than summaryInterval. It is in-memory; a
// restart only delays the next update, because the message is always
// rendered from the store.
type flusher struct {
mu sync.Mutex
state map[string]*flushState
}
type flushState struct {
num int
scheduled bool
last time.Time
retries int // failed updates in a row
}
// touchSummary schedules an update of the live results message of the chat
// topic's day. Only group chats (negative IDs) get one.
func (s *service) touchSummary(num int, chatID int64, threadID int) {
if s.cfg.api == nil || chatID >= 0 {
return
}
key := chatDayKey(num, chatID, threadID)
now := s.cfg.now()
f := &s.flush
f.mu.Lock()
if f.state == nil {
f.state = map[string]*flushState{}
}
st := f.state[key]
if st == nil {
for k, old := range f.state {
if old.num < num-1 && !old.scheduled {
delete(f.state, k)
}
}
st = &flushState{num: num}
f.state[key] = st
}
if st.scheduled {
f.mu.Unlock()
return
}
st.scheduled = true
delay := max(summaryInterval-now.Sub(st.last), 0)
if st.last.IsZero() {
delay = 0
}
f.mu.Unlock()
s.cfg.schedule(delay, func() { s.flushSummary(num, chatID, threadID) })
}
// flushSummary brings the chat topic's live results message up to date and
// schedules a bounded retry when that failed for a reason that may pass (a
// 429, a timeout, a 5xx, a store error) or when a deleted message may not be
// sent again yet.
func (s *service) flushSummary(num int, chatID int64, threadID int) {
key := chatDayKey(num, chatID, threadID)
f := &s.flush
f.mu.Lock()
if st := f.state[key]; st != nil {
st.scheduled, st.last = false, s.cfg.now()
}
f.mu.Unlock()
retry, after := s.syncSummary(key, chatID, threadID)
f.mu.Lock()
if f.state == nil {
f.state = map[string]*flushState{}
}
st := f.state[key]
if st == nil {
st = &flushState{num: num, last: s.cfg.now()}
f.state[key] = st
}
if !retry {
st.retries = 0
f.mu.Unlock()
return
}
if st.scheduled {
// A newer update is already queued and renders the same state.
f.mu.Unlock()
return
}
if st.retries >= maxSummaryRetries {
st.retries = 0
f.mu.Unlock()
log.Warn("wordledaily summary update abandoned", "puzzle", num, "chat", chatID)
return
}
delay := max(after, summaryInterval<<st.retries)
st.retries++
st.scheduled = true
f.mu.Unlock()
s.cfg.schedule(delay, func() { s.flushSummary(num, chatID, threadID) })
}
// syncSummary sends the chat day's results message the first time and
// edits it afterwards. A deleted message is sent again, at most once per
// resendInterval. It reports whether the update must be retried, and the
// shortest wait before that.
//
// The lock is per chat topic, not per day: only the current day's message
// is updated, and keylock never frees a key, so a daily key would grow the
// lock map forever.
func (s *service) syncSummary(key string, chatID int64, threadID int) (bool, time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
defer s.locks.Acquire("summary:" + chatTopic(chatID, threadID))()
cd, _, err := s.chatDays.Get(ctx, key)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return false, 0
}
log.Warn("wordledaily summary read failed", "err", err)
return true, 0
}
text, err := s.renderLive(ctx, cd)
if err != nil {
log.Warn("wordledaily summary render failed", "err", err)
return true, 0
}
if text == "" {
return false, 0
}
now := s.cfg.now()
if cd.MsgID != 0 {
_, err := s.cfg.api.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: cd.ChatID, MessageID: cd.MsgID, Text: text})
switch {
case err == nil || messageNotModified(err):
return false, 0
case !messageGone(err):
log.Warn("wordledaily summary edit failed", "err", err)
return retryAfter(err)
}
if wait := resendInterval - now.Sub(time.UnixMilli(cd.MsgSentAt)); wait > 0 {
// Deleted too recently to send again: try once the interval
// has passed, so the final state still reaches the chat.
return true, wait
}
}
params := &bot.SendMessageParams{
ChatID: cd.ChatID,
MessageThreadID: cd.ThreadID,
Text: text,
DisableNotification: true,
}
if cd.CardID != 0 {
params.ReplyParameters = &models.ReplyParameters{MessageID: cd.CardID, AllowSendingWithoutReply: true}
}
msg, err := s.cfg.api.SendMessage(ctx, params)
if err != nil {
log.Warn("wordledaily summary send failed", "err", err)
return retryAfter(err)
}
err = updateVersioned(ctx, s.chatDays, key, func(v *chatDay, found bool) bool {
v.MsgID, v.MsgSentAt = msg.ID, now.UnixMilli()
return found
})
if err != nil {
log.Warn("wordledaily summary id save failed", "err", err)
}
return false, 0
}
// retryAfter classifies a failed Telegram call: a request Telegram refused
// (bad request, forbidden, not found, unauthorized) fails the same way
// again, while a 429 says how long to wait and a timeout or 5xx may pass.
func retryAfter(err error) (bool, time.Duration) {
var tooMany *bot.TooManyRequestsError
switch {
case errors.As(err, &tooMany):
return true, time.Duration(tooMany.RetryAfter) * time.Second
case errors.Is(err, bot.ErrorBadRequest), errors.Is(err, bot.ErrorForbidden),
errors.Is(err, bot.ErrorNotFound), errors.Is(err, bot.ErrorUnauthorized):
return false, 0
}
return true, 0
}
func messageNotModified(err error) bool {
return strings.Contains(err.Error(), "message is not modified")
}
// messageGone reports an edit that can never succeed, so the summary has to
// be sent again.
func messageGone(err error) bool {
msg := err.Error()
return strings.Contains(msg, "message to edit not found") ||
strings.Contains(msg, "message can't be edited") ||
strings.Contains(msg, "MESSAGE_ID_INVALID")
}
// chatResult is one player's game as a chat's results show it.
type chatResult struct {
name string
p progress
}
// chatResults loads the day's game of every player listed in cd who made a
// guess. Players are few per chat and this runs off the request path, so
// one read per player is fine.
func (s *service) chatResults(ctx context.Context, cd chatDay) ([]chatResult, error) {
out := make([]chatResult, 0, len(cd.Players))
for _, pl := range cd.Players {
p, _, err := s.plays.Get(ctx, playKey(cd.Num, pl.UserID))
if errors.Is(err, storage.ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
if len(p.Guesses) == 0 {
continue
}
name := p.Name
if name == "" {
name = pl.Name
}
out = append(out, chatResult{name: name, p: p})
}
return out, nil
}
// sortResults orders winners by fewer guesses then earlier finish, then
// losses by finish, then players still playing with the most guesses first.
func sortResults(rs []chatResult) {
rank := func(r chatResult) int {
switch r.p.Status {
case statusWon:
return 0
case statusLost:
return 1
}
return 2
}
slices.SortStableFunc(rs, func(a, b chatResult) int {
if c := cmp.Compare(rank(a), rank(b)); c != 0 {
return c
}
switch a.p.Status {
case statusWon:
return cmp.Or(cmp.Compare(len(a.p.Guesses), len(b.p.Guesses)), cmp.Compare(a.p.FinishedAt, b.p.FinishedAt))
case statusLost:
return cmp.Compare(a.p.FinishedAt, b.p.FinishedAt)
}
return cmp.Compare(len(b.p.Guesses), len(a.p.Guesses))
})
}
// resultLine is "3/6" for a win, "X/6" for a loss.
func resultLine(p progress) string {
if p.Status == statusWon {
return strconv.Itoa(len(p.Guesses)) + "/" + strconv.Itoa(maxGuesses)
}
return "X/" + strconv.Itoa(maxGuesses)
}
func (s *service) chatStreakAt(ctx context.Context, chatID int64, threadID int) (chatStreak, error) {
st, _, err := s.streaks.Get(ctx, chatStreakKey(chatID, threadID))
if errors.Is(err, storage.ErrNotFound) {
return chatStreak{}, nil
}
return st, err
}
// renderLive is the live results message: every player's colour grid,
// never a letter. It returns "" when nobody in the chat has guessed yet.
func (s *service) renderLive(ctx context.Context, cd chatDay) (string, error) {
results, err := s.chatResults(ctx, cd)
if err != nil || len(results) == 0 {
return "", err
}
sortResults(results)
st, err := s.chatStreakAt(ctx, cd.ChatID, cd.ThreadID)
if err != nil {
return "", err
}
var head strings.Builder
head.WriteString("Wordle Daily #" + strconv.Itoa(cd.Num) + " · live results\n")
if n := st.liveAt(cd.Num); n >= minStreakShown {
head.WriteString("🔥 Group streak: " + strconv.Itoa(n) + " days\n")
}
blocks := make([]string, len(results))
for i, r := range results {
var b strings.Builder
b.WriteString(r.name + " ")
if r.p.finished() {
b.WriteString(resultLine(r.p))
} else {
b.WriteString("playing " + strconv.Itoa(len(r.p.Guesses)) + "/" + strconv.Itoa(maxGuesses))
}
for _, g := range r.p.Guesses {
b.WriteString("\n" + emojiRow(g.Marks))
}
blocks[i] = b.String()
}
return joinLimited(head.String()+"\n", blocks, "\n"), nil
}
// joinLimited appends parts to head, separated by sep, while the text stays
// under maxTextUnits UTF-16 units, then says how many were left out.
func joinLimited(head string, parts []string, sep string) string {
text := head
for i, p := range parts {
next := text
if i > 0 {
next += sep
}
next += p
if utf16Len(next) > maxTextUnits {
return text + sep + "+" + strconv.Itoa(len(parts)-i) + " more"
}
text = next
}
return text
}
func utf16Len(s string) int { return len(utf16.Encode([]rune(s))) }
@@ -0,0 +1,254 @@
package wordledaily
import (
"context"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func TestSummary_FirstJoinRepliesLaterGuessesEditOnce(t *testing.T) {
h := newHarness(t)
alice := h.tokenFor(1, groupChat, 7, 77, "Alice")
words := h.wrong(5)
h.guess(alice, 1, words[0])
if h.sched.pending() != 1 || h.sched.delays[0] != 0 {
t.Fatalf("first touch: pending %d delays %v", h.sched.pending(), h.sched.delays)
}
h.sched.run()
sends := sentMethod(h.rb, "sendMessage")
if len(sends) != 1 {
t.Fatalf("sends = %+v", h.rb.Sent())
}
f := sends[0].Form
if f["chat_id"] != "-100" || f["message_thread_id"] != "77" || f["disable_notification"] != "true" ||
!strings.Contains(f["reply_parameters"], `"message_id":7`) || !strings.Contains(f["reply_parameters"], `"allow_sending_without_reply":true`) {
t.Fatalf("summary send form = %v", f)
}
if !strings.HasPrefix(f["text"], "Wordle Daily #1 · live results\n\nAlice playing 1/6\n") {
t.Fatalf("summary text = %q", f["text"])
}
// Four quick guesses (one by another player) schedule one delayed edit.
h.clock.advance(time.Second)
for _, w := range words[1:4] {
h.guess(alice, 1, w)
}
h.guess(h.tokenFor(2, groupChat, 7, 77, "Bob"), 1, words[4])
if h.sched.pending() != 1 || h.sched.delays[0] != summaryInterval-time.Second {
t.Fatalf("coalesced: pending %d delays %v", h.sched.pending(), h.sched.delays)
}
h.sched.run()
edits := sentMethod(h.rb, "editMessageText")
if len(edits) != 1 || len(sentMethod(h.rb, "sendMessage")) != 1 {
t.Fatalf("edits = %d, sends = %d", len(edits), len(sentMethod(h.rb, "sendMessage")))
}
if e := edits[0].Form; e["message_id"] != "1" || !strings.Contains(e["text"], "Alice playing 4/6") || !strings.Contains(e["text"], "Bob playing 1/6") {
t.Fatalf("edit = %v", e)
}
}
func TestSummary_DeletedMessageIsResentAtMostEveryTenMinutes(t *testing.T) {
h := newHarness(t)
tok := h.tokenFor(1, groupChat, 7, 0, "Alice")
words := h.wrong(4)
h.guess(tok, 1, words[0])
h.sched.run()
h.rb.FailMethodCode("editMessageText", 400, "Bad Request: message is not modified: specified new message content is the same")
h.clock.advance(summaryInterval)
h.guess(tok, 1, words[1])
h.sched.run()
if n := len(sentMethod(h.rb, "sendMessage")); n != 1 {
t.Fatalf("not-modified edit resent: %d sends", n)
}
h.rb.FailMethodCode("editMessageText", 400, "Bad Request: message to edit not found")
h.clock.advance(summaryInterval)
h.guess(tok, 1, words[2])
h.sched.run()
if n := len(sentMethod(h.rb, "sendMessage")); n != 1 {
t.Fatalf("resent within ten minutes: %d sends", n)
}
h.clock.advance(resendInterval)
h.guess(tok, 1, words[3])
h.sched.run()
if n := len(sentMethod(h.rb, "sendMessage")); n != 2 {
t.Fatalf("not resent after ten minutes: %d sends", n)
}
cd, _, err := h.svc.chatDays.Get(context.Background(), chatDayKey(1, groupChat, 0))
if err != nil || cd.MsgID != 2 {
t.Fatalf("stored summary id = %+v, %v", cd, err)
}
}
func TestSummary_NoneInPrivateChatsOrInline(t *testing.T) {
h := newHarness(t)
h.guess(h.dmToken(1), 1, h.wrong(1)[0])
inline, _ := h.svc.signToken(claims{UserID: 2, InlineID: "BAAA", Expiry: h.clock.now().Add(tokenTTL).Unix()})
h.guess(inline, 1, h.wrong(1)[0])
h.sched.run()
if len(h.rb.Sent()) != 0 {
t.Fatalf("sent %+v", h.rb.Sent())
}
if keys, _ := h.svc.chatDays.List(context.Background(), "cday:"); len(keys) != 0 {
t.Fatalf("chat days %v", keys)
}
}
func TestSummary_PlayerFinishedElsewhereJoinsOnOpen(t *testing.T) {
h := newHarness(t)
h.win(1)
h.state(h.tokenFor(1, groupChat, 7, 0, "Alice"))
h.sched.run()
sends := sentMethod(h.rb, "sendMessage")
if len(sends) != 1 || !strings.Contains(sends[0].Text(), "Alice 1/6\n🟩🟩🟩🟩🟩") {
t.Fatalf("sends = %+v", sends)
}
st, _ := h.svc.chatStreakAt(context.Background(), groupChat, 0)
if st.Streak != 1 || st.LastNum != 1 {
t.Fatalf("group streak = %+v", st)
}
}
var asciiLetters = regexp.MustCompile(`[A-Za-z]`)
func TestRenderLive_OrderGridsAndTruncation(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
put := func(user int64, name, status string, finishedAt int64, marks ...string) {
p := progress{Num: 1, UserID: user, Name: name, Status: status, FinishedAt: finishedAt}
for _, m := range marks {
p.Guesses = append(p.Guesses, guess{Word: "crane", Marks: m})
}
if err := h.svc.plays.Put(ctx, playKey(1, user), p); err != nil {
t.Fatal(err)
}
}
put(1, "Carol", statusPlaying, 0, "wwpww", "wccww")
put(2, "Bob", statusLost, 50, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "pwwww")
put(3, "Alice", statusWon, 90, "pwwpw", "ccccc")
put(4, "Dan", statusWon, 40, "wpwww", "wpcww", "ccccc")
put(5, "Eve", statusWon, 30, "wpwww", "wpcww", "ccccc")
put(6, "Frank", statusPlaying, 0, "wwwww")
if err := h.svc.streaks.Put(ctx, chatStreakKey(groupChat, 0), chatStreak{Streak: 5, LastNum: 1}); err != nil {
t.Fatal(err)
}
cd := chatDay{Num: 1, ChatID: groupChat}
for _, u := range []int64{1, 2, 3, 4, 5, 6, 99} { // 99 never guessed
cd.Players = append(cd.Players, chatPlayer{UserID: u, Name: "p" + strconv.FormatInt(u, 10)})
}
text, err := h.svc.renderLive(ctx, cd)
if err != nil {
t.Fatal(err)
}
want := "Wordle Daily #1 · live results\n🔥 Group streak: 5 days\n\n" +
"Alice 2/6\n🟨⬜⬜🟨⬜\n🟩🟩🟩🟩🟩\n" +
"Eve 3/6\n⬜🟨⬜⬜⬜\n⬜🟨🟩⬜⬜\n🟩🟩🟩🟩🟩\n" +
"Dan 3/6\n⬜🟨⬜⬜⬜\n⬜🟨🟩⬜⬜\n🟩🟩🟩🟩🟩\n" +
"Bob X/6\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜\n🟨⬜⬜⬜⬜\n" +
"Carol playing 2/6\n⬜⬜🟨⬜⬜\n⬜🟩🟩⬜⬜\n" +
"Frank playing 1/6\n⬜⬜⬜⬜⬜"
if text != want {
t.Fatalf("live text:\n%s\nwant:\n%s", text, want)
}
for _, line := range strings.Split(text, "\n") {
if strings.ContainsAny(line, "🟩🟨⬜") && asciiLetters.MatchString(line) {
t.Fatalf("grid line shows letters: %q", line)
}
}
// Many players: the message stops under Telegram's limit.
cd.Players = nil
for u := int64(100); u < 160; u++ {
put(u, strings.Repeat("N", 60), statusLost, u, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "wwwww")
cd.Players = append(cd.Players, chatPlayer{UserID: u})
}
text, _ = h.svc.renderLive(ctx, cd)
if utf16Len(text) > 4096 || !regexp.MustCompile(`\n\+\d+ more$`).MatchString(text) {
t.Fatalf("truncated text: %d units, tail %q", utf16Len(text), text[len(text)-20:])
}
}
// A transient failure (429 with retry_after, a 5xx) reschedules the update
// instead of leaving the message stale; Telegram's wait is honoured.
func TestSummary_TransientFailureIsRetried(t *testing.T) {
h := newHarness(t)
tok := h.tokenFor(1, groupChat, 7, 0, "Alice")
words := h.wrong(1)
h.guess(tok, 1, words[0])
h.sched.run()
h.rb.FailMethod("editMessageText", 429, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 30","parameters":{"retry_after":30}}`)
h.clock.advance(summaryInterval)
h.guess(tok, 1, h.answer())
h.sched.run1()
if h.sched.pending() != 1 || h.sched.delays[0] != 30*time.Second {
t.Fatalf("after 429: pending %d delays %v", h.sched.pending(), h.sched.delays)
}
h.rb.FailMethod("editMessageText", 502, "")
h.sched.run1()
if h.sched.pending() != 1 || h.sched.delays[0] != 2*summaryInterval {
t.Fatalf("after 5xx: pending %d delays %v", h.sched.pending(), h.sched.delays)
}
h.rb.ClearFailure("editMessageText")
h.sched.run()
edits := sentMethod(h.rb, "editMessageText")
if last := edits[len(edits)-1].Form["text"]; !strings.Contains(last, "Alice 2/6") {
t.Fatalf("final edit = %q", last)
}
// A refused request is not retried, and retries stop after a bound.
h.rb.FailMethodCode("editMessageText", 403, "Forbidden: bot was kicked from the supergroup chat")
h.svc.touchSummary(1, groupChat, 0)
h.sched.run1()
if h.sched.pending() != 0 {
t.Fatalf("forbidden retried: %v", h.sched.delays)
}
h.rb.FailMethod("editMessageText", 500, "")
h.clock.advance(summaryInterval)
h.svc.touchSummary(1, groupChat, 0)
h.sched.run()
if n := len(sentMethod(h.rb, "editMessageText")) - len(edits) - 1; n != 1+maxSummaryRetries {
t.Fatalf("bounded retries: %d edits", n)
}
}
// A deleted message inside the resend interval is sent once the interval
// has passed, so the final results still reach the chat.
func TestSummary_DeletedMessageIsResentWhenTheIntervalEnds(t *testing.T) {
h := newHarness(t)
tok := h.tokenFor(1, groupChat, 7, 0, "Alice")
h.guess(tok, 1, h.wrong(1)[0])
h.sched.run()
h.rb.FailMethodCode("editMessageText", 400, "Bad Request: message to edit not found")
h.clock.advance(time.Minute)
h.guess(tok, 1, h.answer())
h.sched.run1()
if h.sched.pending() != 1 || h.sched.delays[0] != resendInterval-time.Minute {
t.Fatalf("deferred resend: pending %d delays %v", h.sched.pending(), h.sched.delays)
}
h.clock.advance(resendInterval)
h.sched.run()
sends := sentMethod(h.rb, "sendMessage")
if len(sends) != 2 || !strings.Contains(sends[1].Text(), "Alice 2/6") {
t.Fatalf("sends = %+v", sends)
}
}
// The flush lock is keyed by chat topic, not by day, so the lock map stays
// bounded by the number of chats however long the process runs.
func TestSummary_LockKeyIsPerTopicNotPerDay(t *testing.T) {
h := newHarness(t)
for day := 1; day <= 3; day++ {
h.guess(h.tokenFor(1, groupChat, 7, 0, "Alice"), day, h.wrong(1)[0])
h.sched.run()
h.nextDay()
}
// One user lock and one summary lock, whatever the number of days.
if n := h.svc.locks.Len(); n != 2 {
t.Fatalf("lock keys = %d, want 2", n)
}
}
+66
View File
@@ -0,0 +1,66 @@
package wordledaily
import (
"strings"
"time"
"unicode/utf8"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
// tokenTTL bounds how long a Play link stays usable. The token is not tied
// to a puzzle: the page always plays the puzzle current at request time.
const tokenTTL = 6 * time.Hour
// maxNameRunes caps the display name carried in a token.
const maxNameRunes = 64
// claims is what a game token asserts: who pressed Play, on which card,
// until when. Exactly one address is set: ChatID+MessageID for a card the
// bot sent into a chat (ThreadID is its forum topic), or InlineID for one
// sent via the bot.
type claims struct {
UserID int64 `json:"u"`
Name string `json:"n,omitempty"`
ChatID int64 `json:"c,omitempty"`
MessageID int `json:"m,omitempty"`
InlineID string `json:"i,omitempty"`
ThreadID int `json:"t,omitempty"`
Expiry int64 `json:"e"`
}
// Valid requires a player, an expiry and exactly one card address.
func (c claims) Valid() bool {
if c.UserID <= 0 || c.Expiry <= 0 {
return false
}
chat := c.ChatID != 0 && c.MessageID != 0
inline := c.InlineID != ""
if c.ThreadID != 0 && !chat {
return false
}
return chat != inline
}
// ExpiresAt is the unix second the token stops working.
func (c claims) ExpiresAt() int64 { return c.Expiry }
func (s *service) signToken(c claims) (string, error) { return htmlgame.Sign(s.tokenKey, c) }
func (s *service) verifyToken(token string, now time.Time) (claims, error) {
var c claims
if err := htmlgame.Verify(s.tokenKey, token, now, &c); err != nil {
return claims{}, err
}
return c, nil
}
// displayName joins a Telegram first and last name within maxNameRunes.
func displayName(first, last string) string {
name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
name = strings.Join(strings.Fields(name), " ")
if utf8.RuneCountInString(name) > maxNameRunes {
name = string([]rune(name)[:maxNameRunes])
}
return name
}
+253
View File
@@ -0,0 +1,253 @@
:root {
--bg: #ffffff;
--surface: #ffffff;
--text: #1a1a1b;
--muted: #6b6f76;
--border: #d3d6da;
--tile-border: #878a8c;
--key: #d3d6da;
--key-text: #1a1a1b;
--correct: #6aaa64;
--present: #c9b458;
--absent: #787c7e;
--mark-text: #ffffff;
--accent: #2481cc;
--accent-text: #ffffff;
--overlay: rgba(0, 0, 0, .5);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121213;
--surface: #1e1f20;
--text: #f8f8f8;
--muted: #9aa0a6;
--border: #3a3a3c;
--tile-border: #565758;
--key: #818384;
--key-text: #f8f8f8;
--correct: #538d4e;
--present: #b59f3b;
--absent: #3a3a3c;
--accent: #5aa7e6;
--accent-text: #0e1621;
--overlay: rgba(0, 0, 0, .65);
}
}
/* Colour-blind-safe palette, toggled from the header. */
body.contrast {
--correct: #f5793a;
--present: #85c0f9;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 16px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
/* The page fills the viewport exactly: the board takes the height the
header and keyboard leave, so Enter never falls below the fold. */
.app {
max-width: 500px;
margin: 0 auto;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 6px;
border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.35rem; margin: 0; }
h2 { font-size: 1.2rem; margin: 0 0 4px; text-align: center; }
h3 { font-size: .9rem; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: .04em; text-align: center; }
.title p { margin: 0; font-size: .85rem; }
.muted { color: var(--muted); }
.lead { margin-top: 0; font-size: 1.05rem; }
.top-actions { display: flex; gap: 4px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
padding: 10px 16px;
min-height: 44px;
cursor: pointer;
}
button:disabled { opacity: .6; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
button.icon { min-width: 40px; min-height: 40px; padding: 4px 8px; font-size: 1.15rem; line-height: 1; }
button.icon[aria-pressed="true"] { background: var(--key); }
#screen-game { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
.board-wrap { flex: 1 1 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; }
/* app.js sets the width (and --tile) from the space .board-wrap has. */
.board {
display: grid;
grid-template-rows: repeat(6, 1fr);
gap: 5px;
width: min(330px, 100%);
}
.row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; }
.row.shake { animation: shake .4s; }
.tile {
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--border);
font-size: calc(var(--tile, 62px) * .5);
font-weight: 700;
text-transform: uppercase;
user-select: none;
}
.tile.filled { border-color: var(--tile-border); animation: pop .1s; }
.tile.correct { --mark: var(--correct); }
.tile.present { --mark: var(--present); }
.tile.absent { --mark: var(--absent); }
.tile.correct, .tile.present, .tile.absent { border-color: transparent; color: var(--mark-text); background: var(--mark); }
/* The colour lands at the half-turn, so each tile reveals its mark in turn. */
.tile.flip { animation: reveal .5s ease both; }
.keyboard { flex: none; display: flex; flex-direction: column; gap: 8px; user-select: none; }
.krow { display: flex; gap: 6px; justify-content: center; }
.key {
flex: 1;
max-width: 44px;
min-height: 56px;
padding: 0;
border: 0;
border-radius: 4px;
background: var(--key);
color: var(--key-text);
font-weight: 700;
font-size: 1rem;
text-transform: uppercase;
}
.key.wide { flex: 1.5; max-width: 68px; font-size: .78rem; }
.key.correct { background: var(--correct); color: var(--mark-text); }
.key.present { background: var(--present); color: var(--mark-text); }
.key.absent { background: var(--absent); color: var(--mark-text); }
.krow .spacer { flex: .5; max-width: 22px; }
.toast {
position: fixed;
left: 50%;
top: 72px;
transform: translateX(-50%);
max-width: calc(100% - 32px);
margin: 0;
padding: 10px 16px;
border-radius: 6px;
background: var(--text);
color: var(--bg);
font-weight: 600;
text-align: center;
z-index: 20;
}
.overlay {
position: fixed;
inset: 0;
background: var(--overlay);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 10;
}
.modal {
position: relative;
width: min(420px, 100%);
max-height: 100%;
overflow-y: auto;
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
padding: 20px 16px 16px;
}
.modal .close { position: absolute; top: 8px; right: 8px; border: 0; }
.answer { text-align: center; margin: 4px 0 0; font-size: 1.1rem; letter-spacing: .12em; font-weight: 700; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; text-align: center; }
.stats strong { display: block; font-size: 1.8rem; font-weight: 600; }
.stats span { font-size: .75rem; color: var(--muted); }
.dist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.dist li { display: flex; align-items: center; gap: 6px; font-size: .9rem; }
.dist .n { width: 1em; text-align: right; }
.dist .bar {
min-width: 8%;
padding: 2px 6px;
background: var(--absent);
color: var(--mark-text);
text-align: right;
font-weight: 700;
}
.dist li.today .bar { background: var(--correct); }
.next { text-align: center; margin: 16px 0 8px; }
.next strong { font-variant-numeric: tabular-nums; }
#next-btn { display: block; margin: 0 auto 8px; }
.actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.offscreen { position: fixed; top: -1000px; left: 0; opacity: 0; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@media (max-height: 640px) {
.key { min-height: 48px; }
.keyboard { gap: 6px; }
.top { padding-bottom: 4px; }
h1 { font-size: 1.15rem; }
}
@keyframes pop { 50% { transform: scale(1.1); } }
@keyframes reveal {
0% { transform: rotateX(0); background: transparent; border-color: var(--tile-border); color: var(--text); }
49% { transform: rotateX(88deg); background: transparent; border-color: var(--tile-border); color: var(--text); }
50% { transform: rotateX(90deg); background: var(--mark); border-color: transparent; color: var(--mark-text); }
100% { transform: rotateX(0); background: var(--mark); border-color: transparent; color: var(--mark-text); }
}
@keyframes shake {
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
@media (prefers-reduced-motion: reduce) {
.tile.flip, .tile.filled, .row.shake { animation: none; }
}
+496
View File
@@ -0,0 +1,496 @@
// Wordle Daily page. Plain JavaScript, no build step. The server owns the
// game: this page only sends guesses and renders the state it gets back. It
// never knows the answer until the game is over.
(function () {
'use strict';
/**
* @typedef {{word: string, marks: string}} GuessView
* @typedef {{played: number, win_pct: number, cur: number, max: number, dist: number[]}} StatsView
* @typedef {{
* num: number, date: string, player: string, max: number, len: number,
* guesses: GuessView[], status: 'playing'|'won'|'lost', answer?: string,
* stats?: StatsView, next_at: number, share?: string
* }} View
* @typedef {{code: string, message: string}} ApiError
*/
var TOKEN_KEY = 'wordledaily-token';
var CONTRAST_KEY = 'wordledaily-contrast';
var ROWS = 6;
var COLS = 5;
var FLIP_MS = 250;
var KEY_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
var MARK_CLASS = { c: 'correct', p: 'present', w: 'absent' };
var MARK_RANK = { absent: 1, present: 2, correct: 3 };
/** One flip: half a turn per FLIP_MS. */
var REVEAL_MS = FLIP_MS * 2;
/** The last tile starts its flip this long after the first. */
var STAGGER_MS = FLIP_MS / 2;
var TILE_GAP = 5;
var MAX_TILE = 62;
var MIN_TILE = 24;
var WIN_WORDS = ['Genius', 'Magnificent', 'Impressive', 'Splendid', 'Great', 'Phew'];
/** @param {string} id @returns {HTMLElement} */
function $(id) {
var el = document.getElementById(id);
if (!el) throw new Error('missing #' + id);
return el;
}
var board = $('board');
var keyboard = $('keyboard');
var toast = $('toast');
var endBox = $('end');
var statsBtn = $('stats-btn');
var contrastBtn = $('contrast-btn');
/** @type {View|null} */
var view = null;
var typed = '';
var busy = false;
var toastTimer = 0;
var countdownTimer = 0;
/** The row whose marks are being revealed, and when that ends. */
var revealRow = -1;
var revealUntil = 0;
var revealTimer = 0;
/** @type {Object<string, HTMLButtonElement>} */
var keys = {};
var token = readToken();
/**
* Takes the signed token from the URL, then strips it from the address bar
* so copying or sharing the page does not leak it. sessionStorage keeps it
* for a reload; the page still works when storage is blocked.
* @returns {string}
*/
function readToken() {
var fromURL = new URLSearchParams(window.location.search).get('t') || '';
if (fromURL) {
try { window.sessionStorage.setItem(TOKEN_KEY, fromURL); } catch (e) { /* storage blocked */ }
try {
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
} catch (e) { /* history API unavailable */ }
return fromURL;
}
try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
}
/**
* @param {string} path
* @param {Object} body
* @returns {Promise<View>}
*/
function api(path, body) {
return fetch('api/' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'omit',
cache: 'no-store'
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) {
throw { code: data.error || 'http_' + res.status, message: data.message || 'Something went wrong. Try again later.' };
}
return data;
});
}, function () {
throw { code: 'network', message: 'No connection to the server. Check your network and try again.' };
});
}
/** @param {string} text @param {number} [ms] 0 keeps it shown */
function say(text, ms) {
toast.textContent = text;
toast.hidden = false;
clearTimeout(toastTimer);
if (ms !== 0) {
toastTimer = window.setTimeout(function () { toast.hidden = true; }, ms || 1800);
}
}
function buildBoard() {
for (var r = 0; r < ROWS; r++) {
var row = document.createElement('div');
row.className = 'row';
row.setAttribute('role', 'group');
row.setAttribute('aria-label', 'Guess ' + (r + 1));
for (var c = 0; c < COLS; c++) {
var tile = document.createElement('div');
tile.className = 'tile';
tile.setAttribute('role', 'img');
tile.setAttribute('aria-label', 'empty');
row.appendChild(tile);
}
board.appendChild(row);
}
}
/**
* Sizes the board from the space between the header and the keyboard, so
* the whole game fits a short phone screen without scrolling.
*/
function fitBoard() {
var wrap = board.parentElement;
if (!wrap || !wrap.clientHeight) return;
var tile = Math.floor(Math.min(
MAX_TILE,
(wrap.clientWidth - (COLS - 1) * TILE_GAP) / COLS,
(wrap.clientHeight - (ROWS - 1) * TILE_GAP) / ROWS
));
tile = Math.max(MIN_TILE, tile);
board.style.width = (tile * COLS + (COLS - 1) * TILE_GAP) + 'px';
board.style.setProperty('--tile', tile + 'px');
}
/** @param {string} label @param {string} key @param {boolean} wide */
function keyButton(label, key, wide) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'key' + (wide ? ' wide' : '');
b.textContent = label;
b.setAttribute('data-key', key);
if (key === 'back') b.setAttribute('aria-label', 'Delete letter');
if (key === 'enter') b.setAttribute('aria-label', 'Submit guess');
return b;
}
function buildKeyboard() {
KEY_ROWS.forEach(function (letters, i) {
var row = document.createElement('div');
row.className = 'krow';
if (i === 1) row.appendChild(spacer());
if (i === 2) row.appendChild(keyButton('Enter', 'enter', true));
letters.split('').forEach(function (ch) {
var b = keyButton(ch, ch, false);
keys[ch] = b;
row.appendChild(b);
});
if (i === 1) row.appendChild(spacer());
if (i === 2) row.appendChild(keyButton('⌫', 'back', true));
keyboard.appendChild(row);
});
keyboard.addEventListener('click', function (ev) {
var t = /** @type {HTMLElement} */ (ev.target);
var key = t && t.getAttribute('data-key');
if (key) press(key);
});
}
function spacer() {
var s = document.createElement('div');
s.className = 'spacer';
return s;
}
/** @param {number} r @returns {HTMLElement} */
function rowEl(r) { return /** @type {HTMLElement} */ (board.children[r]); }
/**
* Paints the board, the typed word and the keyboard colours. The row
* being revealed keeps its flip classes until the reveal ends, so typing
* meanwhile does not cut it off, and its letters colour the keyboard only
* once it is revealed.
*/
function render() {
if (!view) return;
$('num').textContent = '#' + view.num;
$('subtitle').textContent = view.date + (view.player ? ' · ' + view.player : '');
var revealing = Date.now() < revealUntil;
var best = {};
for (var r = 0; r < ROWS; r++) {
var g = view.guesses[r];
var row = rowEl(r);
var flipping = revealing && r === revealRow;
for (var c = 0; c < COLS; c++) {
var tile = /** @type {HTMLElement} */ (row.children[c]);
var letter = '';
var cls = 'tile';
var label = 'empty';
var delay = '';
if (g) {
letter = g.word.charAt(c);
var mark = MARK_CLASS[g.marks.charAt(c)] || 'absent';
cls += ' ' + mark;
label = letter + ' ' + mark;
var k = letter.toLowerCase();
if (!flipping && (!best[k] || MARK_RANK[mark] > MARK_RANK[best[k]])) best[k] = mark;
if (flipping) {
cls += ' flip';
delay = (c * STAGGER_MS) + 'ms';
}
} else if (r === view.guesses.length && view.status === 'playing') {
letter = typed.charAt(c).toUpperCase();
if (letter) {
cls += ' filled';
label = letter;
}
}
// Unchanged values leave a running animation alone.
if (tile.className !== cls) tile.className = cls;
if (tile.style.animationDelay !== delay) tile.style.animationDelay = delay;
if (tile.textContent !== letter) tile.textContent = letter;
if (tile.getAttribute('aria-label') !== label) tile.setAttribute('aria-label', label);
}
}
Object.keys(keys).forEach(function (k) {
keys[k].className = 'key' + (best[k] ? ' ' + best[k] : '');
keys[k].setAttribute('aria-label', best[k] ? k + ', ' + best[k] : k);
});
statsBtn.hidden = view.status === 'playing';
}
/** @param {GuessView} g @returns {string} the row's result in words */
function describe(g) {
var parts = [];
for (var c = 0; c < COLS; c++) {
parts.push(g.word.charAt(c) + ' ' + (MARK_CLASS[g.marks.charAt(c)] || 'absent'));
}
return g.word + ': ' + parts.join(', ');
}
/**
* @param {View} v
* @param {boolean} fromGuess v answers a guess, so its new row flips
*/
function apply(v, fromGuess) {
if (view && view.num !== v.num) typed = '';
var fresh = fromGuess && v.status !== 'playing';
revealRow = -1;
revealUntil = 0;
clearTimeout(revealTimer);
if (fromGuess && v.guesses.length > 0) {
revealRow = v.guesses.length - 1;
var ms = (COLS - 1) * STAGGER_MS + REVEAL_MS;
revealUntil = Date.now() + ms;
// Colours the keyboard once the row is revealed.
revealTimer = window.setTimeout(render, ms + 20);
$('announce').textContent = describe(v.guesses[revealRow]);
}
view = v;
render();
if (v.status !== 'playing') {
window.setTimeout(showEnd, fresh ? FLIP_MS * 2 + 600 : 0);
if (fresh) say(v.status === 'won' ? WIN_WORDS[v.guesses.length - 1] || 'Solved' : v.answer || '', 2500);
} else {
endBox.hidden = true;
}
}
function showEnd() {
if (!view || !view.stats) return;
var won = view.status === 'won';
$('end-title').textContent = won ? 'Solved in ' + view.guesses.length + '/' + view.max + '!' : 'Better luck tomorrow';
$('end-answer').textContent = view.answer || '';
var st = view.stats;
$('st-played').textContent = String(st.played);
$('st-pct').textContent = String(st.win_pct);
$('st-cur').textContent = String(st.cur);
$('st-max').textContent = String(st.max);
var dist = $('dist');
dist.textContent = '';
var top = Math.max.apply(null, st.dist.concat([1]));
st.dist.forEach(function (n, i) {
var li = document.createElement('li');
if (won && i === view.guesses.length - 1) li.className = 'today';
var label = document.createElement('span');
label.className = 'n';
label.textContent = String(i + 1);
var bar = document.createElement('span');
bar.className = 'bar';
bar.textContent = String(n);
bar.style.width = Math.max(8, Math.round(n / top * 100)) + '%';
li.appendChild(label);
li.appendChild(bar);
dist.appendChild(li);
});
updateShare();
endBox.hidden = false;
tick();
clearInterval(countdownTimer);
countdownTimer = window.setInterval(tick, 1000);
}
/**
* Shows Share when Telegram's games.js is loaded. It loads async, so this
* runs again on window load in case the results opened first.
*/
function updateShare() {
$('share-btn').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function');
}
/** Counts down to the next puzzle; at zero it offers to load it. */
function tick() {
if (!view) return;
var left = Math.max(0, Math.floor(view.next_at - Date.now() / 1000));
var h = Math.floor(left / 3600);
var m = Math.floor(left % 3600 / 60);
var s = left % 60;
$('countdown').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
$('next-btn').hidden = left > 0;
}
/** @param {number} n */
function pad(n) { return (n < 10 ? '0' : '') + n; }
function shake() {
if (!view) return;
var row = rowEl(Math.min(view.guesses.length, ROWS - 1));
row.classList.remove('shake');
void row.offsetWidth;
row.classList.add('shake');
}
/** @param {ApiError} err */
function fail(err) {
switch (err.code) {
case 'new_puzzle':
say('A new puzzle has started.', 2500);
load();
return;
case 'finished':
load();
return;
case 'bad_token':
case 'expired':
say('Open the game again from the chat.', 0);
return;
case 'length':
case 'unknown':
shake();
say(err.message);
return;
}
say(err.message, 3000);
}
function load() {
if (busy) return;
busy = true;
api('state', { token: token }).then(function (v) {
busy = false;
$('screen-game').hidden = false;
fitBoard();
apply(v, false);
}, function (err) {
busy = false;
$('screen-game').hidden = false;
fitBoard();
fail(err);
});
}
function submit() {
if (!view || view.status !== 'playing') return;
if (typed.length < COLS) {
shake();
say('Not enough letters');
return;
}
busy = true;
api('guess', { token: token, num: view.num, word: typed }).then(function (v) {
busy = false;
typed = '';
apply(v, true);
}, function (err) {
busy = false;
fail(err);
});
}
/** @param {string} key a letter, 'enter' or 'back' */
function press(key) {
if (busy || !view || view.status !== 'playing') return;
if (key === 'enter') {
submit();
return;
}
if (key === 'back') {
typed = typed.slice(0, -1);
} else if (/^[a-z]$/.test(key) && typed.length < COLS) {
typed += key;
} else {
return;
}
render();
}
document.addEventListener('keydown', function (ev) {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (!endBox.hidden) {
if (ev.key === 'Escape') endBox.hidden = true;
return;
}
var k = ev.key;
if (k === 'Enter') {
ev.preventDefault();
press('enter');
} else if (k === 'Backspace') {
press('back');
} else if (k.length === 1 && /[a-z]/i.test(k)) {
press(k.toLowerCase());
}
});
/** Copies text, falling back to a hidden textarea where the clipboard API is blocked. */
function copy(text) {
var done = function () { say('Copied results to clipboard'); };
var fallback = function () {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.className = 'offscreen';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
if (ok) done(); else say('Copy failed. Select and copy the result by hand.', 3000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else {
fallback();
}
}
function setContrast(on) {
document.body.classList.toggle('contrast', on);
contrastBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
contrastBtn.addEventListener('click', function () {
var on = !document.body.classList.contains('contrast');
setContrast(on);
try { window.localStorage.setItem(CONTRAST_KEY, on ? '1' : '0'); } catch (e) { /* storage blocked */ }
});
statsBtn.addEventListener('click', showEnd);
$('end-close').addEventListener('click', function () { endBox.hidden = true; });
endBox.addEventListener('click', function (ev) { if (ev.target === endBox) endBox.hidden = true; });
$('share-btn').addEventListener('click', function () {
if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore();
});
$('copy-btn').addEventListener('click', function () {
if (view && view.share) copy(view.share);
});
$('next-btn').addEventListener('click', function () {
endBox.hidden = true;
load();
});
window.addEventListener('resize', fitBoard);
window.addEventListener('load', updateShare);
try { setContrast(window.localStorage.getItem(CONTRAST_KEY) === '1'); } catch (e) { /* storage blocked */ }
buildBoard();
buildKeyboard();
if (!token) {
$('screen-missing').hidden = false;
} else {
load();
}
}());
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<title>Wordle Daily</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<main class="app">
<header class="top">
<div class="title">
<h1>Wordle Daily <span id="num"></span></h1>
<p id="subtitle" class="muted"></p>
</div>
<div class="top-actions">
<button type="button" id="contrast-btn" class="icon" aria-pressed="false" aria-label="High contrast colours" title="High contrast colours"><span aria-hidden="true">◐</span></button>
<button type="button" id="stats-btn" class="icon" aria-label="Results" title="Results" hidden><span aria-hidden="true">▤</span></button>
</div>
</header>
<section id="screen-missing" class="card" hidden>
<p class="lead">Open the game from Telegram.</p>
<p class="muted">Send <code>/wordledaily</code> in a chat with the bot, then press Play.</p>
</section>
<section id="screen-game" hidden>
<div class="board-wrap">
<div id="board" class="board" role="group" aria-label="Guesses"></div>
</div>
<div id="keyboard" class="keyboard" aria-label="Keyboard"></div>
</section>
<p id="toast" class="toast" role="status" aria-live="polite" hidden></p>
<p id="announce" class="sr-only" aria-live="polite"></p>
<div id="end" class="overlay" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="end-title">
<button type="button" id="end-close" class="icon close" aria-label="Close" title="Close"><span aria-hidden="true">✕</span></button>
<h2 id="end-title"></h2>
<p id="end-answer" class="answer"></p>
<h3>Statistics</h3>
<div class="stats">
<div><strong id="st-played">0</strong><span>Played</span></div>
<div><strong id="st-pct">0</strong><span>Win %</span></div>
<div><strong id="st-cur">0</strong><span>Current Streak</span></div>
<div><strong id="st-max">0</strong><span>Max Streak</span></div>
</div>
<h3>Guess Distribution</h3>
<ol id="dist" class="dist"></ol>
<p class="next">Next puzzle in <strong id="countdown">--:--:--</strong></p>
<button type="button" id="next-btn" class="primary" hidden>Play the new puzzle</button>
<div class="actions">
<button type="button" id="share-btn" class="primary" hidden>Share</button>
<button type="button" id="copy-btn">Copy result</button>
</div>
</section>
</div>
</main>
<script src="app.js"></script>
<script src="https://telegram.org/js/games.js" async></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
// Package web holds the Wordle Daily page: plain HTML, CSS and JavaScript
// with no build step, embedded into the binary.
package web
import "embed"
// FS holds index.html, app.css and app.js.
//
//go:embed index.html app.css app.js
var FS embed.FS
+216
View File
@@ -0,0 +1,216 @@
// Package wordledaily is the Wordle Daily Telegram HTML5 game: one shared
// English five-letter puzzle a day, like NYT Wordle. /wordledaily sends the
// BotFather game; Play opens a board page this bot serves. A new puzzle
// starts every day at 07:00 ICT (00:00 UTC).
//
// The server owns the game. The page only sends guesses; the answer, the
// colours, each player's progress and stats live server-side, and the answer
// reaches the page only once that player's game is over. In a group the bot
// keeps one live results message per chat and day with spoiler-free colour
// grids, and subscribed chats get the day's card plus yesterday's group
// results at 07:00 ICT. See docs/wordledaily.md.
package wordledaily
import (
"context"
"os"
"sync"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/keylock"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/modules/util/subscription"
"github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// ShortName is the game's BotFather short name and the module's catalog
// key, so its routes live under /games/wordledaily/.
ShortName = "wordledaily"
subscribeCommand = ShortName + "_subscribe"
unsubscribeCommand = ShortName + "_unsubscribe"
secretEnv = "WORDLEDAILY_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential
// minSecretBytes is the shortest WORDLEDAILY_GAME_SECRET accepted.
minSecretBytes = 32
// tokenKeyLabel and answerKeyLabel derive the two keys from the root
// secret. Their own labels keep a noitu token from verifying here and
// keep the answer order independent of the link-signing key.
tokenKeyLabel = "tiennm99bot/wordledaily/token/v1" //nolint:gosec // G101: a public derivation label
answerKeyLabel = "tiennm99bot/wordledaily/answer/v1" //nolint:gosec // G101: a public derivation label
routePrefix = "/games/" + ShortName + "/"
)
// telegramAPI is the subset of *bot.Bot the summary and the daily push use.
type telegramAPI interface {
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
EditMessageText(ctx context.Context, params *bot.EditMessageTextParams) (*models.Message, error)
SendGame(ctx context.Context, params *bot.SendGameParams) (*models.Message, error)
}
// scoreReporter records a won game's score on the card it was played from.
type scoreReporter interface {
Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error
}
type botReporter struct{ b *bot.Bot }
func (r botReporter) Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error {
return htmlgame.ReportScore(ctx, r.b, a, userID, score)
}
// config is everything New reads from the environment, injectable in tests.
type config struct {
baseURL string // public https base without trailing slash; "" disables
rootKey []byte // derives the token and answer keys; empty disables
now func() time.Time
store storage.Collection // nil disables
reporter scoreReporter // nil disables
api telegramAPI // nil skips the live group summary
schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default
async func(f func()) // runs a score report; a goroutine by default
answers []string
dict map[string]struct{}
epoch time.Time // puzzle #1 starts here; zero means the package epoch
}
// New builds the module from the environment. The game is enabled only when
// GAME_BASE_URL is a valid https URL, a key is available and storage exists.
func New(deps modules.Deps) modules.Module {
_, dict := wordlist.Load()
cfg := config{
baseURL: htmlgame.ParseBaseURL(os.Getenv(htmlgame.BaseURLEnv), ShortName),
rootKey: rootKey(os.Getenv(secretEnv), deps),
now: time.Now,
store: deps.Store,
schedule: func(d time.Duration, f func()) { time.AfterFunc(d, f) },
answers: wordlist.Answers(),
dict: dict,
}
if deps.Bot != nil {
cfg.reporter = botReporter{b: deps.Bot}
cfg.api = deps.Bot
}
return newService(cfg).module()
}
// rootKey returns WORDLEDAILY_GAME_SECRET when set, otherwise the bot token.
// A secret that is set but too short disables the game rather than silently
// falling back.
func rootKey(secret string, deps modules.Deps) []byte {
if secret != "" {
if len(secret) < minSecretBytes {
log.Warn("WORDLEDAILY_GAME_SECRET is shorter than 32 bytes; wordledaily game disabled")
return nil
}
return []byte(secret)
}
if deps.Bot == nil || deps.Bot.Token() == "" {
return nil
}
return []byte(deps.Bot.Token())
}
// service is the module's runtime state.
type service struct {
cfg config
tokenKey []byte
answerKey []byte
epoch time.Time
puzzles storage.DocStore[puzzleDoc]
plays storage.DocStore[progress]
stats storage.DocStore[userStats]
chatDays storage.DocStore[chatDay]
streaks storage.DocStore[chatStreak]
subscribers subscription.Store
pushDate subscription.DayStore
subscribersMu sync.Mutex
locks keylock.Map
limiter *htmlgame.Limiter
perms permCache
cache puzzleCache
flush flusher
}
func newService(cfg config) *service {
if cfg.now == nil {
cfg.now = time.Now
}
if cfg.schedule == nil {
cfg.schedule = func(d time.Duration, f func()) { time.AfterFunc(d, f) }
}
svc := &service{
cfg: cfg,
tokenKey: htmlgame.DeriveKey(cfg.rootKey, tokenKeyLabel),
answerKey: htmlgame.DeriveKey(cfg.rootKey, answerKeyLabel),
epoch: epoch,
limiter: &htmlgame.Limiter{Per: requestsPerMinute, Window: time.Minute},
}
if !cfg.epoch.IsZero() {
svc.epoch = cfg.epoch
}
if cfg.store != nil {
svc.puzzles = storage.Typed[puzzleDoc](cfg.store)
svc.plays = storage.Typed[progress](cfg.store)
svc.stats = storage.Typed[userStats](cfg.store)
svc.chatDays = storage.Typed[chatDay](cfg.store)
svc.streaks = storage.Typed[chatStreak](cfg.store)
svc.subscribers = storage.Typed[subscription.Doc](cfg.store)
svc.pushDate = storage.Typed[subscription.DayDoc](cfg.store)
}
if svc.enabled() {
log.Info("wordledaily game enabled", "answers", len(cfg.answers), "words", len(cfg.dict))
}
return svc
}
// enabled reports whether Play can open the game.
func (s *service) enabled() bool {
return s.cfg.baseURL != "" && len(s.tokenKey) > 0 && len(s.answerKey) > 0 &&
s.cfg.store != nil && s.cfg.reporter != nil && len(s.cfg.answers) > 0 && len(s.cfg.dict) > 0
}
// module describes the service to the registry. Routes and the daily push
// exist only when the game is enabled, so a disabled game exposes nothing
// over HTTP and pushes nothing.
func (s *service) module() modules.Module {
mod := modules.Module{
Commands: []modules.Command{{
Name: ShortName,
Visibility: modules.VisibilityPublic,
Description: "Play today's Wordle Daily puzzle",
Handler: s.handleCommand,
}, {
Name: subscribeCommand,
Visibility: modules.VisibilityPublic,
Description: "Get the Wordle Daily puzzle and group results at 07:00 ICT",
Handler: s.handleSubscribe,
}, {
Name: unsubscribeCommand,
Visibility: modules.VisibilityPublic,
Description: "Stop the daily Wordle Daily push",
Handler: s.handleUnsubscribe,
}},
Games: []modules.Game{{
ShortName: ShortName,
Visibility: modules.VisibilityPublic,
Handler: s.handlePlay,
}},
}
if s.enabled() {
mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: s.handler()}}
mod.Crons = []modules.Cron{{Name: dailyPushCronName, Schedule: dailyPushSchedule, Handler: s.dailyPushHandler}}
}
return mod
}
+9
View File
@@ -165,6 +165,15 @@ func (rb *RecordingBot) FailMethod(method string, status int, body string) {
rb.failures[method] = failureResponse{status: status, body: body}
}
// ClearFailure removes a failure registered with FailMethod or
// FailMethodCode, so the method succeeds again (a test of a retry after a
// transient error).
func (rb *RecordingBot) ClearFailure(method string) {
rb.mu.Lock()
defer rb.mu.Unlock()
delete(rb.failures, method)
}
// handle is the httptest server's request handler. Path shape is
// "/bot<token>/<method>" per the go-telegram/bot URL builder. We extract the
// method, parse the multipart form, record, and respond with a minimal-ok