From 996bb18566d7ed7602fdd334891b14a4b617b296 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:49:35 -0700 Subject: [PATCH] Add moderation system with deterministic detection and strike ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic-only detection (no LLM): word list with precompiled leetspeak variation matching, text/image flood, wall of text, repeated messages (Levenshtein similarity), mention flooding, link rate limiting for new members, invite flooding, join/leave cycling detection. Three-strike response ladder: warn + redact → temp mute → permanent ban. Strikes expire after configurable period. Admin room notifications with context cards. DMs over public callouts. Admin commands: !mod warn/mute/unmute/ban/strikes/forgive/history/reload/ status/test. All require ADMIN_USERS membership. Feature-flagged: disabled by default, set FEATURE_MODERATION=true to enable. All thresholds configurable via env vars. New member grace period with stricter thresholds. Word list hot-reload via !mod reload. Co-Authored-By: Claude Opus 4.6 --- .env.example | 28 + README.md | 51 +- internal/db/db.go | 25 + internal/plugin/moderation.go | 1301 +++++++++++++++++++++++++++++++++ main.go | 18 +- 5 files changed, 1420 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/moderation.go diff --git a/.env.example b/.env.example index 8e19eb0..3acc703 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,34 @@ MISSING_EXCLUDE_USERS= # comma-separated user IDs to never list as missin # ---- Encryption ---- QUOTE_ENCRYPTION_KEY= # Base64-encoded 32-byte AES-256 key (required for !quote) +# ---- Moderation (disabled by default) ---- +FEATURE_MODERATION= # set to "true" to enable the moderation system +MOD_WORDLIST= # path to newline-delimited word list file +MOD_WORDLIST_VARIATIONS=true # check leetspeak/spaced variants +MOD_STRIKE_EXPIRY_DAYS=30 # strikes expire after N days +MOD_MUTE_DURATION_MINUTES=60 # temp mute duration on strike 2 +MOD_MAX_STRIKES=3 # strikes before permanent ban +MOD_FLOOD_MESSAGE_COUNT=5 # messages within window = flood +MOD_FLOOD_MESSAGE_WINDOW_SECONDS=10 +MOD_FLOOD_IMAGE_COUNT=3 # images/files within window = flood +MOD_FLOOD_IMAGE_WINDOW_SECONDS=30 +MOD_MAX_MESSAGE_LENGTH=2000 # max chars per message (0 = disabled) +MOD_REPEAT_COUNT=3 # same message N times = strike +MOD_REPEAT_WINDOW_SECONDS=60 +MOD_REPEAT_SIMILARITY_THRESHOLD=0.85 +MOD_MENTION_MAX=5 # max unique @mentions per message +MOD_MENTION_FLOOD_COUNT=3 +MOD_MENTION_FLOOD_WINDOW_SECONDS=30 +MOD_LINK_RATE_NEW_MEMBER=3 # max links per window (new members only) +MOD_LINK_RATE_WINDOW_SECONDS=60 +MOD_INVITE_MAX_PER_HOUR=5 +MOD_JOIN_LEAVE_COUNT=3 # join/leave cycles = flag (no auto-strike) +MOD_JOIN_LEAVE_WINDOW_MINUTES=10 +MOD_NEW_MEMBER_DAYS=14 # accounts newer than N days = stricter thresholds +MOD_NEW_MEMBER_FLOOD_MULTIPLIER=0.5 +MOD_ADMIN_ROOM= # dedicated admin room for mod notifications +MOD_DM_ON_ACTION=true # DM users when action is taken + # ---- Rate Limits (per user per day) ---- RATELIMIT_WEATHER=5 RATELIMIT_TRANSLATE=20 diff --git a/README.md b/README.md index 3508f89..23ac908 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a - **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want. -- **37 plugins** with dependency injection and ordered registration +- **38 plugins** with dependency injection and ordered registration +- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts. - **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently - **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - WOTD, holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members - **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries @@ -136,6 +137,39 @@ Everything is configured through environment variables or a `.env` file. | `FEATURE_TRIVIA` | Set to `false` to disable trivia (default: enabled) | | `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts | | `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) | +| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) | + +### Moderation + +All moderation settings are optional. The system is disabled unless `FEATURE_MODERATION=true`. + +| Variable | Default | Description | +|----------|---------|-------------| +| `MOD_WORDLIST` | | Path to newline-delimited prohibited word list | +| `MOD_WORDLIST_VARIATIONS` | `true` | Check leetspeak/spaced variants | +| `MOD_STRIKE_EXPIRY_DAYS` | `30` | Days before strikes expire | +| `MOD_MUTE_DURATION_MINUTES` | `60` | Temp mute duration on strike 2 | +| `MOD_MAX_STRIKES` | `3` | Strikes before permanent ban | +| `MOD_FLOOD_MESSAGE_COUNT` | `5` | Messages in window = flood | +| `MOD_FLOOD_MESSAGE_WINDOW_SECONDS` | `10` | Text flood window | +| `MOD_FLOOD_IMAGE_COUNT` | `3` | Images/files in window = flood | +| `MOD_FLOOD_IMAGE_WINDOW_SECONDS` | `30` | Image flood window | +| `MOD_MAX_MESSAGE_LENGTH` | `2000` | Max chars per message (0 = disabled) | +| `MOD_REPEAT_COUNT` | `3` | Repeated messages before strike | +| `MOD_REPEAT_WINDOW_SECONDS` | `60` | Repeat detection window | +| `MOD_REPEAT_SIMILARITY_THRESHOLD` | `0.85` | How similar counts as "same" (0.0–1.0) | +| `MOD_MENTION_MAX` | `5` | Max unique @mentions per message | +| `MOD_MENTION_FLOOD_COUNT` | `3` | Mention-heavy messages in window | +| `MOD_MENTION_FLOOD_WINDOW_SECONDS` | `30` | Mention flood window | +| `MOD_LINK_RATE_NEW_MEMBER` | `3` | Max links per window (new members only) | +| `MOD_LINK_RATE_WINDOW_SECONDS` | `60` | Link rate window | +| `MOD_INVITE_MAX_PER_HOUR` | `5` | Max room invites per user per hour | +| `MOD_JOIN_LEAVE_COUNT` | `3` | Join/leave cycles before flag | +| `MOD_JOIN_LEAVE_WINDOW_MINUTES` | `10` | Join/leave window | +| `MOD_NEW_MEMBER_DAYS` | `14` | Days before a member is no longer "new" | +| `MOD_NEW_MEMBER_FLOOD_MULTIPLIER` | `0.5` | Multiply flood thresholds for new members | +| `MOD_ADMIN_ROOM` | | Dedicated room for mod notifications | +| `MOD_DM_ON_ACTION` | `true` | DM users when action is taken | ### Space Groups @@ -337,6 +371,20 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!tarot [@user]` | Draw a tarot card + LLM reading | | `!tarotspread [@user]` | Three-card spread (Past/Present/Future) | +### Moderation (admin only, requires `FEATURE_MODERATION=true`) +| Command | Description | +|---------|-------------| +| `!mod warn @user [reason]` | Issue a manual warning (counts as a strike) | +| `!mod mute @user [duration]` | Manual mute (does not consume a strike) | +| `!mod unmute @user` | Remove mute | +| `!mod ban @user [reason]` | Permanent ban | +| `!mod strikes @user` | Show active strikes | +| `!mod forgive @user` | Clear all active strikes | +| `!mod history @user` | Full moderation history | +| `!mod reload` | Reload word list from disk | +| `!mod status` | Show current moderation config | +| `!mod test @user` | Simulate next violation without taking action | + ### Other | Command | Description | |---------|-------------| @@ -526,6 +574,7 @@ gogobee/ │ │ ├── tarot.go # LLM-powered tarot readings │ │ ├── horoscope.go # Daily horoscopes │ │ ├── esteemed.go # Satirical esteemed member posts +│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ └── ratelimits.go # Rate limiting │ └── util/ │ ├── auth.go # Matrix login, token check diff --git a/internal/db/db.go b/internal/db/db.go index b0b269b..308af2f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -621,6 +621,31 @@ CREATE TABLE IF NOT EXISTS api_cache ( cached_at INTEGER DEFAULT (unixepoch()) ); +-- Moderation: strikes +CREATE TABLE IF NOT EXISTS mod_strikes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + reason TEXT NOT NULL, + issued_by TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE +); +CREATE INDEX IF NOT EXISTS idx_mod_strikes_user ON mod_strikes(user_id, issued_at); + +-- Moderation: action log +CREATE TABLE IF NOT EXISTS mod_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT, + taken_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + taken_by TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mod_actions_user ON mod_actions(user_id, taken_at); + -- Space groups (rooms with overlapping membership) CREATE TABLE IF NOT EXISTS space_groups ( room_id TEXT PRIMARY KEY, diff --git a/internal/plugin/moderation.go b/internal/plugin/moderation.go new file mode 100644 index 0000000..333bb06 --- /dev/null +++ b/internal/plugin/moderation.go @@ -0,0 +1,1301 @@ +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: Base{Client: 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 [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) { + d := db.Get() + _, err := d.Exec( + `INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`, + string(userID), string(roomID), action, reason, takenBy, + ) + if err != nil { + slog.Error("moderation: failed to log action", "err", err) + } +} + +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 [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) +} diff --git a/main.go b/main.go index f6359eb..a9ae2e0 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,10 @@ func main() { // ---- Register plugins in dependency order ---- + // Moderation (runs first in dispatch order) + modPlugin := plugin.NewModerationPlugin(client) + registry.Register(modPlugin) + // Foundation (passive tracking) xpPlugin := plugin.NewXPPlugin(client) registry.Register(xpPlugin) @@ -154,10 +158,15 @@ func main() { syncer := client.Syncer.(*mautrix.DefaultSyncer) - // Auto-join on invite + // Auto-join on invite + moderation member tracking syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + mem := evt.Content.AsMember() + if mem == nil { + return + } + + // Auto-join invites for the bot if evt.GetStateKey() == string(client.UserID) { - mem := evt.Content.AsMember() if mem.Membership == event.MembershipInvite { _, err := client.JoinRoomByID(ctx, evt.RoomID) if err != nil { @@ -166,7 +175,12 @@ func main() { slog.Info("joined room", "room", evt.RoomID) } } + return } + + // Track join/leave/invite for moderation + targetUser := id.UserID(evt.GetStateKey()) + modPlugin.OnMemberEvent(evt.RoomID, targetUser, mem.Membership) }) // Message handler