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=
BANDSINTOWN_API_KEY=
TMDB_API_KEY=
SERPAPI_KEY= # SerpAPI key for image fetching (esteemed members)
# ---- Self-Hosted Services (optional) ----
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_SHADE=
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_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,
day INTEGER NOT NULL,
year INTEGER DEFAULT 0,
timezone TEXT DEFAULT 'UTC'
timezone TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS birthday_fired (

View File

@@ -114,9 +114,8 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "achievements")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}
@@ -164,7 +163,7 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
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 {
return []achievementDef{
// 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) },
},
{
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: "🐦",
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: "🏆",
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()
_, 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 = ?`,
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: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", 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: "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"},
@@ -229,7 +229,7 @@ func (p *FunPlugin) handleCoin(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 == "" {
// Show a few major cities
cities := []string{"new york", "london", "tokyo", "sydney"}
@@ -240,23 +240,55 @@ func (p *FunPlugin) handleTime(ctx MessageContext) error {
t := time.Now().In(tz)
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())
}
tzName, ok := cityTimezones[args]
if !ok {
// Try loading it as a raw IANA zone
tzName = args
// If it looks like a Matrix user ID, go straight to user lookup
if strings.HasPrefix(args, "@") && strings.Contains(args, ":") {
return p.showUserTime(ctx, 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 {
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)
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 {

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
args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami"))
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}

View File

@@ -46,10 +46,11 @@ type classificationResult struct {
// queueItem holds a message pending classification.
type queueItem struct {
UserID id.UserID
RoomID id.RoomID
EventID id.EventID
Body string
UserID id.UserID
RoomID id.RoomID
EventID id.EventID
Body string
FormattedBody string
}
// 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
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))
return nil
}
// Enqueue for async classification
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.queue = append(p.queue, queueItem{
UserID: ctx.Sender,
RoomID: ctx.RoomID,
EventID: ctx.EventID,
Body: ctx.Body,
UserID: ctx.Sender,
RoomID: ctx.RoomID,
EventID: ctx.EventID,
Body: ctx.Body,
FormattedBody: formattedBody,
})
p.mu.Unlock()
@@ -170,7 +184,7 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
}
// 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.)
if len(strings.TrimSpace(body)) <= 1 {
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) {
return true
}
if strings.Contains(formattedBody, "matrix.to/#/@") {
return true
}
// Check WOTD pattern (simple heuristic)
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.
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 {
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()
// Store classification
@@ -336,7 +396,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
"\U0001f52a", // 🔪
"\U0001f5e1", // 🗡️
"\U0001fa78", // 🩸
"\U0001f480", // 💀
"\U0001f4a3", // 💣
}
emoji := botReactions[rand.Intn(len(botReactions))]
_ = p.SendReact(item.RoomID, item.EventID, emoji)
@@ -519,9 +579,8 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "potty")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}
@@ -542,9 +601,11 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
}
func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
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 {
slog.Error("llm: pottyboard query", "err", err)
@@ -557,12 +618,15 @@ func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
i := 0
for rows.Next() {
for rows.Next() && i < 10 {
var userID string
var count int
if err := rows.Scan(&userID, &count); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]
@@ -582,9 +646,8 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "insults")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}
@@ -605,9 +668,11 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
}
func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
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 {
slog.Error("llm: insultboard query", "err", err)
@@ -620,12 +685,15 @@ func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
i := 0
for rows.Next() {
for rows.Next() && i < 10 {
var userID string
var count int
if err := rows.Scan(&userID, &count); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]
@@ -645,9 +713,8 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "sentiment")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}

View File

@@ -100,8 +100,9 @@ func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
targetUser = ctx.Sender
default:
// Treat as user ID
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
targetUser = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
targetUser = resolved
}
}
// 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")
}
targetID := id.UserID(strings.TrimPrefix(strings.TrimSpace(args), "@"))
if !strings.Contains(string(targetID), ":") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide a full Matrix user ID (e.g. @user:server.com)")
targetID, ok := p.ResolveUser(args)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find a user matching that name.")
}
// Rate limit: 1 carton per room per day
@@ -414,7 +414,7 @@ func (p *MilkCartonPlugin) renderCarton(
dc.DrawImage(avatarResized, int(photoX), int(photoY))
} else {
// Draw silhouette
p.drawSilhouette(dc, photoX, photoY, float64(photoSize))
drawSilhouette(dc, photoX, photoY, float64(photoSize))
}
// Name
@@ -503,22 +503,6 @@ func (p *MilkCartonPlugin) renderCarton(
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.
func (p *MilkCartonPlugin) deriveCharacteristics(userID string) []string {
d := db.Get()
@@ -613,11 +597,11 @@ func resizeImage(src image.Image, targetW, targetH int) image.Image {
dc := gg.NewContext(targetW, targetH)
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.Scale(scale, 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))
return dcScaled.Image()

View File

@@ -7,6 +7,7 @@ import (
"os"
"strings"
"gogobee/internal/db"
"gogobee/internal/util"
"maunium.net/go/mautrix"
@@ -87,6 +88,93 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
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.
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
content := &event.MessageEventContent{
@@ -227,9 +315,10 @@ func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption str
return fmt.Errorf("upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: caption,
URL: uri.CUString(),
MsgType: event.MsgImage,
Body: caption,
FileName: filename,
URL: uri.CUString(),
Info: &event.FileInfo{
MimeType: "image/png",
Size: len(imgData),

View File

@@ -9,7 +9,6 @@ import (
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// 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 {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
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
var displayName string
@@ -183,12 +185,13 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
}
}
// Get reputation (from XP log with reason = 'rep')
var repCount int
// Get reputation (from XP log with reason = 'reputation')
var repXP int
_ = 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),
).Scan(&repCount)
).Scan(&repXP)
repCount := repXP / 5
// Get presence status
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 {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
var sb strings.Builder
// Top 10 emoji givers
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
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 {
slog.Error("reactions: givers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
giverCount := appendUserBoard(&sb, rows)
giverCount := appendUserBoard(&sb, rows, members)
rows.Close()
// Top 10 emoji receivers
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
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 {
slog.Error("reactions: receivers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
receiverCount := appendUserBoard(&sb, rows)
receiverCount := appendUserBoard(&sb, rows, members)
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")
rows, err = d.Query(
`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())
}
// appendUserBoard writes ranked user lines from query rows.
func appendUserBoard(sb *strings.Builder, rows *sql.Rows) int {
// appendUserBoard writes ranked user lines from query rows, filtered by room members.
func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() {
for rows.Next() && i < 10 {
var name string
var cnt int
if err := rows.Scan(&name, &cnt); err != nil {
continue
}
if members != nil && !members[id.UserID(name)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
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`)
// 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.-]+`)
// 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.
type ReputationPlugin struct {
Base
@@ -63,8 +67,8 @@ func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
}
func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
// Find mentioned users
mentions := userMentionRe.FindAllString(ctx.Body, -1)
// Find mentioned users from both plain text and HTML formatted body
mentions := extractMentions(ctx)
if len(mentions) == 0 {
return nil
}
@@ -125,9 +129,8 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "rep")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}
@@ -150,11 +153,13 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
}
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
rows, err := d.Query(
`SELECT user_id, SUM(amount) as total
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 {
slog.Error("rep: repboard query", "err", err)
@@ -167,12 +172,15 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() {
for rows.Next() && i < 10 {
var userID string
var totalXP int
if err := rows.Scan(&userID, &totalXP); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
repCount := totalXP / 5
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
@@ -188,3 +196,33 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
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
args := p.GetArgs(ctx.Body, "stats")
if args != "" {
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
if resolved, ok := p.ResolveUser(args); ok {
target = resolved
}
}
@@ -235,10 +234,12 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
}
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
// Using fmt.Sprintf for column name is safe here since we control the value above
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,
)
rows, err := d.Query(query)
@@ -253,12 +254,15 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() {
for rows.Next() && i < 10 {
var userID string
var val int
if err := rows.Scan(&userID, &val); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]

View File

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

View File

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

26
main.go
View File

@@ -124,6 +124,14 @@ func main() {
birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin)
registry.Register(birthdayPlugin)
// Satirical
esteemedPlugin := plugin.NewEsteemPlugin(client)
registry.Register(esteemedPlugin)
// Horoscope
horoscopePlugin := plugin.NewHoroscopePlugin(client)
registry.Register(horoscopePlugin)
// Utility / Meta
registry.Register(plugin.NewBotInfoPlugin(client))
registry.Register(plugin.NewHowAmIPlugin(client))
@@ -206,7 +214,7 @@ func main() {
// ---- Set up cron scheduler ----
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()
// ---- Start syncing ----
@@ -241,6 +249,8 @@ func setupScheduledJobs(
anime *plugin.AnimePlugin,
movies *plugin.MoviesPlugin,
concerts *plugin.ConcertsPlugin,
esteemed *plugin.EsteemPlugin,
horoscope *plugin.HoroscopePlugin,
) {
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
c.AddFunc("0 7 * * *", func() {
slog.Info("scheduler: posting holidays")
@@ -312,6 +330,12 @@ func setupScheduledJobs(
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
c.AddFunc("0 3 * * *", func() {
slog.Info("scheduler: running database maintenance")