mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
418
internal/plugin/esteemed.go
Normal file
418
internal/plugin/esteemed.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user