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

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