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