aboutsummaryrefslogtreecommitdiffstats
path: root/dev-docs/osterman.md
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /dev-docs/osterman.md
init: mostly vibed
Diffstat (limited to 'dev-docs/osterman.md')
-rw-r--r--dev-docs/osterman.md274
1 files changed, 274 insertions, 0 deletions
diff --git a/dev-docs/osterman.md b/dev-docs/osterman.md
new file mode 100644
index 0000000..7676def
--- /dev/null
+++ b/dev-docs/osterman.md
@@ -0,0 +1,274 @@
+# osterman - developer documentation
+
+## Overview
+
+Osterman is a full channel moderation bot. It handles automatic protection
+(flood, caps, repeat, badword, clone, join flood, nick flood), manual
+moderation commands, auto-modes on join, persistent ban tracking, user
+event logging, and idle detection.
+
+It is split across five plugin files that share state through `bot.memory`.
+
+---
+
+## Module structure
+
+```
+osterman/
+ protect.py - setup, passive protection, on_join, on_message, MODE/NICK/PART/QUIT handlers
+ commands.py - all user-facing moderation and config commands
+ tracking.py - SQLite event log, greeting, !seen, !info, !log
+ idle.py - idle detection tick, !afk, !idle
+ help.py - three-level help system (!help)
+ helpstrings.py - help string constants shared across modules
+ __init__.py - empty
+```
+
+---
+
+## Help system
+
+Help strings live in `helpstrings.py` as plain string constants and dicts.
+No Sopel imports, no decorators. `help.py` imports it via sys.path insertion
+(same pattern as alfred):
+
+```python
+import os as _os, sys as _sys
+_osterman_dir = _os.path.dirname(_os.path.abspath(__file__))
+if _osterman_dir not in _sys.path:
+ _sys.path.insert(0, _osterman_dir)
+import helpstrings as h
+```
+
+Three-level hierarchy:
+```
+!help → TOPICS (list of topic names and commands)
+!help <topic> → topic line listing commands
+!help <cmd> → single command description + usage
+!help <topic> <cmd> → same as above
+```
+
+`helpstrings.lookup(args)` handles all dispatch. Unknown args fall back to
+the TOPICS line with an "no help for X" prefix.
+
+---
+
+## Shared state
+
+`protect.py`'s `setup()` initializes all shared state in `bot.memory`:
+
+```
+bot.memory["os_db"] - full database dict (loaded from JSON)
+bot.memory["os_db_path"] - path to the JSON db file
+bot.memory["os_lock"] - threading.Lock() for db writes
+bot.memory["os_flood"] - {(channel, nick): [timestamps]} flood tracking
+bot.memory["os_repeat"] - {(channel, nick): [(hash, timestamp)]} repeat tracking
+bot.memory["os_join_flood"] - {(channel, host): [timestamps]} join flood (per channel)
+bot.memory["os_nick_flood"] - {"user@host": [timestamps]} nick flood (network-wide)
+bot.memory["os_last"] - {(channel, nick): timestamp} last message time
+```
+
+`os_join_flood` is keyed by `(channel, host)` so join flood counts are isolated
+per channel. A user joining multiple channels quickly does not bleed the flood
+counter across channels. `os_nick_flood` is network-wide by design — NICK changes
+apply across all channels.
+
+The other modules access these keys with safe fallbacks in case of load order
+differences. Sopel does not guarantee plugin load order, so every module that
+touches `os_db` uses `bot.memory.get("os_db", {})` rather than a direct dict
+access.
+
+`tracking.py` derives its SQLite database path from `os_db_path` by replacing
+the filename with `track.db`. This keeps both files in the same directory
+without requiring a separate config key.
+
+---
+
+## Database structure
+
+### JSON database (os_db)
+
+```json
+{
+ "config": {"flood_threshold": 5, ...},
+ "acl": ["nick1"],
+ "whitelist": ["*!*@services.dal.net"],
+ "blacklist": [],
+ "exceptions": [],
+ "channels": {
+ "#example": {
+ "config": {"flood_threshold": 3},
+ "bans": [{"mask": "...", "added_by": "...", "timestamp": 0}],
+ "filters": ["regex1"],
+ "badwords": ["word1"],
+ "exceptions": [],
+ "autoop": [],
+ "autovoice": [],
+ "autohalfop": []
+ }
+ }
+}
+```
+
+Config resolution is a three-level merge: hardcoded defaults → global config
+override → channel config override. `_cfg(bot, channel)` returns the merged
+result. `!set` in a channel writes to channel config; `!set` in PM writes to
+global config (owner only).
+
+### SQLite database (track.db)
+
+Single `events` table: `id`, `nick`, `type`, `channel`, `detail`, `timestamp`.
+Indexed on `nick` (NOCASE) and `timestamp DESC`.
+
+Event types: `join`, `part`, `quit`, `kick`, `ban`, `nick`, `mute`, `unmute`,
+`lock+m`, `lock+i`, `unlock-m`, `unlock-i`.
+
+---
+
+## Message routing
+
+| Output | Method | Destination |
+|--------|--------|-------------|
+| `!seen`, `!idle <nick>` results | `bot.say` | channel (`trigger.sender`) |
+| `!info`, `!log` results | `bot.say` | PM (`trigger.nick`) |
+| `!ban list`, `!config`, `!filter list`, `!badword list` | `bot.notice` | nick |
+| All other command feedback | `bot.notice` | nick |
+| Greetings, idle warns, bad word warn | `bot.notice` | nick |
+
+`_notice(bot, nick, text)` in `commands.py` and `help.py` calls
+`bot.notice(text, nick)` — a true IRC NOTICE, not a PRIVMSG. This is the
+standard approach for automated bot feedback that should not trigger other
+bots or appear as a regular chat message.
+
+---
+
+## protect.py: passive protection
+
+### on_join
+
+Runs in order:
+1. Blacklist check (global instant-ban, bypasses whitelist).
+2. Whitelist/exception check. If exempt, still applies auto-modes.
+3. Persistent ban reapply.
+4. Clone detection.
+5. Join flood detection (keyed per channel+host).
+6. Auto-modes (`_apply_auto_modes()`).
+
+Each step returns early if it takes action.
+
+### on_message
+
+Runs in order under `os_lock`:
+1. Regex content filters.
+2. Badword list.
+3. Caps filter.
+4. Repeat filter.
+5. Flood control.
+
+Each filter returns early on action. Flood is last because it is the least
+specific.
+
+### Privilege degradation
+
+- Op: full enforcement (kick, ban, +b mode).
+- Halfop: kick only (no +b, no re-op).
+- None: tracking only (`os_last` updates, no enforcement).
+
+### Takeover mitigation
+
+`on_mode()` watches for `-o` events on ACL nicks. If the bot has op, it
+immediately re-ops them.
+
+---
+
+## commands.py
+
+### ban vs bankick
+
+`!ban` and `!bankick` are functionally identical. They both ban and kick.
+`!ban` also handles the `!ban list` subcommand. `!bankick` exists as an
+explicit alias without the list subcommand ambiguity.
+
+### tempban
+
+Auto-unban is implemented with `threading.Timer`. The timer is not persisted.
+If the bot restarts before the timer fires, the ban is never lifted. The expiry
+is stored in the JSON db (`"expires"` key), so the data is there; it is just
+not acted on at startup.
+
+### !filter del / !badword del
+
+Deleted by index, not by value. Use `!filter list` / `!badword list` to see
+indices, then `del N`. This avoids ambiguity with regex special characters.
+
+### !unban db cleanup
+
+`cmd_unban` removes matching entries from the internal ban database using
+`fnmatch.fnmatch(b["mask"], mask)` — tests whether the stored ban mask matches
+the target being unbanned. The argument order matters: `fnmatch(pattern, string)`
+where the stored mask is the pattern and the unban target is the string being
+tested against it.
+
+---
+
+## tracking.py
+
+### Greeting
+
+On JOIN, checks for prior events before logging the join itself. If the nick
+has any prior events: "Welcome back, nick!". If not: "Welcome to #channel, nick!".
+Sent as IRC NOTICE to the joining user. Controlled by the `greet` config key.
+
+### !seen scoping
+
+In channel: checks only the current channel — live presence check against
+`bot.channels[channel]`, then DB query with `AND channel = ?`. Reports in channel.
+
+In PM: owner/ACL only. Cross-channel lookup across all joined channels and
+the full event log. Reports as IRC NOTICE to nick. All other callers get
+"use !seen in a channel".
+
+### !info scoping
+
+In channel: all DB queries scoped to `channel = ?`. Alias lookups (nick-change
+events) are global because NICK events have no channel. Reports in PM.
+
+In PM: owner/ACL only. All queries are global. Reports in PM. All other
+callers get "use !info in a channel".
+
+---
+
+## idle.py
+
+### Idle tick
+
+`@plugin.interval(60)` checks all users in all channels against
+`idle_warn_min` and `idle_kick_min` (minutes). Uses `os_last` timestamps.
+Users with no `os_last` entry are treated as active.
+
+---
+
+## Known issues and tradeoffs
+
+**tempban does not survive restarts.** The expiry timestamp is stored in the
+JSON db but not checked on startup. Tempbans that expire while the bot is down
+are never lifted.
+
+**Clone detection is host-only.** Checks `trigger.host`, not `user@host`.
+Users behind a BNC share the same host. Either raise `clone_limit` or whitelist
+the BNC host.
+
+**ACL management requires a channel.** `!acl` has `@plugin.require_chanmsg`.
+Cannot manage the ACL from PM. Workaround: call from any channel the bot is in.
+
+**`!ban list` only shows bot-tracked bans.** Bans set manually by channel ops
+(not through osterman) do not appear. The list reflects the internal JSON db,
+not the IRC +b list.
+
+---
+
+## Config
+
+| Key | Description |
+|-----|-------------|
+| `[osterman] db_path` | Path to JSON database (auto-created if missing) |