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>
1298 lines
40 KiB
Go
1298 lines
40 KiB
Go
package plugin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type modConfig struct {
|
|
// Word list
|
|
WordListPath string
|
|
WordListVariations bool
|
|
|
|
// Strike system
|
|
StrikeExpiryDays int
|
|
MuteDurationMinutes int
|
|
MaxStrikes int
|
|
|
|
// Text flood
|
|
FloodMessageCount int
|
|
FloodMessageWindowSeconds int
|
|
|
|
// Image/file flood
|
|
FloodImageCount int
|
|
FloodImageWindowSeconds int
|
|
|
|
// Wall of text
|
|
MaxMessageLength int
|
|
|
|
// Repeated messages
|
|
RepeatCount int
|
|
RepeatWindowSeconds int
|
|
RepeatSimilarityThreshold float64
|
|
|
|
// Mention flood
|
|
MentionMax int
|
|
MentionFloodCount int
|
|
MentionFloodWindowSeconds int
|
|
|
|
// Link rate (new members)
|
|
LinkRateNewMember int
|
|
LinkRateWindowSeconds int
|
|
|
|
// Invite flooding
|
|
InviteMaxPerHour int
|
|
|
|
// Join/leave cycling
|
|
JoinLeaveCount int
|
|
JoinLeaveWindowMinutes int
|
|
|
|
// New member
|
|
NewMemberDays int
|
|
NewMemberFloodMultiplier float64
|
|
|
|
// Admin
|
|
AdminRoom id.RoomID
|
|
DMOnAction bool
|
|
}
|
|
|
|
func loadModConfig() modConfig {
|
|
return modConfig{
|
|
WordListPath: envOrDefault("MOD_WORDLIST", ""),
|
|
WordListVariations: envOrDefault("MOD_WORDLIST_VARIATIONS", "true") == "true",
|
|
|
|
StrikeExpiryDays: envInt("MOD_STRIKE_EXPIRY_DAYS", 30),
|
|
MuteDurationMinutes: envInt("MOD_MUTE_DURATION_MINUTES", 60),
|
|
MaxStrikes: envInt("MOD_MAX_STRIKES", 3),
|
|
|
|
FloodMessageCount: envInt("MOD_FLOOD_MESSAGE_COUNT", 5),
|
|
FloodMessageWindowSeconds: envInt("MOD_FLOOD_MESSAGE_WINDOW_SECONDS", 10),
|
|
|
|
FloodImageCount: envInt("MOD_FLOOD_IMAGE_COUNT", 3),
|
|
FloodImageWindowSeconds: envInt("MOD_FLOOD_IMAGE_WINDOW_SECONDS", 30),
|
|
|
|
MaxMessageLength: envInt("MOD_MAX_MESSAGE_LENGTH", 2000),
|
|
|
|
RepeatCount: envInt("MOD_REPEAT_COUNT", 3),
|
|
RepeatWindowSeconds: envInt("MOD_REPEAT_WINDOW_SECONDS", 60),
|
|
RepeatSimilarityThreshold: envFloat("MOD_REPEAT_SIMILARITY_THRESHOLD", 0.85),
|
|
|
|
MentionMax: envInt("MOD_MENTION_MAX", 5),
|
|
MentionFloodCount: envInt("MOD_MENTION_FLOOD_COUNT", 3),
|
|
MentionFloodWindowSeconds: envInt("MOD_MENTION_FLOOD_WINDOW_SECONDS", 30),
|
|
|
|
LinkRateNewMember: envInt("MOD_LINK_RATE_NEW_MEMBER", 3),
|
|
LinkRateWindowSeconds: envInt("MOD_LINK_RATE_WINDOW_SECONDS", 60),
|
|
|
|
InviteMaxPerHour: envInt("MOD_INVITE_MAX_PER_HOUR", 5),
|
|
|
|
JoinLeaveCount: envInt("MOD_JOIN_LEAVE_COUNT", 3),
|
|
JoinLeaveWindowMinutes: envInt("MOD_JOIN_LEAVE_WINDOW_MINUTES", 10),
|
|
|
|
NewMemberDays: envInt("MOD_NEW_MEMBER_DAYS", 14),
|
|
NewMemberFloodMultiplier: envFloat("MOD_NEW_MEMBER_FLOOD_MULTIPLIER", 0.5),
|
|
|
|
AdminRoom: id.RoomID(envOrDefault("MOD_ADMIN_ROOM", "")),
|
|
DMOnAction: envOrDefault("MOD_DM_ON_ACTION", "true") == "true",
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DM Templates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var dmTemplates = map[string]string{
|
|
"strike1_word": "Hey — that word isn't something we allow in this community. Your message has been removed. This has been noted. Please keep it in mind going forward.",
|
|
"strike1_flood": "Hey — looks like you might have gotten a bit trigger-happy there. Slow it down a little. Your messages have been removed and this has been noted.",
|
|
"strike1_wall": "Hey — that message was way too long for this room. It's been removed and noted. Please keep it shorter.",
|
|
"strike1_repeat": "Hey — repeating the same message isn't cool. Your messages have been removed and this has been noted.",
|
|
"strike1_mention": "Hey — too many mentions in a short time. Please don't mass-ping. This has been noted.",
|
|
"strike1_link": "Hey — slow down on the links. New members have a limit. This has been noted.",
|
|
"strike1_invite": "Hey — you've been sending too many invites. This has been flagged and noted.",
|
|
"strike2": "This is the second time we've had to step in. You've been muted for %s. When you're back, please keep the community guidelines in mind. This is your last warning before a permanent ban.",
|
|
"strike3": "This is the third strike. You're being removed from the community permanently. Take care.",
|
|
"manual_warn": "A community admin has issued you a formal warning: %s. Please keep this in mind.",
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sliding window tracker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type windowKey struct {
|
|
UserID id.UserID
|
|
RoomID id.RoomID
|
|
}
|
|
|
|
type slidingWindow struct {
|
|
mu sync.Mutex
|
|
entries map[windowKey][]time.Time
|
|
}
|
|
|
|
func newSlidingWindow() *slidingWindow {
|
|
return &slidingWindow{entries: make(map[windowKey][]time.Time)}
|
|
}
|
|
|
|
func (sw *slidingWindow) add(userID id.UserID, roomID id.RoomID) {
|
|
sw.mu.Lock()
|
|
defer sw.mu.Unlock()
|
|
k := windowKey{userID, roomID}
|
|
sw.entries[k] = append(sw.entries[k], time.Now())
|
|
}
|
|
|
|
func (sw *slidingWindow) count(userID id.UserID, roomID id.RoomID, window time.Duration) int {
|
|
sw.mu.Lock()
|
|
defer sw.mu.Unlock()
|
|
k := windowKey{userID, roomID}
|
|
cutoff := time.Now().Add(-window)
|
|
// prune and count
|
|
kept := sw.entries[k][:0]
|
|
for _, t := range sw.entries[k] {
|
|
if t.After(cutoff) {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
sw.entries[k] = kept
|
|
return len(kept)
|
|
}
|
|
|
|
// messageWindow tracks recent message text for repeat detection
|
|
type messageWindow struct {
|
|
mu sync.Mutex
|
|
entries map[windowKey][]msgEntry
|
|
}
|
|
|
|
type msgEntry struct {
|
|
text string
|
|
at time.Time
|
|
}
|
|
|
|
func newMessageWindow() *messageWindow {
|
|
return &messageWindow{entries: make(map[windowKey][]msgEntry)}
|
|
}
|
|
|
|
func (mw *messageWindow) addAndCheck(userID id.UserID, roomID id.RoomID, text string, window time.Duration, threshold float64, count int) bool {
|
|
mw.mu.Lock()
|
|
defer mw.mu.Unlock()
|
|
k := windowKey{userID, roomID}
|
|
cutoff := time.Now().Add(-window)
|
|
|
|
// prune old entries
|
|
kept := mw.entries[k][:0]
|
|
for _, e := range mw.entries[k] {
|
|
if e.at.After(cutoff) {
|
|
kept = append(kept, e)
|
|
}
|
|
}
|
|
|
|
// count similar
|
|
similar := 0
|
|
for _, e := range kept {
|
|
if normalizedSimilarity(e.text, text) >= threshold {
|
|
similar++
|
|
}
|
|
}
|
|
|
|
kept = append(kept, msgEntry{text: text, at: time.Now()})
|
|
mw.entries[k] = kept
|
|
|
|
return similar >= count-1 // current message is the Nth
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Word list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type wordList struct {
|
|
mu sync.RWMutex
|
|
words []string
|
|
variationPats []*regexp.Regexp // precompiled variation patterns (1:1 with words)
|
|
variations bool
|
|
}
|
|
|
|
func newWordList(path string, variations bool) *wordList {
|
|
wl := &wordList{variations: variations}
|
|
if path != "" {
|
|
wl.load(path)
|
|
}
|
|
return wl
|
|
}
|
|
|
|
func (wl *wordList) load(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
var words []string
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
words = append(words, strings.ToLower(line))
|
|
}
|
|
}
|
|
|
|
// Precompile variation patterns
|
|
var pats []*regexp.Regexp
|
|
if wl.variations {
|
|
for _, word := range words {
|
|
pat := buildVariationPattern(word)
|
|
if re, err := regexp.Compile(pat); err == nil {
|
|
pats = append(pats, re)
|
|
} else {
|
|
pats = append(pats, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
wl.mu.Lock()
|
|
wl.words = words
|
|
wl.variationPats = pats
|
|
wl.mu.Unlock()
|
|
|
|
slog.Info("moderation: word list loaded", "count", len(words), "path", path)
|
|
return scanner.Err()
|
|
}
|
|
|
|
func (wl *wordList) check(text string) (bool, string) {
|
|
wl.mu.RLock()
|
|
defer wl.mu.RUnlock()
|
|
|
|
normalized := normalizeText(text)
|
|
|
|
for i, word := range wl.words {
|
|
if containsWord(normalized, word) {
|
|
return true, word
|
|
}
|
|
if wl.variations && i < len(wl.variationPats) && wl.variationPats[i] != nil {
|
|
if wl.variationPats[i].MatchString(normalized) {
|
|
return true, word
|
|
}
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
func (wl *wordList) count() int {
|
|
wl.mu.RLock()
|
|
defer wl.mu.RUnlock()
|
|
return len(wl.words)
|
|
}
|
|
|
|
// normalizeText lowercases and strips non-alphanumeric (keeping spaces)
|
|
func normalizeText(s string) string {
|
|
s = strings.ToLower(s)
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' {
|
|
b.WriteRune(r)
|
|
} else {
|
|
b.WriteRune(' ')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// containsWord checks for whole-word match
|
|
func containsWord(text, word string) bool {
|
|
idx := strings.Index(text, word)
|
|
for idx >= 0 {
|
|
before := idx == 0 || text[idx-1] == ' '
|
|
after := idx+len(word) >= len(text) || text[idx+len(word)] == ' '
|
|
if before && after {
|
|
return true
|
|
}
|
|
next := strings.Index(text[idx+1:], word)
|
|
if next < 0 {
|
|
break
|
|
}
|
|
idx = idx + 1 + next
|
|
}
|
|
return false
|
|
}
|
|
|
|
// buildVariationPattern builds a regex for leetspeak and character-separated variants.
|
|
func buildVariationPattern(word string) string {
|
|
var pattern strings.Builder
|
|
pattern.WriteString(`(?i)`)
|
|
for i, ch := range strings.ToLower(word) {
|
|
if i > 0 {
|
|
pattern.WriteString(`[\s.\-_]*`)
|
|
}
|
|
switch ch {
|
|
case 'a':
|
|
pattern.WriteString(`[a4@]+`)
|
|
case 'e':
|
|
pattern.WriteString(`[e3]+`)
|
|
case 'i':
|
|
pattern.WriteString(`[i1!|]+`)
|
|
case 'o':
|
|
pattern.WriteString(`[o0]+`)
|
|
case 's':
|
|
pattern.WriteString(`[s5$]+`)
|
|
case 'l':
|
|
pattern.WriteString(`[l1|]+`)
|
|
case 't':
|
|
pattern.WriteString(`[t7+]+`)
|
|
default:
|
|
pattern.WriteRune(ch)
|
|
pattern.WriteString(`+`)
|
|
}
|
|
}
|
|
return pattern.String()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Similarity (Levenshtein-based)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func levenshteinDistance(a, b string) int {
|
|
la, lb := len(a), len(b)
|
|
if la == 0 {
|
|
return lb
|
|
}
|
|
if lb == 0 {
|
|
return la
|
|
}
|
|
|
|
prev := make([]int, lb+1)
|
|
curr := make([]int, lb+1)
|
|
for j := 0; j <= lb; j++ {
|
|
prev[j] = j
|
|
}
|
|
|
|
for i := 1; i <= la; i++ {
|
|
curr[0] = i
|
|
for j := 1; j <= lb; j++ {
|
|
cost := 1
|
|
if a[i-1] == b[j-1] {
|
|
cost = 0
|
|
}
|
|
curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost))
|
|
}
|
|
prev, curr = curr, prev
|
|
}
|
|
return prev[lb]
|
|
}
|
|
|
|
func normalizedSimilarity(a, b string) float64 {
|
|
a = strings.ToLower(strings.TrimSpace(a))
|
|
b = strings.ToLower(strings.TrimSpace(b))
|
|
if a == b {
|
|
return 1.0
|
|
}
|
|
maxLen := max(len(a), len(b))
|
|
if maxLen == 0 {
|
|
return 1.0
|
|
}
|
|
dist := levenshteinDistance(a, b)
|
|
return 1.0 - float64(dist)/float64(maxLen)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mention / link extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var modLinkRe = regexp.MustCompile(`https?://\S+`)
|
|
|
|
func modCountMentions(text string) int {
|
|
return len(mentionRe.FindAllString(text, -1))
|
|
}
|
|
|
|
func modCountLinks(text string) int {
|
|
return len(modLinkRe.FindAllString(text, -1))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ModerationPlugin struct {
|
|
Base
|
|
cfg modConfig
|
|
wl *wordList
|
|
enabled bool
|
|
|
|
// Sliding windows (in-memory, reset on restart)
|
|
textFlood *slidingWindow
|
|
imageFlood *slidingWindow
|
|
mentionFlood *slidingWindow
|
|
linkRate *slidingWindow
|
|
inviteFlood *slidingWindow
|
|
joinLeave *slidingWindow
|
|
repeatMsgs *messageWindow
|
|
|
|
// Mute scheduler
|
|
muteMu sync.Mutex
|
|
muteTimer map[windowKey]*time.Timer
|
|
}
|
|
|
|
func NewModerationPlugin(client *mautrix.Client) *ModerationPlugin {
|
|
cfg := loadModConfig()
|
|
enabled := strings.ToLower(envOrDefault("FEATURE_MODERATION", "")) != "" &&
|
|
strings.ToLower(envOrDefault("FEATURE_MODERATION", "")) != "false"
|
|
|
|
if !enabled {
|
|
slog.Info("moderation: disabled (set FEATURE_MODERATION=true to enable)")
|
|
}
|
|
|
|
return &ModerationPlugin{
|
|
Base: NewBase(client),
|
|
cfg: cfg,
|
|
enabled: enabled,
|
|
wl: newWordList(cfg.WordListPath, cfg.WordListVariations),
|
|
textFlood: newSlidingWindow(),
|
|
imageFlood: newSlidingWindow(),
|
|
mentionFlood: newSlidingWindow(),
|
|
linkRate: newSlidingWindow(),
|
|
inviteFlood: newSlidingWindow(),
|
|
joinLeave: newSlidingWindow(),
|
|
repeatMsgs: newMessageWindow(),
|
|
muteTimer: make(map[windowKey]*time.Timer),
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) Name() string { return "moderation" }
|
|
|
|
func (p *ModerationPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "mod", Description: "Moderation commands (admin only)", Usage: "!mod <warn|mute|unmute|ban|strikes|forgive|history|reload|status|test> [args]", AdminOnly: true, Category: "Admin"},
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) Init() error {
|
|
if !p.enabled {
|
|
return nil
|
|
}
|
|
|
|
// Validate admin room
|
|
if p.cfg.AdminRoom != "" {
|
|
_, err := p.Client.JoinedMembers(context.Background(), p.cfg.AdminRoom)
|
|
if err != nil {
|
|
slog.Warn("moderation: bot not in admin room, admin notifications disabled", "room", p.cfg.AdminRoom, "err", err)
|
|
p.cfg.AdminRoom = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *ModerationPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
// OnMessage handles both detection and admin commands.
|
|
func (p *ModerationPlugin) OnMessage(ctx MessageContext) error {
|
|
if !p.enabled {
|
|
return nil
|
|
}
|
|
|
|
// Admin commands first
|
|
if p.IsCommand(ctx.Body, "mod") {
|
|
if !p.IsAdmin(ctx.Sender) {
|
|
return nil
|
|
}
|
|
return p.handleModCommand(ctx)
|
|
}
|
|
|
|
// Run detectors (skip admins)
|
|
if p.IsAdmin(ctx.Sender) {
|
|
return nil
|
|
}
|
|
|
|
return p.runDetectors(ctx)
|
|
}
|
|
|
|
// OnMemberEvent is called from main.go for join/leave/invite tracking.
|
|
func (p *ModerationPlugin) OnMemberEvent(roomID id.RoomID, userID id.UserID, membership event.Membership) {
|
|
if !p.enabled {
|
|
return
|
|
}
|
|
if p.IsAdmin(userID) || userID == p.Client.UserID {
|
|
return
|
|
}
|
|
|
|
switch membership {
|
|
case event.MembershipInvite:
|
|
// Track invite flooding (invites issued BY this user)
|
|
p.inviteFlood.add(userID, roomID)
|
|
window := time.Hour
|
|
cnt := p.inviteFlood.count(userID, roomID, window)
|
|
if cnt >= p.cfg.InviteMaxPerHour {
|
|
p.issueStrike(userID, roomID, id.EventID(""), "invite flooding", fmt.Sprintf("%d invites in 1 hour", cnt))
|
|
}
|
|
|
|
case event.MembershipJoin, event.MembershipLeave:
|
|
p.joinLeave.add(userID, roomID)
|
|
window := time.Duration(p.cfg.JoinLeaveWindowMinutes) * time.Minute
|
|
cnt := p.joinLeave.count(userID, roomID, window)
|
|
if cnt >= p.cfg.JoinLeaveCount {
|
|
// Flag only — no strike
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"👀 **Join/Leave Pattern Detected**\nUser: `%s`\nRoom: `%s`\nEvents: %d join/leave cycles in %d minutes\nNo action taken. Monitor or use `!mod warn` if needed.",
|
|
userID, roomID, cnt, p.cfg.JoinLeaveWindowMinutes,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detection pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ModerationPlugin) runDetectors(ctx MessageContext) error {
|
|
isNew := p.isNewMember(ctx.Sender)
|
|
|
|
// 1. Word list
|
|
if hit, word := p.wl.check(ctx.Body); hit {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "prohibited word", fmt.Sprintf("matched: %s", word))
|
|
}
|
|
|
|
// 2. Text flood
|
|
p.textFlood.add(ctx.Sender, ctx.RoomID)
|
|
floodThreshold := p.cfg.FloodMessageCount
|
|
if isNew {
|
|
floodThreshold = p.adjustThreshold(floodThreshold)
|
|
}
|
|
floodWindow := time.Duration(p.cfg.FloodMessageWindowSeconds) * time.Second
|
|
if p.textFlood.count(ctx.Sender, ctx.RoomID, floodWindow) >= floodThreshold {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "text flood", fmt.Sprintf("%d+ messages in %ds", floodThreshold, p.cfg.FloodMessageWindowSeconds))
|
|
}
|
|
|
|
// 3. Image/file flood
|
|
if ctx.Event != nil {
|
|
msg := ctx.Event.Content.AsMessage()
|
|
if msg != nil && (msg.MsgType == event.MsgImage || msg.MsgType == event.MsgFile || msg.MsgType == event.MsgVideo) {
|
|
p.imageFlood.add(ctx.Sender, ctx.RoomID)
|
|
imgThreshold := p.cfg.FloodImageCount
|
|
if isNew {
|
|
imgThreshold = p.adjustThreshold(imgThreshold)
|
|
}
|
|
imgWindow := time.Duration(p.cfg.FloodImageWindowSeconds) * time.Second
|
|
if p.imageFlood.count(ctx.Sender, ctx.RoomID, imgWindow) >= imgThreshold {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "image/file flood", fmt.Sprintf("%d+ files in %ds", imgThreshold, p.cfg.FloodImageWindowSeconds))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Wall of text
|
|
if p.cfg.MaxMessageLength > 0 && len(ctx.Body) > p.cfg.MaxMessageLength {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "wall of text", fmt.Sprintf("%d chars (limit %d)", len(ctx.Body), p.cfg.MaxMessageLength))
|
|
}
|
|
|
|
// 5. Repeated messages
|
|
repeatThreshold := p.cfg.RepeatCount
|
|
if isNew {
|
|
repeatThreshold = p.adjustThreshold(repeatThreshold)
|
|
}
|
|
repeatWindow := time.Duration(p.cfg.RepeatWindowSeconds) * time.Second
|
|
if p.repeatMsgs.addAndCheck(ctx.Sender, ctx.RoomID, ctx.Body, repeatWindow, p.cfg.RepeatSimilarityThreshold, repeatThreshold) {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "repeated messages", fmt.Sprintf("%d+ similar messages in %ds", repeatThreshold, p.cfg.RepeatWindowSeconds))
|
|
}
|
|
|
|
// 6. Mention flood
|
|
mentions := modCountMentions(ctx.Body)
|
|
if mentions >= p.cfg.MentionMax {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "mention flood", fmt.Sprintf("%d mentions in one message", mentions))
|
|
}
|
|
if mentions > 0 {
|
|
p.mentionFlood.add(ctx.Sender, ctx.RoomID)
|
|
mentionThreshold := p.cfg.MentionFloodCount
|
|
if isNew {
|
|
mentionThreshold = p.adjustThreshold(mentionThreshold)
|
|
}
|
|
mentionWindow := time.Duration(p.cfg.MentionFloodWindowSeconds) * time.Second
|
|
if p.mentionFlood.count(ctx.Sender, ctx.RoomID, mentionWindow) >= mentionThreshold {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "mention flood", fmt.Sprintf("%d+ mention-heavy messages in %ds", mentionThreshold, p.cfg.MentionFloodWindowSeconds))
|
|
}
|
|
}
|
|
|
|
// 7. Link rate (new members only)
|
|
if isNew {
|
|
links := modCountLinks(ctx.Body)
|
|
if links > 0 {
|
|
for range links {
|
|
p.linkRate.add(ctx.Sender, ctx.RoomID)
|
|
}
|
|
linkWindow := time.Duration(p.cfg.LinkRateWindowSeconds) * time.Second
|
|
if p.linkRate.count(ctx.Sender, ctx.RoomID, linkWindow) > p.cfg.LinkRateNewMember {
|
|
return p.issueStrike(ctx.Sender, ctx.RoomID, ctx.EventID, "link rate exceeded", fmt.Sprintf("%d+ links in %ds (new member)", p.cfg.LinkRateNewMember, p.cfg.LinkRateWindowSeconds))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *ModerationPlugin) adjustThreshold(threshold int) int {
|
|
adjusted := int(math.Ceil(float64(threshold) * p.cfg.NewMemberFloodMultiplier))
|
|
if adjusted < 1 {
|
|
adjusted = 1
|
|
}
|
|
return adjusted
|
|
}
|
|
|
|
func (p *ModerationPlugin) isNewMember(userID id.UserID) bool {
|
|
d := db.Get()
|
|
var firstSeen sql.NullInt64
|
|
_ = d.QueryRow("SELECT MIN(first_seen) FROM user_stats WHERE user_id = ?", string(userID)).Scan(&firstSeen)
|
|
if !firstSeen.Valid {
|
|
return true // never seen = treat as new
|
|
}
|
|
return time.Since(time.Unix(firstSeen.Int64, 0)) < time.Duration(p.cfg.NewMemberDays)*24*time.Hour
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Strike system
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ModerationPlugin) issueStrike(userID id.UserID, roomID id.RoomID, eventID id.EventID, trigger, detail string) error {
|
|
d := db.Get()
|
|
expiresAt := time.Now().Add(time.Duration(p.cfg.StrikeExpiryDays) * 24 * time.Hour)
|
|
|
|
// Insert strike
|
|
_, err := d.Exec(
|
|
`INSERT INTO mod_strikes (user_id, room_id, issued_at, expires_at, reason, issued_by)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?, 'gogobee')`,
|
|
string(userID), string(roomID), expiresAt.Format("2006-01-02 15:04:05"),
|
|
fmt.Sprintf("%s: %s", trigger, detail),
|
|
)
|
|
if err != nil {
|
|
slog.Error("moderation: failed to insert strike", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Count active strikes
|
|
activeStrikes := p.countActiveStrikes(userID)
|
|
|
|
slog.Info("moderation: strike issued",
|
|
"user", userID, "room", roomID, "trigger", trigger,
|
|
"detail", detail, "total_active", activeStrikes)
|
|
|
|
// Redact the message
|
|
if eventID != "" {
|
|
p.redactMessage(roomID, eventID, trigger)
|
|
}
|
|
|
|
// Log the action
|
|
p.logAction(userID, roomID, "strike", fmt.Sprintf("strike %d: %s — %s", activeStrikes, trigger, detail), "gogobee")
|
|
|
|
// Response ladder
|
|
switch {
|
|
case activeStrikes >= p.cfg.MaxStrikes:
|
|
// Strike 3: ban
|
|
p.dmUser(userID, dmTemplates["strike3"])
|
|
p.banUser(userID, roomID, fmt.Sprintf("strike %d: %s", activeStrikes, trigger))
|
|
p.logAction(userID, roomID, "ban", fmt.Sprintf("automatic: strike %d", activeStrikes), "gogobee")
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🚨 **Moderation Action**\nUser: `%s`\nRoom: `%s`\nTrigger: %s\nStrike: %d of %d\nAction taken: **Permanent ban**\nDetail: %s",
|
|
userID, roomID, trigger, activeStrikes, p.cfg.MaxStrikes, modTruncate(detail, 200),
|
|
))
|
|
|
|
case activeStrikes == p.cfg.MaxStrikes-1:
|
|
// Strike 2: mute
|
|
duration := time.Duration(p.cfg.MuteDurationMinutes) * time.Minute
|
|
p.dmUser(userID, fmt.Sprintf(dmTemplates["strike2"], modFormatDuration(duration)))
|
|
p.muteUser(userID, roomID, duration)
|
|
p.logAction(userID, roomID, "mute", fmt.Sprintf("automatic: strike %d, %s", activeStrikes, modFormatDuration(duration)), "gogobee")
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🚨 **Moderation Action**\nUser: `%s`\nRoom: `%s`\nTrigger: %s\nStrike: %d of %d (expires %s)\nAction taken: Temp mute (%s)\nDetail: %s",
|
|
userID, roomID, trigger, activeStrikes, p.cfg.MaxStrikes,
|
|
expiresAt.Format("2006-01-02"), modFormatDuration(duration), modTruncate(detail, 200),
|
|
))
|
|
|
|
default:
|
|
// Strike 1: warn
|
|
tmpl := "strike1_flood"
|
|
switch trigger {
|
|
case "prohibited word":
|
|
tmpl = "strike1_word"
|
|
case "wall of text":
|
|
tmpl = "strike1_wall"
|
|
case "repeated messages":
|
|
tmpl = "strike1_repeat"
|
|
case "mention flood":
|
|
tmpl = "strike1_mention"
|
|
case "link rate exceeded":
|
|
tmpl = "strike1_link"
|
|
case "invite flooding":
|
|
tmpl = "strike1_invite"
|
|
}
|
|
p.dmUser(userID, dmTemplates[tmpl])
|
|
p.logAction(userID, roomID, "warn", fmt.Sprintf("automatic: strike %d — %s", activeStrikes, trigger), "gogobee")
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🚨 **Moderation Action**\nUser: `%s`\nRoom: `%s`\nTrigger: %s\nStrike: %d of %d (expires %s)\nAction taken: Warning + redact\nDetail: %s",
|
|
userID, roomID, trigger, activeStrikes, p.cfg.MaxStrikes,
|
|
expiresAt.Format("2006-01-02"), modTruncate(detail, 200),
|
|
))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *ModerationPlugin) countActiveStrikes(userID id.UserID) int {
|
|
d := db.Get()
|
|
var count int
|
|
_ = d.QueryRow(
|
|
`SELECT COUNT(*) FROM mod_strikes WHERE user_id = ? AND active = TRUE AND expires_at > CURRENT_TIMESTAMP`,
|
|
string(userID),
|
|
).Scan(&count)
|
|
return count
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions: redact, mute, unmute, ban
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ModerationPlugin) redactMessage(roomID id.RoomID, eventID id.EventID, reason string) {
|
|
_, err := p.Client.RedactEvent(context.Background(), roomID, eventID, mautrix.ReqRedact{Reason: reason})
|
|
if err != nil {
|
|
slog.Error("moderation: failed to redact", "room", roomID, "event", eventID, "err", err)
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) muteUser(userID id.UserID, roomID id.RoomID, duration time.Duration) {
|
|
ctx := context.Background()
|
|
|
|
// Get current power levels
|
|
pl := &event.PowerLevelsEventContent{}
|
|
if err := p.Client.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl); err != nil {
|
|
slog.Error("moderation: failed to get power levels", "room", roomID, "err", err)
|
|
return
|
|
}
|
|
|
|
// Store original level for restore
|
|
originalLevel := pl.GetUserLevel(userID)
|
|
|
|
// Set user power level to -1 (below events_default which is typically 0)
|
|
pl.SetUserLevel(userID, -1)
|
|
if _, err := p.Client.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", pl); err != nil {
|
|
slog.Error("moderation: failed to mute user", "user", userID, "room", roomID, "err", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("moderation: user muted", "user", userID, "room", roomID, "duration", duration)
|
|
|
|
// Schedule unmute
|
|
p.muteMu.Lock()
|
|
k := windowKey{userID, roomID}
|
|
if existing, ok := p.muteTimer[k]; ok {
|
|
existing.Stop()
|
|
}
|
|
p.muteTimer[k] = time.AfterFunc(duration, func() {
|
|
p.unmuteUser(userID, roomID, originalLevel)
|
|
p.muteMu.Lock()
|
|
delete(p.muteTimer, k)
|
|
p.muteMu.Unlock()
|
|
})
|
|
p.muteMu.Unlock()
|
|
}
|
|
|
|
func (p *ModerationPlugin) unmuteUser(userID id.UserID, roomID id.RoomID, restoreLevel int) {
|
|
ctx := context.Background()
|
|
|
|
pl := &event.PowerLevelsEventContent{}
|
|
if err := p.Client.StateEvent(ctx, roomID, event.StatePowerLevels, "", pl); err != nil {
|
|
slog.Error("moderation: failed to get power levels for unmute", "err", err)
|
|
return
|
|
}
|
|
|
|
pl.SetUserLevel(userID, restoreLevel)
|
|
if _, err := p.Client.SendStateEvent(ctx, roomID, event.StatePowerLevels, "", pl); err != nil {
|
|
slog.Error("moderation: failed to unmute user", "user", userID, "room", roomID, "err", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("moderation: user unmuted", "user", userID, "room", roomID)
|
|
p.logAction(userID, roomID, "unmute", "automatic: mute expired", "gogobee")
|
|
p.notifyAdmin(fmt.Sprintf("🔊 **Unmuted** `%s` in `%s` (mute expired)", userID, roomID))
|
|
}
|
|
|
|
func (p *ModerationPlugin) banUser(userID id.UserID, roomID id.RoomID, reason string) {
|
|
_, err := p.Client.BanUser(context.Background(), roomID, &mautrix.ReqBanUser{
|
|
UserID: userID,
|
|
Reason: reason,
|
|
})
|
|
if err != nil {
|
|
slog.Error("moderation: failed to ban user", "user", userID, "room", roomID, "err", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ModerationPlugin) dmUser(userID id.UserID, text string) {
|
|
if !p.cfg.DMOnAction {
|
|
return
|
|
}
|
|
if err := p.SendDM(userID, text); err != nil {
|
|
slog.Error("moderation: failed to DM user", "user", userID, "err", err)
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) notifyAdmin(text string) {
|
|
if p.cfg.AdminRoom == "" {
|
|
return
|
|
}
|
|
if err := p.SendMessage(p.cfg.AdminRoom, text); err != nil {
|
|
slog.Error("moderation: failed to notify admin room", "err", err)
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) logAction(userID id.UserID, roomID id.RoomID, action, reason, takenBy string) {
|
|
db.Exec("moderation: log action",
|
|
`INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by)
|
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`,
|
|
string(userID), string(roomID), action, reason, takenBy,
|
|
)
|
|
}
|
|
|
|
func modTruncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "..."
|
|
}
|
|
|
|
func modFormatDuration(d time.Duration) string {
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%d minutes", int(d.Minutes()))
|
|
}
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
if m == 0 {
|
|
return fmt.Sprintf("%d hour(s)", h)
|
|
}
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ModerationPlugin) handleModCommand(ctx MessageContext) error {
|
|
args := p.GetArgs(ctx.Body, "mod")
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Usage: `!mod <warn|mute|unmute|ban|strikes|forgive|history|reload|status|test> [args]`")
|
|
}
|
|
|
|
parts := strings.Fields(args)
|
|
sub := strings.ToLower(parts[0])
|
|
rest := ""
|
|
if len(parts) > 1 {
|
|
rest = strings.Join(parts[1:], " ")
|
|
}
|
|
|
|
switch sub {
|
|
case "warn":
|
|
return p.handleWarn(ctx, rest)
|
|
case "mute":
|
|
return p.handleMute(ctx, rest)
|
|
case "unmute":
|
|
return p.handleUnmute(ctx, rest)
|
|
case "ban":
|
|
return p.handleBan(ctx, rest)
|
|
case "strikes":
|
|
return p.handleStrikes(ctx, rest)
|
|
case "forgive":
|
|
return p.handleForgive(ctx, rest)
|
|
case "history":
|
|
return p.handleHistory(ctx, rest)
|
|
case "reload":
|
|
return p.handleReload(ctx)
|
|
case "status":
|
|
return p.handleStatus(ctx)
|
|
case "test":
|
|
return p.handleTest(ctx, rest)
|
|
default:
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Unknown subcommand. Available: warn, mute, unmute, ban, strikes, forgive, history, reload, status, test")
|
|
}
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleWarn(ctx MessageContext, args string) error {
|
|
parts := strings.SplitN(args, " ", 2)
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod warn @user [reason]`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(parts[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
reason := "No reason provided"
|
|
if len(parts) > 1 {
|
|
reason = parts[1]
|
|
}
|
|
|
|
d := db.Get()
|
|
expiresAt := time.Now().Add(time.Duration(p.cfg.StrikeExpiryDays) * 24 * time.Hour)
|
|
_, err := d.Exec(
|
|
`INSERT INTO mod_strikes (user_id, room_id, issued_at, expires_at, reason, issued_by)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?, ?)`,
|
|
string(targetID), string(ctx.RoomID), expiresAt.Format("2006-01-02 15:04:05"),
|
|
"manual warn: "+reason, string(ctx.Sender),
|
|
)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to issue strike.")
|
|
}
|
|
|
|
activeStrikes := p.countActiveStrikes(targetID)
|
|
p.logAction(targetID, ctx.RoomID, "warn", reason, string(ctx.Sender))
|
|
p.dmUser(targetID, fmt.Sprintf(dmTemplates["manual_warn"], reason))
|
|
|
|
// Apply ladder
|
|
switch {
|
|
case activeStrikes >= p.cfg.MaxStrikes:
|
|
p.dmUser(targetID, dmTemplates["strike3"])
|
|
p.banUser(targetID, ctx.RoomID, fmt.Sprintf("manual warn strike %d: %s", activeStrikes, reason))
|
|
p.logAction(targetID, ctx.RoomID, "ban", fmt.Sprintf("manual warn triggered ban at strike %d", activeStrikes), string(ctx.Sender))
|
|
case activeStrikes == p.cfg.MaxStrikes-1:
|
|
duration := time.Duration(p.cfg.MuteDurationMinutes) * time.Minute
|
|
p.dmUser(targetID, fmt.Sprintf(dmTemplates["strike2"], modFormatDuration(duration)))
|
|
p.muteUser(targetID, ctx.RoomID, duration)
|
|
p.logAction(targetID, ctx.RoomID, "mute", fmt.Sprintf("manual warn triggered mute at strike %d", activeStrikes), string(ctx.Sender))
|
|
}
|
|
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"⚠️ **Manual Warning**\nUser: `%s`\nRoom: `%s`\nIssued by: `%s`\nReason: %s\nActive strikes: %d/%d",
|
|
targetID, ctx.RoomID, ctx.Sender, reason, activeStrikes, p.cfg.MaxStrikes,
|
|
))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("Warning issued to `%s`. Active strikes: %d/%d.", targetID, activeStrikes, p.cfg.MaxStrikes))
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleMute(ctx MessageContext, args string) error {
|
|
parts := strings.Fields(args)
|
|
if len(parts) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod mute @user [duration]` (default: configured mute duration)")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(parts[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
duration := time.Duration(p.cfg.MuteDurationMinutes) * time.Minute
|
|
if len(parts) > 1 {
|
|
d, err := parseDuration(parts[1])
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid duration. Use format like `30m`, `2h`, `60`.")
|
|
}
|
|
duration = d
|
|
}
|
|
|
|
p.muteUser(targetID, ctx.RoomID, duration)
|
|
p.logAction(targetID, ctx.RoomID, "mute", fmt.Sprintf("manual: %s", modFormatDuration(duration)), string(ctx.Sender))
|
|
p.dmUser(targetID, fmt.Sprintf("You have been muted for %s by an admin.", modFormatDuration(duration)))
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🔇 **Manual Mute**\nUser: `%s`\nRoom: `%s`\nDuration: %s\nIssued by: `%s`",
|
|
targetID, ctx.RoomID, modFormatDuration(duration), ctx.Sender,
|
|
))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("Muted `%s` for %s.", targetID, modFormatDuration(duration)))
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleUnmute(ctx MessageContext, args string) error {
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod unmute @user`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(strings.Fields(args)[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
// Cancel any pending unmute timer
|
|
p.muteMu.Lock()
|
|
k := windowKey{targetID, ctx.RoomID}
|
|
if timer, ok := p.muteTimer[k]; ok {
|
|
timer.Stop()
|
|
delete(p.muteTimer, k)
|
|
}
|
|
p.muteMu.Unlock()
|
|
|
|
p.unmuteUser(targetID, ctx.RoomID, 0)
|
|
p.logAction(targetID, ctx.RoomID, "unmute", "manual", string(ctx.Sender))
|
|
p.dmUser(targetID, "You have been unmuted by an admin.")
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🔊 **Manual Unmute**\nUser: `%s`\nRoom: `%s`\nIssued by: `%s`",
|
|
targetID, ctx.RoomID, ctx.Sender,
|
|
))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unmuted `%s`.", targetID))
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleBan(ctx MessageContext, args string) error {
|
|
parts := strings.SplitN(args, " ", 2)
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod ban @user [reason]`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(parts[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
reason := "No reason provided"
|
|
if len(parts) > 1 {
|
|
reason = parts[1]
|
|
}
|
|
|
|
p.dmUser(targetID, "You are being banned from this community. "+reason)
|
|
p.banUser(targetID, ctx.RoomID, reason)
|
|
p.logAction(targetID, ctx.RoomID, "ban", "manual: "+reason, string(ctx.Sender))
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🔨 **Manual Ban**\nUser: `%s`\nRoom: `%s`\nReason: %s\nIssued by: `%s`",
|
|
targetID, ctx.RoomID, reason, ctx.Sender,
|
|
))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Banned `%s`. Reason: %s", targetID, reason))
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleStrikes(ctx MessageContext, args string) error {
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod strikes @user`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(strings.Fields(args)[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT reason, issued_at, expires_at, issued_by FROM mod_strikes
|
|
WHERE user_id = ? AND active = TRUE AND expires_at > CURRENT_TIMESTAMP
|
|
ORDER BY issued_at DESC`,
|
|
string(targetID),
|
|
)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to query strikes.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("**Active strikes for `%s`:**\n", targetID))
|
|
count := 0
|
|
for rows.Next() {
|
|
var reason, issuedAt, expiresAt, issuedBy string
|
|
rows.Scan(&reason, &issuedAt, &expiresAt, &issuedBy)
|
|
count++
|
|
sb.WriteString(fmt.Sprintf("%d. %s (by `%s`, expires %s)\n", count, reason, issuedBy, expiresAt[:10]))
|
|
}
|
|
|
|
if count == 0 {
|
|
sb.WriteString("No active strikes.")
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("\n**Total: %d/%d**", count, p.cfg.MaxStrikes))
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleForgive(ctx MessageContext, args string) error {
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod forgive @user`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(strings.Fields(args)[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
d := db.Get()
|
|
result, err := d.Exec(
|
|
`UPDATE mod_strikes SET active = FALSE WHERE user_id = ? AND active = TRUE`,
|
|
string(targetID),
|
|
)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to clear strikes.")
|
|
}
|
|
|
|
affected, _ := result.RowsAffected()
|
|
p.logAction(targetID, ctx.RoomID, "forgive", fmt.Sprintf("cleared %d strikes", affected), string(ctx.Sender))
|
|
p.notifyAdmin(fmt.Sprintf(
|
|
"🕊️ **Forgive**\nUser: `%s`\nCleared %d active strike(s)\nIssued by: `%s`",
|
|
targetID, affected, ctx.Sender,
|
|
))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("Cleared %d active strike(s) for `%s`.", affected, targetID))
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleHistory(ctx MessageContext, args string) error {
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod history @user`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(strings.Fields(args)[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT action, reason, taken_at, taken_by FROM mod_actions
|
|
WHERE user_id = ? ORDER BY taken_at DESC LIMIT 20`,
|
|
string(targetID),
|
|
)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to query history.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("**Moderation history for `%s`** (last 20):\n", targetID))
|
|
count := 0
|
|
for rows.Next() {
|
|
var action, reason, takenAt, takenBy string
|
|
rows.Scan(&action, &reason, &takenAt, &takenBy)
|
|
count++
|
|
sb.WriteString(fmt.Sprintf("- **%s** — %s (by `%s`, %s)\n", action, reason, takenBy, takenAt[:10]))
|
|
}
|
|
|
|
if count == 0 {
|
|
sb.WriteString("No moderation history.")
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleReload(ctx MessageContext) error {
|
|
if p.cfg.WordListPath == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No word list path configured.")
|
|
}
|
|
|
|
err := p.wl.load(p.cfg.WordListPath)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Failed to reload: %v", err))
|
|
}
|
|
|
|
msg := fmt.Sprintf("Word list reloaded: %d entries.", p.wl.count())
|
|
p.notifyAdmin(fmt.Sprintf("🔄 **Word list reloaded** by `%s` — %d entries", ctx.Sender, p.wl.count()))
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleStatus(ctx MessageContext) error {
|
|
var sb strings.Builder
|
|
sb.WriteString("**Moderation Configuration**\n")
|
|
sb.WriteString(fmt.Sprintf("Word list: %d words (variations: %v)\n", p.wl.count(), p.cfg.WordListVariations))
|
|
sb.WriteString(fmt.Sprintf("Strike expiry: %d days, max strikes: %d\n", p.cfg.StrikeExpiryDays, p.cfg.MaxStrikes))
|
|
sb.WriteString(fmt.Sprintf("Mute duration: %d minutes\n", p.cfg.MuteDurationMinutes))
|
|
sb.WriteString(fmt.Sprintf("Text flood: %d msgs / %ds\n", p.cfg.FloodMessageCount, p.cfg.FloodMessageWindowSeconds))
|
|
sb.WriteString(fmt.Sprintf("Image flood: %d files / %ds\n", p.cfg.FloodImageCount, p.cfg.FloodImageWindowSeconds))
|
|
sb.WriteString(fmt.Sprintf("Max message length: %d chars\n", p.cfg.MaxMessageLength))
|
|
sb.WriteString(fmt.Sprintf("Repeat detection: %d msgs / %ds (%.0f%% similarity)\n", p.cfg.RepeatCount, p.cfg.RepeatWindowSeconds, p.cfg.RepeatSimilarityThreshold*100))
|
|
sb.WriteString(fmt.Sprintf("Mention flood: max %d/msg, %d heavy/%ds\n", p.cfg.MentionMax, p.cfg.MentionFloodCount, p.cfg.MentionFloodWindowSeconds))
|
|
sb.WriteString(fmt.Sprintf("Link rate (new): %d / %ds\n", p.cfg.LinkRateNewMember, p.cfg.LinkRateWindowSeconds))
|
|
sb.WriteString(fmt.Sprintf("Invite flood: %d/hour\n", p.cfg.InviteMaxPerHour))
|
|
sb.WriteString(fmt.Sprintf("Join/leave: %d / %d min\n", p.cfg.JoinLeaveCount, p.cfg.JoinLeaveWindowMinutes))
|
|
sb.WriteString(fmt.Sprintf("New member: %d days, flood multiplier: %.1f\n", p.cfg.NewMemberDays, p.cfg.NewMemberFloodMultiplier))
|
|
sb.WriteString(fmt.Sprintf("Admin room: %s\n", p.cfg.AdminRoom))
|
|
sb.WriteString(fmt.Sprintf("DM on action: %v", p.cfg.DMOnAction))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *ModerationPlugin) handleTest(ctx MessageContext, args string) error {
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!mod test @user`")
|
|
}
|
|
|
|
targetID, ok := p.ResolveUser(strings.Fields(args)[0], ctx.RoomID)
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
|
|
}
|
|
|
|
activeStrikes := p.countActiveStrikes(targetID)
|
|
isNew := p.isNewMember(targetID)
|
|
|
|
var nextAction string
|
|
switch {
|
|
case activeStrikes+1 >= p.cfg.MaxStrikes:
|
|
nextAction = "**permanent ban**"
|
|
case activeStrikes+1 == p.cfg.MaxStrikes-1:
|
|
nextAction = fmt.Sprintf("**temp mute** (%d min)", p.cfg.MuteDurationMinutes)
|
|
default:
|
|
nextAction = "**warning + redact**"
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("**Test for `%s`:**\nActive strikes: %d/%d\nNew member: %v\nNext violation would result in: %s",
|
|
targetID, activeStrikes, p.cfg.MaxStrikes, isNew, nextAction))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func envOrDefault(key, def string) string {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func envInt(key string, def int) int {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
func envFloat(key string, def float64) float64 {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return f
|
|
}
|
|
|
|
func parseDuration(s string) (time.Duration, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
// Try Go duration format first
|
|
if d, err := time.ParseDuration(s); err == nil {
|
|
return d, nil
|
|
}
|
|
// Try plain number as minutes
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
return time.Duration(n) * time.Minute, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
|
}
|