mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user