Files
tiennm99bot/internal/modules/wordledaily/wordledaily.go
T
tiennm99 bc8a7d8869 feat(wordledaily): send the wordle BotFather game
Cards from /wordledaily and the 07:00 push now use the BotFather game
wordle, and Play answers it. The module, commands and page route keep the
wordledaily name.
2026-10-09 17:23:17 +07:00

219 lines
7.8 KiB
Go

// 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 module's catalog key and command name, so its routes
// live under /games/wordledaily/.
ShortName = "wordledaily"
// 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 + "/"
)
// 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: 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: dailyPushSchedule, Handler: s.dailyPushHandler}}
}
return mod
}