diff --git a/.env.example b/.env.example index 7b91bd2..0a64101 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ MISSING_MAX_DAYS=90 # days after which they're considered gone, not mi MISSING_MIN_MESSAGES=10 # minimum lifetime messages to be eligible MISSING_EXCLUDE_USERS= # comma-separated user IDs to never list as missing +# ---- Encryption ---- +QUOTE_ENCRYPTION_KEY= # Base64-encoded 32-byte AES-256 key (required for !quote) + # ---- Rate Limits (per user per day) ---- RATELIMIT_WEATHER=5 RATELIMIT_TRANSLATE=20 diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..d544070 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,81 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key. +// The returned ciphertext has a 12-byte random nonce prepended. +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm new: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) // 12 bytes + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// Decrypt decrypts ciphertext produced by Encrypt. It expects the first +// 12 bytes to be the nonce followed by the GCM-sealed data. +func Decrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm new: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, sealed := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + return nil, fmt.Errorf("gcm open: %w", err) + } + + return plaintext, nil +} + +// HMAC computes HMAC-SHA256 of data with the given key and returns a hex-encoded string. +func HMAC(key, data []byte) string { + mac := hmac.New(sha256.New, key) + mac.Write(data) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ParseKey base64-decodes b64Key and validates the result is exactly 32 bytes +// (required for AES-256). +func ParseKey(b64Key string) ([]byte, error) { + key, err := base64.StdEncoding.DecodeString(b64Key) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("key must be exactly 32 bytes, got %d", len(key)) + } + return key, nil +} diff --git a/internal/db/db.go b/internal/db/db.go index ab15212..251499e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -303,14 +303,16 @@ CREATE TABLE IF NOT EXISTS achievements ( PRIMARY KEY (user_id, achievement_id) ); --- Quotes +-- Quotes (encrypted at rest) CREATE TABLE IF NOT EXISTS quotes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id TEXT NOT NULL, - user_id TEXT NOT NULL, - quote_text TEXT NOT NULL, - saved_by TEXT NOT NULL, - created_at INTEGER DEFAULT (unixepoch()) + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + submitted_by TEXT NOT NULL, + saved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + content_hmac TEXT NOT NULL UNIQUE, + quote_text BLOB NOT NULL, + attributed_to BLOB NOT NULL, + context BLOB ); -- Now Playing @@ -618,6 +620,7 @@ CREATE TABLE IF NOT EXISTS api_cache ( data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index 055408c..42aefe5 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -114,7 +114,7 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "achievements") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/fun.go b/internal/plugin/fun.go index 309b7da..f930369 100644 --- a/internal/plugin/fun.go +++ b/internal/plugin/fun.go @@ -268,7 +268,7 @@ func (p *FunPlugin) handleTime(ctx MessageContext) error { } func (p *FunPlugin) showUserTime(ctx MessageContext, input string) error { - resolved, ok := p.ResolveUser(input) + resolved, ok := p.ResolveUser(input, ctx.RoomID) if !ok { return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city, timezone, or user: %s", input)) } diff --git a/internal/plugin/howami.go b/internal/plugin/howami.go index 11f385b..741d2df 100644 --- a/internal/plugin/howami.go +++ b/internal/plugin/howami.go @@ -56,7 +56,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error { target := ctx.Sender args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami")) if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go index 6d13e32..6956065 100644 --- a/internal/plugin/llm_passive.go +++ b/internal/plugin/llm_passive.go @@ -579,7 +579,7 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "potty") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } @@ -646,7 +646,7 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "insults") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } @@ -713,7 +713,7 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "sentiment") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/markov.go b/internal/plugin/markov.go index 98f29cd..a0410c2 100644 --- a/internal/plugin/markov.go +++ b/internal/plugin/markov.go @@ -100,7 +100,7 @@ func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error { targetUser = ctx.Sender default: // Treat as user ID - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { targetUser = resolved } } diff --git a/internal/plugin/milkcarton.go b/internal/plugin/milkcarton.go index e917af0..450fa46 100644 --- a/internal/plugin/milkcarton.go +++ b/internal/plugin/milkcarton.go @@ -151,7 +151,7 @@ func (p *MilkCartonPlugin) handleHaveYouSeenThem(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !haveyouseenthem @user") } - targetID, ok := p.ResolveUser(args) + targetID, ok := p.ResolveUser(args, ctx.RoomID) if !ok { return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find a user matching that name.") } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 29e7564..1fbdc02 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -103,10 +103,11 @@ func (b *Base) RoomMembers(roomID id.RoomID) map[id.UserID]bool { return members } -// ResolveUser resolves a partial username to a full Matrix user ID. -// Accepts any of: "@user:server", "user:server", "user", or partial match. +// ResolveUser resolves a partial username or display name to a full Matrix user ID. +// Accepts any of: "@user:server", "user:server", "user", display name, or partial match. +// Uses room membership to match against display names when a localpart match isn't found. // Returns the matched user ID and true, or empty and false if no unique match. -func (b *Base) ResolveUser(input string) (id.UserID, bool) { +func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool) { input = strings.TrimSpace(input) if input == "" { return "", false @@ -122,7 +123,7 @@ func (b *Base) ResolveUser(input string) (id.UserID, bool) { // Strip leading @ for the search query := strings.TrimPrefix(input, "@") - query = strings.ToLower(query) + queryLower := strings.ToLower(query) d := db.Get() rows, err := d.Query(`SELECT user_id FROM user_stats`) @@ -151,27 +152,49 @@ func (b *Base) ResolveUser(input string) (id.UserID, bool) { lower := strings.ToLower(localpart) - if lower == query { + if lower == queryLower { exact = append(exact, uid) - } else if strings.Contains(lower, query) { + } else if strings.Contains(lower, queryLower) { partial = append(partial, uid) } } // Exact localpart match — return it (even if multiple servers, prefer first) - if len(exact) == 1 { - return id.UserID(exact[0]), true - } - if len(exact) > 1 { - // Multiple exact matches across servers — return the first one + if len(exact) >= 1 { return id.UserID(exact[0]), true } - // Single partial match + // Single partial localpart match if len(partial) == 1 { return id.UserID(partial[0]), true } + // Fall back to display name matching via room membership + if len(roomIDs) > 0 { + resp, err := b.Client.JoinedMembers(context.Background(), roomIDs[0]) + if err == nil { + var dnExact []id.UserID + var dnPartial []id.UserID + for uid, member := range resp.Joined { + if member.DisplayName == "" { + continue + } + dn := strings.ToLower(member.DisplayName) + if dn == queryLower { + dnExact = append(dnExact, uid) + } else if strings.Contains(dn, queryLower) { + dnPartial = append(dnPartial, uid) + } + } + if len(dnExact) >= 1 { + return dnExact[0], true + } + if len(dnPartial) == 1 { + return dnPartial[0], true + } + } + } + return "", false } diff --git a/internal/plugin/presence.go b/internal/plugin/presence.go index 0229226..7b87779 100644 --- a/internal/plugin/presence.go +++ b/internal/plugin/presence.go @@ -130,7 +130,7 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error { return p.SendMessage(ctx.RoomID, "Usage: !whois ") } - targetUser, ok := p.ResolveUser(args) + targetUser, ok := p.ResolveUser(args, ctx.RoomID) if !ok { return p.SendMessage(ctx.RoomID, "Could not find a user matching that name.") } diff --git a/internal/plugin/quotewall.go b/internal/plugin/quotewall.go new file mode 100644 index 0000000..1334f7b --- /dev/null +++ b/internal/plugin/quotewall.go @@ -0,0 +1,526 @@ +package plugin + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "math/rand" + "os" + "strconv" + "strings" + "time" + + "gogobee/internal/crypto" + "gogobee/internal/db" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// QuoteWallPlugin saves and retrieves encrypted quotes from chat messages. +type QuoteWallPlugin struct { + Base + rate *RateLimitsPlugin + encKey []byte + enabled bool +} + +// NewQuoteWallPlugin creates a new quote wall plugin. +func NewQuoteWallPlugin(client *mautrix.Client, rate *RateLimitsPlugin) *QuoteWallPlugin { + p := &QuoteWallPlugin{ + Base: NewBase(client), + rate: rate, + } + + raw := os.Getenv("QUOTE_ENCRYPTION_KEY") + if raw == "" { + slog.Warn("quotewall: disabled (QUOTE_ENCRYPTION_KEY not set)") + p.enabled = false + return p + } + + key, err := crypto.ParseKey(raw) + if err != nil { + slog.Error("quotewall: invalid encryption key", "err", err) + p.enabled = false + return p + } + + p.encKey = key + p.enabled = true + return p +} + +func (p *QuoteWallPlugin) Name() string { return "quotewall" } + +func (p *QuoteWallPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "quote", Description: "Save or retrieve quotes", Usage: "!quote [random|search|@user|delete]", Category: "Community"}, + {Name: "quoteboard", Description: "Top 5 most-quoted members", Usage: "!quoteboard", Category: "Community"}, + } +} + +func (p *QuoteWallPlugin) Init() error { return nil } + +func (p *QuoteWallPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *QuoteWallPlugin) OnMessage(ctx MessageContext) error { + if p.IsCommand(ctx.Body, "quoteboard") { + return p.handleQuoteboard(ctx) + } + if p.IsCommand(ctx.Body, "quote") { + return p.handleQuote(ctx) + } + return nil +} + +func (p *QuoteWallPlugin) handleQuote(ctx MessageContext) error { + if !p.enabled { + return p.SendReply(ctx.RoomID, ctx.EventID, "Quote wall is not configured.") + } + + args := strings.TrimSpace(p.GetArgs(ctx.Body, "quote")) + + // Check if this is a reply to another message + content := ctx.Event.Content.AsMessage() + if content != nil && content.RelatesTo != nil && content.RelatesTo.InReplyTo != nil && args == "" { + return p.handleQuoteSaveReply(ctx, content.RelatesTo.InReplyTo.EventID) + } + + // !quote delete [id] + if strings.HasPrefix(strings.ToLower(args), "delete") { + return p.handleQuoteDelete(ctx, strings.TrimSpace(args[6:])) + } + + // !quote search [keyword] + if strings.HasPrefix(strings.ToLower(args), "search") { + keyword := strings.TrimSpace(args[6:]) + return p.handleQuoteSearch(ctx, keyword) + } + + // !quote random or !quote (no args, no reply) + if args == "" || strings.EqualFold(args, "random") { + return p.handleQuoteRandom(ctx) + } + + // !quote "text" -- @user (manual save) + if strings.Contains(args, " -- ") { + return p.handleQuoteManualSave(ctx, args) + } + + // !quote @user — random quote from a specific user + return p.handleQuoteByUser(ctx, args) +} + +// handleQuoteSaveReply saves a quote from a replied-to message. +func (p *QuoteWallPlugin) handleQuoteSaveReply(ctx MessageContext, replyEventID id.EventID) error { + if p.rate != nil && !p.rate.CheckLimit(ctx.Sender, "quote_save", 50) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the daily quote save limit.") + } + + evt, err := p.Client.GetEvent(context.Background(), ctx.RoomID, replyEventID) + if err != nil { + slog.Error("quotewall: fetch reply event", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch the original message.") + } + + err = evt.Content.ParseRaw(evt.Type) + if err != nil { + slog.Error("quotewall: parse reply event content", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse the original message.") + } + + msgContent := evt.Content.AsMessage() + if msgContent == nil || msgContent.Body == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "That message has been redacted and can't be saved.") + } + + quoteText := msgContent.Body + attributedTo := string(evt.Sender) + + return p.saveQuote(ctx, quoteText, attributedTo, "") +} + +// handleQuoteManualSave parses `"text" -- @user` and saves. +func (p *QuoteWallPlugin) handleQuoteManualSave(ctx MessageContext, args string) error { + if p.rate != nil && !p.rate.CheckLimit(ctx.Sender, "quote_save", 50) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the daily quote save limit.") + } + + parts := strings.SplitN(args, " -- ", 2) + if len(parts) != 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !quote \"text\" -- @user") + } + + quoteText := strings.TrimSpace(parts[0]) + quoteText = strings.TrimPrefix(quoteText, "\"") + quoteText = strings.TrimSuffix(quoteText, "\"") + if quoteText == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "Quote text cannot be empty.") + } + + userArg := strings.TrimSpace(parts[1]) + resolvedUser, ok := p.ResolveUser(userArg, ctx.RoomID) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve the attributed user.") + } + + return p.saveQuote(ctx, quoteText, string(resolvedUser), "") +} + +// saveQuote encrypts and inserts a quote, handling dedup and reactions. +func (p *QuoteWallPlugin) saveQuote(ctx MessageContext, quoteText, attributedTo, quoteContext string) error { + hmacVal := crypto.HMAC(p.encKey, []byte(quoteText)) + + d := db.Get() + + // Check for duplicate + var existing int + err := d.QueryRow(`SELECT 1 FROM quotes WHERE content_hmac = ?`, hmacVal).Scan(&existing) + if err == nil { + // Duplicate found + _ = p.SendReact(ctx.RoomID, ctx.EventID, "\U0001f501") // 🔁 + return nil + } + + encText, err := crypto.Encrypt(p.encKey, []byte(quoteText)) + if err != nil { + slog.Error("quotewall: encrypt quote_text", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to encrypt quote.") + } + + encAttr, err := crypto.Encrypt(p.encKey, []byte(attributedTo)) + if err != nil { + slog.Error("quotewall: encrypt attributed_to", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to encrypt quote.") + } + + var encCtx []byte + if quoteContext != "" { + encCtx, err = crypto.Encrypt(p.encKey, []byte(quoteContext)) + if err != nil { + slog.Error("quotewall: encrypt context", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to encrypt quote.") + } + } + + _, err = d.Exec( + `INSERT INTO quotes (room_id, submitted_by, content_hmac, quote_text, attributed_to, context) + VALUES (?, ?, ?, ?, ?, ?)`, + string(ctx.RoomID), string(ctx.Sender), hmacVal, encText, encAttr, encCtx, + ) + if err != nil { + slog.Error("quotewall: insert quote", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save quote.") + } + + _ = p.SendReact(ctx.RoomID, ctx.EventID, "\U0001f4cc") // 📌 + + // Invalidate quoteboard cache for this room + db.CacheSet("quoteboard:"+string(ctx.RoomID), "") + + return nil +} + +// handleQuoteRandom retrieves a random quote from the room. +func (p *QuoteWallPlugin) handleQuoteRandom(ctx MessageContext) error { + if p.rate != nil && !p.rate.CheckLimit(ctx.Sender, "quote_read", 30) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the daily quote read limit.") + } + + d := db.Get() + var qID int + var encText, encAttr []byte + var encCtx []byte + var submittedBy, savedAt string + + err := d.QueryRow( + `SELECT id, quote_text, attributed_to, context, submitted_by, saved_at + FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`, + string(ctx.RoomID), + ).Scan(&qID, &encText, &encAttr, &encCtx, &submittedBy, &savedAt) + if err != nil { + if err == sql.ErrNoRows { + return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes saved in this room yet.") + } + slog.Error("quotewall: query random", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to retrieve quote.") + } + + return p.displayQuote(ctx, qID, encText, encAttr, encCtx, submittedBy, savedAt, "") +} + +// handleQuoteByUser retrieves a random quote attributed to a specific user. +func (p *QuoteWallPlugin) handleQuoteByUser(ctx MessageContext, userArg string) error { + if p.rate != nil && !p.rate.CheckLimit(ctx.Sender, "quote_read", 30) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the daily quote read limit.") + } + + resolvedUser, ok := p.ResolveUser(userArg, ctx.RoomID) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user.") + } + + d := db.Get() + rows, err := d.Query( + `SELECT id, quote_text, attributed_to, context, submitted_by, saved_at + FROM quotes WHERE room_id = ?`, + string(ctx.RoomID), + ) + if err != nil { + slog.Error("quotewall: query by user", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search quotes.") + } + defer rows.Close() + + type quoteRow struct { + id int + encText []byte + encAttr []byte + encCtx []byte + submittedBy string + savedAt string + } + + var matches []quoteRow + targetStr := string(resolvedUser) + + for rows.Next() { + var q quoteRow + if err := rows.Scan(&q.id, &q.encText, &q.encAttr, &q.encCtx, &q.submittedBy, &q.savedAt); err != nil { + continue + } + attr, err := crypto.Decrypt(p.encKey, q.encAttr) + if err != nil { + continue + } + if string(attr) == targetStr { + matches = append(matches, q) + } + } + + if len(matches) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes found for that user in this room.") + } + + // Pick a random match + q := matches[rand.Intn(len(matches))] + return p.displayQuote(ctx, q.id, q.encText, q.encAttr, q.encCtx, q.submittedBy, q.savedAt, "") +} + +// handleQuoteSearch searches quotes by keyword. +func (p *QuoteWallPlugin) handleQuoteSearch(ctx MessageContext, keyword string) error { + if keyword == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !quote search [keyword]") + } + + if p.rate != nil && !p.rate.CheckLimit(ctx.Sender, "quote_read", 30) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the daily quote read limit.") + } + + d := db.Get() + rows, err := d.Query( + `SELECT id, quote_text, attributed_to, context, submitted_by, saved_at + FROM quotes WHERE room_id = ?`, + string(ctx.RoomID), + ) + if err != nil { + slog.Error("quotewall: query search", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search quotes.") + } + defer rows.Close() + + type quoteRow struct { + id int + encText []byte + encAttr []byte + encCtx []byte + submittedBy string + savedAt string + } + + keywordLower := strings.ToLower(keyword) + var matches []quoteRow + + for rows.Next() { + var q quoteRow + if err := rows.Scan(&q.id, &q.encText, &q.encAttr, &q.encCtx, &q.submittedBy, &q.savedAt); err != nil { + continue + } + plaintext, err := crypto.Decrypt(p.encKey, q.encText) + if err != nil { + continue + } + if strings.Contains(strings.ToLower(string(plaintext)), keywordLower) { + matches = append(matches, q) + } + } + + if len(matches) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No quotes matching '%s' found.", keyword)) + } + + q := matches[rand.Intn(len(matches))] + prefix := fmt.Sprintf("%d matches found, showing one:\n\n", len(matches)) + return p.displayQuote(ctx, q.id, q.encText, q.encAttr, q.encCtx, q.submittedBy, q.savedAt, prefix) +} + +// handleQuoteDelete deletes a quote by ID (admin only). +func (p *QuoteWallPlugin) handleQuoteDelete(ctx MessageContext, idStr string) error { + if !p.enabled { + return p.SendReply(ctx.RoomID, ctx.EventID, "Quote wall is not configured.") + } + + if !p.IsAdmin(ctx.Sender) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can delete quotes.") + } + + quoteID, err := strconv.Atoi(strings.TrimSpace(idStr)) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !quote delete [id]") + } + + d := db.Get() + res, err := d.Exec(`DELETE FROM quotes WHERE id = ? AND room_id = ?`, quoteID, string(ctx.RoomID)) + if err != nil { + slog.Error("quotewall: delete", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete quote.") + } + + affected, _ := res.RowsAffected() + if affected == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Quote not found.") + } + + // Invalidate quoteboard cache + db.CacheSet("quoteboard:"+string(ctx.RoomID), "") + + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Quote #%d deleted.", quoteID)) +} + +// handleQuoteboard shows the top 5 most-quoted members. +func (p *QuoteWallPlugin) handleQuoteboard(ctx MessageContext) error { + if !p.enabled { + return p.SendReply(ctx.RoomID, ctx.EventID, "Quote wall is not configured.") + } + + // Check cache first + cacheKey := "quoteboard:" + string(ctx.RoomID) + if cached := db.CacheGet(cacheKey, 300); cached != "" { + return p.SendReply(ctx.RoomID, ctx.EventID, cached) + } + + d := db.Get() + rows, err := d.Query( + `SELECT attributed_to FROM quotes WHERE room_id = ?`, + string(ctx.RoomID), + ) + if err != nil { + slog.Error("quotewall: quoteboard query", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load quote board.") + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var encAttr []byte + if err := rows.Scan(&encAttr); err != nil { + continue + } + attr, err := crypto.Decrypt(p.encKey, encAttr) + if err != nil { + continue + } + counts[string(attr)]++ + } + + if len(counts) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes saved in this room yet.") + } + + // Sort by count descending + type entry struct { + user string + count int + } + var sorted []entry + for u, c := range counts { + sorted = append(sorted, entry{u, c}) + } + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].count > sorted[i].count { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"} // 🥇🥈🥉 + var sb strings.Builder + sb.WriteString("\U0001f4cc Quote Board — Top 5\n\n") + + limit := 5 + if len(sorted) < limit { + limit = len(sorted) + } + for i := 0; i < limit; i++ { + prefix := fmt.Sprintf("#%d", i+1) + if i < len(medals) { + prefix = medals[i] + } + sb.WriteString(fmt.Sprintf("%s %s — %d quotes\n", prefix, sorted[i].user, sorted[i].count)) + } + + result := sb.String() + db.CacheSet(cacheKey, result) + + return p.SendReply(ctx.RoomID, ctx.EventID, result) +} + +// displayQuote decrypts and formats a quote for display. +func (p *QuoteWallPlugin) displayQuote(ctx MessageContext, qID int, encText, encAttr, encCtx []byte, submittedBy, savedAt, prefix string) error { + plainText, err := crypto.Decrypt(p.encKey, encText) + if err != nil { + slog.Error("quotewall: decrypt quote_text", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to decrypt quote.") + } + + plainAttr, err := crypto.Decrypt(p.encKey, encAttr) + if err != nil { + slog.Error("quotewall: decrypt attributed_to", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to decrypt quote.") + } + + // Parse saved_at for display + var dateStr string + t, err := time.Parse("2006-01-02 15:04:05", savedAt) + if err != nil { + t, err = time.Parse(time.RFC3339, savedAt) + } + if err == nil { + dateStr = t.Format("January 2, 2006") + } else { + dateStr = savedAt + } + + var sb strings.Builder + if prefix != "" { + sb.WriteString(prefix) + } + sb.WriteString(fmt.Sprintf("\U0001f4cc Quote #%d\n", qID)) + sb.WriteString(fmt.Sprintf("\"%s\"\n", string(plainText))) + sb.WriteString(fmt.Sprintf(" -- %s \u2022 %s\n", string(plainAttr), dateStr)) + + // Decrypt and show context if present + if len(encCtx) > 0 { + plainCtx, err := crypto.Decrypt(p.encKey, encCtx) + if err == nil && len(plainCtx) > 0 { + sb.WriteString(fmt.Sprintf(" Context: %s\n", string(plainCtx))) + } + } + + sb.WriteString(fmt.Sprintf(" (saved by %s)", submittedBy)) + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} diff --git a/internal/plugin/reputation.go b/internal/plugin/reputation.go index 89d9e0b..4b32b3a 100644 --- a/internal/plugin/reputation.go +++ b/internal/plugin/reputation.go @@ -129,7 +129,7 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "rep") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index 81c5460..d7703f5 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -166,7 +166,7 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "stats") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/streaks.go b/internal/plugin/streaks.go index 70fffb8..03f0d89 100644 --- a/internal/plugin/streaks.go +++ b/internal/plugin/streaks.go @@ -91,7 +91,7 @@ func (p *StreaksPlugin) handleStreak(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "streak") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/internal/plugin/tarot.go b/internal/plugin/tarot.go new file mode 100644 index 0000000..6108ba5 --- /dev/null +++ b/internal/plugin/tarot.go @@ -0,0 +1,222 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math/rand" + "os" + "strings" + + "gogobee/internal/db" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +var tarotDeck = []string{ + // Major Arcana + "The Fool", "The Magician", "The High Priestess", "The Empress", "The Emperor", + "The Hierophant", "The Lovers", "The Chariot", "Strength", "The Hermit", + "Wheel of Fortune", "Justice", "The Hanged Man", "Death", "Temperance", + "The Devil", "The Tower", "The Star", "The Moon", "The Sun", + "Judgement", "The World", + // Wands + "Ace of Wands", "Two of Wands", "Three of Wands", "Four of Wands", "Five of Wands", + "Six of Wands", "Seven of Wands", "Eight of Wands", "Nine of Wands", "Ten of Wands", + "Page of Wands", "Knight of Wands", "Queen of Wands", "King of Wands", + // Cups + "Ace of Cups", "Two of Cups", "Three of Cups", "Four of Cups", "Five of Cups", + "Six of Cups", "Seven of Cups", "Eight of Cups", "Nine of Cups", "Ten of Cups", + "Page of Cups", "Knight of Cups", "Queen of Cups", "King of Cups", + // Swords + "Ace of Swords", "Two of Swords", "Three of Swords", "Four of Swords", "Five of Swords", + "Six of Swords", "Seven of Swords", "Eight of Swords", "Nine of Swords", "Ten of Swords", + "Page of Swords", "Knight of Swords", "Queen of Swords", "King of Swords", + // Pentacles + "Ace of Pentacles", "Two of Pentacles", "Three of Pentacles", "Four of Pentacles", "Five of Pentacles", + "Six of Pentacles", "Seven of Pentacles", "Eight of Pentacles", "Nine of Pentacles", "Ten of Pentacles", + "Page of Pentacles", "Knight of Pentacles", "Queen of Pentacles", "King of Pentacles", +} + +const tarotSystemPrompt = `You are a tarot reader of extraordinary accuracy and absolutely no sympathy. +You deliver readings with the warm professionalism of a customer service representative +who has fully accepted that everyone who consults you is, in some specific and personal way, +a disappointment. Your readings are detailed, specific, and correct. They are also mean. +Not vague mean -- specific mean. You will compliment one thing and immediately undermine it. +You will predict great fortune followed by a precise and avoidable tragedy. The tragedy +should always be mundane, specific, and disproportionate to the fortune. You close every +reading politely. Under 5 sentences. Do not explain the card. Do not break character. +The querent asked for this.` + +const tarotFewShot = `Example 1: +Card: The Sun +Reading: Despite your great smell and genuinely low personality, the cards indicate you will be granted the greatest riches the world has ever seen -- generational wealth, adoring fans, a street named after you in at least three countries. Dignitaries will seek your counsel. Songs will be written. Unfortunately, on the eve of your first major celebration, you will be struck down in a parking lot by a runaway shopping cart traveling at a speed that investigators will later describe as "really not that fast, honestly." You will not survive. The street will be renamed. Thank you for requesting a reading today! Have a nice day! + +Example 2: +Card: The Tower +Reading: Your unique combination of misplaced confidence and correct instincts will finally pay off -- a life-changing opportunity lands directly in your lap this season. You will, with characteristic timing, be in the bathroom when it calls and miss it entirely. Your voicemail is full. It will not call again. Best of luck going forward! + +Example 3: +Card: Ace of Cups +Reading: Those who know you best have always said you have a good heart, which is generous of them given everything. A great love is coming -- patient, kind, genuinely perfect for you in every measurable way. They will briefly date your friend instead. You were so close! Thank you for your continued interest in tarot!` + +// TarotPlugin provides tarot card readings with LLM-generated interpretations. +type TarotPlugin struct { + Base + rate *RateLimitsPlugin +} + +// NewTarotPlugin creates a new tarot plugin. +func NewTarotPlugin(client *mautrix.Client, rate *RateLimitsPlugin) *TarotPlugin { + return &TarotPlugin{ + Base: NewBase(client), + rate: rate, + } +} + +func (p *TarotPlugin) Name() string { return "tarot" } + +func (p *TarotPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "tarot", Description: "Draw a tarot card and receive an LLM reading", Usage: "!tarot [@user]", Category: "LLM & Sentiment"}, + {Name: "tarotspread", Description: "Draw a three-card spread (Past/Present/Future)", Usage: "!tarotspread [@user]", Category: "LLM & Sentiment"}, + } +} + +func (p *TarotPlugin) Init() error { return nil } + +func (p *TarotPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *TarotPlugin) OnMessage(ctx MessageContext) error { + if p.IsCommand(ctx.Body, "tarotspread") { + return p.handleSpread(ctx) + } + if p.IsCommand(ctx.Body, "tarot") { + return p.handleTarot(ctx) + } + return nil +} + +func (p *TarotPlugin) handleTarot(ctx MessageContext) error { + if !p.rate.CheckLimit(ctx.Sender, "tarot", 10) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.") + } + + ollamaHost := os.Getenv("OLLAMA_HOST") + ollamaModel := os.Getenv("OLLAMA_MODEL") + if ollamaHost == "" || ollamaModel == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") + } + + // Resolve target user + target := "" + args := strings.TrimSpace(p.GetArgs(ctx.Body, "tarot")) + if args != "" { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { + target = string(resolved) + } + } + + // Send thinking message + if err := p.SendReply(ctx.RoomID, ctx.EventID, "\U0001f52e Drawing a card..."); err != nil { + slog.Error("tarot: send thinking", "err", err) + } + + // Draw a card + card := tarotDeck[rand.Intn(len(tarotDeck))] + + // Build prompt + var extraLines string + readingFor := id.UserID(ctx.Sender) + if target != "" { + extraLines += fmt.Sprintf("\nReading is for: %s", target) + readingFor = id.UserID(target) + } + if sign := lookupZodiac(readingFor); sign != "" { + extraLines += fmt.Sprintf("\nQuerent's zodiac sign: %s", sign) + } + prompt := fmt.Sprintf("%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotSystemPrompt, tarotFewShot, card, extraLines) + + response, err := callOllama(ollamaHost, ollamaModel, prompt) + if err != nil { + slog.Error("tarot: ollama call", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") + } + + msg := fmt.Sprintf("\U0001f0cf %s\n\n%s", card, response) + return p.SendMessage(ctx.RoomID, msg) +} + +func (p *TarotPlugin) handleSpread(ctx MessageContext) error { + if !p.rate.CheckLimit(ctx.Sender, "tarot", 10) { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.") + } + + ollamaHost := os.Getenv("OLLAMA_HOST") + ollamaModel := os.Getenv("OLLAMA_MODEL") + if ollamaHost == "" || ollamaModel == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") + } + + // Resolve target user + target := "" + args := strings.TrimSpace(p.GetArgs(ctx.Body, "tarotspread")) + if args != "" { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { + target = string(resolved) + } + } + + // Send thinking message + if err := p.SendReply(ctx.RoomID, ctx.EventID, "\U0001f52e Drawing three cards..."); err != nil { + slog.Error("tarot: send thinking", "err", err) + } + + // Draw 3 cards without replacement + cards := drawCards(3) + + // Build prompt + var extraLines string + readingFor := id.UserID(ctx.Sender) + if target != "" { + extraLines += fmt.Sprintf("\nReading is for: %s", target) + readingFor = id.UserID(target) + } + if sign := lookupZodiac(readingFor); sign != "" { + extraLines += fmt.Sprintf("\nQuerent's zodiac sign: %s", sign) + } + prompt := fmt.Sprintf("%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive a unified reading that weaves all three cards together.", + tarotSystemPrompt, tarotFewShot, cards[0], cards[1], cards[2], extraLines) + + response, err := callOllama(ollamaHost, ollamaModel, prompt) + if err != nil { + slog.Error("tarot: ollama call", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") + } + + msg := fmt.Sprintf("\U0001f0cf Past: %s | Present: %s | Future: %s\n\n%s", cards[0], cards[1], cards[2], response) + return p.SendMessage(ctx.RoomID, msg) +} + +// lookupZodiac returns the zodiac sign for a user, or "" if no birthday is set. +func lookupZodiac(userID id.UserID) string { + var month, day int + err := db.Get().QueryRow( + `SELECT month, day FROM birthdays WHERE user_id = ? AND month > 0 AND day > 0`, + string(userID), + ).Scan(&month, &day) + if err != nil { + return "" + } + return zodiacSign(month, day) +} + +// drawCards picks n distinct cards from the deck. +func drawCards(n int) []string { + perm := rand.Perm(len(tarotDeck)) + cards := make([]string, n) + for i := 0; i < n; i++ { + cards[i] = tarotDeck[perm[i]] + } + return cards +} diff --git a/internal/plugin/vibe.go b/internal/plugin/vibe.go index 00601f4..938e83a 100644 --- a/internal/plugin/vibe.go +++ b/internal/plugin/vibe.go @@ -30,6 +30,7 @@ type VibePlugin struct { mu sync.Mutex buffers map[id.RoomID][]bufferedMessage cooldowns map[id.RoomID]time.Time + startedAt time.Time } // NewVibePlugin creates a new vibe plugin. @@ -38,6 +39,7 @@ func NewVibePlugin(client *mautrix.Client) *VibePlugin { Base: NewBase(client), buffers: make(map[id.RoomID][]bufferedMessage), cooldowns: make(map[id.RoomID]time.Time), + startedAt: time.Now().UTC(), } } @@ -127,7 +129,7 @@ func (p *VibePlugin) handleVibe(ctx MessageContext) error { buf := p.getBuffer(ctx.RoomID) if len(buf) < vibeMinMessages { return p.SendReply(ctx.RoomID, ctx.EventID, - fmt.Sprintf("Need at least %d messages to read the vibe. Currently have %d.", vibeMinMessages, len(buf))) + fmt.Sprintf("Need at least %d messages to read the vibe. Currently have %d. (uptime: %s)", vibeMinMessages, len(buf), p.uptime())) } if !p.checkCooldown(ctx.RoomID) { @@ -172,7 +174,7 @@ func (p *VibePlugin) handleTLDR(ctx MessageContext) error { buf := p.getBuffer(ctx.RoomID) if len(buf) < vibeMinMessages { return p.SendReply(ctx.RoomID, ctx.EventID, - fmt.Sprintf("Need at least %d messages for a summary. Currently have %d.", vibeMinMessages, len(buf))) + fmt.Sprintf("Need at least %d messages for a summary. Currently have %d. (uptime: %s)", vibeMinMessages, len(buf), p.uptime())) } if !p.checkCooldown(ctx.RoomID) { @@ -207,6 +209,24 @@ Summary:`, tldrBotName, transcript) return p.SendMessage(ctx.RoomID, response) } +func (p *VibePlugin) uptime() string { + d := time.Since(p.startedAt) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h >= 24 { + days := h / 24 + h = h % 24 + return fmt.Sprintf("%dd %dh", days, h) + } + return fmt.Sprintf("%dh %dm", h, m) +} + func formatTranscript(messages []bufferedMessage) string { var sb strings.Builder for _, m := range messages { diff --git a/internal/plugin/xp.go b/internal/plugin/xp.go index 44a3c26..42e45cb 100644 --- a/internal/plugin/xp.go +++ b/internal/plugin/xp.go @@ -158,7 +158,7 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "rank") if args != "" { - if resolved, ok := p.ResolveUser(args); ok { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } diff --git a/main.go b/main.go index 65fec0e..b6c5af8 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,8 @@ func main() { // Community registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin)) + registry.Register(plugin.NewQuoteWallPlugin(client, ratePlugin)) + registry.Register(plugin.NewTarotPlugin(client, ratePlugin)) // Tracking (passive) registry.Register(plugin.NewAchievementsPlugin(client, registry))