mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Rewrite from TypeScript to Go
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>
This commit is contained in:
256
internal/plugin/streaks.go
Normal file
256
internal/plugin/streaks.go
Normal file
@@ -0,0 +1,256 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user