From c1cca5ae128c40c06d17f981dc11d9acbf680b46 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:57:31 -0700 Subject: [PATCH] Add milk carton missing member feature (!haveyouseenthem, !missing) Generates milk carton style "missing person" PNG images using gg library with embedded Go fonts. Tracks inactive members via daily_activity, with configurable thresholds (MISSING_THRESHOLD_DAYS, MISSING_MAX_DAYS, MISSING_MIN_MESSAGES, MISSING_EXCLUDE_USERS). Rate limited to 1 carton per room per day. Derives flavor text characteristics from user stats and sentiment data. Avatar fetching with 3-tier fallback. Co-Authored-By: Claude Opus 4.6 --- .env.example | 6 + README.md | 17 + go.mod | 3 + go.sum | 6 + internal/plugin/milkcarton.go | 648 ++++++++++++++++++++++++++++++++++ internal/plugin/plugin.go | 21 ++ main.go | 3 + 7 files changed, 704 insertions(+) create mode 100644 internal/plugin/milkcarton.go diff --git a/.env.example b/.env.example index e89a24d..4e670f5 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,12 @@ FEATURE_URL_PREVIEW= FEATURE_SHADE= FEATURE_TRIVIA=true # set to "false" to disable trivia +# ---- Missing Members (!haveyouseenthem / !missing) ---- +MISSING_THRESHOLD_DAYS=14 # days of inactivity before considered missing +MISSING_MAX_DAYS=90 # days after which they're considered gone, not missing +MISSING_MIN_MESSAGES=10 # minimum lifetime messages to be eligible +MISSING_EXCLUDE_USERS= # comma-separated user IDs to never list as missing + # ---- Rate Limits (per user per day) ---- RATELIMIT_WEATHER=5 RATELIMIT_TRANSLATE=20 diff --git a/README.md b/README.md index f5b429b..941f2ac 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ Everything is configured through environment variables or a `.env` file. | `FEATURE_URL_PREVIEW` | Set to anything to enable automatic URL previews | | `FEATURE_SHADE` | Set to anything to enable the shade plugin (stub) | +### Missing Members + +| Variable | Default | Description | +|----------|---------|-------------| +| `MISSING_THRESHOLD_DAYS` | `14` | Days of inactivity before considered missing | +| `MISSING_MAX_DAYS` | `90` | Days after which they're considered gone, not missing | +| `MISSING_MIN_MESSAGES` | `10` | Minimum lifetime messages to be eligible | +| `MISSING_EXCLUDE_USERS` | | Comma-separated user IDs to never list as missing | + ### Rate Limits | Variable | Default | Description | @@ -281,6 +290,13 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!birthday show` | Show yours | | `!birthdays` | Upcoming (next 30 days) | +### Community +| Command | Description | +|---------|-------------| +| `!missing` | List members who haven't posted recently | +| `!missing post [@user]` | Generate a milk carton poster for the longest-absent (or specified) member | +| `!haveyouseenthem @user` | Generate a milk carton missing poster for a user | + ### LLM (requires Ollama) | Command | Description | |---------|-------------| @@ -471,6 +487,7 @@ gogobee/ │ │ ├── howami.go # LLM roasts │ │ ├── vibe.go # Room vibe, TLDR │ │ ├── shade.go # Stub +│ │ ├── milkcarton.go # Missing member milk carton posters │ │ └── ratelimits.go # Rate limiting │ └── util/ │ ├── auth.go # Matrix login, token check diff --git a/go.mod b/go.mod index 0334aaa..389b670 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/AlekSi/pointer v1.0.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect @@ -34,6 +36,7 @@ require ( go.mau.fi/util v0.9.6 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/image v0.36.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 1de4521..d124e02 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,11 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -76,6 +80,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/plugin/milkcarton.go b/internal/plugin/milkcarton.go new file mode 100644 index 0000000..026533b --- /dev/null +++ b/internal/plugin/milkcarton.go @@ -0,0 +1,648 @@ +package plugin + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/png" + "log/slog" + "math" + "math/rand/v2" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + // Embedded Go fonts + _ "image/jpeg" + + "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" + "golang.org/x/image/font/opentype" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +const ( + cartonWidth = 400 + cartonHeight = 600 + photoSize = 140 +) + +// MilkCartonPlugin generates milk carton style "missing person" images. +type MilkCartonPlugin struct { + Base + rateLimiter *RateLimitsPlugin + thresholdDays int + maxDays int + minMessages int + excludeUsers map[string]bool + regularFont font.Face + boldFont font.Face + smallFont font.Face + headerFont font.Face +} + +// NewMilkCartonPlugin creates a new milk carton plugin. +func NewMilkCartonPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *MilkCartonPlugin { + threshold := 14 + if v := os.Getenv("MISSING_THRESHOLD_DAYS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + threshold = n + } + } + + maxDays := 90 + if v := os.Getenv("MISSING_MAX_DAYS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxDays = n + } + } + + minMsgs := 10 + if v := os.Getenv("MISSING_MIN_MESSAGES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + minMsgs = n + } + } + + exclude := make(map[string]bool) + if v := os.Getenv("MISSING_EXCLUDE_USERS"); v != "" { + for _, u := range strings.Split(v, ",") { + u = strings.TrimSpace(u) + if u != "" { + exclude[u] = true + } + } + } + + // Load embedded fonts + regFont := loadFont(goregular.TTF, 16) + bldFont := loadFont(gobold.TTF, 18) + smlFont := loadFont(goregular.TTF, 13) + hdrFont := loadFont(gobold.TTF, 22) + + return &MilkCartonPlugin{ + Base: NewBase(client), + rateLimiter: rateLimiter, + thresholdDays: threshold, + maxDays: maxDays, + minMessages: minMsgs, + excludeUsers: exclude, + regularFont: regFont, + boldFont: bldFont, + smallFont: smlFont, + headerFont: hdrFont, + } +} + +func loadFont(ttfData []byte, size float64) font.Face { + f, err := opentype.Parse(ttfData) + if err != nil { + slog.Error("milkcarton: parse font", "err", err) + return nil + } + face, err := opentype.NewFace(f, &opentype.FaceOptions{ + Size: size, + DPI: 72, + Hinting: font.HintingFull, + }) + if err != nil { + slog.Error("milkcarton: create face", "err", err) + return nil + } + return face +} + +func (p *MilkCartonPlugin) Name() string { return "milkcarton" } + +func (p *MilkCartonPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "haveyouseenthem", Description: "Generate a milk carton missing poster for a user", Usage: "!haveyouseenthem @user", Category: "Fun & Games"}, + {Name: "missing", Description: "List members who haven't posted recently", Usage: "!missing [post [@user]]", Category: "Fun & Games"}, + } +} + +func (p *MilkCartonPlugin) Init() error { return nil } + +func (p *MilkCartonPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *MilkCartonPlugin) OnMessage(ctx MessageContext) error { + if p.IsCommand(ctx.Body, "haveyouseenthem") { + return p.handleHaveYouSeenThem(ctx) + } + if p.IsCommand(ctx.Body, "missing") { + return p.handleMissing(ctx) + } + return nil +} + +func (p *MilkCartonPlugin) handleHaveYouSeenThem(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "haveyouseenthem")) + if args == "" { + 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)") + } + + // Rate limit: 1 carton per room per day + if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(id.UserID(ctx.RoomID), "milkcarton", 1) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Milk carton limit reached for today. Try again tomorrow.") + } + + return p.generateAndPost(ctx.RoomID, targetID) +} + +func (p *MilkCartonPlugin) handleMissing(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "missing")) + + if strings.HasPrefix(strings.ToLower(args), "post") { + postArgs := strings.TrimSpace(args[4:]) + + // Rate limit: 1 carton per room per day + if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(id.UserID(ctx.RoomID), "milkcarton", 1) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Milk carton limit reached for today. Try again tomorrow.") + } + + if postArgs != "" { + // !missing post @user + targetID := id.UserID(strings.TrimPrefix(strings.TrimSpace(postArgs), "@")) + return p.generateAndPost(ctx.RoomID, targetID) + } + + // !missing post — generate for longest-absent member + missing := p.getMissingMembers() + if len(missing) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No missing members found. Everyone's been active!") + } + return p.generateAndPost(ctx.RoomID, id.UserID(missing[0].userID)) + } + + // !missing — list missing members + missing := p.getMissingMembers() + if len(missing) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No missing members found. Everyone's been active!") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Missing members (inactive %d-%d days):\n\n", p.thresholdDays, p.maxDays)) + + limit := 15 + if len(missing) < limit { + limit = len(missing) + } + for i := 0; i < limit; i++ { + m := missing[i] + sb.WriteString(fmt.Sprintf(" %s — last seen %s\n", m.userID, humanDuration(m.daysSince))) + } + if len(missing) > limit { + sb.WriteString(fmt.Sprintf("\n...and %d more", len(missing)-limit)) + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +type missingMember struct { + userID string + daysSince int +} + +func (p *MilkCartonPlugin) getMissingMembers() []missingMember { + d := db.Get() + now := time.Now().UTC() + minDate := now.AddDate(0, 0, -p.maxDays).Format("2006-01-02") + maxDate := now.AddDate(0, 0, -p.thresholdDays).Format("2006-01-02") + + rows, err := d.Query(` + SELECT da.user_id, MAX(da.date) as last_date + FROM daily_activity da + JOIN user_stats us ON us.user_id = da.user_id + WHERE us.total_messages >= ? + GROUP BY da.user_id + HAVING last_date >= ? AND last_date <= ? + ORDER BY last_date ASC`, + p.minMessages, minDate, maxDate, + ) + if err != nil { + slog.Error("milkcarton: query missing", "err", err) + return nil + } + defer rows.Close() + + var result []missingMember + for rows.Next() { + var userID, lastDate string + if err := rows.Scan(&userID, &lastDate); err != nil { + continue + } + + // Skip excluded users + if p.excludeUsers[userID] { + continue + } + + // Skip bots (our own user ID) + if id.UserID(userID) == p.Client.UserID { + continue + } + + // Skip users with active away/afk status + var awayStatus int + _ = d.QueryRow(`SELECT 1 FROM presence WHERE user_id = ? AND status IN ('away', 'afk')`, userID).Scan(&awayStatus) + if awayStatus == 1 { + continue + } + + lastTime, err := time.Parse("2006-01-02", lastDate) + if err != nil { + continue + } + days := int(now.Sub(lastTime).Hours() / 24) + result = append(result, missingMember{userID: userID, daysSince: days}) + } + return result +} + +func (p *MilkCartonPlugin) generateAndPost(roomID id.RoomID, targetID id.UserID) error { + d := db.Get() + uid := string(targetID) + + // Get display name + displayName := uid + resp, err := p.Client.GetDisplayName(context.Background(), targetID) + if err == nil && resp.DisplayName != "" { + displayName = resp.DisplayName + } + + // Get last seen + var lastDate string + err = d.QueryRow(`SELECT MAX(date) FROM daily_activity WHERE user_id = ?`, uid).Scan(&lastDate) + lastSeen := "Unknown" + if err == nil && lastDate != "" { + if t, err := time.Parse("2006-01-02", lastDate); err == nil { + days := int(time.Since(t).Hours() / 24) + lastSeen = humanDuration(days) + } + } + + // Get level and XP + var xp, level int + _ = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level) + + // Get characteristics + characteristics := p.deriveCharacteristics(uid) + + // Get avatar + avatarImg := p.fetchAvatar(targetID) + + // Generate milk carton image + imgData, err := p.renderCarton(displayName, uid, lastSeen, level, xp, characteristics, avatarImg) + if err != nil { + slog.Error("milkcarton: render", "err", err) + return p.SendReply(roomID, id.EventID(""), "Failed to generate milk carton image.") + } + + caption := fmt.Sprintf("🥛 Has anyone seen %s lately?", displayName) + return p.SendImage(roomID, imgData, "milkcarton.png", caption, cartonWidth, cartonHeight) +} + +func (p *MilkCartonPlugin) fetchAvatar(userID id.UserID) image.Image { + // Try Matrix avatar + avatarURL, err := p.Client.GetAvatarURL(context.Background(), userID) + if err == nil && !avatarURL.IsEmpty() { + data, err := p.Client.DownloadBytes(context.Background(), avatarURL) + if err == nil { + img, _, err := image.Decode(bytes.NewReader(data)) + if err == nil { + return img + } + } + } + + // Try placeholder directory + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + placeholderDir := filepath.Join(dataDir, "placeholders") + if entries, err := os.ReadDir(placeholderDir); err == nil && len(entries) > 0 { + var images []string + for _, e := range entries { + name := strings.ToLower(e.Name()) + if strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg") { + images = append(images, filepath.Join(placeholderDir, e.Name())) + } + } + if len(images) > 0 { + chosen := images[rand.IntN(len(images))] + f, err := os.Open(chosen) + if err == nil { + defer f.Close() + img, _, err := image.Decode(f) + if err == nil { + return img + } + } + } + } + + // Fallback: nil — renderCarton will draw a silhouette + return nil +} + +func (p *MilkCartonPlugin) renderCarton( + displayName, userID, lastSeen string, + level, xp int, + characteristics []string, + avatar image.Image, +) ([]byte, error) { + dc := gg.NewContext(cartonWidth, cartonHeight) + + // Background — cream/off-white like a milk carton + 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: "HAVE YOU SEEN THIS PERSON?" + if p.headerFont != nil { + dc.SetFontFace(p.headerFont) + } + dc.SetColor(color.RGBA{180, 30, 30, 255}) + dc.DrawStringAnchored("HAVE YOU SEEN", float64(cartonWidth)/2, 48, 0.5, 0.5) + dc.DrawStringAnchored("THIS PERSON?", float64(cartonWidth)/2, 74, 0.5, 0.5) + + // Photo area + photoX := float64(cartonWidth)/2 - float64(photoSize)/2 + photoY := 95.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() + + if avatar != nil { + // Resize and crop avatar to fit photo area + avatarResized := resizeImage(avatar, photoSize, photoSize) + dc.DrawImage(avatarResized, int(photoX), int(photoY)) + } else { + // Draw silhouette + p.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}) + + // Truncate display name if too long + name := displayName + if len(name) > 28 { + name = name[:25] + "..." + } + dc.DrawStringAnchored(name, float64(cartonWidth)/2, yPos, 0.5, 0.5) + + // User ID + yPos += 22 + if p.smallFont != nil { + dc.SetFontFace(p.smallFont) + } + dc.SetColor(color.RGBA{100, 100, 100, 255}) + shortID := userID + if len(shortID) > 35 { + shortID = shortID[:32] + "..." + } + dc.DrawStringAnchored(shortID, float64(cartonWidth)/2, yPos, 0.5, 0.5) + + // Last seen + yPos += 28 + if p.regularFont != nil { + dc.SetFontFace(p.regularFont) + } + dc.SetColor(color.RGBA{180, 30, 30, 255}) + dc.DrawStringAnchored(fmt.Sprintf("Last seen: %s", lastSeen), float64(cartonWidth)/2, yPos, 0.5, 0.5) + + // Level / XP + yPos += 22 + dc.SetColor(color.RGBA{80, 80, 80, 255}) + if p.smallFont != nil { + dc.SetFontFace(p.smallFont) + } + dc.DrawStringAnchored(fmt.Sprintf("Level %d | %d XP", level, xp), 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 += 20 + 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 += 8 + if p.smallFont != nil { + dc.SetFontFace(p.smallFont) + } + dc.SetColor(color.RGBA{80, 80, 80, 255}) + for _, c := range characteristics { + yPos += 18 + dc.DrawStringAnchored(fmt.Sprintf("• %s", c), 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 the community", float64(cartonWidth)/2, float64(cartonHeight)-42, 0.5, 0.5) + + dc.SetColor(color.RGBA{180, 180, 180, 255}) + dc.DrawStringAnchored("🥛 milk carton community alert 🥛", float64(cartonWidth)/2, float64(cartonHeight)-24, 0.5, 0.5) + + // Encode to PNG + var buf bytes.Buffer + if err := png.Encode(&buf, dc.Image()); err != nil { + return nil, err + } + 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() + var chars []string + + var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int + err := d.QueryRow( + `SELECT COALESCE(total_messages,0), COALESCE(total_words,0), COALESCE(total_emojis,0), + COALESCE(total_questions,0), COALESCE(total_links,0) + FROM user_stats WHERE user_id = ?`, userID, + ).Scan(&totalMsgs, &totalWords, &totalEmojis, &totalQuestions, &totalLinks) + if err != nil { + return []string{"Whereabouts unknown", "Considered a person of interest"} + } + + avgWords := 0 + if totalMsgs > 0 { + avgWords = totalWords / totalMsgs + } + + // Sentiment data + var positive, negative, sarcastic, humorous int + _ = d.QueryRow( + `SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(sarcastic,0), COALESCE(humorous,0) + FROM sentiment_stats WHERE user_id = ?`, userID, + ).Scan(&positive, &negative, &sarcastic, &humorous) + + // Profanity + var profanityCount int + _ = d.QueryRow(`SELECT COALESCE(count,0) FROM potty_mouth WHERE user_id = ?`, userID).Scan(&profanityCount) + + // Build characteristics based on stat thresholds + type trait struct { + condition bool + text string + } + + traits := []trait{ + {totalMsgs > 1000, "Known to be extremely chatty"}, + {totalMsgs > 500 && totalMsgs <= 1000, "Considered a regular contributor"}, + {totalMsgs < 50 && totalMsgs > 0, "Frequently lurks, rarely commits"}, + {avgWords > 12, "Known to post novellas"}, + {avgWords > 0 && avgWords <= 3, "A person of few words"}, + {totalQuestions > totalMsgs/4 && totalQuestions > 20, "Asks questions compulsively"}, + {totalEmojis > totalMsgs/3 && totalEmojis > 30, "Communicates primarily in emoji"}, + {totalLinks > totalMsgs/8 && totalLinks > 20, "Prone to sharing unsolicited links"}, + {profanityCount > 100, "Has a mouth that could strip paint"}, + {profanityCount > 30, "Known to use colorful language"}, + {sarcastic > positive && sarcastic > 10, "Armed with weapons-grade sarcasm"}, + {humorous > 20, "Considered dangerously funny"}, + {negative > positive && negative > 15, "Last seen expressing strong opinions"}, + {positive > 50, "Generally considered a ray of sunshine"}, + } + + for _, t := range traits { + if t.condition { + chars = append(chars, t.text) + } + if len(chars) >= 3 { + break + } + } + + // Fallbacks + fallbacks := []string{ + "Considered armed with strong opinions", + "May be found near a keyboard", + "Last seen staring at a screen", + "Approach with memes", + } + for len(chars) < 2 { + chars = append(chars, fallbacks[rand.IntN(len(fallbacks))]) + } + + return chars +} + +// resizeImage scales an image to fit within the target dimensions. +func resizeImage(src image.Image, targetW, targetH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate scale to fill target (crop to fit) + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Max(scaleX, scaleY) + + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + + dc := gg.NewContext(targetW, targetH) + dc.DrawImage(src, (targetW-newW)/2, (targetH-newH)/2) + + // Use gg's built-in scaling + 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) + dcScaled.DrawImage(src, int(offsetX), int(offsetY)) + + return dcScaled.Image() +} + +func humanDuration(days int) string { + if days == 0 { + return "today" + } + if days == 1 { + return "yesterday" + } + if days < 7 { + return fmt.Sprintf("%d days ago", days) + } + weeks := days / 7 + if weeks == 1 { + return "1 week ago" + } + if days < 30 { + return fmt.Sprintf("%d weeks ago", weeks) + } + months := days / 30 + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 9134ca7..a6068b7 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -219,3 +219,24 @@ func (b *Base) UploadContent(data []byte, contentType, filename string) (id.Cont } return resp.ContentURI, nil } + +// SendImage uploads image data and sends it as an m.image message with a caption. +func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption string, width, height int) error { + uri, err := b.UploadContent(imgData, "image/png", filename) + if err != nil { + return fmt.Errorf("upload image: %w", err) + } + content := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: caption, + URL: uri.CUString(), + Info: &event.FileInfo{ + MimeType: "image/png", + Size: len(imgData), + Width: width, + Height: height, + }, + } + _, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) + return err +} diff --git a/main.go b/main.go index 90cca7e..4d71c6d 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,9 @@ func main() { moviesPlugin := plugin.NewMoviesPlugin(client) registry.Register(moviesPlugin) + // Community + registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin)) + // Tracking (passive) registry.Register(plugin.NewAchievementsPlugin(client, registry)) registry.Register(plugin.NewReactionsPlugin(client))