Add moderation system with deterministic detection and strike ladder

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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-11 17:49:35 -07:00
parent 56f69cd4b7
commit 996bb18566
5 changed files with 1420 additions and 3 deletions

View File

@@ -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

View File

@@ -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.01.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

View File

@@ -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,

File diff suppressed because it is too large Load Diff

18
main.go
View File

@@ -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