Add forex plugin, stability fixes, and async HTTP dispatch

- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis,
  DM-based alerts, and daily cron poll. Backfills 1 year of history
  on startup for moving averages and buy signal scoring.

- Fix bot hang caused by SQLite lock contention in reminder polling:
  rows cursor was held open while writing to the same DB. Collect
  results first, close cursor, then process. Same fix in milkcarton.

- Add sync retry loop so the bot reconnects after network drops
  instead of silently exiting. StopSync() for clean Ctrl+C shutdown.

- Add panic recovery to all dispatch, syncer, and cron paths.

- Make all HTTP-calling plugin commands async (goroutines) so a slow
  or dead external API cannot block the message dispatch pipeline.
  Affects: lookup, stocks, forex, anime, movies, concerts, gaming,
  retro, wotd, urls, howami.

- Extract DisplayName to Base, add db.Exec helper, convert silent
  error discards across the codebase.

- Fix UNO mercy-kill bug (eliminated bot continues playing), adventure
  DM nag spam, stats column mismatch, per-call regex/replacer allocs.

- Update README: forex commands, Finance section, 47 plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-26 09:22:02 -07:00
parent c7c1b76589
commit 9c6ded13fa
68 changed files with 5784 additions and 724 deletions

View File

@@ -1,7 +1,6 @@
package plugin
import (
"context"
"database/sql"
"fmt"
"log/slog"
@@ -21,6 +20,7 @@ import (
// WordlePlugin provides a daily cooperative Wordle game.
type WordlePlugin struct {
Base
euro *EuroPlugin
apiKey string
httpClient *http.Client
defaultLength int
@@ -33,7 +33,7 @@ type WordlePlugin struct {
}
// NewWordlePlugin creates a new WordlePlugin.
func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin {
length := 5
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
@@ -43,6 +43,7 @@ func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
return &WordlePlugin{
Base: NewBase(client),
euro: euro,
apiKey: os.Getenv("WORDNIK_API_KEY"),
httpClient: &http.Client{Timeout: 10 * time.Second},
defaultLength: length,
@@ -163,7 +164,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
}
// Get display name.
displayName := p.displayName(ctx.Sender)
displayName := p.DisplayName(ctx.Sender)
// Score the guess.
results := scoreGuess(guess, puzzle.Answer)
@@ -187,8 +188,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
definition := p.fetchDefinition(puzzle.Answer)
p.updateStats(puzzle)
p.markPuzzleDone(puzzle)
payouts := p.awardPrize(puzzle)
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition))
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
}
// Check for failure (all guesses used).
@@ -216,8 +218,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
}
if len(puzzle.Guesses) == 0 {
hint := ""
if isCustomAllowedWord(puzzle.Answer) {
hint = "Today's word is video game related"
}
return p.SendReply(ctx.RoomID, ctx.EventID,
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength))
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength, hint))
}
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
@@ -306,18 +312,11 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
// 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.")
}
// Use local word list for puzzle selection. Wordnik's randomWord endpoint
// requires a paid API tier; we keep Wordnik for guess validation and definitions only.
word := pickFallbackWord(wordLength)
if word == "" {
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length.")
}
puzzleNumber := p.nextPuzzleNumber()
@@ -336,24 +335,30 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err
}
// Persist to DB.
d := db.Get()
_, err = d.Exec(
db.Exec("wordle: persist puzzle",
`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
// Evict stale cache entries from previous days.
for key := range p.validCache {
if key != today {
delete(p.validCache, key)
}
}
p.validCache[today] = make(map[string]bool)
p.mu.Unlock()
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength))
hint := ""
if isCustomAllowedWord(word) {
hint = "Today's word is video game related"
}
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint))
}
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
@@ -372,7 +377,8 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
return p.createAndPostPuzzle(roomID, p.defaultLength)
}
// isValidWord checks if a word is valid, using the in-memory cache first.
// isValidWord checks if a word is valid, using the in-memory cache first,
// then the custom allow-list, then Wordnik.
// Returns (valid, apiError). apiError is true when the API is unreachable.
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
cache := p.validCache[puzzleID]
@@ -385,6 +391,12 @@ func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
return valid, false
}
// Check custom allow-list (game titles, etc.) before hitting the API.
if isCustomAllowedWord(word) {
cache[word] = true
return true, false
}
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
if !apiErr {
cache[word] = valid // only cache definitive results
@@ -396,13 +408,6 @@ 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()
@@ -415,18 +420,14 @@ func (p *WordlePlugin) nextPuzzleNumber() int {
}
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
d := db.Get()
solved := 0
if puzzle.Solved {
solved = 1
}
_, err := d.Exec(
db.Exec("wordle: mark puzzle done",
`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) {
@@ -491,6 +492,66 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
}
}
// WordlePayout tracks a payout for rendering.
type WordlePayout struct {
Name string
Amount int
Solver bool
}
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25}
// awardPrize credits euros to all contributors when a puzzle is solved.
// The solver who got the final guess gets a 50% bonus on their share.
func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
if !puzzle.Solved || p.euro == nil {
return nil
}
guessesUsed := len(puzzle.Guesses)
pot := 25
if guessesUsed >= 1 && guessesUsed < len(wordleBasePots) {
pot = wordleBasePots[guessesUsed]
}
// Tally contributors.
type info struct {
name string
solver bool
}
contributors := map[id.UserID]*info{}
var order []id.UserID
for i, g := range puzzle.Guesses {
if _, ok := contributors[g.PlayerID]; !ok {
contributors[g.PlayerID] = &info{name: g.PlayerName}
order = append(order, g.PlayerID)
}
if i == len(puzzle.Guesses)-1 {
contributors[g.PlayerID].solver = true
}
}
numPlayers := len(contributors)
share := pot / numPlayers
if share < 5 {
share = 5
}
solverBonus := share / 2
var payouts []WordlePayout
for _, uid := range order {
c := contributors[uid]
amount := share
if c.solver {
amount += solverBonus
}
p.euro.Credit(uid, float64(amount), "wordle_win")
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
}
return payouts
}
func (p *WordlePlugin) communityStreak() int {
d := db.Get()
rows, err := d.Query(
@@ -520,6 +581,17 @@ func (p *WordlePlugin) rehydratePuzzles() {
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
// Collect rows first, then close the cursor before doing any nested queries.
// SQLite deadlocks if a nested query runs while rows are still open on a
// single-connection pool.
type puzzleRow struct {
pid string
roomStr string
answer string
wordLength int
startedAt time.Time
}
rows, err := d.Query(
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
@@ -527,8 +599,8 @@ func (p *WordlePlugin) rehydratePuzzles() {
slog.Warn("wordle: rehydrate query failed", "err", err)
return
}
defer rows.Close()
var pending []puzzleRow
for rows.Next() {
var pid, roomStr, answer string
var wordLength, solved, guessCount int
@@ -541,32 +613,37 @@ func (p *WordlePlugin) rehydratePuzzles() {
continue // already done
}
roomID := id.RoomID(roomStr)
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, startedAt})
}
rows.Close()
// Get puzzle number.
for _, pr := range pending {
roomID := id.RoomID(pr.roomStr)
// Get puzzle number (safe now — no open cursor).
var puzzleNumber int
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber)
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pr.pid).Scan(&puzzleNumber)
if err != nil {
puzzleNumber = 1
}
puzzle := &WordlePuzzle{
PuzzleID: pid,
PuzzleID: pr.pid,
PuzzleNumber: puzzleNumber,
RoomID: roomID,
Answer: answer,
WordLength: wordLength,
Answer: pr.answer,
WordLength: pr.wordLength,
MaxGuesses: 6,
StartedAt: startedAt,
StartedAt: pr.startedAt,
LetterStates: make(map[rune]LetterResult),
}
p.mu.Lock()
p.puzzles[roomID] = puzzle
p.validCache[pid] = make(map[string]bool)
p.validCache[pr.pid] = make(map[string]bool)
p.mu.Unlock()
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength)
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength)
}
}