Files
gogobee/internal/plugin/moderation.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

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)
}