mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add horoscope, !time @user, profile achievements; fix mention detection and multiple bugs
- Add !horoscope command and daily horoscope digest using Free Horoscope API - Enhance !time to support @user timezone lookups - Add profile completeness achievements (birthday, timezone, both set) - Fix Matrix mention detection in reputation and LLM passive plugins - Fix !whois reputation query (reason string mismatch and calculation) - Fix !time IANA case-sensitivity and username/city collision - Fix timezone default to empty string for achievement detection - Scope all leaderboards to room members (XP, rep, potty, insult, first, rankings, emoji) - Fix XP rank calculation to handle tied scores correctly - Replace skull emoji with bomb for bot insult reactions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -376,7 +376,7 @@ CREATE TABLE IF NOT EXISTS birthdays (
|
||||
month INTEGER NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
year INTEGER DEFAULT 0,
|
||||
timezone TEXT DEFAULT 'UTC'
|
||||
timezone TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS birthday_fired (
|
||||
|
||||
@@ -114,9 +114,8 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "achievements")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +163,7 @@ func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// buildAchievements returns all 32 achievement definitions.
|
||||
// buildAchievements returns all achievement definitions.
|
||||
func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return []achievementDef{
|
||||
// Message milestones
|
||||
@@ -196,7 +195,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 100) },
|
||||
},
|
||||
{
|
||||
ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 5am and 9am",
|
||||
ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 6am and noon",
|
||||
Emoji: "🐦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 100) },
|
||||
},
|
||||
@@ -411,6 +410,45 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Emoji: "🏆",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 365) },
|
||||
},
|
||||
|
||||
// Profile completeness
|
||||
{
|
||||
ID: "birthday_set", Name: "Born to Party", Description: "Set your birthday",
|
||||
Emoji: "🎂",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var month int
|
||||
err := d.QueryRow(
|
||||
`SELECT month FROM birthdays WHERE user_id = ? AND month > 0`,
|
||||
string(u),
|
||||
).Scan(&month)
|
||||
return err == nil && month > 0
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "timezone_set", Name: "Time Traveler", Description: "Set your timezone",
|
||||
Emoji: "🌍",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var tz string
|
||||
err := d.QueryRow(
|
||||
`SELECT timezone FROM birthdays WHERE user_id = ? AND timezone != ''`,
|
||||
string(u),
|
||||
).Scan(&tz)
|
||||
return err == nil && tz != ""
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "profile_complete", Name: "Identity Established", Description: "Set both birthday and timezone",
|
||||
Emoji: "🪪",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var month int
|
||||
var tz string
|
||||
err := d.QueryRow(
|
||||
`SELECT month, timezone FROM birthdays WHERE user_id = ? AND month > 0 AND timezone != ''`,
|
||||
string(u),
|
||||
).Scan(&month, &tz)
|
||||
return err == nil && month > 0 && tz != ""
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func (p *BirthdayPlugin) handleSet(ctx MessageContext, dateStr string) error {
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO birthdays (user_id, month, day, year, timezone) VALUES (?, ?, ?, ?, 'UTC')
|
||||
`INSERT INTO birthdays (user_id, month, day, year) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`,
|
||||
string(ctx.Sender), month, day, year, month, day, year,
|
||||
)
|
||||
|
||||
418
internal/plugin/esteemed.go
Normal file
418
internal/plugin/esteemed.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "image/jpeg"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// esteemedEntry is a single satirical "esteemed community member" entry.
|
||||
type esteemedEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Characteristics []string `json:"distinguishing_characteristics"`
|
||||
}
|
||||
|
||||
// EsteemPlugin posts satirical milk carton missing posters for fictional "esteemed community members."
|
||||
type EsteemPlugin struct {
|
||||
Base
|
||||
entries []esteemedEntry
|
||||
enabled bool
|
||||
room id.RoomID
|
||||
dataDir string
|
||||
regularFont font.Face
|
||||
boldFont font.Face
|
||||
smallFont font.Face
|
||||
headerFont font.Face
|
||||
titleFont font.Face
|
||||
}
|
||||
|
||||
// NewEsteemPlugin creates a new esteemed members plugin.
|
||||
func NewEsteemPlugin(client *mautrix.Client) *EsteemPlugin {
|
||||
enabled := os.Getenv("FEATURE_ESTEEMED") != ""
|
||||
roomID := os.Getenv("ESTEEMED_ROOM")
|
||||
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
|
||||
p := &EsteemPlugin{
|
||||
Base: NewBase(client),
|
||||
enabled: enabled,
|
||||
room: id.RoomID(roomID),
|
||||
dataDir: dataDir,
|
||||
}
|
||||
|
||||
if enabled {
|
||||
p.regularFont = loadFont(goregular.TTF, 15)
|
||||
p.boldFont = loadFont(gobold.TTF, 16)
|
||||
p.smallFont = loadFont(goregular.TTF, 12)
|
||||
p.headerFont = loadFont(gobold.TTF, 14)
|
||||
p.titleFont = loadFont(gobold.TTF, 20)
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) Name() string { return "esteemed" }
|
||||
|
||||
func (p *EsteemPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "esteemed", Description: "Preview a random esteemed member carton (admin only)", Usage: "!esteemed [name]", Category: "Admin", AdminOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) Init() error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load entries from esteemed.json
|
||||
jsonPath := "esteemed.json"
|
||||
data, err := os.ReadFile(jsonPath)
|
||||
if err != nil {
|
||||
slog.Warn("esteemed: could not load esteemed.json", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &p.entries); err != nil {
|
||||
slog.Warn("esteemed: invalid JSON", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("esteemed: loaded entries", "count", len(p.entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "esteemed") {
|
||||
return nil
|
||||
}
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
if len(p.entries) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Esteemed members list not loaded.")
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "esteemed"))
|
||||
|
||||
var entry esteemedEntry
|
||||
if args == "" {
|
||||
// Random entry (ignores dedup — this is a preview)
|
||||
entry = p.entries[rand.IntN(len(p.entries))]
|
||||
} else {
|
||||
// Search by name
|
||||
query := strings.ToLower(args)
|
||||
found := false
|
||||
for _, e := range p.entries {
|
||||
if strings.Contains(strings.ToLower(e.Name), query) || e.ID == query {
|
||||
entry = e
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No esteemed member matching \"%s\" found.", args))
|
||||
}
|
||||
}
|
||||
|
||||
imgData, err := p.renderCarton(entry)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Render failed: %v", err))
|
||||
}
|
||||
|
||||
caption := fmt.Sprintf("Yet another one of our esteemed community has gone missing.\nIf found, please return %s to the community immediately.", entry.Name)
|
||||
return p.SendImage(ctx.RoomID, imgData, "esteemed.png", caption, cartonWidth, cartonHeight)
|
||||
}
|
||||
func (p *EsteemPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
// PostWeekly selects an unposted entry, generates a milk carton, and posts it.
|
||||
// Called by the scheduler once a week.
|
||||
func (p *EsteemPlugin) PostWeekly() {
|
||||
if !p.enabled || p.room == "" || len(p.entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Pick an entry that hasn't been posted yet
|
||||
entry, ok := p.pickEntry()
|
||||
if !ok {
|
||||
slog.Info("esteemed: all entries have been posted")
|
||||
return
|
||||
}
|
||||
|
||||
imgData, err := p.renderCarton(entry)
|
||||
if err != nil {
|
||||
slog.Error("esteemed: render failed", "entry", entry.ID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
caption := fmt.Sprintf("Yet another one of our esteemed community has gone missing.\nIf found, please return %s to the community immediately.", entry.Name)
|
||||
if err := p.SendImage(p.room, imgData, "esteemed.png", caption, cartonWidth, cartonHeight); err != nil {
|
||||
slog.Error("esteemed: send failed", "entry", entry.ID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as posted
|
||||
db.MarkJobCompleted("esteemed", entry.ID)
|
||||
slog.Info("esteemed: posted", "entry", entry.ID, "name", entry.Name)
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) pickEntry() (esteemedEntry, bool) {
|
||||
// Gather unposted entries
|
||||
var candidates []esteemedEntry
|
||||
for _, e := range p.entries {
|
||||
if !db.JobCompleted("esteemed", e.ID) {
|
||||
candidates = append(candidates, e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return esteemedEntry{}, false
|
||||
}
|
||||
|
||||
// Pick a random one
|
||||
return candidates[rand.IntN(len(candidates))], true
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) loadEntryImage(entry esteemedEntry) image.Image {
|
||||
imgPath := filepath.Join(p.dataDir, "esteemed", entry.ID+".jpg")
|
||||
f, err := os.Open(imgPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
slog.Warn("esteemed: decode image", "entry", entry.ID, "err", err)
|
||||
return nil
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func (p *EsteemPlugin) renderCarton(entry esteemedEntry) ([]byte, error) {
|
||||
dc := gg.NewContext(cartonWidth, cartonHeight)
|
||||
|
||||
// Background — cream/off-white
|
||||
dc.SetColor(color.RGBA{255, 253, 245, 255})
|
||||
dc.Clear()
|
||||
|
||||
// Border
|
||||
dc.SetColor(color.RGBA{180, 60, 60, 255})
|
||||
dc.SetLineWidth(4)
|
||||
dc.DrawRoundedRectangle(8, 8, float64(cartonWidth-16), float64(cartonHeight-16), 12)
|
||||
dc.Stroke()
|
||||
|
||||
// Inner border
|
||||
dc.SetColor(color.RGBA{200, 80, 80, 255})
|
||||
dc.SetLineWidth(1.5)
|
||||
dc.DrawRoundedRectangle(16, 16, float64(cartonWidth-32), float64(cartonHeight-32), 8)
|
||||
dc.Stroke()
|
||||
|
||||
// Header: "ESTEEMED COMMUNITY MEMBER"
|
||||
if p.headerFont != nil {
|
||||
dc.SetFontFace(p.headerFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{180, 30, 30, 255})
|
||||
dc.DrawStringAnchored("ESTEEMED COMMUNITY MEMBER", float64(cartonWidth)/2, 40, 0.5, 0.5)
|
||||
|
||||
// Sub-header: "MISSING"
|
||||
if p.titleFont != nil {
|
||||
dc.SetFontFace(p.titleFont)
|
||||
}
|
||||
dc.DrawStringAnchored("MISSING", float64(cartonWidth)/2, 66, 0.5, 0.5)
|
||||
|
||||
// Photo area
|
||||
photoX := float64(cartonWidth)/2 - float64(photoSize)/2
|
||||
photoY := 85.0
|
||||
|
||||
// Photo border
|
||||
dc.SetColor(color.RGBA{160, 50, 50, 255})
|
||||
dc.SetLineWidth(2)
|
||||
dc.DrawRectangle(photoX-3, photoY-3, float64(photoSize)+6, float64(photoSize)+6)
|
||||
dc.Stroke()
|
||||
|
||||
// Photo background
|
||||
dc.SetColor(color.RGBA{230, 225, 215, 255})
|
||||
dc.DrawRectangle(photoX, photoY, float64(photoSize), float64(photoSize))
|
||||
dc.Fill()
|
||||
|
||||
avatar := p.loadEntryImage(entry)
|
||||
if avatar != nil {
|
||||
avatarResized := resizeImage(avatar, photoSize, photoSize)
|
||||
dc.DrawImage(avatarResized, int(photoX), int(photoY))
|
||||
} else {
|
||||
// Silhouette for missing images
|
||||
drawSilhouette(dc, photoX, photoY, float64(photoSize))
|
||||
}
|
||||
|
||||
// Name
|
||||
yPos := photoY + float64(photoSize) + 25
|
||||
if p.boldFont != nil {
|
||||
dc.SetFontFace(p.boldFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{40, 40, 40, 255})
|
||||
|
||||
name := entry.Name
|
||||
if len(name) > 30 {
|
||||
name = name[:27] + "..."
|
||||
}
|
||||
dc.DrawStringAnchored(name, float64(cartonWidth)/2, yPos, 0.5, 0.5)
|
||||
|
||||
// Category
|
||||
yPos += 20
|
||||
if p.smallFont != nil {
|
||||
dc.SetFontFace(p.smallFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{120, 100, 100, 255})
|
||||
catDisplay := formatCategory(entry.Category)
|
||||
dc.DrawStringAnchored(catDisplay, float64(cartonWidth)/2, yPos, 0.5, 0.5)
|
||||
|
||||
// "Last seen" with a random funny timeframe
|
||||
yPos += 24
|
||||
if p.regularFont != nil {
|
||||
dc.SetFontFace(p.regularFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{180, 30, 30, 255})
|
||||
lastSeen := randomLastSeen()
|
||||
dc.DrawStringAnchored(fmt.Sprintf("Last seen: %s", lastSeen), float64(cartonWidth)/2, yPos, 0.5, 0.5)
|
||||
|
||||
// Divider
|
||||
yPos += 18
|
||||
dc.SetColor(color.RGBA{200, 80, 80, 180})
|
||||
dc.SetLineWidth(1)
|
||||
dc.DrawLine(40, yPos, float64(cartonWidth)-40, yPos)
|
||||
dc.Stroke()
|
||||
|
||||
// Characteristics header
|
||||
yPos += 18
|
||||
if p.boldFont != nil {
|
||||
dc.SetFontFace(p.boldFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{60, 60, 60, 255})
|
||||
dc.DrawStringAnchored("Distinguishing Characteristics", float64(cartonWidth)/2, yPos, 0.5, 0.5)
|
||||
|
||||
// Characteristics
|
||||
yPos += 6
|
||||
if p.smallFont != nil {
|
||||
dc.SetFontFace(p.smallFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{80, 80, 80, 255})
|
||||
|
||||
maxItems := 4
|
||||
if len(entry.Characteristics) < maxItems {
|
||||
maxItems = len(entry.Characteristics)
|
||||
}
|
||||
maxWidth := float64(cartonWidth) - 60 // padding on each side
|
||||
for i := 0; i < maxItems; i++ {
|
||||
wrapped := dc.WordWrap(entry.Characteristics[i], maxWidth)
|
||||
for _, wline := range wrapped {
|
||||
yPos += 16
|
||||
if yPos > float64(cartonHeight)-55 {
|
||||
break
|
||||
}
|
||||
dc.DrawStringAnchored(wline, float64(cartonWidth)/2, yPos, 0.5, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
if p.smallFont != nil {
|
||||
dc.SetFontFace(p.smallFont)
|
||||
}
|
||||
dc.SetColor(color.RGBA{140, 50, 50, 255})
|
||||
dc.DrawStringAnchored("If found, please return to our", float64(cartonWidth)/2, float64(cartonHeight)-48, 0.5, 0.5)
|
||||
dc.DrawStringAnchored("community for \"cuddles\"", float64(cartonWidth)/2, float64(cartonHeight)-34, 0.5, 0.5)
|
||||
|
||||
// Encode
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dc.Image()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func formatCategory(cat string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"tech_grifters_and_crypto", "Tech & Crypto Division",
|
||||
"politicians", "Political Affairs Bureau",
|
||||
"corporate_villains", "Corporate Relations Dept.",
|
||||
"reality_tv_royalty", "Entertainment Division",
|
||||
"internet_infamous", "Digital Community Outreach",
|
||||
"fictional_characters", "Literary & Media Wing",
|
||||
"historical_figures", "Heritage Society",
|
||||
)
|
||||
result := replacer.Replace(cat)
|
||||
if result == cat {
|
||||
// Fallback: replace underscores and capitalize first letter
|
||||
s := strings.ReplaceAll(cat, "_", " ")
|
||||
if len(s) > 0 {
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func randomLastSeen() string {
|
||||
options := []string{
|
||||
"fleeing the group chat",
|
||||
"updating their LinkedIn bio",
|
||||
"somewhere near a microphone",
|
||||
"posting without reading the room",
|
||||
"starting a new venture",
|
||||
"giving unsolicited advice",
|
||||
"somewhere on the timeline",
|
||||
"rebranding after the incident",
|
||||
"in the replies, unfortunately",
|
||||
"near a camera crew",
|
||||
"drafting a press release",
|
||||
"pivoting to their next opportunity",
|
||||
"explaining why it was taken out of context",
|
||||
"launching a podcast",
|
||||
"signing autographs for no one",
|
||||
}
|
||||
return options[rand.IntN(len(options))]
|
||||
}
|
||||
|
||||
// drawSilhouette draws a generic person silhouette. Extracted as a shared helper.
|
||||
func drawSilhouette(dc *gg.Context, x, y, size float64) {
|
||||
cx := x + size/2
|
||||
cy := y + size/2
|
||||
|
||||
dc.SetColor(color.RGBA{170, 165, 155, 255})
|
||||
|
||||
// Head
|
||||
headRadius := size * 0.18
|
||||
dc.DrawCircle(cx, cy-size*0.12, headRadius)
|
||||
dc.Fill()
|
||||
|
||||
// Body
|
||||
dc.DrawEllipse(cx, cy+size*0.22, size*0.28, size*0.25)
|
||||
dc.Fill()
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (p *FunPlugin) Commands() []CommandDef {
|
||||
{Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"},
|
||||
{Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", Category: "Fun & Games"},
|
||||
{Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"},
|
||||
{Name: "time", Description: "World clock", Usage: "!time [city]", Category: "Lookup & Reference"},
|
||||
{Name: "time", Description: "World clock or user's local time", Usage: "!time [city|@user]", Category: "Lookup & Reference"},
|
||||
{Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"},
|
||||
{Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"},
|
||||
{Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"},
|
||||
@@ -229,7 +229,7 @@ func (p *FunPlugin) handleCoin(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleTime(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(strings.ToLower(p.GetArgs(ctx.Body, "time")))
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "time"))
|
||||
if args == "" {
|
||||
// Show a few major cities
|
||||
cities := []string{"new york", "london", "tokyo", "sydney"}
|
||||
@@ -240,23 +240,55 @@ func (p *FunPlugin) handleTime(ctx MessageContext) error {
|
||||
t := time.Now().In(tz)
|
||||
sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST")))
|
||||
}
|
||||
sb.WriteString("\nUse !time <city> for a specific location.")
|
||||
sb.WriteString("\nUse !time <city> or !time <@user> for a specific location or person.")
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
tzName, ok := cityTimezones[args]
|
||||
if !ok {
|
||||
// Try loading it as a raw IANA zone
|
||||
tzName = args
|
||||
// If it looks like a Matrix user ID, go straight to user lookup
|
||||
if strings.HasPrefix(args, "@") && strings.Contains(args, ":") {
|
||||
return p.showUserTime(ctx, args)
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
// Try city map first (cheap, deterministic lookup — avoids username collisions)
|
||||
argsLower := strings.ToLower(args)
|
||||
if tzName, ok := cityTimezones[argsLower]; ok {
|
||||
loc, _ := time.LoadLocation(tzName)
|
||||
t := time.Now().In(loc)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", titleCase(argsLower), t.Format("Monday, January 2, 2006 3:04 PM MST")))
|
||||
}
|
||||
|
||||
// Try raw IANA timezone (preserve original case — IANA names are case-sensitive)
|
||||
if loc, err := time.LoadLocation(args); err == nil {
|
||||
t := time.Now().In(loc)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST")))
|
||||
}
|
||||
|
||||
// Fall back to user lookup
|
||||
return p.showUserTime(ctx, args)
|
||||
}
|
||||
|
||||
func (p *FunPlugin) showUserTime(ctx MessageContext, input string) error {
|
||||
resolved, ok := p.ResolveUser(input)
|
||||
if !ok {
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city, timezone, or user: %s", input))
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var tz string
|
||||
err := d.QueryRow(`SELECT timezone FROM birthdays WHERE user_id = ?`, string(resolved)).Scan(&tz)
|
||||
if err != nil || tz == "" {
|
||||
return p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("%s hasn't set their timezone yet. They can use !settz <timezone> to set it.", string(resolved)))
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city or timezone: %s", args))
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Invalid timezone stored for %s: %s", string(resolved), tz))
|
||||
}
|
||||
|
||||
t := time.Now().In(loc)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST")))
|
||||
return p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("🕐 %s: **%s** (%s)", string(resolved), t.Format("Monday, January 2, 2006 3:04 PM"), tz))
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
|
||||
|
||||
216
internal/plugin/horoscope.go
Normal file
216
internal/plugin/horoscope.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// zodiacSign maps month/day to a zodiac sign name.
|
||||
func zodiacSign(month, day int) string {
|
||||
switch {
|
||||
case (month == 3 && day >= 21) || (month == 4 && day <= 19):
|
||||
return "aries"
|
||||
case (month == 4 && day >= 20) || (month == 5 && day <= 20):
|
||||
return "taurus"
|
||||
case (month == 5 && day >= 21) || (month == 6 && day <= 20):
|
||||
return "gemini"
|
||||
case (month == 6 && day >= 21) || (month == 7 && day <= 22):
|
||||
return "cancer"
|
||||
case (month == 7 && day >= 23) || (month == 8 && day <= 22):
|
||||
return "leo"
|
||||
case (month == 8 && day >= 23) || (month == 9 && day <= 22):
|
||||
return "virgo"
|
||||
case (month == 9 && day >= 23) || (month == 10 && day <= 22):
|
||||
return "libra"
|
||||
case (month == 10 && day >= 23) || (month == 11 && day <= 21):
|
||||
return "scorpio"
|
||||
case (month == 11 && day >= 22) || (month == 12 && day <= 21):
|
||||
return "sagittarius"
|
||||
case (month == 12 && day >= 22) || (month == 1 && day <= 19):
|
||||
return "capricorn"
|
||||
case (month == 1 && day >= 20) || (month == 2 && day <= 18):
|
||||
return "aquarius"
|
||||
case (month == 2 && day >= 19) || (month == 3 && day <= 20):
|
||||
return "pisces"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var zodiacEmoji = map[string]string{
|
||||
"aries": "♈",
|
||||
"taurus": "♉",
|
||||
"gemini": "♊",
|
||||
"cancer": "♋",
|
||||
"leo": "♌",
|
||||
"virgo": "♍",
|
||||
"libra": "♎",
|
||||
"scorpio": "♏",
|
||||
"sagittarius": "♐",
|
||||
"capricorn": "♑",
|
||||
"aquarius": "♒",
|
||||
"pisces": "♓",
|
||||
}
|
||||
|
||||
// HoroscopePlugin provides daily horoscope readings based on user birthdays.
|
||||
type HoroscopePlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewHoroscopePlugin creates a new HoroscopePlugin.
|
||||
func NewHoroscopePlugin(client *mautrix.Client) *HoroscopePlugin {
|
||||
return &HoroscopePlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoroscopePlugin) Name() string { return "horoscope" }
|
||||
|
||||
func (p *HoroscopePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "horoscope", Description: "Get your daily horoscope (requires birthday)", Usage: "!horoscope", Category: "Fun & Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoroscopePlugin) Init() error { return nil }
|
||||
|
||||
func (p *HoroscopePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *HoroscopePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "horoscope") {
|
||||
return p.handleHoroscope(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *HoroscopePlugin) handleHoroscope(ctx MessageContext) error {
|
||||
// Check if user has a birthday set
|
||||
d := db.Get()
|
||||
var month, day int
|
||||
err := d.QueryRow(
|
||||
`SELECT month, day FROM birthdays WHERE user_id = ? AND month > 0 AND day > 0`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&month, &day)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"You need to set your birthday first! Use !birthday set <MM-DD> to get your horoscope.")
|
||||
}
|
||||
|
||||
sign := zodiacSign(month, day)
|
||||
if sign == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not determine your zodiac sign. Check your birthday with !birthday show.")
|
||||
}
|
||||
|
||||
// Check cache (horoscopes are daily, cache for 6 hours)
|
||||
cacheKey := fmt.Sprintf("horoscope:%s", sign)
|
||||
if cached := db.CacheGet(cacheKey, 21600); cached != "" {
|
||||
emoji := zodiacEmoji[sign]
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), cached))
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
horoscope, err := fetchHoroscope(sign)
|
||||
if err != nil {
|
||||
slog.Error("horoscope: fetch failed", "err", err, "sign", sign)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your horoscope. Try again later.")
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
db.CacheSet(cacheKey, horoscope)
|
||||
|
||||
emoji := zodiacEmoji[sign]
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), horoscope))
|
||||
}
|
||||
|
||||
func fetchHoroscope(sign string) (string, error) {
|
||||
url := fmt.Sprintf("https://freehoroscopeapi.com/api/v1/get-horoscope/daily?sign=%s", sign)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Horoscope string `json:"horoscope"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Data.Horoscope == "" {
|
||||
return "", fmt.Errorf("empty horoscope in response")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.Data.Horoscope), nil
|
||||
}
|
||||
|
||||
// PostDailyHoroscopes posts all 12 horoscopes to a room as a daily digest.
|
||||
func (p *HoroscopePlugin) PostDailyHoroscopes(roomID id.RoomID) {
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if db.JobCompleted("horoscope", dateKey+":"+string(roomID)) {
|
||||
return
|
||||
}
|
||||
|
||||
signs := []string{
|
||||
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
|
||||
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Daily Horoscopes — %s\n\n", time.Now().UTC().Format("Monday, January 2")))
|
||||
|
||||
fetched := 0
|
||||
for _, sign := range signs {
|
||||
horoscope, err := fetchHoroscope(sign)
|
||||
if err != nil {
|
||||
slog.Error("horoscope: daily fetch", "sign", sign, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Cache each one
|
||||
db.CacheSet(fmt.Sprintf("horoscope:%s", sign), horoscope)
|
||||
|
||||
emoji := zodiacEmoji[sign]
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**\n%s\n\n", emoji, titleCase(sign), horoscope))
|
||||
fetched++
|
||||
}
|
||||
|
||||
if fetched == 0 {
|
||||
slog.Error("horoscope: no signs fetched for daily post")
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString("Set your birthday with !birthday set <MM-DD> to get personalized readings!")
|
||||
|
||||
if err := p.SendMessage(roomID, sb.String()); err != nil {
|
||||
slog.Error("horoscope: post daily", "room", roomID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
db.MarkJobCompleted("horoscope", dateKey+":"+string(roomID))
|
||||
}
|
||||
@@ -56,9 +56,8 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami"))
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,11 @@ type classificationResult struct {
|
||||
|
||||
// queueItem holds a message pending classification.
|
||||
type queueItem struct {
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
EventID id.EventID
|
||||
Body string
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
EventID id.EventID
|
||||
Body string
|
||||
FormattedBody string
|
||||
}
|
||||
|
||||
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
|
||||
@@ -150,19 +151,32 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// Pre-filter: only classify messages that match certain criteria
|
||||
if !p.shouldClassify(ctx.Body) {
|
||||
var fmtBody string
|
||||
if ctx.Event != nil {
|
||||
if mc := ctx.Event.Content.AsMessage(); mc != nil {
|
||||
fmtBody = mc.FormattedBody
|
||||
}
|
||||
}
|
||||
if !p.shouldClassify(ctx.Body, fmtBody) {
|
||||
slog.Debug("llm_passive: message did not pass pre-filter", "body_len", len(ctx.Body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enqueue for async classification
|
||||
slog.Debug("llm_passive: enqueuing message for classification", "user", ctx.Sender, "body_len", len(ctx.Body))
|
||||
var formattedBody string
|
||||
if ctx.Event != nil {
|
||||
if mc := ctx.Event.Content.AsMessage(); mc != nil {
|
||||
formattedBody = mc.FormattedBody
|
||||
}
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.queue = append(p.queue, queueItem{
|
||||
UserID: ctx.Sender,
|
||||
RoomID: ctx.RoomID,
|
||||
EventID: ctx.EventID,
|
||||
Body: ctx.Body,
|
||||
UserID: ctx.Sender,
|
||||
RoomID: ctx.RoomID,
|
||||
EventID: ctx.EventID,
|
||||
Body: ctx.Body,
|
||||
FormattedBody: formattedBody,
|
||||
})
|
||||
p.mu.Unlock()
|
||||
|
||||
@@ -170,7 +184,7 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// shouldClassify applies pre-filtering heuristics.
|
||||
func (p *LLMPassivePlugin) shouldClassify(body string) bool {
|
||||
func (p *LLMPassivePlugin) shouldClassify(body, formattedBody string) bool {
|
||||
// Skip single-character messages (trivia answers, etc.)
|
||||
if len(strings.TrimSpace(body)) <= 1 {
|
||||
return false
|
||||
@@ -185,10 +199,13 @@ func (p *LLMPassivePlugin) shouldClassify(body string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Check mentions
|
||||
// Check mentions (plain text or HTML formatted body)
|
||||
if mentionRe.MatchString(body) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(formattedBody, "matrix.to/#/@") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check WOTD pattern (simple heuristic)
|
||||
if strings.Contains(lower, "wotd") {
|
||||
@@ -255,13 +272,56 @@ func (p *LLMPassivePlugin) processQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// extractMentionMap builds a display-name-to-MXID mapping from the HTML formatted body.
|
||||
func extractMentionMap(formattedBody string) map[string]string {
|
||||
if formattedBody == "" {
|
||||
return nil
|
||||
}
|
||||
// Match: <a href="https://matrix.to/#/@user:server">Display Name</a>
|
||||
re := regexp.MustCompile(`<a\s+href="https://matrix\.to/#/(@[^"]+)">([^<]+)</a>`)
|
||||
matches := re.FindAllStringSubmatch(formattedBody, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, m := range matches {
|
||||
if len(m) >= 3 {
|
||||
result[m[2]] = m[1] // display name -> MXID
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// classifyAndProcess sends a message to Ollama and processes the result.
|
||||
func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
result, err := p.callOllama(item.Body)
|
||||
// Build mention context so the LLM knows actual MXIDs
|
||||
mentionMap := extractMentionMap(item.FormattedBody)
|
||||
var mentionHint string
|
||||
if len(mentionMap) > 0 {
|
||||
var parts []string
|
||||
for displayName, mxid := range mentionMap {
|
||||
parts = append(parts, fmt.Sprintf("%s = %s", displayName, mxid))
|
||||
}
|
||||
mentionHint = "\nMentioned users: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
result, err := p.callOllama(item.Body + mentionHint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ollama call: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any display names in LLM targets back to MXIDs
|
||||
if result.InsultTarget != "" && !strings.Contains(result.InsultTarget, ":") {
|
||||
if mxid, ok := mentionMap[result.InsultTarget]; ok {
|
||||
result.InsultTarget = mxid
|
||||
}
|
||||
}
|
||||
if result.GratitudeTarget != "" && !strings.Contains(result.GratitudeTarget, ":") {
|
||||
if mxid, ok := mentionMap[result.GratitudeTarget]; ok {
|
||||
result.GratitudeTarget = mxid
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Store classification
|
||||
@@ -336,7 +396,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
"\U0001f52a", // 🔪
|
||||
"\U0001f5e1", // 🗡️
|
||||
"\U0001fa78", // 🩸
|
||||
"\U0001f480", // 💀
|
||||
"\U0001f4a3", // 💣
|
||||
}
|
||||
emoji := botReactions[rand.Intn(len(botReactions))]
|
||||
_ = p.SendReact(item.RoomID, item.EventID, emoji)
|
||||
@@ -519,9 +579,8 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "potty")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,9 +601,11 @@ func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, count FROM potty_mouth ORDER BY count DESC LIMIT 10`,
|
||||
`SELECT user_id, count FROM potty_mouth ORDER BY count DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: pottyboard query", "err", err)
|
||||
@@ -557,12 +618,15 @@ func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var count int
|
||||
if err := rows.Scan(&userID, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
@@ -582,9 +646,8 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "insults")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,9 +668,11 @@ func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC LIMIT 10`,
|
||||
`SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: insultboard query", "err", err)
|
||||
@@ -620,12 +685,15 @@ func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var count int
|
||||
if err := rows.Scan(&userID, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
@@ -645,9 +713,8 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "sentiment")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,9 @@ func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||
targetUser = ctx.Sender
|
||||
default:
|
||||
// Treat as user ID
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
targetUser = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
targetUser = resolved
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch corpus for the user
|
||||
|
||||
@@ -151,9 +151,9 @@ func (p *MilkCartonPlugin) handleHaveYouSeenThem(ctx MessageContext) error {
|
||||
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)")
|
||||
targetID, ok := p.ResolveUser(args)
|
||||
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
|
||||
@@ -414,7 +414,7 @@ func (p *MilkCartonPlugin) renderCarton(
|
||||
dc.DrawImage(avatarResized, int(photoX), int(photoY))
|
||||
} else {
|
||||
// Draw silhouette
|
||||
p.drawSilhouette(dc, photoX, photoY, float64(photoSize))
|
||||
drawSilhouette(dc, photoX, photoY, float64(photoSize))
|
||||
}
|
||||
|
||||
// Name
|
||||
@@ -503,22 +503,6 @@ func (p *MilkCartonPlugin) renderCarton(
|
||||
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()
|
||||
@@ -613,11 +597,11 @@ func resizeImage(src image.Image, targetW, targetH int) image.Image {
|
||||
dc := gg.NewContext(targetW, targetH)
|
||||
dc.DrawImage(src, (targetW-newW)/2, (targetH-newH)/2)
|
||||
|
||||
// Use gg's built-in scaling
|
||||
// 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 := -float64(srcH)/2 + float64(targetH)/(2*scale)
|
||||
offsetY := 0.0 // top-anchored crop
|
||||
dcScaled.DrawImage(src, int(offsetX), int(offsetY))
|
||||
|
||||
return dcScaled.Image()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/util"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
@@ -87,6 +88,93 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RoomMembers returns the set of user IDs in a room. Used to scope leaderboards
|
||||
// to only show users present in the room where the command was issued.
|
||||
func (b *Base) RoomMembers(roomID id.RoomID) map[id.UserID]bool {
|
||||
resp, err := b.Client.JoinedMembers(context.Background(), roomID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get room members", "room", roomID, "err", err)
|
||||
return nil
|
||||
}
|
||||
members := make(map[id.UserID]bool, len(resp.Joined))
|
||||
for uid := range resp.Joined {
|
||||
members[uid] = true
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
// ResolveUser resolves a partial username to a full Matrix user ID.
|
||||
// Accepts any of: "@user:server", "user:server", "user", or partial match.
|
||||
// Returns the matched user ID and true, or empty and false if no unique match.
|
||||
func (b *Base) ResolveUser(input string) (id.UserID, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// If it already looks like a full Matrix ID, use it directly
|
||||
if strings.Contains(input, ":") {
|
||||
if !strings.HasPrefix(input, "@") {
|
||||
input = "@" + input
|
||||
}
|
||||
return id.UserID(input), true
|
||||
}
|
||||
|
||||
// Strip leading @ for the search
|
||||
query := strings.TrimPrefix(input, "@")
|
||||
query = strings.ToLower(query)
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT user_id FROM user_stats`)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var exact []string
|
||||
var partial []string
|
||||
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract localpart: @localpart:server -> localpart
|
||||
localpart := uid
|
||||
if strings.HasPrefix(localpart, "@") {
|
||||
localpart = localpart[1:]
|
||||
}
|
||||
if idx := strings.Index(localpart, ":"); idx > 0 {
|
||||
localpart = localpart[:idx]
|
||||
}
|
||||
|
||||
lower := strings.ToLower(localpart)
|
||||
|
||||
if lower == query {
|
||||
exact = append(exact, uid)
|
||||
} else if strings.Contains(lower, query) {
|
||||
partial = append(partial, uid)
|
||||
}
|
||||
}
|
||||
|
||||
// Exact localpart match — return it (even if multiple servers, prefer first)
|
||||
if len(exact) == 1 {
|
||||
return id.UserID(exact[0]), true
|
||||
}
|
||||
if len(exact) > 1 {
|
||||
// Multiple exact matches across servers — return the first one
|
||||
return id.UserID(exact[0]), true
|
||||
}
|
||||
|
||||
// Single partial match
|
||||
if len(partial) == 1 {
|
||||
return id.UserID(partial[0]), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SendMessage sends a plain text message to a room.
|
||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
@@ -227,9 +315,10 @@ func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption str
|
||||
return fmt.Errorf("upload image: %w", err)
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
Body: caption,
|
||||
URL: uri.CUString(),
|
||||
MsgType: event.MsgImage,
|
||||
Body: caption,
|
||||
FileName: filename,
|
||||
URL: uri.CUString(),
|
||||
Info: &event.FileInfo{
|
||||
MimeType: "image/png",
|
||||
Size: len(imgData),
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// PresencePlugin handles away status and user profile lookups.
|
||||
@@ -128,10 +127,13 @@ func (p *PresencePlugin) autoClearAway(ctx MessageContext) error {
|
||||
func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
|
||||
if args == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !whois @user:server")
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !whois <user>")
|
||||
}
|
||||
|
||||
targetUser := id.UserID(args)
|
||||
targetUser, ok := p.ResolveUser(args)
|
||||
if !ok {
|
||||
return p.SendMessage(ctx.RoomID, "Could not find a user matching that name.")
|
||||
}
|
||||
|
||||
// Gather profile data
|
||||
var displayName string
|
||||
@@ -183,12 +185,13 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get reputation (from XP log with reason = 'rep')
|
||||
var repCount int
|
||||
// Get reputation (from XP log with reason = 'reputation')
|
||||
var repXP int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM xp_log WHERE user_id = ? AND reason = 'rep'`,
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||
string(targetUser),
|
||||
).Scan(&repCount)
|
||||
).Scan(&repXP)
|
||||
repCount := repXP / 5
|
||||
|
||||
// Get presence status
|
||||
var status, statusMsg string
|
||||
|
||||
@@ -77,34 +77,36 @@ func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventI
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
var sb strings.Builder
|
||||
|
||||
// Top 10 emoji givers
|
||||
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
|
||||
rows, err := d.Query(
|
||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC LIMIT 10`,
|
||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: givers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
giverCount := appendUserBoard(&sb, rows)
|
||||
giverCount := appendUserBoard(&sb, rows, members)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 emoji receivers
|
||||
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC LIMIT 10`,
|
||||
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: receivers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
receiverCount := appendUserBoard(&sb, rows)
|
||||
receiverCount := appendUserBoard(&sb, rows, members)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 most used emojis
|
||||
// Top 10 most used emojis (no user filtering needed)
|
||||
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
@@ -123,16 +125,19 @@ func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// appendUserBoard writes ranked user lines from query rows.
|
||||
func appendUserBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||
// appendUserBoard writes ranked user lines from query rows, filtered by room members.
|
||||
func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var name string
|
||||
var cnt int
|
||||
if err := rows.Scan(&name, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(name)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
|
||||
@@ -16,9 +16,13 @@ import (
|
||||
|
||||
var thankRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm)\b`)
|
||||
|
||||
// userMentionRe matches Matrix user IDs like @user:server.tld
|
||||
// userMentionRe matches Matrix user IDs like @user:server.tld in plain text.
|
||||
var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||
|
||||
// matrixToMentionRe extracts Matrix user IDs from HTML formatted_body mentions.
|
||||
// Element and most clients format mentions as: <a href="https://matrix.to/#/@user:server">Name</a>
|
||||
var matrixToMentionRe = regexp.MustCompile(`https://matrix\.to/#/(@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+)`)
|
||||
|
||||
// ReputationPlugin tracks gratitude and awards reputation XP.
|
||||
type ReputationPlugin struct {
|
||||
Base
|
||||
@@ -63,8 +67,8 @@ func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
|
||||
// Find mentioned users
|
||||
mentions := userMentionRe.FindAllString(ctx.Body, -1)
|
||||
// Find mentioned users from both plain text and HTML formatted body
|
||||
mentions := extractMentions(ctx)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -125,9 +129,8 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "rep")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +153,13 @@ func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, SUM(amount) as total
|
||||
FROM xp_log WHERE reason = 'reputation'
|
||||
GROUP BY user_id ORDER BY total DESC LIMIT 10`,
|
||||
GROUP BY user_id ORDER BY total DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rep: repboard query", "err", err)
|
||||
@@ -167,12 +172,15 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var totalXP int
|
||||
if err := rows.Scan(&userID, &totalXP); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
repCount := totalXP / 5
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
@@ -188,3 +196,33 @@ func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// extractMentions pulls Matrix user IDs from both the plain text body and
|
||||
// the HTML formatted_body (where most clients put actual @user:server mentions).
|
||||
func extractMentions(ctx MessageContext) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
|
||||
// Check plain text body for raw @user:server mentions
|
||||
for _, m := range userMentionRe.FindAllString(ctx.Body, -1) {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Check HTML formatted_body for matrix.to mention links
|
||||
if ctx.Event != nil {
|
||||
content := ctx.Event.Content.AsMessage()
|
||||
if content != nil && content.FormattedBody != "" {
|
||||
for _, match := range matrixToMentionRe.FindAllStringSubmatch(content.FormattedBody, -1) {
|
||||
if len(match) > 1 && !seen[match[1]] {
|
||||
seen[match[1]] = true
|
||||
result = append(result, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -166,9 +166,8 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "stats")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,10 +234,12 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
|
||||
}
|
||||
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
// Using fmt.Sprintf for column name is safe here since we control the value above
|
||||
query := fmt.Sprintf(
|
||||
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC LIMIT 10`,
|
||||
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC`,
|
||||
column, column, column,
|
||||
)
|
||||
rows, err := d.Query(query)
|
||||
@@ -253,12 +254,15 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var val int
|
||||
if err := rows.Scan(&userID, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
|
||||
@@ -91,9 +91,8 @@ func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "streak")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,14 +201,15 @@ func calculateStreaks(dates []string) (current int, longest int) {
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, COUNT(*) as wins
|
||||
FROM daily_first
|
||||
GROUP BY user_id
|
||||
ORDER BY wins DESC
|
||||
LIMIT 10`,
|
||||
ORDER BY wins DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: firstboard query", "err", err)
|
||||
@@ -222,12 +222,15 @@ func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var wins int
|
||||
if err := rows.Scan(&userID, &wins); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
|
||||
@@ -62,6 +62,11 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
// Skip Matrix internal links (user mentions, room links, etc.)
|
||||
if strings.Contains(u, "matrix.to/") {
|
||||
continue
|
||||
}
|
||||
|
||||
title, desc, err := p.fetchPreview(u)
|
||||
if err != nil {
|
||||
slog.Debug("urls: fetch preview failed", "url", u, "err", err)
|
||||
|
||||
@@ -158,10 +158,8 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "rank")
|
||||
if args != "" {
|
||||
// Trim optional @ prefix and whitespace
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
if resolved, ok := p.ResolveUser(args); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,9 +181,9 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
|
||||
needed := xpForNext - xpForCurrent
|
||||
bar := util.ProgressBar(progress, needed, 20)
|
||||
|
||||
// Get rank position
|
||||
// Get rank position (users with same XP share the same rank)
|
||||
var rank int
|
||||
err = d.QueryRow(`SELECT COUNT(*) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
|
||||
err = d.QueryRow(`SELECT COUNT(DISTINCT xp) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
|
||||
if err != nil {
|
||||
rank = 0
|
||||
}
|
||||
@@ -199,8 +197,10 @@ func (p *XPPlugin) handleRank(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC LIMIT 10`)
|
||||
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC`)
|
||||
if err != nil {
|
||||
slog.Error("xp: leaderboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
||||
@@ -212,12 +212,15 @@ func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
for rows.Next() && i < 10 {
|
||||
var userID string
|
||||
var xp, level int
|
||||
if err := rows.Scan(&userID, &xp, &level); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(userID)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
|
||||
Reference in New Issue
Block a user