Files
gogobee/internal/plugin/holdem_tips.go
prosolis 9c6ded13fa 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>
2026-03-26 09:22:02 -07:00

401 lines
12 KiB
Go

package plugin
import (
"bytes"
"encoding/json"
"fmt"
"io"
"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: 60 * time.Second}
// loadTipsPref loads a user's tip preference from the database.
func loadTipsPref(userID id.UserID) bool {
d := db.Get()
var enabled int
err := d.QueryRow(`SELECT enabled FROM holdem_tips_prefs WHERE user_id = ?`, string(userID)).Scan(&enabled)
if err != nil {
return true // default: tips on
}
return enabled == 1
}
// saveTipsPref saves a user's tip preference.
func saveTipsPref(userID id.UserID, enabled bool) {
val := 0
if enabled {
val = 1
}
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,
)
}
// 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
HandCategory string // from poker.RankString(), e.g. "Pair", "Flush"
Draw DrawInfo // flush/straight draw analysis
HeadsUp bool
}
// 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
for _, pp := range g.Players {
totalPot += pp.Bet
}
toCall := g.CurrentBet - p.Bet
if toCall < 0 {
toCall = 0
}
numActive := g.activeCount()
numOpp := numActive - 1
if numOpp < 1 {
numOpp = 1
}
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(snap.hole, snap.community, snap.numOpp, iterations)
spr := 0.0
if snap.totalPot > 0 {
spr = float64(snap.stack) / float64(snap.totalPot)
}
potOdds := 0.0
if snap.toCall > 0 {
potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100
}
// 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: 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,
}
}
// generateTip generates a coaching tip, trying LLM first then falling back to rules.
func generateTip(ctx holdemTipContext) string {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
if host != "" && model != "" {
tip, err := generateLLMTip(host, model, ctx)
if err != nil {
slog.Warn("holdem: LLM tip failed, using fallback", "err", err)
} else if tip != "" {
return "💡 " + tip
}
}
return "💡 " + generateRulesTip(ctx)
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type ollamaChatResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
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)
}
// 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:
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 {
exceeds := "exceeds"
if ctx.Equity.Win*100 < ctx.PotOddsPct {
exceeds = "falls short of"
}
b.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
} else {
b.WriteString("Free card available — no bet to call\n")
}
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))
return b.String()
}
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
req := ollamaChatRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: buildTipSystemPrompt()},
{Role: "user", Content: buildTipUserPrompt(ctx)},
},
Stream: false,
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
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)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
tip := extractTipFromResponse(ollamaResp.Message.Content)
if tip == "" {
return "", fmt.Errorf("empty response")
}
return tip, nil
}
func generateRulesTip(ctx holdemTipContext) string {
equity := ctx.Equity.Win + ctx.Equity.Tie*0.5
var tip 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.", 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.", 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)
}
// Position note.
switch ctx.Position {
case "BTN", "CO":
tip += " You have positional advantage — use it."
case "SB", "BB":
tip += " Out of position — play tighter."
case "UTG":
tip += " Early position — you need a strong range here."
}
if ctx.NumActive >= 4 {
tip += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value."
}
return tip
}