mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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:
190
internal/plugin/reputation.go
Normal file
190
internal/plugin/reputation.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
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
|
||||
var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||
|
||||
// ReputationPlugin tracks gratitude and awards reputation XP.
|
||||
type ReputationPlugin struct {
|
||||
Base
|
||||
xp *XPPlugin
|
||||
}
|
||||
|
||||
// NewReputationPlugin creates a new reputation plugin.
|
||||
func NewReputationPlugin(client *mautrix.Client, xp *XPPlugin) *ReputationPlugin {
|
||||
return &ReputationPlugin{
|
||||
Base: NewBase(client),
|
||||
xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) Name() string { return "reputation" }
|
||||
|
||||
func (p *ReputationPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "rep", Description: "Show reputation count for a user", Usage: "!rep [@user]", Category: "Leveling & Stats"},
|
||||
{Name: "repboard", Description: "Show top 10 reputation receivers", Usage: "!repboard", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) Init() error { return nil }
|
||||
|
||||
func (p *ReputationPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rep") {
|
||||
return p.handleRep(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "repboard") {
|
||||
return p.handleRepboard(ctx)
|
||||
}
|
||||
|
||||
// Passive: detect thank-you messages
|
||||
if thankRe.MatchString(ctx.Body) {
|
||||
return p.handleThank(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
|
||||
// Find mentioned users
|
||||
mentions := userMentionRe.FindAllString(ctx.Body, -1)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
now := time.Now().UTC().Unix()
|
||||
cooldownDuration := int64(24 * 60 * 60) // 24 hours
|
||||
|
||||
for _, mention := range mentions {
|
||||
receiver := id.UserID(mention)
|
||||
|
||||
// Can't thank yourself
|
||||
if receiver == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
var lastGiven int64
|
||||
err := d.QueryRow(
|
||||
`SELECT last_given FROM rep_cooldowns WHERE giver = ? AND receiver = ?`,
|
||||
string(ctx.Sender), string(receiver),
|
||||
).Scan(&lastGiven)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
slog.Error("rep: cooldown check", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && now-lastGiven < cooldownDuration {
|
||||
continue // Still on cooldown
|
||||
}
|
||||
|
||||
// Update cooldown
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO rep_cooldowns (giver, receiver, last_given) VALUES (?, ?, ?)
|
||||
ON CONFLICT(giver, receiver) DO UPDATE SET last_given = ?`,
|
||||
string(ctx.Sender), string(receiver), now, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rep: update cooldown", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Award 5 XP via the XP plugin
|
||||
if p.xp != nil {
|
||||
p.xp.GrantXP(receiver, 5, "reputation")
|
||||
}
|
||||
|
||||
if err := p.SendReact(ctx.RoomID, ctx.EventID, "💜"); err != nil {
|
||||
slog.Error("rep: send react", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||
string(target),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
slog.Error("rep: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up reputation.")
|
||||
}
|
||||
|
||||
// Count is total XP from rep, each rep gives 5 XP, so rep count = count / 5
|
||||
repCount := count / 5
|
||||
msg := fmt.Sprintf("💜 %s has received %s reputation points (%s XP from gratitude).",
|
||||
string(target), formatNumber(repCount), formatNumber(count))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||
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`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rep: repboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load reputation board.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("💜 Reputation Board — Top 10\n\n")
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var totalXP int
|
||||
if err := rows.Scan(&userID, &totalXP); err != nil {
|
||||
continue
|
||||
}
|
||||
repCount := totalXP / 5
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s rep (%s XP)\n", prefix, userID, formatNumber(repCount), formatNumber(totalXP)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No reputation data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
Reference in New Issue
Block a user