mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user