Add tarot readings, encrypted quote wall, display name resolution, and uptime reporting

New features:
- !tarot and !tarotspread commands with LLM-powered readings (zodiac-aware)
- !quote system with AES-256-GCM encryption at rest, reply-to-save, search, and quoteboard
- Shared crypto utility (internal/crypto) for AES-256-GCM encrypt/decrypt/HMAC
- ResolveUser now matches display names via room membership as fallback
- !vibe and !tldr show bot uptime when insufficient messages buffered

Bug fixes:
- Fix duplicate quotes table schema that would crash quotewall at runtime
- Fix biased random selection in quote retrieval (use rand.Intn)
- Fix XP rank calculation to handle tied scores with COUNT(DISTINCT xp)
- Scope all leaderboards to room members (repboard, pottyboard, insultboard, firstboard, rankings, emojiboard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-10 18:08:05 -07:00
parent 03def6463f
commit 1c02732445
19 changed files with 914 additions and 34 deletions

View File

@@ -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

81
internal/crypto/crypto.go Normal file
View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
}
}

View File

@@ -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))
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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.")
}

View File

@@ -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
}

View File

@@ -130,7 +130,7 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
return p.SendMessage(ctx.RoomID, "Usage: !whois <user>")
}
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.")
}

View File

@@ -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())
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

222
internal/plugin/tarot.go Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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))