mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
- 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>
419 lines
11 KiB
Go
419 lines
11 KiB
Go
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()
|
|
}
|