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

@@ -8,15 +8,17 @@ import (
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
"gogobee/internal/db"
"github.com/chehsunliu/poker"
"maunium.net/go/mautrix/id"
)
var holdemTipsClient = &http.Client{Timeout: 15 * time.Second}
var holdemTipsClient = &http.Client{Timeout: 60 * time.Second}
// loadTipsPref loads a user's tip preference from the database.
func loadTipsPref(userID id.UserID) bool {
@@ -31,12 +33,11 @@ func loadTipsPref(userID id.UserID) bool {
// saveTipsPref saves a user's tip preference.
func saveTipsPref(userID id.UserID, enabled bool) {
d := db.Get()
val := 0
if enabled {
val = 1
}
_, _ = d.Exec(
db.Exec("holdem: save tips preference",
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
string(userID), val, val,
@@ -45,22 +46,43 @@ func saveTipsPref(userID id.UserID, enabled bool) {
// holdemTipContext holds the data needed to generate a tip.
type holdemTipContext struct {
PlayerName string
Hole [2]string // rendered card strings
Community string // rendered board string
Equity EquityResult
SPR float64
PotOddsPct float64
ToCall int64
Pot int64
Stack int64
Street Street
Position string
NumActive int
PlayerName string
Hole [2]string // rendered card strings
Community string // rendered board string
Equity EquityResult
SPR float64
PotOddsPct float64
ToCall int64
Pot int64
Stack int64
Street Street
Position string
NumActive int
HandCategory string // from poker.RankString(), e.g. "Pair", "Flush"
Draw DrawInfo // flush/straight draw analysis
HeadsUp bool
}
// buildTipContext creates a tip context from the current game state.
func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
// tipSnapshot holds game data captured under lock for async tip generation.
type tipSnapshot struct {
playerName string
hole [2]poker.Card
holeStr [2]string
community []poker.Card
communityS string
numActive int
numOpp int
toCall int64
totalPot int64
stack int64
street Street
position string
headsUp bool
isDealer bool
}
// snapshotForTip captures game state under lock. Cheap — no MC here.
func snapshotForTip(g *HoldemGame, playerIdx int) tipSnapshot {
p := g.Players[playerIdx]
totalPot := g.Pot
@@ -79,40 +101,104 @@ func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
numOpp = 1
}
iterations := 5000
communityS := "—"
if len(g.Community) > 0 {
communityS = renderCards(g.Community)
}
// Copy community cards so the slice is safe outside the lock.
comm := make([]poker.Card, len(g.Community))
copy(comm, g.Community)
headsUp := numActive == 2
isDealer := playerIdx == g.DealerIdx
// Compute position with heads-up street awareness.
position := g.positionLabel(playerIdx)
if headsUp {
position = tipPositionLabel(isDealer, g.Street)
}
return tipSnapshot{
playerName: p.DisplayName,
hole: p.Hole,
holeStr: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
community: comm,
communityS: communityS,
numActive: numActive,
numOpp: numOpp,
toCall: toCall,
totalPot: totalPot,
stack: p.Stack,
street: g.Street,
position: position,
headsUp: headsUp,
isDealer: isDealer,
}
}
// tipPositionLabel returns the correct position label for heads-up play,
// accounting for the fact that position semantics change between preflop and postflop.
// Pre-flop: dealer/SB acts first (out of position for tips), BB acts last.
// Post-flop: dealer/BTN acts last (positional advantage), BB acts first (out of position).
func tipPositionLabel(isDealer bool, street Street) string {
if street == StreetPreFlop {
if isDealer {
return "SB" // dealer is SB in heads-up, acts first preflop
}
return "BB"
}
// Post-flop: dealer has position
if isDealer {
return "BTN" // acts last = positional advantage
}
return "BB" // acts first = out of position
}
// buildTipContext computes the full tip context including equity MC.
// Call this OUTSIDE the lock — the expensive equity computation runs here.
func buildTipContext(snap tipSnapshot) holdemTipContext {
iterations := 5000
if len(snap.community) > 0 {
iterations = 10000
}
eq := Equity(p.Hole, g.Community, numOpp, iterations)
eq := Equity(snap.hole, snap.community, snap.numOpp, iterations)
spr := 0.0
if totalPot > 0 {
spr = float64(p.Stack) / float64(totalPot)
if snap.totalPot > 0 {
spr = float64(snap.stack) / float64(snap.totalPot)
}
potOdds := 0.0
if toCall > 0 {
potOdds = float64(toCall) / float64(totalPot+toCall) * 100
if snap.toCall > 0 {
potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100
}
community := "—"
if len(g.Community) > 0 {
community = renderCards(g.Community)
// Compute hand category from current best 5-card hand.
handCategory := ""
if len(snap.community) >= 3 {
_, handCategory = handRank(snap.hole, snap.community)
}
// Compute draw info (meaningful on flop/turn only).
draw := computeDraws(snap.hole, snap.community)
return holdemTipContext{
PlayerName: p.DisplayName,
Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
Community: community,
Equity: eq,
SPR: spr,
PotOddsPct: potOdds,
ToCall: toCall,
Pot: totalPot,
Stack: p.Stack,
Street: g.Street,
Position: g.positionLabel(playerIdx),
NumActive: numActive,
PlayerName: snap.playerName,
Hole: snap.holeStr,
Community: snap.communityS,
Equity: eq,
SPR: spr,
PotOddsPct: potOdds,
ToCall: snap.toCall,
Pot: snap.totalPot,
Stack: snap.stack,
Street: snap.street,
Position: snap.position,
NumActive: snap.numActive,
HandCategory: handCategory,
Draw: draw,
HeadsUp: snap.headsUp,
}
}
@@ -124,7 +210,7 @@ func generateTip(ctx holdemTipContext) string {
if host != "" && model != "" {
tip, err := generateLLMTip(host, model, ctx)
if err != nil {
slog.Debug("holdem: LLM tip failed, using fallback", "err", err)
slog.Warn("holdem: LLM tip failed, using fallback", "err", err)
} else if tip != "" {
return "💡 " + tip
}
@@ -138,32 +224,62 @@ type chatMessage struct {
Content string `json:"content"`
}
type chatRequest struct {
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type chatChoice struct {
Message chatMessage `json:"message"`
type ollamaChatResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
type chatResponse struct {
Choices []chatChoice `json:"choices"`
var thinkTagRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
// extractTipFromResponse strips thinking tags from LLM output.
func extractTipFromResponse(raw string) string {
cleaned := thinkTagRe.ReplaceAllString(raw, "")
return strings.TrimSpace(cleaned)
}
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot.
You will be given structured game context including pre-computed equity.
Give exactly 2-4 sentences of actionable advice for the player's current decision.
Lead with the equity vs pot odds relationship when a bet is to call.
Be direct. No preamble. No praise. No "great hand" filler.`
// buildTipSystemPrompt returns the system prompt for poker tip generation.
func buildTipSystemPrompt() string {
return `You are a Texas Hold'em coach giving advice to a single player via private message.
You will receive structured game context. Reason through it in this order:
var userPrompt strings.Builder
userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
1. What type of hand do I have — made hand, drawing hand, or air?
2. If drawing: how many outs, and do pot odds justify continuing?
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
4. Does position affect what I should do here?
5. Is a free card available, and if so, is taking it correct?
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
what to do and why, using the specific cards and numbers provided.
Do not list concepts. Do not use generic poker vocabulary without connecting it to
this specific hand. If the correct play is obvious (e.g. free card with a draw),
say so plainly and briefly.`
}
// buildTipUserPrompt builds the structured user prompt with hand context.
func buildTipUserPrompt(ctx holdemTipContext) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
if ctx.HandCategory != "" {
b.WriteString(fmt.Sprintf("Your hand: %s %s [%s]\n", ctx.Hole[0], ctx.Hole[1], ctx.HandCategory))
} else {
b.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
}
b.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
if ctx.Draw.IsDraw {
b.WriteString(fmt.Sprintf("Draw outs: %d (%s)\n", ctx.Draw.TotalOuts, ctx.Draw.Description))
}
b.WriteString(fmt.Sprintf("Equity vs %d opponent(s): Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100))
if ctx.ToCall > 0 {
@@ -171,20 +287,27 @@ Be direct. No preamble. No praise. No "great hand" filler.`
if ctx.Equity.Win*100 < ctx.PotOddsPct {
exceeds = "falls short of"
}
userPrompt.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
b.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
} else {
userPrompt.WriteString("Check available — no bet to call\n")
b.WriteString("Free card available — no bet to call\n")
}
userPrompt.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Active players: %d\n",
ctx.SPR, ctx.Position, ctx.NumActive))
userPrompt.WriteString("\nWhat should I consider for my decision?")
headsUp := "no"
if ctx.HeadsUp {
headsUp = "yes"
}
b.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Heads-up: %s | Active players: %d\n",
ctx.SPR, ctx.Position, headsUp, ctx.NumActive))
req := chatRequest{
return b.String()
}
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
req := ollamaChatRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt.String()},
{Role: "system", Content: buildTipSystemPrompt()},
{Role: "user", Content: buildTipUserPrompt(ctx)},
},
Stream: false,
}
@@ -194,7 +317,7 @@ Be direct. No preamble. No praise. No "great hand" filler.`
return "", fmt.Errorf("marshal: %w", err)
}
url := strings.TrimRight(host, "/") + "/v1/chat/completions"
url := strings.TrimRight(host, "/") + "/api/chat"
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("request: %w", err)
@@ -206,23 +329,16 @@ Be direct. No preamble. No praise. No "great hand" filler.`
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
var chatResp chatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
if len(chatResp.Choices) == 0 || chatResp.Choices[0].Message.Content == "" {
tip := extractTipFromResponse(ollamaResp.Message.Content)
if tip == "" {
return "", fmt.Errorf("empty response")
}
tip := strings.TrimSpace(chatResp.Choices[0].Message.Content)
// Strip thinking tags if present.
if i := strings.Index(tip, "<think>"); i != -1 {
if j := strings.Index(tip, "</think>"); j != -1 {
tip = strings.TrimSpace(tip[:i] + tip[j+len("</think>"):])
}
}
return tip, nil
}
@@ -234,18 +350,34 @@ func generateRulesTip(ctx holdemTipContext) string {
switch {
case equity > 0.80:
tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100)
case ctx.Draw.IsDraw && ctx.ToCall == 0:
tip = fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description)
case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
tip = fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equity*100, ctx.PotOddsPct)
case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
tip = fmt.Sprintf("Drawing hand (%s) but equity %.0f%% falls short of pot odds %.0f%% — consider folding unless implied odds justify calling.", ctx.Draw.Description, equity*100, ctx.PotOddsPct)
case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct)
case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding unless you have a strong draw.", equity*100, ctx.PotOddsPct)
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding.", equity*100, ctx.PotOddsPct)
case ctx.ToCall == 0 && equity > 0.65:
tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100)
case ctx.ToCall == 0 && equity < 0.40:
tip = fmt.Sprintf("%.0f%% equity — check to control pot size. Not enough equity to bet.", equity*100)
tip = fmt.Sprintf("%.0f%% equity — check to control pot size.", equity*100)
case ctx.SPR < 1:
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
default:
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
}