mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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.
219 lines
7.8 KiB
Go
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
|
|
}
|