1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
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) |
|