mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
641 lines
18 KiB
Go
641 lines
18 KiB
Go
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, ok := p.ResolveUser(args, ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find a user matching that name.")
|
|
}
|
|
|
|
// 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()
|
|
|
|
type candidate struct {
|
|
userID, lastDate string
|
|
}
|
|
var candidates []candidate
|
|
for rows.Next() {
|
|
var c candidate
|
|
if err := rows.Scan(&c.userID, &c.lastDate); err != nil {
|
|
continue
|
|
}
|
|
candidates = append(candidates, c)
|
|
}
|
|
rows.Close()
|
|
|
|
var result []missingMember
|
|
for _, c := range candidates {
|
|
// Skip excluded users
|
|
if p.excludeUsers[c.userID] {
|
|
continue
|
|
}
|
|
|
|
// Skip bots (our own user ID)
|
|
if id.UserID(c.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')`, c.userID).Scan(&awayStatus)
|
|
if awayStatus == 1 {
|
|
continue
|
|
}
|
|
|
|
lastTime, err := time.Parse("2006-01-02", c.lastDate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
days := int(now.Sub(lastTime).Hours() / 24)
|
|
result = append(result, missingMember{userID: c.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
|
|
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
|
|
}
|
|
|
|
// 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 — anchor to top so heads aren't cropped
|
|
dcScaled := gg.NewContext(targetW, targetH)
|
|
dcScaled.Scale(scale, scale)
|
|
offsetX := -float64(srcW)/2 + float64(targetW)/(2*scale)
|
|
offsetY := 0.0 // top-anchored crop
|
|
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)
|
|
}
|