Files
gogobee/internal/plugin/markov.go
prosolis 81e6cecad9 Add markov, emojiboard, achievements, miniflux plugins and adventure catch-up fix
New plugins: encrypted Markov chain generation (9 commands), emojiboard
reaction stats (5 views), Miniflux RSS feed integration (8 commands),
and 48 new achievements across 9 categories. Adventure startup now
unconditionally resets daily actions and respawns expired deaths to
handle missed resets from SQLite contention. README updated with all
new features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 11:55:47 -07:00

779 lines
22 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"encoding/base64"
"fmt"
"log/slog"
"math/rand"
"os"
"regexp"
"strings"
"time"
"gogobee/internal/crypto"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// MarkovPlugin collects messages and generates trigram-based text.
type MarkovPlugin struct {
Base
encKey []byte // AES-256 key; nil means disabled
enabled bool
}
// NewMarkovPlugin creates a new Markov chain plugin.
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
p := &MarkovPlugin{
Base: NewBase(client),
}
raw := os.Getenv("QUOTE_ENCRYPTION_KEY")
if raw == "" {
slog.Error("markov: QUOTE_ENCRYPTION_KEY not set — markov collection and commands disabled")
return p
}
key, err := crypto.ParseKey(raw)
if err != nil {
slog.Error("markov: invalid QUOTE_ENCRYPTION_KEY — markov disabled", "err", err)
return p
}
p.encKey = key
p.enabled = true
// Migration: purge any unencrypted rows
p.purgeUnencryptedRows()
return p
}
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|stats|forget|leaderboard]", Category: "Fun & Games"},
{Name: "impersonate", Description: "Alias for !markov @user", Usage: "!impersonate @user", Category: "Fun & Games"},
{Name: "ghostwrite", Description: "Seed Markov chain with a starting phrase", Usage: "!ghostwrite @user <prompt>", 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.enabled {
if p.IsCommand(ctx.Body, "markov") || p.IsCommand(ctx.Body, "impersonate") || p.IsCommand(ctx.Body, "ghostwrite") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Markov is disabled (encryption key not configured).")
}
return nil
}
if p.IsCommand(ctx.Body, "markov") {
return p.handleMarkov(ctx)
}
if p.IsCommand(ctx.Body, "impersonate") {
return p.handleImpersonate(ctx)
}
if p.IsCommand(ctx.Body, "ghostwrite") {
return p.handleGhostwrite(ctx)
}
// Passive: collect non-command messages
if !ctx.IsCommand {
p.collectMessage(ctx.Sender, ctx.Body)
}
return nil
}
// ── Regex for stripping noise from corpus entries ───────────────────────────
var (
matrixEventIDRe = regexp.MustCompile(`\$[A-Za-z0-9_/+=.-]{20,}`)
matrixRoomIDRe = regexp.MustCompile(`![A-Za-z0-9_/+=.-]+:[A-Za-z0-9._-]+`)
commandPrefixRe = regexp.MustCompile(`^!\S+\s*`)
)
// stripNoise removes Matrix event IDs, room IDs, and !command prefixes from text.
func stripNoise(text string) string {
text = matrixEventIDRe.ReplaceAllString(text, "")
text = matrixRoomIDRe.ReplaceAllString(text, "")
text = commandPrefixRe.ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// ── Encryption helpers ──────────────────────────────────────────────────────
func (p *MarkovPlugin) encryptText(plaintext string) (string, error) {
ct, err := crypto.Encrypt(p.encKey, []byte(plaintext))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(ct), nil
}
func (p *MarkovPlugin) decryptText(encoded string) (string, error) {
ct, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
pt, err := crypto.Decrypt(p.encKey, ct)
if err != nil {
return "", err
}
return string(pt), nil
}
// ── Corpus collection ───────────────────────────────────────────────────────
// collectMessage stores an encrypted message in the markov_corpus, capping at 10,000 per user.
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
cleaned := stripNoise(text)
if len(strings.Fields(cleaned)) < 3 {
return
}
enc, err := p.encryptText(cleaned)
if err != nil {
slog.Error("markov: encrypt message", "err", err)
return
}
d := db.Get()
_, err = d.Exec(
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
string(userID), enc,
)
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),
)
}
// loadCorpus loads and decrypts all corpus entries for a user. Unencrypted/corrupt rows are skipped.
func (p *MarkovPlugin) loadCorpus(userID id.UserID) []string {
d := db.Get()
rows, err := d.Query(
`SELECT text FROM markov_corpus WHERE user_id = ?`,
string(userID),
)
if err != nil {
slog.Error("markov: query corpus", "err", err)
return nil
}
defer rows.Close()
var texts []string
for rows.Next() {
var enc string
if err := rows.Scan(&enc); err != nil {
continue
}
pt, err := p.decryptText(enc)
if err != nil {
// Unencrypted or corrupt row — skip
continue
}
texts = append(texts, pt)
}
return texts
}
// corpusCount returns the number of corpus entries for a user.
func corpusCount(userID id.UserID) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(userID)).Scan(&count)
return count
}
// ── Migration ───────────────────────────────────────────────────────────────
// purgeUnencryptedRows detects unencrypted rows (decrypt fails on non-base64) and purges them.
func (p *MarkovPlugin) purgeUnencryptedRows() {
d := db.Get()
rows, err := d.Query(`SELECT id, text FROM markov_corpus`)
if err != nil {
slog.Error("markov: migration scan", "err", err)
return
}
defer rows.Close()
var badIDs []interface{}
for rows.Next() {
var rowID int64
var enc string
if err := rows.Scan(&rowID, &enc); err != nil {
continue
}
_, err := p.decryptText(enc)
if err != nil {
badIDs = append(badIDs, rowID)
}
}
if len(badIDs) == 0 {
return
}
slog.Warn("markov: purging unencrypted/corrupt rows", "count", len(badIDs))
// Delete in batches of 100
for i := 0; i < len(badIDs); i += 100 {
end := i + 100
if end > len(badIDs) {
end = len(badIDs)
}
batch := badIDs[i:end]
placeholders := strings.Repeat("?,", len(batch))
placeholders = placeholders[:len(placeholders)-1]
_, err := d.Exec(
fmt.Sprintf(`DELETE FROM markov_corpus WHERE id IN (%s)`, placeholders),
batch...,
)
if err != nil {
slog.Error("markov: migration delete batch", "err", err)
}
}
}
// ── TTL purge ───────────────────────────────────────────────────────────────
// MarkovPurgeExpired deletes corpus entries older than 90 days. Intended to be
// called from the nightly scheduled jobs in main.go.
func MarkovPurgeExpired() {
d := db.Get()
cutoff := time.Now().UTC().AddDate(0, 0, -90).Unix()
res, err := d.Exec(`DELETE FROM markov_corpus WHERE created_at < ?`, cutoff)
if err != nil {
slog.Error("markov: purge expired", "err", err)
return
}
n, _ := res.RowsAffected()
if n > 0 {
slog.Info("markov: purged expired entries", "rows", n)
}
}
// ── Command handlers ────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "markov")
switch {
case args == "":
return p.generateForUser(ctx, ctx.Sender, "")
case args == "me":
return p.generateForUser(ctx, ctx.Sender, "")
case strings.HasPrefix(args, "stats"):
return p.handleStats(ctx, strings.TrimSpace(strings.TrimPrefix(args, "stats")))
case args == "forget":
return p.handleForgetSelf(ctx)
case strings.HasPrefix(args, "forget "):
return p.handleForgetAdmin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "forget ")))
case args == "leaderboard":
return p.handleLeaderboard(ctx)
default:
// Could be one user or two users (mashup)
return p.handleGenerate(ctx, args)
}
}
func (p *MarkovPlugin) handleImpersonate(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "impersonate")
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !impersonate @user")
}
resolved, ok := p.ResolveUser(args, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", args))
}
return p.generateForUser(ctx, resolved, "")
}
func (p *MarkovPlugin) handleGhostwrite(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "ghostwrite")
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !ghostwrite @user <prompt>")
}
resolved, ok := p.ResolveUser(parts[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", parts[0]))
}
seed := strings.TrimSpace(parts[1])
return p.generateForUser(ctx, resolved, seed)
}
// handleGenerate parses one or two user mentions and generates accordingly.
func (p *MarkovPlugin) handleGenerate(ctx MessageContext, args string) error {
fields := strings.Fields(args)
if len(fields) == 1 {
resolved, ok := p.ResolveUser(fields[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", fields[0]))
}
return p.generateForUser(ctx, resolved, "")
}
if len(fields) == 2 {
user1, ok1 := p.ResolveUser(fields[0], ctx.RoomID)
user2, ok2 := p.ResolveUser(fields[1], ctx.RoomID)
if !ok1 || !ok2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve one or both users.")
}
return p.generateMashup(ctx, user1, user2)
}
// More than 2 — cap at 2
return p.SendReply(ctx.RoomID, ctx.EventID, "Mashup supports at most 2 users.")
}
// generateForUser generates markov text for a single user, optionally seeded.
func (p *MarkovPlugin) generateForUser(ctx MessageContext, userID id.UserID, seed string) error {
texts := p.loadCorpus(userID)
if len(texts) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(userID), len(texts)))
}
result := p.generateOutput(texts, seed)
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
// Re-roll once if exact corpus match
for _, t := range texts {
if result == t {
result = p.generateOutput(texts, seed)
break
}
}
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
name := p.DisplayName(userID)
msg := fmt.Sprintf("[%s]: %s", name, result)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// generateMashup interleaves corpora of two users and generates.
func (p *MarkovPlugin) generateMashup(ctx MessageContext, user1, user2 id.UserID) error {
texts1 := p.loadCorpus(user1)
texts2 := p.loadCorpus(user2)
if len(texts1) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(user1), len(texts1)))
}
if len(texts2) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(user2), len(texts2)))
}
// Interleave corpora
combined := make([]string, 0, len(texts1)+len(texts2))
i, j := 0, 0
for i < len(texts1) || j < len(texts2) {
if i < len(texts1) {
combined = append(combined, texts1[i])
i++
}
if j < len(texts2) {
combined = append(combined, texts2[j])
j++
}
}
result := p.generateOutput(combined, "")
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
name1 := p.DisplayName(user1)
name2 := p.DisplayName(user2)
msg := fmt.Sprintf("[%s × %s]: %s", name1, name2, result)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// generateOutput builds a trigram model and generates 1-3 sentences, max 280 chars.
// If seed is non-empty, tries to start the chain near the seed words.
func (p *MarkovPlugin) generateOutput(texts []string, seed string) string {
chain, starters := buildTrigramModel(texts)
if len(starters) == 0 {
return ""
}
var start trigramKey
if seed != "" {
// Try to find a starter that begins with/near the seed
seedWords := strings.Fields(strings.ToLower(seed))
if len(seedWords) >= 2 {
// Look for exact bigram match
target := trigramKey{seedWords[0], seedWords[1]}
if _, ok := chain[target]; ok {
start = target
}
}
if start.w1 == "" && len(seedWords) >= 1 {
// Look for a starter beginning with the first seed word
seedLower := seedWords[0]
var candidates []trigramKey
for _, s := range starters {
if strings.ToLower(s.w1) == seedLower {
candidates = append(candidates, s)
}
}
if len(candidates) > 0 {
start = candidates[rand.Intn(len(candidates))]
}
}
}
// Fallback to random starter
if start.w1 == "" {
start = starters[rand.Intn(len(starters))]
}
result := []string{start.w1, start.w2}
sentences := countSentenceEnds(start.w1) + countSentenceEnds(start.w2)
for len(result) < 60 {
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)
if endsWithPunctuation(next) {
sentences++
if sentences >= 3 {
break
}
// After at least 8 words and 1 sentence, chance to stop
if len(result) > 8 && rand.Float64() < 0.3 {
break
}
}
}
output := strings.Join(result, " ")
// Truncate to 280 chars at sentence boundary
if len(output) > 280 {
output = truncateAtSentence(output, 280)
}
return output
}
func countSentenceEnds(s string) int {
n := 0
for _, c := range s {
if c == '.' || c == '!' || c == '?' {
n++
}
}
return n
}
// truncateAtSentence truncates text to maxLen, preferring to cut at a sentence boundary.
func truncateAtSentence(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
sub := text[:maxLen]
// Find last sentence-ending punctuation
lastEnd := -1
for i := len(sub) - 1; i >= 0; i-- {
if sub[i] == '.' || sub[i] == '!' || sub[i] == '?' {
lastEnd = i
break
}
}
if lastEnd > 0 {
return sub[:lastEnd+1]
}
// No sentence boundary — hard truncate at word boundary
lastSpace := strings.LastIndex(sub, " ")
if lastSpace > 0 {
return sub[:lastSpace] + "..."
}
return sub
}
// ── Stats ───────────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleStats(ctx MessageContext, userArg string) error {
var targetUser id.UserID
if userArg == "" || userArg == "me" {
targetUser = ctx.Sender
} else {
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
targetUser = resolved
}
d := db.Get()
// Message count
var msgCount int
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&msgCount)
if msgCount == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s has no Markov data.", p.DisplayName(targetUser)))
}
// Earliest date
var earliest int64
_ = d.QueryRow(`SELECT MIN(created_at) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&earliest)
earliestDate := time.Unix(earliest, 0).UTC().Format("2006-01-02")
// Decrypt all to compute word stats
texts := p.loadCorpus(targetUser)
wordFreq := make(map[string]int)
totalWords := 0
for _, t := range texts {
for _, w := range strings.Fields(t) {
w = strings.ToLower(w)
wordFreq[w]++
totalWords++
}
}
uniqueWords := len(wordFreq)
// Top 5 words
type wordCount struct {
word string
count int
}
var top []wordCount
for w, c := range wordFreq {
if len(w) < 3 { // skip very short words
continue
}
inserted := false
for i, tc := range top {
if c > tc.count {
top = append(top, wordCount{})
copy(top[i+1:], top[i:])
top[i] = wordCount{w, c}
inserted = true
break
}
}
if !inserted && len(top) < 5 {
top = append(top, wordCount{w, c})
}
if len(top) > 5 {
top = top[:5]
}
}
name := p.DisplayName(targetUser)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 Markov Stats — %s\n\n", name))
sb.WriteString(fmt.Sprintf("Messages: %d\n", msgCount))
sb.WriteString(fmt.Sprintf("Unique words: %d\n", uniqueWords))
sb.WriteString(fmt.Sprintf("Total words: %d\n", totalWords))
sb.WriteString(fmt.Sprintf("Since: %s\n", earliestDate))
if len(top) > 0 {
sb.WriteString("\nTop words: ")
for i, tc := range top {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s (%d)", tc.word, tc.count))
}
sb.WriteString("\n")
}
sb.WriteString("\n Entries older than 90 days are purged automatically.")
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Forget ──────────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleForgetSelf(ctx MessageContext) error {
count := corpusCount(ctx.Sender)
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus is already empty.")
}
d := db.Get()
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(ctx.Sender))
if err != nil {
slog.Error("markov: forget self", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete your Markov corpus.")
}
_ = p.SendDM(ctx.Sender,
fmt.Sprintf("Deleted %d entries from your Markov corpus. This cannot be undone.", count))
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus has been deleted.")
}
func (p *MarkovPlugin) handleForgetAdmin(ctx MessageContext, userArg string) error {
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can delete another user's corpus.")
}
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
count := corpusCount(resolved)
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s has no Markov data.", p.DisplayName(resolved)))
}
d := db.Get()
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(resolved))
if err != nil {
slog.Error("markov: forget admin", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete corpus.")
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Deleted %d Markov entries for %s.", count, p.DisplayName(resolved)))
}
// ── Leaderboard ─────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleLeaderboard(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT user_id, COUNT(*) as cnt FROM markov_corpus
GROUP BY user_id ORDER BY cnt DESC LIMIT 10`,
)
if err != nil {
slog.Error("markov: leaderboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🏆 Markov Leaderboard — Top 10 by Corpus Size\n\n")
rank := 0
for rows.Next() {
var uid string
var cnt int
if err := rows.Scan(&uid, &cnt); err != nil {
continue
}
rank++
name := p.DisplayName(id.UserID(uid))
sb.WriteString(fmt.Sprintf("%d. %s — %d messages\n", rank, name, cnt))
}
if rank == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Trigram model ───────────────────────────────────────────────────────────
// trigram key
type trigramKey struct {
w1, w2 string
}
// buildTrigramModel builds a trigram chain and collects starters from texts.
func buildTrigramModel(texts []string) (map[trigramKey][]string, []trigramKey) {
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])
}
}
return chain, starters
}
// generateMarkov builds a trigram model from texts and generates output.
// Kept for backward compatibility but delegates to the new model.
func generateMarkov(texts []string, maxWords int) string {
chain, starters := buildTrigramModel(texts)
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 == '?'
}