Files
gogobee/internal/plugin/markov.go
prosolis 03def6463f Add horoscope, !time @user, profile achievements; fix mention detection and multiple bugs
- Add !horoscope command and daily horoscope digest using Free Horoscope API
- Enhance !time to support @user timezone lookups
- Add profile completeness achievements (birthday, timezone, both set)
- Fix Matrix mention detection in reputation and LLM passive plugins
- Fix !whois reputation query (reason string mismatch and calculation)
- Fix !time IANA case-sensitivity and username/city collision
- Fix timezone default to empty string for achievement detection
- Scope all leaderboards to room members (XP, rep, potty, insult, first, rankings, emoji)
- Fix XP rank calculation to handle tied scores correctly
- Replace skull emoji with bomb for bot insult reactions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:58:15 -07:00

200 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
_, err = d.Exec(
`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),
)
if err != nil {
slog.Error("markov: prune corpus", "err", err)
}
}
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); 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 == '?'
}