mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
6.2 KiB
Go
257 lines
6.2 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// StreaksPlugin tracks daily activity streaks and first-poster records.
|
|
type StreaksPlugin struct {
|
|
Base
|
|
}
|
|
|
|
// NewStreaksPlugin creates a new streaks plugin.
|
|
func NewStreaksPlugin(client *mautrix.Client) *StreaksPlugin {
|
|
return &StreaksPlugin{
|
|
Base: NewBase(client),
|
|
}
|
|
}
|
|
|
|
func (p *StreaksPlugin) Name() string { return "streaks" }
|
|
|
|
func (p *StreaksPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "streak", Description: "Show your current and longest activity streak", Usage: "!streak", Category: "Leveling & Stats"},
|
|
{Name: "firstboard", Description: "Show top first-posters of the day", Usage: "!firstboard", Category: "Leveling & Stats"},
|
|
}
|
|
}
|
|
|
|
func (p *StreaksPlugin) Init() error { return nil }
|
|
|
|
func (p *StreaksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
|
|
if p.IsCommand(ctx.Body, "streak") {
|
|
return p.handleStreak(ctx)
|
|
}
|
|
if p.IsCommand(ctx.Body, "firstboard") {
|
|
return p.handleFirstboard(ctx)
|
|
}
|
|
|
|
// Skip tracking for bot commands
|
|
if ctx.IsCommand {
|
|
return nil
|
|
}
|
|
|
|
// Passive: track daily activity and first poster
|
|
p.trackDailyActivity(ctx)
|
|
p.trackFirstPoster(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
|
d := db.Get()
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
|
|
_, err := d.Exec(
|
|
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
|
|
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
|
string(ctx.Sender), today,
|
|
)
|
|
if err != nil {
|
|
slog.Error("streaks: track daily activity", "err", err)
|
|
}
|
|
}
|
|
|
|
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
|
d := db.Get()
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
now := time.Now().UTC().Unix()
|
|
|
|
// INSERT OR IGNORE — only the first poster for this room+date wins
|
|
_, err := d.Exec(
|
|
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
|
|
string(ctx.RoomID), today, string(ctx.Sender), now,
|
|
)
|
|
if err != nil {
|
|
slog.Error("streaks: track first poster", "err", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
d := db.Get()
|
|
|
|
// Get all active dates for this user, sorted descending
|
|
rows, err := d.Query(
|
|
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC`,
|
|
string(target),
|
|
)
|
|
if err != nil {
|
|
slog.Error("streaks: query", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up streak.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var dates []string
|
|
for rows.Next() {
|
|
var date string
|
|
if err := rows.Scan(&date); err != nil {
|
|
continue
|
|
}
|
|
dates = append(dates, date)
|
|
}
|
|
|
|
if len(dates) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no activity recorded yet.", string(target)))
|
|
}
|
|
|
|
currentStreak, longestStreak := calculateStreaks(dates)
|
|
|
|
msg := fmt.Sprintf(
|
|
"🔥 %s — Streak Report\nCurrent streak: %s days\nLongest streak: %s days\nTotal active days: %s",
|
|
string(target),
|
|
formatNumber(currentStreak),
|
|
formatNumber(longestStreak),
|
|
formatNumber(len(dates)),
|
|
)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
// calculateStreaks computes current and longest streaks from descending-sorted date strings.
|
|
func calculateStreaks(dates []string) (current int, longest int) {
|
|
if len(dates) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
|
|
// Parse first date — current streak only counts if it includes today or yesterday
|
|
firstDate, err := time.Parse("2006-01-02", dates[0])
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
|
|
todayDate, _ := time.Parse("2006-01-02", today)
|
|
daysSinceFirst := int(todayDate.Sub(firstDate).Hours() / 24)
|
|
if daysSinceFirst > 1 {
|
|
// Streak is broken — current streak is 0
|
|
// Still calculate longest
|
|
current = 0
|
|
} else {
|
|
// Count current streak
|
|
current = 1
|
|
for i := 1; i < len(dates); i++ {
|
|
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
|
curr, err2 := time.Parse("2006-01-02", dates[i])
|
|
if err1 != nil || err2 != nil {
|
|
break
|
|
}
|
|
diff := int(prev.Sub(curr).Hours() / 24)
|
|
if diff == 1 {
|
|
current++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate longest streak (walk all dates)
|
|
streak := 1
|
|
longest = 1
|
|
for i := 1; i < len(dates); i++ {
|
|
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
|
curr, err2 := time.Parse("2006-01-02", dates[i])
|
|
if err1 != nil || err2 != nil {
|
|
streak = 1
|
|
continue
|
|
}
|
|
diff := int(prev.Sub(curr).Hours() / 24)
|
|
if diff == 1 {
|
|
streak++
|
|
} else {
|
|
streak = 1
|
|
}
|
|
if streak > longest {
|
|
longest = streak
|
|
}
|
|
}
|
|
|
|
if current > longest {
|
|
longest = current
|
|
}
|
|
|
|
return current, longest
|
|
}
|
|
|
|
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
|
|
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`,
|
|
)
|
|
if err != nil {
|
|
slog.Error("streaks: firstboard query", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load first-poster board.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("🌅 First Poster Board — Top 10\n\n")
|
|
|
|
medals := []string{"🥇", "🥈", "🥉"}
|
|
i := 0
|
|
for rows.Next() {
|
|
var userID string
|
|
var wins int
|
|
if err := rows.Scan(&userID, &wins); err != nil {
|
|
continue
|
|
}
|
|
prefix := fmt.Sprintf("#%d", i+1)
|
|
if i < len(medals) {
|
|
prefix = medals[i]
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%s %s — %s first posts\n", prefix, userID, formatNumber(wins)))
|
|
i++
|
|
}
|
|
|
|
if i == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No first-poster data yet.")
|
|
}
|
|
|
|
// Also show today's first poster
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
var todayFirst sql.NullString
|
|
_ = d.QueryRow(
|
|
`SELECT user_id FROM daily_first WHERE room_id = ? AND date = ?`,
|
|
string(ctx.RoomID), today,
|
|
).Scan(&todayFirst)
|
|
|
|
if todayFirst.Valid {
|
|
sb.WriteString(fmt.Sprintf("\nToday's first poster in this room: %s", todayFirst.String))
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|