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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-08 23:57:31 -07:00
parent 4fefbf7553
commit c1cca5ae12
7 changed files with 704 additions and 0 deletions

View File

@@ -35,6 +35,12 @@ FEATURE_URL_PREVIEW=
FEATURE_SHADE= FEATURE_SHADE=
FEATURE_TRIVIA=true # set to "false" to disable trivia 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) ---- # ---- Rate Limits (per user per day) ----
RATELIMIT_WEATHER=5 RATELIMIT_WEATHER=5
RATELIMIT_TRANSLATE=20 RATELIMIT_TRANSLATE=20

View File

@@ -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_URL_PREVIEW` | Set to anything to enable automatic URL previews |
| `FEATURE_SHADE` | Set to anything to enable the shade plugin (stub) | | `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 ### Rate Limits
| Variable | Default | Description | | Variable | Default | Description |
@@ -281,6 +290,13 @@ Rep is earned when someone thanks you. The bot detects this automatically.
| `!birthday show` | Show yours | | `!birthday show` | Show yours |
| `!birthdays` | Upcoming (next 30 days) | | `!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) ### LLM (requires Ollama)
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
@@ -471,6 +487,7 @@ gogobee/
│ │ ├── howami.go # LLM roasts │ │ ├── howami.go # LLM roasts
│ │ ├── vibe.go # Room vibe, TLDR │ │ ├── vibe.go # Room vibe, TLDR
│ │ ├── shade.go # Stub │ │ ├── shade.go # Stub
│ │ ├── milkcarton.go # Missing member milk carton posters
│ │ └── ratelimits.go # Rate limiting │ │ └── ratelimits.go # Rate limiting
│ └── util/ │ └── util/
│ ├── auth.go # Matrix login, token check │ ├── auth.go # Matrix login, token check

3
go.mod
View File

@@ -19,6 +19,8 @@ require (
github.com/AlekSi/pointer v1.0.0 // indirect github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/dustin/go-humanize v1.0.1 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
@@ -34,6 +36,7 @@ require (
go.mau.fi/util v0.9.6 // indirect go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // 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/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect

6
go.sum
View File

@@ -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/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 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= 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/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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/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 h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=

View File

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

View File

@@ -219,3 +219,24 @@ func (b *Base) UploadContent(data []byte, contentType, filename string) (id.Cont
} }
return resp.ContentURI, nil 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
}

View File

@@ -102,6 +102,9 @@ func main() {
moviesPlugin := plugin.NewMoviesPlugin(client) moviesPlugin := plugin.NewMoviesPlugin(client)
registry.Register(moviesPlugin) registry.Register(moviesPlugin)
// Community
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))
// Tracking (passive) // Tracking (passive)
registry.Register(plugin.NewAchievementsPlugin(client, registry)) registry.Register(plugin.NewAchievementsPlugin(client, registry))
registry.Register(plugin.NewReactionsPlugin(client)) registry.Register(plugin.NewReactionsPlugin(client))