package plugin import ( "context" "database/sql" "fmt" "log/slog" "net/http" "os" "strconv" "strings" "sync" "time" "gogobee/internal/db" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) // WordlePlugin provides a daily cooperative Wordle game. type WordlePlugin struct { Base apiKey string httpClient *http.Client defaultLength int mu sync.Mutex puzzles map[id.RoomID]*WordlePuzzle // In-memory cache of validated words per puzzle to avoid redundant API calls. validCache map[string]map[string]bool // puzzleID -> word -> valid } // NewWordlePlugin creates a new WordlePlugin. func NewWordlePlugin(client *mautrix.Client) *WordlePlugin { length := 5 if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 { length = n } } return &WordlePlugin{ Base: NewBase(client), apiKey: os.Getenv("WORDNIK_API_KEY"), httpClient: &http.Client{Timeout: 10 * time.Second}, defaultLength: length, puzzles: make(map[id.RoomID]*WordlePuzzle), validCache: make(map[string]map[string]bool), } } func (p *WordlePlugin) Name() string { return "wordle" } func (p *WordlePlugin) Commands() []CommandDef { return []CommandDef{ {Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle ", Category: "Games"}, } } func (p *WordlePlugin) Init() error { // Rehydrate today's puzzle from DB if it exists. p.rehydratePuzzles() // Start the midnight ticker for auto-posting. go p.midnightTicker() return nil } func (p *WordlePlugin) OnReaction(_ ReactionContext) error { return nil } func (p *WordlePlugin) OnMessage(ctx MessageContext) error { if !p.IsCommand(ctx.Body, "wordle") { return nil } if !isGamesRoom(ctx.RoomID) { gr := gamesRoom() if gr != "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") } } args := strings.TrimSpace(p.GetArgs(ctx.Body, "wordle")) if args == "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!wordle ` — guess today's puzzle.\nSee `!wordle help` for all commands.") } switch { case args == "help": return p.handleHelp(ctx) case args == "stats": return p.handleStats(ctx) case args == "grid": return p.handleGrid(ctx) case args == "new" || strings.HasPrefix(args, "new "): return p.handleNew(ctx, args) case args == "skip": return p.handleSkip(ctx) default: return p.handleGuess(ctx, args) } } func (p *WordlePlugin) handleHelp(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "🟩 **Wordle Commands**\n\n"+ "`!wordle ` — Submit a guess\n"+ "`!wordle grid` — Re-post current puzzle grid\n"+ "`!wordle stats` — All-time leaderboard\n"+ "`!wordle new` — Start a new puzzle (admin)\n"+ "`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+ "`!wordle skip` — Reveal answer and end puzzle (admin)") } func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { guess = strings.ToUpper(strings.TrimSpace(guess)) p.mu.Lock() defer p.mu.Unlock() puzzle := p.puzzles[ctx.RoomID] if puzzle == nil { return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.") } if puzzle.Solved || puzzle.Failed { return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!") } // Check length. if len([]rune(guess)) != puzzle.WordLength { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength)) } // Check for non-alphabetic characters. for _, r := range guess { if r < 'A' || r > 'Z' { return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.") } } // Check duplicate guess. for _, g := range puzzle.Guesses { if g.Word == guess { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("**%s** has already been tried.", guess)) } } // Validate word via Wordnik (with caching). valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess) if apiErr { return p.SendReply(ctx.RoomID, ctx.EventID, "Word validation is temporarily unavailable. Try again in a moment.") } if !valid { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("āŒ **%s** is not a valid word.", guess)) } // Get display name. displayName := p.displayName(ctx.Sender) // Score the guess. results := scoreGuess(guess, puzzle.Answer) updateLetterStates(puzzle.LetterStates, guess, results) now := time.Now().UTC() g := WordleGuess{ Word: guess, PlayerID: ctx.Sender, PlayerName: displayName, Results: results, Timestamp: now, } puzzle.Guesses = append(puzzle.Guesses, g) // Check for win. if guess == puzzle.Answer { puzzle.Solved = true puzzle.SolvedAt = &now definition := p.fetchDefinition(puzzle.Answer) p.updateStats(puzzle) p.markPuzzleDone(puzzle) return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition)) } // Check for failure (all guesses used). if len(puzzle.Guesses) >= puzzle.MaxGuesses { puzzle.Failed = true definition := p.fetchDefinition(puzzle.Answer) p.updateStats(puzzle) p.markPuzzleDone(puzzle) return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)) } // Post updated grid. return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle)) } func (p *WordlePlugin) handleGrid(ctx MessageContext) error { p.mu.Lock() puzzle := p.puzzles[ctx.RoomID] p.mu.Unlock() if puzzle == nil { return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.") } if len(puzzle.Guesses) == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength)) } return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle)) } func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error { if !p.IsAdmin(ctx.Sender) { return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can start a new puzzle.") } wordLength := p.defaultLength parts := strings.Fields(args) if len(parts) > 1 { if n, err := strconv.Atoi(parts[1]); err == nil && n >= 5 && n <= 7 { wordLength = n } else { return p.SendReply(ctx.RoomID, ctx.EventID, "Word length must be 5, 6, or 7.") } } p.mu.Lock() existing := p.puzzles[ctx.RoomID] if existing != nil && !existing.Solved && !existing.Failed { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "There's already an active puzzle. Use `!wordle skip` to end it first.") } p.mu.Unlock() return p.createAndPostPuzzle(ctx.RoomID, wordLength) } func (p *WordlePlugin) handleSkip(ctx MessageContext) error { if !p.IsAdmin(ctx.Sender) { return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can skip a puzzle.") } p.mu.Lock() puzzle := p.puzzles[ctx.RoomID] if puzzle == nil || puzzle.Solved || puzzle.Failed { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle to skip.") } puzzle.Failed = true p.markPuzzleDone(puzzle) p.mu.Unlock() definition := p.fetchDefinition(puzzle.Answer) defLine := "" if definition != "" { defLine = fmt.Sprintf("\nšŸ“– *%s*\n", definition) } return p.SendMessage(ctx.RoomID, fmt.Sprintf("ā­ļø **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine)) } func (p *WordlePlugin) handleStats(ctx MessageContext) error { d := db.Get() // Fetch top 10. rows, err := d.Query( `SELECT user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses FROM wordle_stats ORDER BY puzzles_solved DESC, winning_guesses DESC LIMIT 10`) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load stats.") } defer rows.Close() var stats []WordlePlayerStat for rows.Next() { var s WordlePlayerStat if err := rows.Scan(&s.UserID, &s.DisplayName, &s.TotalGuesses, &s.PuzzlesPlayed, &s.PuzzlesSolved, &s.WinningGuesses); err != nil { continue } stats = append(stats, s) } if len(stats) == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "No Wordle stats yet. Play some puzzles first!") } streak := p.communityStreak() return p.SendMessage(ctx.RoomID, renderWordleLeaderboard(stats, streak)) } // createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement. func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error { if p.apiKey == "" { return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.") } // Try Wordnik first, fall back to local word list. word, err := wordnikFetchRandomWord(p.apiKey, p.httpClient, wordLength) if err != nil { slog.Warn("wordle: Wordnik fetch failed, trying fallback", "err", err) word = pickFallbackWord(wordLength) if word == "" { return p.SendMessage(roomID, "Failed to select a puzzle word. Try again later.") } } puzzleNumber := p.nextPuzzleNumber() today := time.Now().UTC().Format("2006-01-02") now := time.Now().UTC() puzzle := &WordlePuzzle{ PuzzleID: today, PuzzleNumber: puzzleNumber, RoomID: roomID, Answer: word, WordLength: wordLength, MaxGuesses: 6, StartedAt: now, LetterStates: make(map[rune]LetterResult), } // Persist to DB. d := db.Get() _, err = d.Exec( `INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at) VALUES (?, ?, ?, ?, 0, 0, ?) ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`, today, string(roomID), word, wordLength, now, word, wordLength, now, ) if err != nil { slog.Error("wordle: persist puzzle", "err", err) } p.mu.Lock() p.puzzles[roomID] = puzzle p.validCache[today] = make(map[string]bool) p.mu.Unlock() return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength)) } // PostDailyPuzzle is called by the scheduler to post today's puzzle. func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error { today := time.Now().UTC().Format("2006-01-02") // Check if already posted today. p.mu.Lock() existing := p.puzzles[roomID] if existing != nil && existing.PuzzleID == today { p.mu.Unlock() return nil // already posted } p.mu.Unlock() return p.createAndPostPuzzle(roomID, p.defaultLength) } // isValidWord checks if a word is valid, using the in-memory cache first. // Returns (valid, apiError). apiError is true when the API is unreachable. func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) { cache := p.validCache[puzzleID] if cache == nil { cache = make(map[string]bool) p.validCache[puzzleID] = cache } if valid, ok := cache[word]; ok { return valid, false } valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word) if !apiErr { cache[word] = valid // only cache definitive results } return valid, apiErr } func (p *WordlePlugin) fetchDefinition(word string) string { return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word) } func (p *WordlePlugin) displayName(userID id.UserID) string { resp, err := p.Client.GetDisplayName(context.Background(), userID) if err != nil || resp.DisplayName == "" { return string(userID) } return resp.DisplayName } func (p *WordlePlugin) nextPuzzleNumber() int { d := db.Get() var count int err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles`).Scan(&count) if err != nil { return 1 } return count + 1 } func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) { d := db.Get() solved := 0 if puzzle.Solved { solved = 1 } _, err := d.Exec( `UPDATE wordle_puzzles SET solved = ?, guess_count = ?, solved_at = ? WHERE puzzle_id = ? AND room_id = ?`, solved, len(puzzle.Guesses), puzzle.SolvedAt, puzzle.PuzzleID, string(puzzle.RoomID), ) if err != nil { slog.Error("wordle: mark puzzle done", "err", err) } } func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { d := db.Get() // Tally per-player contributions. type contrib struct { name string guesses int solved bool } players := map[id.UserID]*contrib{} for i, g := range puzzle.Guesses { c, ok := players[g.PlayerID] if !ok { c = &contrib{name: g.PlayerName} players[g.PlayerID] = c } c.guesses++ if i == len(puzzle.Guesses)-1 && puzzle.Solved { c.solved = true } } tx, err := d.Begin() if err != nil { slog.Error("wordle: begin tx", "err", err) return } defer tx.Rollback() for uid, c := range players { puzzlesSolved := 0 if puzzle.Solved { puzzlesSolved = 1 } winningGuesses := 0 if c.solved { winningGuesses = 1 } _, err := tx.Exec( `INSERT INTO wordle_stats (user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses) VALUES (?, ?, ?, 1, ?, ?) ON CONFLICT(user_id) DO UPDATE SET display_name = ?, total_guesses = total_guesses + ?, puzzles_played = puzzles_played + 1, puzzles_solved = puzzles_solved + ?, winning_guesses = winning_guesses + ?, updated_at = CURRENT_TIMESTAMP`, string(uid), c.name, c.guesses, puzzlesSolved, winningGuesses, c.name, c.guesses, puzzlesSolved, winningGuesses, ) if err != nil { slog.Error("wordle: update stats", "user", uid, "err", err) } } if err := tx.Commit(); err != nil { slog.Error("wordle: commit stats", "err", err) } } func (p *WordlePlugin) communityStreak() int { d := db.Get() rows, err := d.Query( `SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`) if err != nil { return 0 } defer rows.Close() streak := 0 for rows.Next() { var pid string var solved int if err := rows.Scan(&pid, &solved); err != nil { break } if solved == 1 { streak++ } else { break } } return streak } func (p *WordlePlugin) rehydratePuzzles() { today := time.Now().UTC().Format("2006-01-02") d := db.Get() rows, err := d.Query( `SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at FROM wordle_puzzles WHERE puzzle_id = ?`, today) if err != nil { slog.Warn("wordle: rehydrate query failed", "err", err) return } defer rows.Close() for rows.Next() { var pid, roomStr, answer string var wordLength, solved, guessCount int var startedAt time.Time if err := rows.Scan(&pid, &roomStr, &answer, &wordLength, &solved, &guessCount, &startedAt); err != nil { continue } if solved == 1 || guessCount >= 6 { continue // already done } roomID := id.RoomID(roomStr) // Get puzzle number. var puzzleNumber int err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber) if err != nil { puzzleNumber = 1 } puzzle := &WordlePuzzle{ PuzzleID: pid, PuzzleNumber: puzzleNumber, RoomID: roomID, Answer: answer, WordLength: wordLength, MaxGuesses: 6, StartedAt: startedAt, LetterStates: make(map[rune]LetterResult), } p.mu.Lock() p.puzzles[roomID] = puzzle p.validCache[pid] = make(map[string]bool) p.mu.Unlock() slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength) } } // midnightTicker checks every minute if it's time to post a new daily puzzle. func (p *WordlePlugin) midnightTicker() { // Check immediately on startup. p.checkAndPostDaily() ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() lastDate := time.Now().UTC().Format("2006-01-02") for range ticker.C { today := time.Now().UTC().Format("2006-01-02") if today != lastDate { lastDate = today p.checkAndPostDaily() } } } func (p *WordlePlugin) checkAndPostDaily() { gr := gamesRoom() if gr == "" { return } today := time.Now().UTC().Format("2006-01-02") d := db.Get() // Check if today's puzzle already exists for this room. var exists int err := d.QueryRow( `SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`, today, string(gr), ).Scan(&exists) if err == nil { return // already exists } if err != sql.ErrNoRows { slog.Error("wordle: check daily puzzle", "err", err) return } slog.Info("wordle: posting daily puzzle", "room", gr) if err := p.createAndPostPuzzle(gr, p.defaultLength); err != nil { slog.Error("wordle: daily puzzle failed", "err", err) } }