mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 08:18:44 +00:00
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:
1 parent
871b4a1042
commit
c0231ceadf
56 files changed
+8013
-306
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"tth": "[target...]",
|
||||
"wheelofnames": "<option,...>",
|
||||
"wordle": "[word]",
|
||||
|
||||
"wordledaily": "",
|
||||
"wordledaily_subscribe": "",
|
||||
"wordledaily_unsubscribe": "",
|
||||
}
|
||||
|
||||
menu := botCommandMenu(reg)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
File renamed without changes.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user