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>
197 lines
4.6 KiB
Go
197 lines
4.6 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"strings"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// MarkovPlugin collects messages and generates trigram-based text.
|
|
type MarkovPlugin struct {
|
|
Base
|
|
}
|
|
|
|
// NewMarkovPlugin creates a new Markov chain plugin.
|
|
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
|
return &MarkovPlugin{
|
|
Base: NewBase(client),
|
|
}
|
|
}
|
|
|
|
func (p *MarkovPlugin) Name() string { return "markov" }
|
|
|
|
func (p *MarkovPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me]", Category: "Fun & Games"},
|
|
}
|
|
}
|
|
|
|
func (p *MarkovPlugin) Init() error { return nil }
|
|
|
|
func (p *MarkovPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
|
if p.IsCommand(ctx.Body, "markov") {
|
|
return p.handleMarkov(ctx)
|
|
}
|
|
|
|
// Passive: collect non-command messages
|
|
if !ctx.IsCommand {
|
|
p.collectMessage(ctx.Sender, ctx.Body)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// collectMessage stores a message in the markov_corpus, capping at 10,000 per user.
|
|
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
|
// Skip very short messages
|
|
if len(strings.Fields(text)) < 3 {
|
|
return
|
|
}
|
|
|
|
d := db.Get()
|
|
|
|
_, err := d.Exec(
|
|
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
|
string(userID), text,
|
|
)
|
|
if err != nil {
|
|
slog.Error("markov: insert message", "err", err)
|
|
return
|
|
}
|
|
|
|
// Cap at 10,000 messages per user — delete oldest excess
|
|
db.Exec("markov: prune corpus",
|
|
`DELETE FROM markov_corpus WHERE user_id = ? AND id NOT IN (
|
|
SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000
|
|
)`,
|
|
string(userID), string(userID),
|
|
)
|
|
}
|
|
|
|
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
|
d := db.Get()
|
|
args := p.GetArgs(ctx.Body, "markov")
|
|
|
|
var targetUser id.UserID
|
|
|
|
switch {
|
|
case args == "":
|
|
// No argument — pick a random user from the corpus
|
|
var randomUser string
|
|
err := d.QueryRow(
|
|
`SELECT user_id FROM markov_corpus ORDER BY RANDOM() LIMIT 1`,
|
|
).Scan(&randomUser)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
|
|
}
|
|
targetUser = id.UserID(randomUser)
|
|
case args == "me":
|
|
targetUser = ctx.Sender
|
|
default:
|
|
// Treat as user ID
|
|
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
|
|
targetUser = resolved
|
|
}
|
|
}
|
|
|
|
// Fetch corpus for the user
|
|
rows, err := d.Query(
|
|
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
|
string(targetUser),
|
|
)
|
|
if err != nil {
|
|
slog.Error("markov: query corpus", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var texts []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
continue
|
|
}
|
|
texts = append(texts, t)
|
|
}
|
|
|
|
if len(texts) < 10 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("Not enough data for %s (need at least 10 messages).", string(targetUser)))
|
|
}
|
|
|
|
// Build trigram model and generate
|
|
result := generateMarkov(texts, 50)
|
|
if result == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
|
}
|
|
|
|
msg := fmt.Sprintf("[%s]: %s", string(targetUser), result)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
// trigram key
|
|
type trigramKey struct {
|
|
w1, w2 string
|
|
}
|
|
|
|
// generateMarkov builds a trigram model from texts and generates output.
|
|
func generateMarkov(texts []string, maxWords int) string {
|
|
chain := make(map[trigramKey][]string)
|
|
var starters []trigramKey
|
|
|
|
for _, text := range texts {
|
|
words := strings.Fields(text)
|
|
if len(words) < 3 {
|
|
continue
|
|
}
|
|
|
|
starters = append(starters, trigramKey{words[0], words[1]})
|
|
|
|
for i := 0; i < len(words)-2; i++ {
|
|
key := trigramKey{words[i], words[i+1]}
|
|
chain[key] = append(chain[key], words[i+2])
|
|
}
|
|
}
|
|
|
|
if len(starters) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Pick a random starter
|
|
start := starters[rand.Intn(len(starters))]
|
|
result := []string{start.w1, start.w2}
|
|
|
|
for len(result) < maxWords {
|
|
key := trigramKey{result[len(result)-2], result[len(result)-1]}
|
|
nextWords, ok := chain[key]
|
|
if !ok || len(nextWords) == 0 {
|
|
break
|
|
}
|
|
next := nextWords[rand.Intn(len(nextWords))]
|
|
result = append(result, next)
|
|
|
|
// Stop at sentence-ending punctuation sometimes
|
|
if len(result) > 8 && endsWithPunctuation(next) && rand.Float64() < 0.3 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return strings.Join(result, " ")
|
|
}
|
|
|
|
func endsWithPunctuation(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
last := s[len(s)-1]
|
|
return last == '.' || last == '!' || last == '?'
|
|
}
|