mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
- 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>
401 lines
12 KiB
Go
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
|
|
}
|