// Package wordledaily is the Wordle Telegram HTML5 game in both of its // modes, behind the one BotFather game "wordle": // // - Wordle Daily: one shared English five-letter puzzle a day, like NYT // Wordle. /wordledaily sends the card; a new puzzle starts every day at // 07:00 ICT (00:00 UTC). 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. // - Unlimited: /wordle sends a card that opens the player's own round // with a random word; the page's New game button starts another. // /wordle , /wordle_new, /wordle_giveup and /wordle_stats play // and read the same round from the chat. // // Play picks the mode from the card: /wordle cards are recorded, and any // other card plays daily. 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 game is over. // See docs/wordledaily.md and docs/wordle.md. package wordledaily import ( "math/rand/v2" "os" "time" "github.com/tiennm99/tiennm99bot/internal/log" "github.com/tiennm99/tiennm99bot/internal/modules" "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" "github.com/tiennm99/tiennm99bot/internal/storage" ) const ( // ShortName is the module's catalog key and the daily command name, so // its routes live under /games/wordledaily/. ShortName = "wordledaily" // LegacyShortName is the catalog key of the retired in-chat wordle // module, whose commands and data this module took over. LegacyShortName = "wordle" // GameShortName is the BotFather game the cards send and Play answers. GameShortName = "wordle" 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 + "/" // maxGuesses is the guess budget of both modes. maxGuesses = 6 // dailyPushCronName and cardsCronName must be unique across all // modules' crons. dailyPushCronName = "wordledaily_daily_push" cardsCronName = "wordle_unlimited_cards" // cardsSchedule runs the unlimited card cleanup at 03:35 ICT. cardsSchedule = "35 20 * * *" ) // 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 guessgame.ScoreReporter // nil disables api guessgame.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 shared epoch pick func(prev string) string // an unlimited round's word; random from answers by default } // New builds the module from the environment. The web game is enabled only // when GAME_BASE_URL is a valid https URL, a key is available and storage // exists; the in-chat /wordle commands need storage only. 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 = guessgame.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. The daily mode is embedded, so // its stores and methods read as the service's own. type service struct { *guessgame.Daily cfg config tokenKey []byte answerKey []byte rules rules rounds *guessgame.Rounds // nil without storage cards *guessgame.Cards // nil without storage limiter *htmlgame.Limiter } func newService(cfg config) *service { if cfg.now == nil { cfg.now = time.Now } if cfg.pick == nil { cfg.pick = randomPick(cfg.answers) } svc := &service{ cfg: cfg, tokenKey: htmlgame.DeriveKey(cfg.rootKey, tokenKeyLabel), answerKey: htmlgame.DeriveKey(cfg.rootKey, answerKeyLabel), rules: rules{dict: cfg.dict}, limiter: &htmlgame.Limiter{Per: requestsPerMinute, Window: time.Minute}, } svc.Daily = guessgame.NewDaily(guessgame.DailyConfig{ Rules: svc.rules, Answers: cfg.answers, AnswerKey: svc.answerKey, Store: cfg.store, Epoch: cfg.epoch, Now: cfg.now, Schedule: cfg.schedule, Async: cfg.async, Reporter: cfg.reporter, API: cfg.api, GameShortName: GameShortName, LogName: ShortName, }) if len(cfg.answers) > 0 { svc.rounds = guessgame.NewRounds(guessgame.RoundsConfig{ Store: cfg.store, Rules: svc.rules, Pick: cfg.pick, DistLen: maxGuesses, Now: cfg.now, }) } svc.cards = guessgame.NewCards(cfg.store, cfg.now, ShortName) if svc.enabled() { log.Info("wordledaily game enabled", "answers", len(cfg.answers), "words", len(cfg.dict)) } return svc } // randomPick draws an unlimited round's word from answers, never the word // of the round it replaces. func randomPick(answers []string) func(prev string) string { return func(prev string) string { for { w := answers[rand.IntN(len(answers))] if w != prev || len(answers) < 2 { return w } } } } // 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 crons exist only // when the game is enabled, so a disabled game exposes nothing over HTTP and // pushes nothing. func (s *service) module() modules.Module { daily := guessgame.DailyCommands{ Daily: s.Daily, Enabled: s.enabled, Texts: guessgame.DailyTexts{Channel: msgChannel, Disabled: msgDisabled, SendFail: msgSendGameFail}, } mod := modules.Module{ Commands: []modules.Command{{ Name: LegacyShortName, Visibility: modules.VisibilityPublic, Description: "Play unlimited Wordle; with a word, guess in your round", Parameters: "[word]", Handler: s.handleWordle, }, { Name: "wordle_new", Visibility: modules.VisibilityPublic, Description: "Start a new unlimited Wordle round (gives up the current one)", Handler: s.handleNew, }, { Name: "wordle_giveup", Visibility: modules.VisibilityPublic, Description: "Reveal the answer of your unlimited Wordle round", Handler: s.handleGiveup, }, { Name: "wordle_stats", Visibility: modules.VisibilityPublic, Description: "Show your unlimited Wordle stats", Handler: s.handleStats, }, { Name: ShortName, Visibility: modules.VisibilityPublic, Description: "Play today's Wordle Daily puzzle", Handler: daily.HandleCard, }, { Name: subscribeCommand, Visibility: modules.VisibilityPublic, Description: "Get the Wordle Daily puzzle and group results at 07:00 ICT", Handler: daily.HandleSubscribe, }, { Name: unsubscribeCommand, Visibility: modules.VisibilityPublic, Description: "Stop the daily Wordle Daily push", Handler: daily.HandleUnsubscribe, }}, Games: []modules.Game{{ ShortName: GameShortName, 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: guessgame.DailyPushSchedule, Handler: s.PushHandler}, {Name: cardsCronName, Schedule: cardsSchedule, Handler: s.cards.Cleanup}, } } return mod }