Files
tiennm99 c0231ceadf 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.
2026-10-09 17:20:02 +07:00

94 lines
2.7 KiB
Go

// 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
}