Add horoscope, !time @user, profile achievements; fix mention detection and multiple bugs

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-09 23:55:02 -07:00
parent 2c191ec464
commit 03def6463f
24 changed files with 5057 additions and 116 deletions

View File

@@ -23,6 +23,7 @@ OPENWEATHER_API_KEY=
FINNHUB_API_KEY= FINNHUB_API_KEY=
BANDSINTOWN_API_KEY= BANDSINTOWN_API_KEY=
TMDB_API_KEY= TMDB_API_KEY=
SERPAPI_KEY= # SerpAPI key for image fetching (esteemed members)
# ---- Self-Hosted Services (optional) ---- # ---- Self-Hosted Services (optional) ----
LIBRETRANSLATE_URL= LIBRETRANSLATE_URL=
@@ -34,6 +35,8 @@ LLM_SAMPLE_RATE=0.15 # 0.0-1.0, fraction of non-keyword messages to classify (1
FEATURE_URL_PREVIEW= FEATURE_URL_PREVIEW=
FEATURE_SHADE= FEATURE_SHADE=
FEATURE_TRIVIA=true # set to "false" to disable trivia FEATURE_TRIVIA=true # set to "false" to disable trivia
FEATURE_ESTEEMED= # set to anything to enable satirical esteemed member posts
ESTEEMED_ROOM= # room ID for esteemed member posts (separate from broadcast rooms)
# ---- Missing Members (!haveyouseenthem / !missing) ---- # ---- Missing Members (!haveyouseenthem / !missing) ----
MISSING_THRESHOLD_DAYS=14 # days of inactivity before considered missing MISSING_THRESHOLD_DAYS=14 # days of inactivity before considered missing

334
cmd/fetch-esteemed/main.go Normal file
View File

@@ -0,0 +1,334 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
)
type Entry struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
SearchQuery string `json:"search_query,omitempty"`
}
type WikiResponse struct {
Query struct {
Pages map[string]struct {
Thumbnail struct {
Source string `json:"source"`
} `json:"thumbnail"`
} `json:"pages"`
} `json:"query"`
}
type SerpAPIResponse struct {
ImagesResults []struct {
Original string `json:"original"`
Thumbnail string `json:"thumbnail"`
} `json:"images_results"`
}
func main() {
const (
esteemedPath = "esteemed.json"
outputDir = "data/esteemed"
requestDelay = 500 * time.Millisecond
)
_ = godotenv.Load()
serpAPIKey := os.Getenv("SERPAPI_KEY")
client := &http.Client{
Timeout: 30 * time.Second,
}
// Read esteemed.json
data, err := os.ReadFile(esteemedPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read %s: %v\n", esteemedPath, err)
os.Exit(1)
}
var entries []Entry
if err := json.Unmarshal(data, &entries); err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse JSON: %v\n", err)
os.Exit(1)
}
// Create output directory
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create output directory: %v\n", err)
os.Exit(1)
}
if serpAPIKey != "" {
fmt.Println("SerpAPI key found — will use as fallback for missing Wikipedia images")
} else {
fmt.Println("No SERPAPI_KEY set — Wikipedia only")
}
fmt.Printf("Processing %d entries...\n\n", len(entries))
downloaded := 0
skipped := 0
failed := 0
for i, entry := range entries {
outPath := filepath.Join(outputDir, entry.ID+".jpg")
// Skip if already downloaded
if _, err := os.Stat(outPath); err == nil {
fmt.Printf("[%d/%d] SKIP (exists): %s\n", i+1, len(entries), entry.Name)
skipped++
continue
}
if entry.ImageURL == "" {
fmt.Printf("[%d/%d] SKIP (no URL): %s\n", i+1, len(entries), entry.Name)
skipped++
continue
}
var imageURL string
// Try Wikipedia first
if isWikipediaURL(entry.ImageURL) {
pageName := extractPageName(entry.ImageURL)
if pageName != "" {
var err error
imageURL, err = fetchWikipediaImage(client, pageName)
if err != nil {
fmt.Printf("[%d/%d] WARN (wiki API): %s - %v\n", i+1, len(entries), entry.Name, err)
}
}
} else {
imageURL = entry.ImageURL
}
// Fallback to SerpAPI if no Wikipedia image
if imageURL == "" && serpAPIKey != "" {
// Use explicit search_query if provided, else derive from Wikipedia page name
searchQuery := entry.Name
if entry.SearchQuery != "" {
searchQuery = entry.SearchQuery
} else if isWikipediaURL(entry.ImageURL) {
if pn := extractPageName(entry.ImageURL); pn != "" {
q := strings.ReplaceAll(pn, "_", " ")
q = strings.ReplaceAll(q, "(", "")
q = strings.ReplaceAll(q, ")", "")
q = strings.TrimSpace(q)
if q != "" {
searchQuery = q
}
}
}
var err error
imageURL, err = fetchSerpAPIImage(client, serpAPIKey, searchQuery)
if err != nil {
fmt.Printf("[%d/%d] WARN (serpapi): %s - %v\n", i+1, len(entries), entry.Name, err)
}
if imageURL != "" {
fmt.Printf("[%d/%d] SerpAPI fallback: %s\n", i+1, len(entries), entry.Name)
}
}
if imageURL == "" {
fmt.Printf("[%d/%d] FAIL (no image found): %s\n", i+1, len(entries), entry.Name)
failed++
time.Sleep(requestDelay)
continue
}
if err := downloadImage(client, imageURL, outPath); err != nil {
fmt.Printf("[%d/%d] FAIL (download): %s - %v\n", i+1, len(entries), entry.Name, err)
failed++
} else {
// Convert non-JPEG files (WebP, PNG, etc.) to JPEG via ImageMagick
convertToJPEG(outPath)
fmt.Printf("[%d/%d] OK: %s\n", i+1, len(entries), entry.Name)
downloaded++
}
time.Sleep(requestDelay)
}
fmt.Printf("\nDone. Downloaded: %d, Skipped: %d, Failed: %d\n", downloaded, skipped, failed)
}
func isWikipediaURL(rawURL string) bool {
return strings.Contains(rawURL, "wikipedia.org/wiki/")
}
func extractPageName(wikiURL string) string {
parsed, err := url.Parse(wikiURL)
if err != nil {
return ""
}
const prefix = "/wiki/"
if !strings.HasPrefix(parsed.Path, prefix) {
return ""
}
return parsed.Path[len(prefix):]
}
func fetchWikipediaImage(client *http.Client, pageName string) (string, error) {
apiURL := fmt.Sprintf(
"https://en.wikipedia.org/w/api.php?action=query&titles=%s&prop=pageimages&format=json&pithumbsize=400",
url.PathEscape(pageName),
)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "gogobee-fetch-esteemed/1.0 (bot; educational project)")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var wikiResp WikiResponse
if err := json.NewDecoder(resp.Body).Decode(&wikiResp); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
for _, page := range wikiResp.Query.Pages {
if page.Thumbnail.Source != "" {
return page.Thumbnail.Source, nil
}
}
return "", nil
}
func fetchSerpAPIImage(client *http.Client, apiKey, query string) (string, error) {
params := url.Values{
"engine": {"google_images"},
"q": {query},
"api_key": {apiKey},
"num": {"1"},
}
apiURL := "https://serpapi.com/search.json?" + params.Encode()
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var serpResp SerpAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&serpResp); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
if len(serpResp.ImagesResults) == 0 {
return "", nil
}
// Prefer the original full-size image, fall back to thumbnail
result := serpResp.ImagesResults[0]
if result.Original != "" {
return result.Original, nil
}
return result.Thumbnail, nil
}
func downloadImage(client *http.Client, imageURL, outPath string) error {
req, err := http.NewRequest("GET", imageURL, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "gogobee-fetch-esteemed/1.0 (bot; educational project)")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("downloading: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
// Write to a temp file first, then rename for atomicity
tmpPath := outPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
_, err = io.Copy(f, resp.Body)
f.Close()
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("writing file: %w", err)
}
if err := os.Rename(tmpPath, outPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("renaming file: %w", err)
}
return nil
}
// convertToJPEG checks if the file is actually a JPEG; if not (e.g. WebP, PNG),
// it converts it to JPEG via ImageMagick, flattening any alpha channel.
func convertToJPEG(path string) {
f, err := os.Open(path)
if err != nil {
return
}
// Read first 3 bytes to check for JPEG magic bytes (FF D8 FF)
header := make([]byte, 3)
n, _ := f.Read(header)
f.Close()
if n >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF {
return // already JPEG
}
// Convert via ImageMagick
tmpPath := path + ".conv.jpg"
cmd := exec.Command("convert", path, "-background", "white", "-flatten", tmpPath)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, " convert failed for %s: %v\n", filepath.Base(path), err)
os.Remove(tmpPath)
return
}
if err := os.Rename(tmpPath, path); err != nil {
fmt.Fprintf(os.Stderr, " rename failed for %s: %v\n", filepath.Base(path), err)
os.Remove(tmpPath)
return
}
fmt.Printf(" converted %s to JPEG\n", filepath.Base(path))
}

2373
esteemed.json Normal file

File diff suppressed because it is too large Load Diff

1302
esteemed_villains.json Normal file

File diff suppressed because it is too large Load Diff

BIN
fetch-esteemed Executable file

Binary file not shown.

View File

@@ -376,7 +376,7 @@ CREATE TABLE IF NOT EXISTS birthdays (
month INTEGER NOT NULL, month INTEGER NOT NULL,
day INTEGER NOT NULL, day INTEGER NOT NULL,
year INTEGER DEFAULT 0, year INTEGER DEFAULT 0,
timezone TEXT DEFAULT 'UTC' timezone TEXT DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS birthday_fired ( CREATE TABLE IF NOT EXISTS birthday_fired (

View File

@@ -114,9 +114,8 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "achievements") args := p.GetArgs(ctx.Body, "achievements")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -164,7 +163,7 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }
// buildAchievements returns all 32 achievement definitions. // buildAchievements returns all achievement definitions.
func (p *AchievementsPlugin) buildAchievements() []achievementDef { func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return []achievementDef{ return []achievementDef{
// Message milestones // Message milestones
@@ -196,7 +195,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 100) }, Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 100) },
}, },
{ {
ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 5am and 9am", ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 6am and noon",
Emoji: "🐦", Emoji: "🐦",
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 100) }, Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 100) },
}, },
@@ -411,6 +410,45 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
Emoji: "🏆", Emoji: "🏆",
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 365) }, Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 365) },
}, },
// Profile completeness
{
ID: "birthday_set", Name: "Born to Party", Description: "Set your birthday",
Emoji: "🎂",
Check: func(d *sql.DB, u id.UserID) bool {
var month int
err := d.QueryRow(
`SELECT month FROM birthdays WHERE user_id = ? AND month > 0`,
string(u),
).Scan(&month)
return err == nil && month > 0
},
},
{
ID: "timezone_set", Name: "Time Traveler", Description: "Set your timezone",
Emoji: "🌍",
Check: func(d *sql.DB, u id.UserID) bool {
var tz string
err := d.QueryRow(
`SELECT timezone FROM birthdays WHERE user_id = ? AND timezone != ''`,
string(u),
).Scan(&tz)
return err == nil && tz != ""
},
},
{
ID: "profile_complete", Name: "Identity Established", Description: "Set both birthday and timezone",
Emoji: "🪪",
Check: func(d *sql.DB, u id.UserID) bool {
var month int
var tz string
err := d.QueryRow(
`SELECT month, timezone FROM birthdays WHERE user_id = ? AND month > 0 AND timezone != ''`,
string(u),
).Scan(&month, &tz)
return err == nil && month > 0 && tz != ""
},
},
} }
} }

View File

@@ -95,7 +95,7 @@ func (p *BirthdayPlugin) handleSet(ctx MessageContext, dateStr string) error {
d := db.Get() d := db.Get()
_, err = d.Exec( _, err = d.Exec(
`INSERT INTO birthdays (user_id, month, day, year, timezone) VALUES (?, ?, ?, ?, 'UTC') `INSERT INTO birthdays (user_id, month, day, year) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`, ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`,
string(ctx.Sender), month, day, year, month, day, year, string(ctx.Sender), month, day, year, month, day, year,
) )

418
internal/plugin/esteemed.go Normal file
View File

@@ -0,0 +1,418 @@
package plugin
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"log/slog"
"math/rand/v2"
"os"
"path/filepath"
"strings"
_ "image/jpeg"
_ "golang.org/x/image/webp"
"gogobee/internal/db"
"github.com/fogleman/gg"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// esteemedEntry is a single satirical "esteemed community member" entry.
type esteemedEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
Characteristics []string `json:"distinguishing_characteristics"`
}
// EsteemPlugin posts satirical milk carton missing posters for fictional "esteemed community members."
type EsteemPlugin struct {
Base
entries []esteemedEntry
enabled bool
room id.RoomID
dataDir string
regularFont font.Face
boldFont font.Face
smallFont font.Face
headerFont font.Face
titleFont font.Face
}
// NewEsteemPlugin creates a new esteemed members plugin.
func NewEsteemPlugin(client *mautrix.Client) *EsteemPlugin {
enabled := os.Getenv("FEATURE_ESTEEMED") != ""
roomID := os.Getenv("ESTEEMED_ROOM")
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
p := &EsteemPlugin{
Base: NewBase(client),
enabled: enabled,
room: id.RoomID(roomID),
dataDir: dataDir,
}
if enabled {
p.regularFont = loadFont(goregular.TTF, 15)
p.boldFont = loadFont(gobold.TTF, 16)
p.smallFont = loadFont(goregular.TTF, 12)
p.headerFont = loadFont(gobold.TTF, 14)
p.titleFont = loadFont(gobold.TTF, 20)
}
return p
}
func (p *EsteemPlugin) Name() string { return "esteemed" }
func (p *EsteemPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "esteemed", Description: "Preview a random esteemed member carton (admin only)", Usage: "!esteemed [name]", Category: "Admin", AdminOnly: true},
}
}
func (p *EsteemPlugin) Init() error {
if !p.enabled {
return nil
}
// Load entries from esteemed.json
jsonPath := "esteemed.json"
data, err := os.ReadFile(jsonPath)
if err != nil {
slog.Warn("esteemed: could not load esteemed.json", "err", err)
return nil
}
if err := json.Unmarshal(data, &p.entries); err != nil {
slog.Warn("esteemed: invalid JSON", "err", err)
return nil
}
slog.Info("esteemed: loaded entries", "count", len(p.entries))
return nil
}
func (p *EsteemPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "esteemed") {
return nil
}
if !p.IsAdmin(ctx.Sender) {
return nil
}
if len(p.entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Esteemed members list not loaded.")
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "esteemed"))
var entry esteemedEntry
if args == "" {
// Random entry (ignores dedup — this is a preview)
entry = p.entries[rand.IntN(len(p.entries))]
} else {
// Search by name
query := strings.ToLower(args)
found := false
for _, e := range p.entries {
if strings.Contains(strings.ToLower(e.Name), query) || e.ID == query {
entry = e
found = true
break
}
}
if !found {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No esteemed member matching \"%s\" found.", args))
}
}
imgData, err := p.renderCarton(entry)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Render failed: %v", err))
}
caption := fmt.Sprintf("Yet another one of our esteemed community has gone missing.\nIf found, please return %s to the community immediately.", entry.Name)
return p.SendImage(ctx.RoomID, imgData, "esteemed.png", caption, cartonWidth, cartonHeight)
}
func (p *EsteemPlugin) OnReaction(_ ReactionContext) error { return nil }
// PostWeekly selects an unposted entry, generates a milk carton, and posts it.
// Called by the scheduler once a week.
func (p *EsteemPlugin) PostWeekly() {
if !p.enabled || p.room == "" || len(p.entries) == 0 {
return
}
// Pick an entry that hasn't been posted yet
entry, ok := p.pickEntry()
if !ok {
slog.Info("esteemed: all entries have been posted")
return
}
imgData, err := p.renderCarton(entry)
if err != nil {
slog.Error("esteemed: render failed", "entry", entry.ID, "err", err)
return
}
caption := fmt.Sprintf("Yet another one of our esteemed community has gone missing.\nIf found, please return %s to the community immediately.", entry.Name)
if err := p.SendImage(p.room, imgData, "esteemed.png", caption, cartonWidth, cartonHeight); err != nil {
slog.Error("esteemed: send failed", "entry", entry.ID, "err", err)
return
}
// Mark as posted
db.MarkJobCompleted("esteemed", entry.ID)
slog.Info("esteemed: posted", "entry", entry.ID, "name", entry.Name)
}
func (p *EsteemPlugin) pickEntry() (esteemedEntry, bool) {
// Gather unposted entries
var candidates []esteemedEntry
for _, e := range p.entries {
if !db.JobCompleted("esteemed", e.ID) {
candidates = append(candidates, e)
}
}
if len(candidates) == 0 {
return esteemedEntry{}, false
}
// Pick a random one
return candidates[rand.IntN(len(candidates))], true
}
func (p *EsteemPlugin) loadEntryImage(entry esteemedEntry) image.Image {
imgPath := filepath.Join(p.dataDir, "esteemed", entry.ID+".jpg")
f, err := os.Open(imgPath)
if err != nil {
return nil
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
slog.Warn("esteemed: decode image", "entry", entry.ID, "err", err)
return nil
}
return img
}
func (p *EsteemPlugin) renderCarton(entry esteemedEntry) ([]byte, error) {
dc := gg.NewContext(cartonWidth, cartonHeight)
// Background — cream/off-white
dc.SetColor(color.RGBA{255, 253, 245, 255})
dc.Clear()
// Border
dc.SetColor(color.RGBA{180, 60, 60, 255})
dc.SetLineWidth(4)
dc.DrawRoundedRectangle(8, 8, float64(cartonWidth-16), float64(cartonHeight-16), 12)
dc.Stroke()
// Inner border
dc.SetColor(color.RGBA{200, 80, 80, 255})
dc.SetLineWidth(1.5)
dc.DrawRoundedRectangle(16, 16, float64(cartonWidth-32), float64(cartonHeight-32), 8)
dc.Stroke()
// Header: "ESTEEMED COMMUNITY MEMBER"
if p.headerFont != nil {
dc.SetFontFace(p.headerFont)
}
dc.SetColor(color.RGBA{180, 30, 30, 255})
dc.DrawStringAnchored("ESTEEMED COMMUNITY MEMBER", float64(cartonWidth)/2, 40, 0.5, 0.5)
// Sub-header: "MISSING"
if p.titleFont != nil {
dc.SetFontFace(p.titleFont)
}
dc.DrawStringAnchored("MISSING", float64(cartonWidth)/2, 66, 0.5, 0.5)
// Photo area
photoX := float64(cartonWidth)/2 - float64(photoSize)/2
photoY := 85.0
// Photo border
dc.SetColor(color.RGBA{160, 50, 50, 255})
dc.SetLineWidth(2)
dc.DrawRectangle(photoX-3, photoY-3, float64(photoSize)+6, float64(photoSize)+6)
dc.Stroke()
// Photo background
dc.SetColor(color.RGBA{230, 225, 215, 255})
dc.DrawRectangle(photoX, photoY, float64(photoSize), float64(photoSize))
dc.Fill()
avatar := p.loadEntryImage(entry)
if avatar != nil {
avatarResized := resizeImage(avatar, photoSize, photoSize)
dc.DrawImage(avatarResized, int(photoX), int(photoY))
} else {
// Silhouette for missing images
drawSilhouette(dc, photoX, photoY, float64(photoSize))
}
// Name
yPos := photoY + float64(photoSize) + 25
if p.boldFont != nil {
dc.SetFontFace(p.boldFont)
}
dc.SetColor(color.RGBA{40, 40, 40, 255})
name := entry.Name
if len(name) > 30 {
name = name[:27] + "..."
}
dc.DrawStringAnchored(name, float64(cartonWidth)/2, yPos, 0.5, 0.5)
// Category
yPos += 20
if p.smallFont != nil {
dc.SetFontFace(p.smallFont)
}
dc.SetColor(color.RGBA{120, 100, 100, 255})
catDisplay := formatCategory(entry.Category)
dc.DrawStringAnchored(catDisplay, float64(cartonWidth)/2, yPos, 0.5, 0.5)
// "Last seen" with a random funny timeframe
yPos += 24
if p.regularFont != nil {
dc.SetFontFace(p.regularFont)
}
dc.SetColor(color.RGBA{180, 30, 30, 255})
lastSeen := randomLastSeen()
dc.DrawStringAnchored(fmt.Sprintf("Last seen: %s", lastSeen), float64(cartonWidth)/2, yPos, 0.5, 0.5)
// Divider
yPos += 18
dc.SetColor(color.RGBA{200, 80, 80, 180})
dc.SetLineWidth(1)
dc.DrawLine(40, yPos, float64(cartonWidth)-40, yPos)
dc.Stroke()
// Characteristics header
yPos += 18
if p.boldFont != nil {
dc.SetFontFace(p.boldFont)
}
dc.SetColor(color.RGBA{60, 60, 60, 255})
dc.DrawStringAnchored("Distinguishing Characteristics", float64(cartonWidth)/2, yPos, 0.5, 0.5)
// Characteristics
yPos += 6
if p.smallFont != nil {
dc.SetFontFace(p.smallFont)
}
dc.SetColor(color.RGBA{80, 80, 80, 255})
maxItems := 4
if len(entry.Characteristics) < maxItems {
maxItems = len(entry.Characteristics)
}
maxWidth := float64(cartonWidth) - 60 // padding on each side
for i := 0; i < maxItems; i++ {
wrapped := dc.WordWrap(entry.Characteristics[i], maxWidth)
for _, wline := range wrapped {
yPos += 16
if yPos > float64(cartonHeight)-55 {
break
}
dc.DrawStringAnchored(wline, float64(cartonWidth)/2, yPos, 0.5, 0.5)
}
}
// Footer
if p.smallFont != nil {
dc.SetFontFace(p.smallFont)
}
dc.SetColor(color.RGBA{140, 50, 50, 255})
dc.DrawStringAnchored("If found, please return to our", float64(cartonWidth)/2, float64(cartonHeight)-48, 0.5, 0.5)
dc.DrawStringAnchored("community for \"cuddles\"", float64(cartonWidth)/2, float64(cartonHeight)-34, 0.5, 0.5)
// Encode
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func formatCategory(cat string) string {
replacer := strings.NewReplacer(
"tech_grifters_and_crypto", "Tech & Crypto Division",
"politicians", "Political Affairs Bureau",
"corporate_villains", "Corporate Relations Dept.",
"reality_tv_royalty", "Entertainment Division",
"internet_infamous", "Digital Community Outreach",
"fictional_characters", "Literary & Media Wing",
"historical_figures", "Heritage Society",
)
result := replacer.Replace(cat)
if result == cat {
// Fallback: replace underscores and capitalize first letter
s := strings.ReplaceAll(cat, "_", " ")
if len(s) > 0 {
return strings.ToUpper(s[:1]) + s[1:]
}
return s
}
return result
}
func randomLastSeen() string {
options := []string{
"fleeing the group chat",
"updating their LinkedIn bio",
"somewhere near a microphone",
"posting without reading the room",
"starting a new venture",
"giving unsolicited advice",
"somewhere on the timeline",
"rebranding after the incident",
"in the replies, unfortunately",
"near a camera crew",
"drafting a press release",
"pivoting to their next opportunity",
"explaining why it was taken out of context",
"launching a podcast",
"signing autographs for no one",
}
return options[rand.IntN(len(options))]
}
// drawSilhouette draws a generic person silhouette. Extracted as a shared helper.
func drawSilhouette(dc *gg.Context, x, y, size float64) {
cx := x + size/2
cy := y + size/2
dc.SetColor(color.RGBA{170, 165, 155, 255})
// Head
headRadius := size * 0.18
dc.DrawCircle(cx, cy-size*0.12, headRadius)
dc.Fill()
// Body
dc.DrawEllipse(cx, cy+size*0.22, size*0.28, size*0.25)
dc.Fill()
}

View File

@@ -115,7 +115,7 @@ func (p *FunPlugin) Commands() []CommandDef {
{Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"}, {Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"},
{Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", Category: "Fun & Games"}, {Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", Category: "Fun & Games"},
{Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"}, {Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"},
{Name: "time", Description: "World clock", Usage: "!time [city]", Category: "Lookup & Reference"}, {Name: "time", Description: "World clock or user's local time", Usage: "!time [city|@user]", Category: "Lookup & Reference"},
{Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"}, {Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"},
{Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"}, {Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"},
{Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"}, {Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"},
@@ -229,7 +229,7 @@ func (p *FunPlugin) handleCoin(ctx MessageContext) error {
} }
func (p *FunPlugin) handleTime(ctx MessageContext) error { func (p *FunPlugin) handleTime(ctx MessageContext) error {
args := strings.TrimSpace(strings.ToLower(p.GetArgs(ctx.Body, "time"))) args := strings.TrimSpace(p.GetArgs(ctx.Body, "time"))
if args == "" { if args == "" {
// Show a few major cities // Show a few major cities
cities := []string{"new york", "london", "tokyo", "sydney"} cities := []string{"new york", "london", "tokyo", "sydney"}
@@ -240,23 +240,55 @@ func (p *FunPlugin) handleTime(ctx MessageContext) error {
t := time.Now().In(tz) t := time.Now().In(tz)
sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST"))) sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST")))
} }
sb.WriteString("\nUse !time <city> for a specific location.") sb.WriteString("\nUse !time <city> or !time <@user> for a specific location or person.")
return p.SendMessage(ctx.RoomID, sb.String()) return p.SendMessage(ctx.RoomID, sb.String())
} }
tzName, ok := cityTimezones[args] // If it looks like a Matrix user ID, go straight to user lookup
if !ok { if strings.HasPrefix(args, "@") && strings.Contains(args, ":") {
// Try loading it as a raw IANA zone return p.showUserTime(ctx, args)
tzName = args
} }
loc, err := time.LoadLocation(tzName) // Try city map first (cheap, deterministic lookup — avoids username collisions)
argsLower := strings.ToLower(args)
if tzName, ok := cityTimezones[argsLower]; ok {
loc, _ := time.LoadLocation(tzName)
t := time.Now().In(loc)
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", titleCase(argsLower), t.Format("Monday, January 2, 2006 3:04 PM MST")))
}
// Try raw IANA timezone (preserve original case — IANA names are case-sensitive)
if loc, err := time.LoadLocation(args); err == nil {
t := time.Now().In(loc)
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST")))
}
// Fall back to user lookup
return p.showUserTime(ctx, args)
}
func (p *FunPlugin) showUserTime(ctx MessageContext, input string) error {
resolved, ok := p.ResolveUser(input)
if !ok {
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city, timezone, or user: %s", input))
}
d := db.Get()
var tz string
err := d.QueryRow(`SELECT timezone FROM birthdays WHERE user_id = ?`, string(resolved)).Scan(&tz)
if err != nil || tz == "" {
return p.SendMessage(ctx.RoomID,
fmt.Sprintf("%s hasn't set their timezone yet. They can use !settz <timezone> to set it.", string(resolved)))
}
loc, err := time.LoadLocation(tz)
if err != nil { if err != nil {
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city or timezone: %s", args)) return p.SendMessage(ctx.RoomID, fmt.Sprintf("Invalid timezone stored for %s: %s", string(resolved), tz))
} }
t := time.Now().In(loc) t := time.Now().In(loc)
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST"))) return p.SendMessage(ctx.RoomID,
fmt.Sprintf("🕐 %s: **%s** (%s)", string(resolved), t.Format("Monday, January 2, 2006 3:04 PM"), tz))
} }
func (p *FunPlugin) handleHLTB(ctx MessageContext) error { func (p *FunPlugin) handleHLTB(ctx MessageContext) error {

View File

@@ -0,0 +1,216 @@
package plugin
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// zodiacSign maps month/day to a zodiac sign name.
func zodiacSign(month, day int) string {
switch {
case (month == 3 && day >= 21) || (month == 4 && day <= 19):
return "aries"
case (month == 4 && day >= 20) || (month == 5 && day <= 20):
return "taurus"
case (month == 5 && day >= 21) || (month == 6 && day <= 20):
return "gemini"
case (month == 6 && day >= 21) || (month == 7 && day <= 22):
return "cancer"
case (month == 7 && day >= 23) || (month == 8 && day <= 22):
return "leo"
case (month == 8 && day >= 23) || (month == 9 && day <= 22):
return "virgo"
case (month == 9 && day >= 23) || (month == 10 && day <= 22):
return "libra"
case (month == 10 && day >= 23) || (month == 11 && day <= 21):
return "scorpio"
case (month == 11 && day >= 22) || (month == 12 && day <= 21):
return "sagittarius"
case (month == 12 && day >= 22) || (month == 1 && day <= 19):
return "capricorn"
case (month == 1 && day >= 20) || (month == 2 && day <= 18):
return "aquarius"
case (month == 2 && day >= 19) || (month == 3 && day <= 20):
return "pisces"
}
return ""
}
var zodiacEmoji = map[string]string{
"aries": "♈",
"taurus": "♉",
"gemini": "♊",
"cancer": "♋",
"leo": "♌",
"virgo": "♍",
"libra": "♎",
"scorpio": "♏",
"sagittarius": "♐",
"capricorn": "♑",
"aquarius": "♒",
"pisces": "♓",
}
// HoroscopePlugin provides daily horoscope readings based on user birthdays.
type HoroscopePlugin struct {
Base
}
// NewHoroscopePlugin creates a new HoroscopePlugin.
func NewHoroscopePlugin(client *mautrix.Client) *HoroscopePlugin {
return &HoroscopePlugin{
Base: NewBase(client),
}
}
func (p *HoroscopePlugin) Name() string { return "horoscope" }
func (p *HoroscopePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "horoscope", Description: "Get your daily horoscope (requires birthday)", Usage: "!horoscope", Category: "Fun & Games"},
}
}
func (p *HoroscopePlugin) Init() error { return nil }
func (p *HoroscopePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *HoroscopePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "horoscope") {
return p.handleHoroscope(ctx)
}
return nil
}
func (p *HoroscopePlugin) handleHoroscope(ctx MessageContext) error {
// Check if user has a birthday set
d := db.Get()
var month, day int
err := d.QueryRow(
`SELECT month, day FROM birthdays WHERE user_id = ? AND month > 0 AND day > 0`,
string(ctx.Sender),
).Scan(&month, &day)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID,
"You need to set your birthday first! Use !birthday set <MM-DD> to get your horoscope.")
}
sign := zodiacSign(month, day)
if sign == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not determine your zodiac sign. Check your birthday with !birthday show.")
}
// Check cache (horoscopes are daily, cache for 6 hours)
cacheKey := fmt.Sprintf("horoscope:%s", sign)
if cached := db.CacheGet(cacheKey, 21600); cached != "" {
emoji := zodiacEmoji[sign]
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), cached))
}
// Fetch from API
horoscope, err := fetchHoroscope(sign)
if err != nil {
slog.Error("horoscope: fetch failed", "err", err, "sign", sign)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your horoscope. Try again later.")
}
// Cache the result
db.CacheSet(cacheKey, horoscope)
emoji := zodiacEmoji[sign]
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), horoscope))
}
func fetchHoroscope(sign string) (string, error) {
url := fmt.Sprintf("https://freehoroscopeapi.com/api/v1/get-horoscope/daily?sign=%s", sign)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
var result struct {
Data struct {
Horoscope string `json:"horoscope"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if result.Data.Horoscope == "" {
return "", fmt.Errorf("empty horoscope in response")
}
return strings.TrimSpace(result.Data.Horoscope), nil
}
// PostDailyHoroscopes posts all 12 horoscopes to a room as a daily digest.
func (p *HoroscopePlugin) PostDailyHoroscopes(roomID id.RoomID) {
dateKey := time.Now().UTC().Format("2006-01-02")
if db.JobCompleted("horoscope", dateKey+":"+string(roomID)) {
return
}
signs := []string{
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Daily Horoscopes — %s\n\n", time.Now().UTC().Format("Monday, January 2")))
fetched := 0
for _, sign := range signs {
horoscope, err := fetchHoroscope(sign)
if err != nil {
slog.Error("horoscope: daily fetch", "sign", sign, "err", err)
continue
}
// Cache each one
db.CacheSet(fmt.Sprintf("horoscope:%s", sign), horoscope)
emoji := zodiacEmoji[sign]
sb.WriteString(fmt.Sprintf("%s **%s**\n%s\n\n", emoji, titleCase(sign), horoscope))
fetched++
}
if fetched == 0 {
slog.Error("horoscope: no signs fetched for daily post")
return
}
sb.WriteString("Set your birthday with !birthday set <MM-DD> to get personalized readings!")
if err := p.SendMessage(roomID, sb.String()); err != nil {
slog.Error("horoscope: post daily", "room", roomID, "err", err)
return
}
db.MarkJobCompleted("horoscope", dateKey+":"+string(roomID))
}

View File

@@ -56,9 +56,8 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami")) args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami"))
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }

View File

@@ -50,6 +50,7 @@ type queueItem struct {
RoomID id.RoomID RoomID id.RoomID
EventID id.EventID EventID id.EventID
Body string Body string
FormattedBody string
} }
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly. // LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
@@ -150,19 +151,32 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
} }
// Pre-filter: only classify messages that match certain criteria // Pre-filter: only classify messages that match certain criteria
if !p.shouldClassify(ctx.Body) { var fmtBody string
if ctx.Event != nil {
if mc := ctx.Event.Content.AsMessage(); mc != nil {
fmtBody = mc.FormattedBody
}
}
if !p.shouldClassify(ctx.Body, fmtBody) {
slog.Debug("llm_passive: message did not pass pre-filter", "body_len", len(ctx.Body)) slog.Debug("llm_passive: message did not pass pre-filter", "body_len", len(ctx.Body))
return nil return nil
} }
// Enqueue for async classification // Enqueue for async classification
slog.Debug("llm_passive: enqueuing message for classification", "user", ctx.Sender, "body_len", len(ctx.Body)) slog.Debug("llm_passive: enqueuing message for classification", "user", ctx.Sender, "body_len", len(ctx.Body))
var formattedBody string
if ctx.Event != nil {
if mc := ctx.Event.Content.AsMessage(); mc != nil {
formattedBody = mc.FormattedBody
}
}
p.mu.Lock() p.mu.Lock()
p.queue = append(p.queue, queueItem{ p.queue = append(p.queue, queueItem{
UserID: ctx.Sender, UserID: ctx.Sender,
RoomID: ctx.RoomID, RoomID: ctx.RoomID,
EventID: ctx.EventID, EventID: ctx.EventID,
Body: ctx.Body, Body: ctx.Body,
FormattedBody: formattedBody,
}) })
p.mu.Unlock() p.mu.Unlock()
@@ -170,7 +184,7 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
} }
// shouldClassify applies pre-filtering heuristics. // shouldClassify applies pre-filtering heuristics.
func (p *LLMPassivePlugin) shouldClassify(body string) bool { func (p *LLMPassivePlugin) shouldClassify(body, formattedBody string) bool {
// Skip single-character messages (trivia answers, etc.) // Skip single-character messages (trivia answers, etc.)
if len(strings.TrimSpace(body)) <= 1 { if len(strings.TrimSpace(body)) <= 1 {
return false return false
@@ -185,10 +199,13 @@ func (p *LLMPassivePlugin) shouldClassify(body string) bool {
} }
} }
// Check mentions // Check mentions (plain text or HTML formatted body)
if mentionRe.MatchString(body) { if mentionRe.MatchString(body) {
return true return true
} }
if strings.Contains(formattedBody, "matrix.to/#/@") {
return true
}
// Check WOTD pattern (simple heuristic) // Check WOTD pattern (simple heuristic)
if strings.Contains(lower, "wotd") { if strings.Contains(lower, "wotd") {
@@ -255,13 +272,56 @@ func (p *LLMPassivePlugin) processQueue() {
} }
} }
// extractMentionMap builds a display-name-to-MXID mapping from the HTML formatted body.
func extractMentionMap(formattedBody string) map[string]string {
if formattedBody == "" {
return nil
}
// Match: <a href="https://matrix.to/#/@user:server">Display Name</a>
re := regexp.MustCompile(`<a\s+href="https://matrix\.to/#/(@[^"]+)">([^<]+)</a>`)
matches := re.FindAllStringSubmatch(formattedBody, -1)
if len(matches) == 0 {
return nil
}
result := make(map[string]string)
for _, m := range matches {
if len(m) >= 3 {
result[m[2]] = m[1] // display name -> MXID
}
}
return result
}
// classifyAndProcess sends a message to Ollama and processes the result. // classifyAndProcess sends a message to Ollama and processes the result.
func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
result, err := p.callOllama(item.Body) // Build mention context so the LLM knows actual MXIDs
mentionMap := extractMentionMap(item.FormattedBody)
var mentionHint string
if len(mentionMap) > 0 {
var parts []string
for displayName, mxid := range mentionMap {
parts = append(parts, fmt.Sprintf("%s = %s", displayName, mxid))
}
mentionHint = "\nMentioned users: " + strings.Join(parts, ", ")
}
result, err := p.callOllama(item.Body + mentionHint)
if err != nil { if err != nil {
return fmt.Errorf("ollama call: %w", err) return fmt.Errorf("ollama call: %w", err)
} }
// Resolve any display names in LLM targets back to MXIDs
if result.InsultTarget != "" && !strings.Contains(result.InsultTarget, ":") {
if mxid, ok := mentionMap[result.InsultTarget]; ok {
result.InsultTarget = mxid
}
}
if result.GratitudeTarget != "" && !strings.Contains(result.GratitudeTarget, ":") {
if mxid, ok := mentionMap[result.GratitudeTarget]; ok {
result.GratitudeTarget = mxid
}
}
d := db.Get() d := db.Get()
// Store classification // Store classification
@@ -336,7 +396,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
"\U0001f52a", // 🔪 "\U0001f52a", // 🔪
"\U0001f5e1", // 🗡️ "\U0001f5e1", // 🗡️
"\U0001fa78", // 🩸 "\U0001fa78", // 🩸
"\U0001f480", // 💀 "\U0001f4a3", // 💣
} }
emoji := botReactions[rand.Intn(len(botReactions))] emoji := botReactions[rand.Intn(len(botReactions))]
_ = p.SendReact(item.RoomID, item.EventID, emoji) _ = p.SendReact(item.RoomID, item.EventID, emoji)
@@ -519,9 +579,8 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "potty") args := p.GetArgs(ctx.Body, "potty")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -542,9 +601,11 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
} }
func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error { func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
rows, err := d.Query( rows, err := d.Query(
`SELECT user_id, count FROM potty_mouth ORDER BY count DESC LIMIT 10`, `SELECT user_id, count FROM potty_mouth ORDER BY count DESC`,
) )
if err != nil { if err != nil {
slog.Error("llm: pottyboard query", "err", err) slog.Error("llm: pottyboard query", "err", err)
@@ -557,12 +618,15 @@ func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"} medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var count int var count int
if err := rows.Scan(&userID, &count); err != nil { if err := rows.Scan(&userID, &count); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]
@@ -582,9 +646,8 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "insults") args := p.GetArgs(ctx.Body, "insults")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -605,9 +668,11 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
} }
func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error { func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
rows, err := d.Query( rows, err := d.Query(
`SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC LIMIT 10`, `SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC`,
) )
if err != nil { if err != nil {
slog.Error("llm: insultboard query", "err", err) slog.Error("llm: insultboard query", "err", err)
@@ -620,12 +685,15 @@ func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"} medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var count int var count int
if err := rows.Scan(&userID, &count); err != nil { if err := rows.Scan(&userID, &count); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]
@@ -645,9 +713,8 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "sentiment") args := p.GetArgs(ctx.Body, "sentiment")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }

View File

@@ -100,8 +100,9 @@ func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
targetUser = ctx.Sender targetUser = ctx.Sender
default: default:
// Treat as user ID // Treat as user ID
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
targetUser = id.UserID(cleaned) targetUser = resolved
}
} }
// Fetch corpus for the user // Fetch corpus for the user

View File

@@ -151,9 +151,9 @@ func (p *MilkCartonPlugin) handleHaveYouSeenThem(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !haveyouseenthem @user") return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !haveyouseenthem @user")
} }
targetID := id.UserID(strings.TrimPrefix(strings.TrimSpace(args), "@")) targetID, ok := p.ResolveUser(args)
if !strings.Contains(string(targetID), ":") { if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide a full Matrix user ID (e.g. @user:server.com)") return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find a user matching that name.")
} }
// Rate limit: 1 carton per room per day // Rate limit: 1 carton per room per day
@@ -414,7 +414,7 @@ func (p *MilkCartonPlugin) renderCarton(
dc.DrawImage(avatarResized, int(photoX), int(photoY)) dc.DrawImage(avatarResized, int(photoX), int(photoY))
} else { } else {
// Draw silhouette // Draw silhouette
p.drawSilhouette(dc, photoX, photoY, float64(photoSize)) drawSilhouette(dc, photoX, photoY, float64(photoSize))
} }
// Name // Name
@@ -503,22 +503,6 @@ func (p *MilkCartonPlugin) renderCarton(
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func (p *MilkCartonPlugin) drawSilhouette(dc *gg.Context, x, y, size float64) {
cx := x + size/2
cy := y + size/2
dc.SetColor(color.RGBA{170, 165, 155, 255})
// Head
headRadius := size * 0.18
dc.DrawCircle(cx, cy-size*0.12, headRadius)
dc.Fill()
// Body
dc.DrawEllipse(cx, cy+size*0.22, size*0.28, size*0.25)
dc.Fill()
}
// deriveCharacteristics generates flavor text from user stats. // deriveCharacteristics generates flavor text from user stats.
func (p *MilkCartonPlugin) deriveCharacteristics(userID string) []string { func (p *MilkCartonPlugin) deriveCharacteristics(userID string) []string {
d := db.Get() d := db.Get()
@@ -613,11 +597,11 @@ func resizeImage(src image.Image, targetW, targetH int) image.Image {
dc := gg.NewContext(targetW, targetH) dc := gg.NewContext(targetW, targetH)
dc.DrawImage(src, (targetW-newW)/2, (targetH-newH)/2) dc.DrawImage(src, (targetW-newW)/2, (targetH-newH)/2)
// Use gg's built-in scaling // Use gg's built-in scaling — anchor to top so heads aren't cropped
dcScaled := gg.NewContext(targetW, targetH) dcScaled := gg.NewContext(targetW, targetH)
dcScaled.Scale(scale, scale) dcScaled.Scale(scale, scale)
offsetX := -float64(srcW)/2 + float64(targetW)/(2*scale) offsetX := -float64(srcW)/2 + float64(targetW)/(2*scale)
offsetY := -float64(srcH)/2 + float64(targetH)/(2*scale) offsetY := 0.0 // top-anchored crop
dcScaled.DrawImage(src, int(offsetX), int(offsetY)) dcScaled.DrawImage(src, int(offsetX), int(offsetY))
return dcScaled.Image() return dcScaled.Image()

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"strings" "strings"
"gogobee/internal/db"
"gogobee/internal/util" "gogobee/internal/util"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
@@ -87,6 +88,93 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
return false return false
} }
// RoomMembers returns the set of user IDs in a room. Used to scope leaderboards
// to only show users present in the room where the command was issued.
func (b *Base) RoomMembers(roomID id.RoomID) map[id.UserID]bool {
resp, err := b.Client.JoinedMembers(context.Background(), roomID)
if err != nil {
slog.Error("failed to get room members", "room", roomID, "err", err)
return nil
}
members := make(map[id.UserID]bool, len(resp.Joined))
for uid := range resp.Joined {
members[uid] = true
}
return members
}
// ResolveUser resolves a partial username to a full Matrix user ID.
// Accepts any of: "@user:server", "user:server", "user", or partial match.
// Returns the matched user ID and true, or empty and false if no unique match.
func (b *Base) ResolveUser(input string) (id.UserID, bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", false
}
// If it already looks like a full Matrix ID, use it directly
if strings.Contains(input, ":") {
if !strings.HasPrefix(input, "@") {
input = "@" + input
}
return id.UserID(input), true
}
// Strip leading @ for the search
query := strings.TrimPrefix(input, "@")
query = strings.ToLower(query)
d := db.Get()
rows, err := d.Query(`SELECT user_id FROM user_stats`)
if err != nil {
return "", false
}
defer rows.Close()
var exact []string
var partial []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
// Extract localpart: @localpart:server -> localpart
localpart := uid
if strings.HasPrefix(localpart, "@") {
localpart = localpart[1:]
}
if idx := strings.Index(localpart, ":"); idx > 0 {
localpart = localpart[:idx]
}
lower := strings.ToLower(localpart)
if lower == query {
exact = append(exact, uid)
} else if strings.Contains(lower, query) {
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
return id.UserID(exact[0]), true
}
// Single partial match
if len(partial) == 1 {
return id.UserID(partial[0]), true
}
return "", false
}
// SendMessage sends a plain text message to a room. // SendMessage sends a plain text message to a room.
func (b *Base) SendMessage(roomID id.RoomID, text string) error { func (b *Base) SendMessage(roomID id.RoomID, text string) error {
content := &event.MessageEventContent{ content := &event.MessageEventContent{
@@ -229,6 +317,7 @@ func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption str
content := &event.MessageEventContent{ content := &event.MessageEventContent{
MsgType: event.MsgImage, MsgType: event.MsgImage,
Body: caption, Body: caption,
FileName: filename,
URL: uri.CUString(), URL: uri.CUString(),
Info: &event.FileInfo{ Info: &event.FileInfo{
MimeType: "image/png", MimeType: "image/png",

View File

@@ -9,7 +9,6 @@ import (
"gogobee/internal/db" "gogobee/internal/db"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
) )
// PresencePlugin handles away status and user profile lookups. // PresencePlugin handles away status and user profile lookups.
@@ -128,10 +127,13 @@ func (p *PresencePlugin) autoClearAway(ctx MessageContext) error {
func (p *PresencePlugin) handleWhois(ctx MessageContext) error { func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois")) args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
if args == "" { if args == "" {
return p.SendMessage(ctx.RoomID, "Usage: !whois @user:server") return p.SendMessage(ctx.RoomID, "Usage: !whois <user>")
} }
targetUser := id.UserID(args) targetUser, ok := p.ResolveUser(args)
if !ok {
return p.SendMessage(ctx.RoomID, "Could not find a user matching that name.")
}
// Gather profile data // Gather profile data
var displayName string var displayName string
@@ -183,12 +185,13 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
} }
} }
// Get reputation (from XP log with reason = 'rep') // Get reputation (from XP log with reason = 'reputation')
var repCount int var repXP int
_ = db.Get().QueryRow( _ = db.Get().QueryRow(
`SELECT COUNT(*) FROM xp_log WHERE user_id = ? AND reason = 'rep'`, `SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
string(targetUser), string(targetUser),
).Scan(&repCount) ).Scan(&repXP)
repCount := repXP / 5
// Get presence status // Get presence status
var status, statusMsg string var status, statusMsg string

View File

@@ -77,34 +77,36 @@ func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventI
} }
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error { func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
var sb strings.Builder var sb strings.Builder
// Top 10 emoji givers // Top 10 emoji givers
sb.WriteString("--- Top 10 Emoji Givers ---\n\n") sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
rows, err := d.Query( rows, err := d.Query(
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC LIMIT 10`, `SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC`,
) )
if err != nil { if err != nil {
slog.Error("reactions: givers query", "err", err) slog.Error("reactions: givers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.") return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
} }
giverCount := appendUserBoard(&sb, rows) giverCount := appendUserBoard(&sb, rows, members)
rows.Close() rows.Close()
// Top 10 emoji receivers // Top 10 emoji receivers
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n") sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
rows, err = d.Query( rows, err = d.Query(
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC LIMIT 10`, `SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC`,
) )
if err != nil { if err != nil {
slog.Error("reactions: receivers query", "err", err) slog.Error("reactions: receivers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.") return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
} }
receiverCount := appendUserBoard(&sb, rows) receiverCount := appendUserBoard(&sb, rows, members)
rows.Close() rows.Close()
// Top 10 most used emojis // Top 10 most used emojis (no user filtering needed)
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n") sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
rows, err = d.Query( rows, err = d.Query(
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`, `SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
@@ -123,16 +125,19 @@ func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }
// appendUserBoard writes ranked user lines from query rows. // appendUserBoard writes ranked user lines from query rows, filtered by room members.
func appendUserBoard(sb *strings.Builder, rows *sql.Rows) int { func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var name string var name string
var cnt int var cnt int
if err := rows.Scan(&name, &cnt); err != nil { if err := rows.Scan(&name, &cnt); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(name)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]

View File

@@ -16,9 +16,13 @@ import (
var thankRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm)\b`) var thankRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm)\b`)
// userMentionRe matches Matrix user IDs like @user:server.tld // userMentionRe matches Matrix user IDs like @user:server.tld in plain text.
var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`) var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
// matrixToMentionRe extracts Matrix user IDs from HTML formatted_body mentions.
// Element and most clients format mentions as: <a href="https://matrix.to/#/@user:server">Name</a>
var matrixToMentionRe = regexp.MustCompile(`https://matrix\.to/#/(@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+)`)
// ReputationPlugin tracks gratitude and awards reputation XP. // ReputationPlugin tracks gratitude and awards reputation XP.
type ReputationPlugin struct { type ReputationPlugin struct {
Base Base
@@ -63,8 +67,8 @@ func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
} }
func (p *ReputationPlugin) handleThank(ctx MessageContext) error { func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
// Find mentioned users // Find mentioned users from both plain text and HTML formatted body
mentions := userMentionRe.FindAllString(ctx.Body, -1) mentions := extractMentions(ctx)
if len(mentions) == 0 { if len(mentions) == 0 {
return nil return nil
} }
@@ -125,9 +129,8 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "rep") args := p.GetArgs(ctx.Body, "rep")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -150,11 +153,13 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
} }
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error { func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
rows, err := d.Query( rows, err := d.Query(
`SELECT user_id, SUM(amount) as total `SELECT user_id, SUM(amount) as total
FROM xp_log WHERE reason = 'reputation' FROM xp_log WHERE reason = 'reputation'
GROUP BY user_id ORDER BY total DESC LIMIT 10`, GROUP BY user_id ORDER BY total DESC`,
) )
if err != nil { if err != nil {
slog.Error("rep: repboard query", "err", err) slog.Error("rep: repboard query", "err", err)
@@ -167,12 +172,15 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var totalXP int var totalXP int
if err := rows.Scan(&userID, &totalXP); err != nil { if err := rows.Scan(&userID, &totalXP); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
repCount := totalXP / 5 repCount := totalXP / 5
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
@@ -188,3 +196,33 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }
// extractMentions pulls Matrix user IDs from both the plain text body and
// the HTML formatted_body (where most clients put actual @user:server mentions).
func extractMentions(ctx MessageContext) []string {
seen := make(map[string]bool)
var result []string
// Check plain text body for raw @user:server mentions
for _, m := range userMentionRe.FindAllString(ctx.Body, -1) {
if !seen[m] {
seen[m] = true
result = append(result, m)
}
}
// Check HTML formatted_body for matrix.to mention links
if ctx.Event != nil {
content := ctx.Event.Content.AsMessage()
if content != nil && content.FormattedBody != "" {
for _, match := range matrixToMentionRe.FindAllStringSubmatch(content.FormattedBody, -1) {
if len(match) > 1 && !seen[match[1]] {
seen[match[1]] = true
result = append(result, match[1])
}
}
}
}
return result
}

View File

@@ -166,9 +166,8 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "stats") args := p.GetArgs(ctx.Body, "stats")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -235,10 +234,12 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis") return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
} }
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
// Using fmt.Sprintf for column name is safe here since we control the value above // Using fmt.Sprintf for column name is safe here since we control the value above
query := fmt.Sprintf( query := fmt.Sprintf(
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC LIMIT 10`, `SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC`,
column, column, column, column, column, column,
) )
rows, err := d.Query(query) rows, err := d.Query(query)
@@ -253,12 +254,15 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var val int var val int
if err := rows.Scan(&userID, &val); err != nil { if err := rows.Scan(&userID, &val); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]

View File

@@ -91,9 +91,8 @@ func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "streak") args := p.GetArgs(ctx.Body, "streak")
if args != "" { if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) if resolved, ok := p.ResolveUser(args); ok {
if cleaned != "" { target = resolved
target = id.UserID(cleaned)
} }
} }
@@ -202,14 +201,15 @@ func calculateStreaks(dates []string) (current int, longest int) {
} }
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error { func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
rows, err := d.Query( rows, err := d.Query(
`SELECT user_id, COUNT(*) as wins `SELECT user_id, COUNT(*) as wins
FROM daily_first FROM daily_first
GROUP BY user_id GROUP BY user_id
ORDER BY wins DESC ORDER BY wins DESC`,
LIMIT 10`,
) )
if err != nil { if err != nil {
slog.Error("streaks: firstboard query", "err", err) slog.Error("streaks: firstboard query", "err", err)
@@ -222,12 +222,15 @@ func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var wins int var wins int
if err := rows.Scan(&userID, &wins); err != nil { if err := rows.Scan(&userID, &wins); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]

View File

@@ -62,6 +62,11 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
} }
for _, u := range urls { for _, u := range urls {
// Skip Matrix internal links (user mentions, room links, etc.)
if strings.Contains(u, "matrix.to/") {
continue
}
title, desc, err := p.fetchPreview(u) title, desc, err := p.fetchPreview(u)
if err != nil { if err != nil {
slog.Debug("urls: fetch preview failed", "url", u, "err", err) slog.Debug("urls: fetch preview failed", "url", u, "err", err)

View File

@@ -158,10 +158,8 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
target := ctx.Sender target := ctx.Sender
args := p.GetArgs(ctx.Body, "rank") args := p.GetArgs(ctx.Body, "rank")
if args != "" { if args != "" {
// Trim optional @ prefix and whitespace if resolved, ok := p.ResolveUser(args); ok {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@")) target = resolved
if cleaned != "" {
target = id.UserID(cleaned)
} }
} }
@@ -183,9 +181,9 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
needed := xpForNext - xpForCurrent needed := xpForNext - xpForCurrent
bar := util.ProgressBar(progress, needed, 20) bar := util.ProgressBar(progress, needed, 20)
// Get rank position // Get rank position (users with same XP share the same rank)
var rank int var rank int
err = d.QueryRow(`SELECT COUNT(*) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank) err = d.QueryRow(`SELECT COUNT(DISTINCT xp) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
if err != nil { if err != nil {
rank = 0 rank = 0
} }
@@ -199,8 +197,10 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
} }
func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error { func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get() d := db.Get()
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC LIMIT 10`) rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC`)
if err != nil { if err != nil {
slog.Error("xp: leaderboard query", "err", err) slog.Error("xp: leaderboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.") return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
@@ -212,12 +212,15 @@ func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
i := 0 i := 0
for rows.Next() { for rows.Next() && i < 10 {
var userID string var userID string
var xp, level int var xp, level int
if err := rows.Scan(&userID, &xp, &level); err != nil { if err := rows.Scan(&userID, &xp, &level); err != nil {
continue continue
} }
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1) prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) { if i < len(medals) {
prefix = medals[i] prefix = medals[i]

26
main.go
View File

@@ -124,6 +124,14 @@ func main() {
birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin) birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin)
registry.Register(birthdayPlugin) registry.Register(birthdayPlugin)
// Satirical
esteemedPlugin := plugin.NewEsteemPlugin(client)
registry.Register(esteemedPlugin)
// Horoscope
horoscopePlugin := plugin.NewHoroscopePlugin(client)
registry.Register(horoscopePlugin)
// Utility / Meta // Utility / Meta
registry.Register(plugin.NewBotInfoPlugin(client)) registry.Register(plugin.NewBotInfoPlugin(client))
registry.Register(plugin.NewHowAmIPlugin(client)) registry.Register(plugin.NewHowAmIPlugin(client))
@@ -206,7 +214,7 @@ func main() {
// ---- Set up cron scheduler ---- // ---- Set up cron scheduler ----
scheduler := cron.New() scheduler := cron.New()
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin) setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, horoscopePlugin)
scheduler.Start() scheduler.Start()
// ---- Start syncing ---- // ---- Start syncing ----
@@ -241,6 +249,8 @@ func setupScheduledJobs(
anime *plugin.AnimePlugin, anime *plugin.AnimePlugin,
movies *plugin.MoviesPlugin, movies *plugin.MoviesPlugin,
concerts *plugin.ConcertsPlugin, concerts *plugin.ConcertsPlugin,
esteemed *plugin.EsteemPlugin,
horoscope *plugin.HoroscopePlugin,
) { ) {
rooms := getRooms() rooms := getRooms()
@@ -259,6 +269,14 @@ func setupScheduledJobs(
} }
}) })
// Daily horoscopes at 06:30
c.AddFunc("30 6 * * *", func() {
slog.Info("scheduler: posting daily horoscopes")
for _, r := range rooms {
horoscope.PostDailyHoroscopes(r)
}
})
// Holidays at 07:00 // Holidays at 07:00
c.AddFunc("0 7 * * *", func() { c.AddFunc("0 7 * * *", func() {
slog.Info("scheduler: posting holidays") slog.Info("scheduler: posting holidays")
@@ -312,6 +330,12 @@ func setupScheduledJobs(
plugin.FirePendingReminders(client) plugin.FirePendingReminders(client)
}) })
// Esteemed community member — Wednesday & Sunday 13:00
c.AddFunc("0 13 * * 0,3", func() {
slog.Info("scheduler: posting esteemed member")
esteemed.PostWeekly()
})
// Database maintenance at 03:00 daily // Database maintenance at 03:00 daily
c.AddFunc("0 3 * * *", func() { c.AddFunc("0 3 * * *", func() {
slog.Info("scheduler: running database maintenance") slog.Info("scheduler: running database maintenance")