aboutsummaryrefslogtreecommitdiffstats
path: root/osterman
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 /osterman
init: mostly vibed
Diffstat (limited to 'osterman')
-rw-r--r--osterman/__init__.py0
-rw-r--r--osterman/commands.py875
-rw-r--r--osterman/help.py30
-rw-r--r--osterman/helpstrings.py93
-rw-r--r--osterman/idle.py137
-rw-r--r--osterman/osterman.cfg23
-rw-r--r--osterman/protect.py486
-rw-r--r--osterman/tracking.py414
8 files changed, 2058 insertions, 0 deletions
diff --git a/osterman/__init__.py b/osterman/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/osterman/__init__.py
diff --git a/osterman/commands.py b/osterman/commands.py
new file mode 100644
index 0000000..4ee2037
--- /dev/null
+++ b/osterman/commands.py
@@ -0,0 +1,875 @@
+"""
+Sopel plugin: osterman admin & moderation commands
+
+Moderation (ACL / op / owner):
+ !kick / !k <nick> [reason] - kick
+ !ban / !b <nick|mask> [reason] - ban + kick
+ !ban list / !b list - view channel ban list (NOTICE)
+ !bankick / !bk <nick|mask> [reason] - ban + kick (explicit)
+ !unban / !ub <nick|mask> - unban
+ !tempban / !tb <nick|mask> <min> [reason] - timed ban
+ !mute / !mu <nick|mask> - quiet (+q)
+ !unmute / !umu <nick|mask> - unquiet (-q)
+ !voice / !v <nick> - give voice
+ !devoice / !dv <nick> - remove voice
+ !op / !o <nick> - give op
+ !deop / !do <nick> - remove op
+ !halfop / !hop <nick> - give halfop
+ !dehalfop / !dhop <nick> - remove halfop
+ !lock / !lk [m|i] - lock channel
+ !unlock / !ulk [m|i] - unlock channel
+
+Auto-mode (ACL / op / owner - per channel):
+ !autoop / !ao <add|del|list> [nick|mask] - auto +o on join
+ !autovoice / !av <add|del|list> [nick|mask] - auto +v on join
+ !autohalfop / !ahop <add|del|list> [nick|mask] - auto +h on join
+
+Config (ACL / owner):
+ !blacklist / !bl <add|del|list> [nick|mask] - global ban list (all channels)
+ !acl <add|del|list> [nick] - trusted nicks (global)
+ !whitelist / !wl <add|del|list> [mask] - bypass all protection (global)
+ !except / !ex <add|del|list> [mask] - bypass protection (per channel)
+ !filter / !f <add|del|list> [pattern] - regex content filter (per channel)
+ !badword / !bw <add|del|list> [word] - word block list (per channel)
+ !set / !s <key> <value> - set a config value
+ !config / !cfg [#channel] - show channel config (NOTICE)
+ !idle / !id on|off - toggle idle checking
+ !idle / !id <nick> - show idle time
+
+Tracking:
+ !seen <nick> - last seen time
+ !info / !i <nick> - nick summary
+ !log / !l <nick> [N] - event log (NOTICE, ACL/owner only)
+ !afk - reset your idle timer
+
+Admin (owner):
+ !join / !j #channel - join a channel
+ !part / !p [#channel] - leave a channel
+ !chans / !c - list channels
+ !help / !h - this list (NOTICE)
+"""
+
+import fnmatch
+import json
+import os
+import threading
+import time
+
+from sopel import plugin
+
+
+_CONFIG_KEYS = {
+ "flood_threshold": int,
+ "flood_window_sec": int,
+ "caps_filter": int,
+ "caps_percent": int,
+ "caps_min_len": int,
+ "repeat_filter": int,
+ "repeat_threshold": int,
+ "repeat_window_sec": int,
+ "clone_limit": int,
+ "join_flood_filter": int,
+ "join_flood_count": int,
+ "join_flood_window": int,
+ "nick_flood_filter": int,
+ "nick_flood_count": int,
+ "nick_flood_window": int,
+ "badword_action": str,
+ "tempban_max_min": int,
+ "idle_warn_min": int,
+ "idle_kick_min": int,
+ "log_limit": int,
+ "greet": int,
+}
+
+_CONFIG_DEFAULTS = {
+ "flood_threshold": 5,
+ "flood_window_sec": 10,
+ "caps_filter": 1,
+ "caps_percent": 70,
+ "caps_min_len": 10,
+ "repeat_filter": 1,
+ "repeat_threshold": 3,
+ "repeat_window_sec": 30,
+ "clone_limit": 2,
+ "join_flood_filter": 1,
+ "join_flood_count": 5,
+ "join_flood_window": 10,
+ "nick_flood_filter": 1,
+ "nick_flood_count": 3,
+ "nick_flood_window": 60,
+ "badword_action": "kick",
+ "tempban_max_min": 60,
+ "idle_warn_min": 45,
+ "idle_kick_min": 60,
+ "log_limit": 6,
+ "greet": 1,
+}
+
+
+def _is_owner(bot, trigger):
+ return trigger.nick == bot.settings.core.owner
+
+
+def _is_acl(bot, trigger):
+ return trigger.nick in bot.memory.get("os_db", {}).get("acl", [])
+
+
+def _is_op(bot, trigger):
+ chan = bot.channels.get(str(trigger.sender))
+ if not chan:
+ return False
+ return chan.privileges.get(trigger.nick, 0) >= plugin.OP
+
+
+def _is_authorized(bot, trigger):
+ return _is_owner(bot, trigger) or _is_acl(bot, trigger) or _is_op(bot, trigger)
+
+
+def _global_db(bot):
+ return bot.memory.get("os_db", {})
+
+
+def _chan_db(bot, channel):
+ db = bot.memory["os_db"]
+ db.setdefault("channels", {})
+ return db["channels"].setdefault(channel.lower(), {})
+
+
+def _save(bot):
+ path = bot.memory["os_db_path"]
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as f:
+ json.dump(bot.memory["os_db"], f, indent=2)
+
+
+def _bot_privs(bot, channel):
+ chan = bot.channels.get(channel)
+ if not chan:
+ return 0
+ return chan.privileges.get(bot.nick, 0)
+
+
+def _bot_has_op(bot, channel):
+ return _bot_privs(bot, channel) >= plugin.OP
+
+
+def _bot_has_halfop(bot, channel):
+ return _bot_privs(bot, channel) >= plugin.HALFOP
+
+
+def _resolve_mask(bot, channel, target):
+ if "!" not in target and "@" not in target:
+ chan = bot.channels.get(channel)
+ if chan:
+ u = chan.users.get(target)
+ if u:
+ return f"*!{u.user}@{u.host}"
+ return target
+
+
+def _notice(bot, nick, text):
+ bot.notice(text, nick)
+
+
+# --- Moderation ---
+
+@plugin.command("kick", "k")
+@plugin.require_chanmsg
+def cmd_kick(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to kick")
+ return
+ args = (trigger.group(2) or "").split(None, 1)
+ if not args or not args[0]:
+ _notice(bot, trigger.nick, "usage: !kick <nick> [reason]")
+ return
+ bot.write(["KICK", channel, args[0], args[1] if len(args) > 1 else "kicked"])
+
+
+@plugin.command("ban", "b")
+@plugin.require_chanmsg
+def cmd_ban(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ args = (trigger.group(2) or "").split(None, 1)
+
+ if args and args[0].lower() == "list":
+ cdb = _chan_db(bot, channel)
+ bans = cdb.get("bans", [])
+ if not bans:
+ _notice(bot, trigger.nick, f"ban list for {channel}: (empty)")
+ return
+ _notice(bot, trigger.nick, f"-- ban list for {channel} ({len(bans)}) --")
+ for b in bans:
+ ts = time.strftime("%m-%d %H:%M", time.localtime(b.get("timestamp", 0)))
+ exp = f" expires {time.strftime('%m-%d %H:%M', time.localtime(b['expires']))}" if b.get("expires") else ""
+ _notice(bot, trigger.nick, f"{b['mask']} set by {b.get('added_by','?')} on {ts}{exp}")
+ return
+
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to ban")
+ return
+ if not args or not args[0]:
+ _notice(bot, trigger.nick, "usage: !ban <nick|mask> [reason] or !ban list")
+ return
+ target = args[0]
+ reason = args[1] if len(args) > 1 else "banned"
+ mask = _resolve_mask(bot, channel, target)
+ has_op = _bot_has_op(bot, channel)
+
+ if has_op:
+ bot.write(["MODE", channel, "+b", mask])
+ else:
+ _notice(bot, trigger.nick, f"(halfop only: kicking {target}, cannot set +b)")
+
+ bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason])
+
+ if has_op:
+ with bot.memory["os_lock"]:
+ _chan_db(bot, channel).setdefault("bans", []).append({
+ "mask": mask, "added_by": trigger.nick,
+ "timestamp": int(time.time()),
+ })
+ _save(bot)
+
+
+@plugin.command("bankick", "bk")
+@plugin.require_chanmsg
+def cmd_bankick(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to bankick")
+ return
+ args = (trigger.group(2) or "").split(None, 1)
+ if not args or not args[0]:
+ _notice(bot, trigger.nick, "usage: !bankick <nick|mask> [reason]")
+ return
+ target = args[0]
+ reason = args[1] if len(args) > 1 else "banned"
+ mask = _resolve_mask(bot, channel, target)
+ has_op = _bot_has_op(bot, channel)
+
+ if has_op:
+ bot.write(["MODE", channel, "+b", mask])
+ else:
+ _notice(bot, trigger.nick, f"(halfop only: kicking {target}, cannot set +b)")
+
+ bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason])
+
+ if has_op:
+ with bot.memory["os_lock"]:
+ _chan_db(bot, channel).setdefault("bans", []).append({
+ "mask": mask, "added_by": trigger.nick,
+ "timestamp": int(time.time()),
+ })
+ _save(bot)
+
+
+@plugin.command("unban", "ub")
+@plugin.require_chanmsg
+def cmd_unban(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to unban")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !unban <nick|mask>")
+ return
+ mask = _resolve_mask(bot, channel, args[0])
+ bot.write(["MODE", channel, "-b", mask])
+
+ with bot.memory["os_lock"]:
+ cdb = _chan_db(bot, channel)
+ before = len(cdb.get("bans", []))
+ cdb["bans"] = [
+ b for b in cdb.get("bans", [])
+ if not fnmatch.fnmatch(b["mask"], mask) and b["mask"] != mask
+ ]
+ removed = before - len(cdb["bans"])
+ _save(bot)
+
+ msg = f"unbanned {mask}"
+ if removed:
+ msg += f" ({removed} removed from db)"
+ _notice(bot, trigger.nick, msg)
+
+
+@plugin.command("tempban", "tb")
+@plugin.require_chanmsg
+def cmd_tempban(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to tempban")
+ return
+ args = (trigger.group(2) or "").split(None, 2)
+ if len(args) < 2:
+ _notice(bot, trigger.nick, "usage: !tempban <nick|mask> <minutes> [reason]")
+ return
+ target = args[0]
+ global_cfg = bot.memory.get("os_db", {}).get("config", {})
+ chan_cfg = bot.memory.get("os_db", {}).get("channels", {}).get(channel.lower(), {}).get("config", {})
+ max_min = {**global_cfg, **chan_cfg}.get("tempban_max_min", 60)
+ try:
+ minutes = max(1, min(int(args[1]), max_min))
+ except ValueError:
+ _notice(bot, trigger.nick, "minutes must be a number")
+ return
+ reason = args[2] if len(args) > 2 else f"tempban ({minutes}m)"
+ mask = _resolve_mask(bot, channel, target)
+ has_op = _bot_has_op(bot, channel)
+
+ if has_op:
+ bot.write(["MODE", channel, "+b", mask])
+
+ bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason])
+
+ if has_op:
+ expires = int(time.time()) + minutes * 60
+
+ def unban():
+ bot.write(["MODE", channel, "-b", mask])
+ with bot.memory["os_lock"]:
+ cdb = _chan_db(bot, channel)
+ cdb["bans"] = [b for b in cdb.get("bans", []) if b.get("mask") != mask]
+ _save(bot)
+
+ t = threading.Timer(minutes * 60, unban)
+ t.daemon = True
+ t.start()
+
+ with bot.memory["os_lock"]:
+ _chan_db(bot, channel).setdefault("bans", []).append({
+ "mask": mask, "added_by": trigger.nick,
+ "timestamp": int(time.time()), "expires": expires,
+ "channel": channel,
+ })
+ _save(bot)
+
+ _notice(bot, trigger.nick, f"tempbanned {mask} for {minutes}m")
+ else:
+ _notice(bot, trigger.nick, f"(halfop only: kicked {target}, cannot set timed ban)")
+
+
+@plugin.command("mute", "mu")
+@plugin.require_chanmsg
+def cmd_mute(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to mute")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !mute <nick|mask>")
+ return
+ mask = _resolve_mask(bot, channel, args[0])
+ bot.write(["MODE", channel, "+q", mask])
+
+
+@plugin.command("unmute", "umu")
+@plugin.require_chanmsg
+def cmd_unmute(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to unmute")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !unmute <nick|mask>")
+ return
+ mask = _resolve_mask(bot, channel, args[0])
+ bot.write(["MODE", channel, "-q", mask])
+
+
+@plugin.command("voice", "v")
+@plugin.require_chanmsg
+def cmd_voice(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to set voice")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !voice <nick>")
+ return
+ bot.write(["MODE", channel, "+v", args[0]])
+
+
+@plugin.command("devoice", "dv")
+@plugin.require_chanmsg
+def cmd_devoice(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_halfop(bot, channel):
+ _notice(bot, trigger.nick, "need at least halfop to remove voice")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !devoice <nick>")
+ return
+ bot.write(["MODE", channel, "-v", args[0]])
+
+
+@plugin.command("op", "o")
+@plugin.require_chanmsg
+def cmd_op(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to give op")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !op <nick>")
+ return
+ bot.write(["MODE", channel, "+o", args[0]])
+
+
+@plugin.command("deop", "do")
+@plugin.require_chanmsg
+def cmd_deop(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to remove op")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !deop <nick>")
+ return
+ bot.write(["MODE", channel, "-o", args[0]])
+
+
+@plugin.command("halfop", "hop")
+@plugin.require_chanmsg
+def cmd_halfop(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to give halfop")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !halfop <nick>")
+ return
+ bot.write(["MODE", channel, "+h", args[0]])
+
+
+@plugin.command("dehalfop", "dhop")
+@plugin.require_chanmsg
+def cmd_dehalfop(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to remove halfop")
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, "usage: !dehalfop <nick>")
+ return
+ bot.write(["MODE", channel, "-h", args[0]])
+
+
+@plugin.command("lock", "lk")
+@plugin.require_chanmsg
+def cmd_lock(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to lock channel")
+ return
+ arg = (trigger.group(2) or "").strip().lower()
+ if arg == "m":
+ bot.write(["MODE", channel, "+m"])
+ _notice(bot, trigger.nick, "channel locked (+m moderated)")
+ elif arg == "i":
+ bot.write(["MODE", channel, "+i"])
+ _notice(bot, trigger.nick, "channel locked (+i invite-only)")
+ else:
+ bot.write(["MODE", channel, "+mi"])
+ _notice(bot, trigger.nick, "channel locked (+mi moderated + invite-only)")
+
+
+@plugin.command("unlock", "ulk")
+@plugin.require_chanmsg
+def cmd_unlock(bot, trigger):
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ if not _bot_has_op(bot, channel):
+ _notice(bot, trigger.nick, "need op to unlock channel")
+ return
+ arg = (trigger.group(2) or "").strip().lower()
+ if arg == "m":
+ bot.write(["MODE", channel, "-m"])
+ _notice(bot, trigger.nick, "channel unlocked (-m moderated)")
+ elif arg == "i":
+ bot.write(["MODE", channel, "-i"])
+ _notice(bot, trigger.nick, "channel unlocked (-i invite-only)")
+ else:
+ bot.write(["MODE", channel, "-mi"])
+ _notice(bot, trigger.nick, "channel unlocked (-mi moderated + invite-only)")
+
+
+# --- Auto-mode ---
+
+def _automode_cmd(bot, trigger, key, mode_char, label):
+ """Generic handler for !autoop, !autovoice, !autohalfop."""
+ if not _is_authorized(bot, trigger):
+ return
+ channel = str(trigger.sender)
+ args = (trigger.group(2) or "").split()
+ if not args:
+ _notice(bot, trigger.nick, f"usage: !{label} <add|del|list> [nick|mask]")
+ return
+ sub = args[0].lower()
+ cdb = _chan_db(bot, channel)
+ if sub == "list":
+ entries = cdb.get(key, [])
+ _notice(bot, trigger.nick,
+ f"{label} for {channel}: " + (", ".join(entries) if entries else "(empty)"))
+ elif sub == "add" and len(args) > 1:
+ mask = args[1]
+ if mask not in cdb.setdefault(key, []):
+ cdb[key].append(mask)
+ _save(bot)
+ _notice(bot, trigger.nick, f"added {mask} to {label} for {channel}")
+ elif sub == "del" and len(args) > 1:
+ mask = args[1]
+ cdb[key] = [m for m in cdb.get(key, []) if m != mask]
+ _save(bot)
+ _notice(bot, trigger.nick, f"removed {mask} from {label} for {channel}")
+ else:
+ _notice(bot, trigger.nick, f"usage: !{label} <add|del|list> [nick|mask]")
+
+
+@plugin.command("autoop", "ao")
+@plugin.require_chanmsg
+def cmd_autoop(bot, trigger):
+ _automode_cmd(bot, trigger, "autoop", "o", "autoop")
+
+
+@plugin.command("autovoice", "av")
+@plugin.require_chanmsg
+def cmd_autovoice(bot, trigger):
+ _automode_cmd(bot, trigger, "autovoice", "v", "autovoice")
+
+
+@plugin.command("autohalfop", "ahop")
+@plugin.require_chanmsg
+def cmd_autohalfop(bot, trigger):
+ _automode_cmd(bot, trigger, "autohalfop", "h", "autohalfop")
+
+
+# --- Config ---
+
+@plugin.command("blacklist", "bl")
+def cmd_blacklist(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ dest = str(trigger.sender) if str(trigger.sender).startswith("#") else trigger.nick
+ if not args:
+ _notice(bot, trigger.nick, "usage: !blacklist <add|del|list> [nick|mask]")
+ return
+ sub = args[0].lower()
+ db = _global_db(bot)
+ if sub == "list":
+ bl = db.get("blacklist", [])
+ _notice(bot, trigger.nick,
+ "blacklist: " + (", ".join(bl) if bl else "(empty)"))
+ elif sub == "add" and len(args) > 1:
+ mask = args[1]
+ if mask not in db.setdefault("blacklist", []):
+ db["blacklist"].append(mask)
+ _save(bot)
+ _notice(bot, trigger.nick, f"added {mask} to blacklist")
+ elif sub == "del" and len(args) > 1:
+ mask = args[1]
+ db["blacklist"] = [m for m in db.get("blacklist", []) if m != mask]
+ _save(bot)
+ _notice(bot, trigger.nick, f"removed {mask} from blacklist")
+ else:
+ _notice(bot, trigger.nick, "usage: !blacklist <add|del|list> [nick|mask]")
+
+
+@plugin.command("acl")
+@plugin.require_chanmsg
+def cmd_acl(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ channel = str(trigger.sender)
+ if not args:
+ _notice(bot, trigger.nick, "usage: !acl <add|del|list> [nick]")
+ return
+ sub = args[0].lower()
+ db = _global_db(bot)
+ if sub == "list":
+ acl = db.get("acl", [])
+ _notice(bot, trigger.nick, "acl: " + (", ".join(acl) if acl else "(empty)"))
+ elif sub == "add" and len(args) > 1:
+ nick = args[1]
+ if nick not in db.setdefault("acl", []):
+ db["acl"].append(nick)
+ _save(bot)
+ _notice(bot, trigger.nick, f"added {nick} to acl")
+ elif sub == "del" and len(args) > 1:
+ nick = args[1]
+ db["acl"] = [n for n in db.get("acl", []) if n != nick]
+ _save(bot)
+ _notice(bot, trigger.nick, f"removed {nick} from acl")
+ else:
+ _notice(bot, trigger.nick, "usage: !acl <add|del|list> [nick]")
+
+
+@plugin.command("whitelist", "wl")
+@plugin.require_chanmsg
+def cmd_wl(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ channel = str(trigger.sender)
+ if not args:
+ _notice(bot, trigger.nick, "usage: !wl <add|del|list> [mask]")
+ return
+ sub = args[0].lower()
+ db = _global_db(bot)
+ if sub == "list":
+ wl = db.get("whitelist", [])
+ _notice(bot, trigger.nick, "whitelist: " + (", ".join(wl) if wl else "(empty)"))
+ elif sub == "add" and len(args) > 1:
+ mask = args[1]
+ if mask not in db.setdefault("whitelist", []):
+ db["whitelist"].append(mask)
+ _save(bot)
+ _notice(bot, trigger.nick, f"added {mask} to whitelist")
+ elif sub == "del" and len(args) > 1:
+ mask = args[1]
+ db["whitelist"] = [m for m in db.get("whitelist", []) if m != mask]
+ _save(bot)
+ _notice(bot, trigger.nick, f"removed {mask} from whitelist")
+ else:
+ _notice(bot, trigger.nick, "usage: !wl <add|del|list> [mask]")
+
+
+@plugin.command("except", "ex")
+@plugin.require_chanmsg
+def cmd_except(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ channel = str(trigger.sender)
+ if not args:
+ _notice(bot, trigger.nick, "usage: !except <add|del|list> [mask]")
+ return
+ sub = args[0].lower()
+ cdb = _chan_db(bot, channel)
+ if sub == "list":
+ exc = cdb.get("exceptions", [])
+ _notice(bot, trigger.nick,
+ f"exceptions for {channel}: " + (", ".join(exc) if exc else "(empty)"))
+ elif sub == "add" and len(args) > 1:
+ mask = args[1]
+ if mask not in cdb.setdefault("exceptions", []):
+ cdb["exceptions"].append(mask)
+ _save(bot)
+ _notice(bot, trigger.nick, f"added {mask} to exceptions for {channel}")
+ elif sub == "del" and len(args) > 1:
+ mask = args[1]
+ cdb["exceptions"] = [m for m in cdb.get("exceptions", []) if m != mask]
+ _save(bot)
+ _notice(bot, trigger.nick, f"removed {mask} from exceptions for {channel}")
+ else:
+ _notice(bot, trigger.nick, "usage: !except <add|del|list> [mask]")
+
+
+@plugin.command("filter", "f")
+@plugin.require_chanmsg
+def cmd_filter(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split(None, 1)
+ channel = str(trigger.sender)
+ if not args:
+ _notice(bot, trigger.nick, "usage: !filter <add|del|list> [pattern]")
+ return
+ sub = args[0].lower()
+ cdb = _chan_db(bot, channel)
+ if sub == "list":
+ filters = cdb.get("filters", [])
+ if not filters:
+ _notice(bot, trigger.nick, f"filters for {channel}: (none)")
+ else:
+ for i, f in enumerate(filters):
+ _notice(bot, trigger.nick, f"[{i}] {f}")
+ elif sub == "add" and len(args) > 1:
+ cdb.setdefault("filters", []).append(args[1])
+ _save(bot)
+ _notice(bot, trigger.nick, f"filter added: {args[1]}")
+ elif sub == "del" and len(args) > 1:
+ try:
+ removed = cdb["filters"].pop(int(args[1]))
+ _save(bot)
+ _notice(bot, trigger.nick, f"filter removed: {removed}")
+ except (ValueError, IndexError):
+ _notice(bot, trigger.nick, "usage: !filter del <index> (use !filter list for indices)")
+ else:
+ _notice(bot, trigger.nick, "usage: !filter <add|del|list> [pattern]")
+
+
+@plugin.command("badword", "bw")
+@plugin.require_chanmsg
+def cmd_badword(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split(None, 1)
+ channel = str(trigger.sender)
+ if not args:
+ _notice(bot, trigger.nick, "usage: !badword <add|del|list> [word]")
+ return
+ sub = args[0].lower()
+ cdb = _chan_db(bot, channel)
+ if sub == "list":
+ words = cdb.get("badwords", [])
+ if not words:
+ _notice(bot, trigger.nick, f"badwords for {channel}: (none)")
+ else:
+ for i, w in enumerate(words):
+ _notice(bot, trigger.nick, f"[{i}] {w}")
+ elif sub == "add" and len(args) > 1:
+ word = args[1].lower()
+ cdb.setdefault("badwords", []).append(word)
+ _save(bot)
+ _notice(bot, trigger.nick, f"badword added: {word}")
+ elif sub == "del" and len(args) > 1:
+ try:
+ removed = cdb["badwords"].pop(int(args[1]))
+ _save(bot)
+ _notice(bot, trigger.nick, f"badword removed: {removed}")
+ except (ValueError, IndexError):
+ _notice(bot, trigger.nick, "usage: !badword del <index> (use !badword list for indices)")
+ else:
+ _notice(bot, trigger.nick, "usage: !badword <add|del|list> [word]")
+
+
+@plugin.command("set", "s")
+def cmd_set(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ in_chan = str(trigger.sender).startswith("#")
+ channel = str(trigger.sender) if in_chan else None
+ dest = trigger.nick
+ if len(args) < 2:
+ keys = ", ".join(_CONFIG_KEYS)
+ _notice(bot, dest, f"usage: !set <key> <value> - keys: {keys}")
+ return
+ key, raw = args[0], args[1]
+ if key not in _CONFIG_KEYS:
+ _notice(bot, dest, f"unknown key '{key}' - valid keys: {', '.join(_CONFIG_KEYS)}")
+ return
+ try:
+ value = _CONFIG_KEYS[key](raw)
+ except ValueError:
+ _notice(bot, dest, f"invalid value for {key}: expected {_CONFIG_KEYS[key].__name__}")
+ return
+
+ if in_chan:
+ _chan_db(bot, channel).setdefault("config", {})[key] = value
+ _notice(bot, dest, f"set {key} = {value} for {channel}")
+ else:
+ if not _is_owner(bot, trigger):
+ return
+ _global_db(bot).setdefault("config", {})[key] = value
+ _notice(bot, dest, f"set global default {key} = {value}")
+ _save(bot)
+
+
+@plugin.command("config", "cfg")
+def cmd_config(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+
+ in_chan = str(trigger.sender).startswith("#")
+ arg = (trigger.group(2) or "").strip()
+
+ if arg.startswith("#"):
+ channel = arg
+ elif in_chan:
+ channel = str(trigger.sender)
+ else:
+ _notice(bot, trigger.nick, "usage: !config [#channel]")
+ return
+
+ global_cfg = _global_db(bot).get("config", {})
+ channel_cfg = _chan_db(bot, channel).get("config", {})
+
+ _notice(bot, trigger.nick, f"-- config for {channel} --")
+ for key in sorted(_CONFIG_KEYS):
+ hardcoded = _CONFIG_DEFAULTS.get(key, "?")
+ global_val = global_cfg.get(key)
+ channel_val = channel_cfg.get(key)
+ if channel_val is not None:
+ _notice(bot, trigger.nick,
+ f"{key} = {channel_val} (channel override; global: {global_val if global_val is not None else hardcoded})")
+ elif global_val is not None:
+ _notice(bot, trigger.nick,
+ f"{key} = {global_val} (global; default: {hardcoded})")
+ else:
+ _notice(bot, trigger.nick, f"{key} = {hardcoded} (default)")
+
+
+# --- Admin ---
+
+@plugin.command("join", "j")
+def cmd_join(bot, trigger):
+ if not _is_owner(bot, trigger):
+ return
+ channel = (trigger.group(2) or "").strip()
+ if not channel.startswith("#"):
+ _notice(bot, trigger.nick, "usage: !join #channel")
+ return
+ bot.join(channel)
+
+
+@plugin.command("part", "p")
+def cmd_part(bot, trigger):
+ if not _is_owner(bot, trigger):
+ return
+ channel = (trigger.group(2) or "").strip() or str(trigger.sender)
+ bot.part(channel)
+
+
+@plugin.command("chans", "c")
+def cmd_chans(bot, trigger):
+ if not _is_owner(bot, trigger):
+ return
+ chans = sorted(str(c) for c in bot.channels)
+ _notice(bot, trigger.nick,
+ ("in: " + " ".join(chans)) if chans else "not in any channels")
+
+
diff --git a/osterman/help.py b/osterman/help.py
new file mode 100644
index 0000000..fdc94c0
--- /dev/null
+++ b/osterman/help.py
@@ -0,0 +1,30 @@
+"""
+Sopel plugin: osterman help system
+
+ !help list topics
+ !help <topic> list commands for a topic
+ !help <topic> <cmd> description and usage for a command
+ !help <cmd> description and usage (topic not required)
+"""
+
+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
+
+from sopel import plugin
+
+
+def _notice(bot, nick, text):
+ bot.notice(text, nick)
+
+
+@plugin.command("help", "h")
+def cmd_help(bot, trigger):
+ args = (trigger.group(2) or "").split()
+ result = h.lookup(args)
+ if result:
+ _notice(bot, trigger.nick, result)
+ else:
+ _notice(bot, trigger.nick, f"no help for '{' '.join(args)}'. {h.TOPICS}")
diff --git a/osterman/helpstrings.py b/osterman/helpstrings.py
new file mode 100644
index 0000000..31ee7d1
--- /dev/null
+++ b/osterman/helpstrings.py
@@ -0,0 +1,93 @@
+TOPICS = (
+ "topics (commands): "
+ "mod (kick, bankick, tempban, ban, unban, mute, unmute, voice, devoice, op, deop, halfop, dehalfop, lock, unlock), "
+ "config (autoop, autovoice, autohalfop, acl, whitelist, blacklist, except, filter, badword, set, config), "
+ "track (seen, info, log), "
+ "idle (idle, afk), "
+ "admin (join, part, chans). "
+ "type !help <topic> or !help <cmd> to know more"
+)
+
+MOD_TOPIC = "mod: kick, bankick, tempban, ban, unban, mute, unmute, voice, devoice, op, deop, halfop, dehalfop, lock, unlock. type !help <cmd> for usage"
+CONFIG_TOPIC = "config: autoop, autovoice, autohalfop, acl, whitelist, blacklist, except, filter, badword, set, config. type !help <cmd> for usage"
+TRACK_TOPIC = "track: seen, info, log. type !help <cmd> for usage"
+IDLE_TOPIC = "idle: idle, afk. type !help <cmd> for usage"
+ADMIN_TOPIC = "admin: join, part, chans. type !help <cmd> for usage"
+
+MOD = {
+ "kick": "kick a user. usage: !kick <nick> [reason]",
+ "bankick": "ban and kick a user. usage: !bankick <nick|mask> [reason]",
+ "tempban": "timed ban and kick. usage: !tempban <nick|mask> <minutes> [reason]",
+ "ban": "ban and kick, or list bans. usage: !ban <nick|mask> [reason] | !ban list",
+ "unban": "remove a ban. usage: !unban <nick|mask>",
+ "mute": "silence a user (+q). usage: !mute <nick|mask>",
+ "unmute": "remove silence (-q). usage: !unmute <nick|mask>",
+ "voice": "give voice. usage: !voice <nick>",
+ "devoice": "remove voice. usage: !devoice <nick>",
+ "op": "give op. usage: !op <nick>",
+ "deop": "remove op. usage: !deop <nick>",
+ "halfop": "give halfop. usage: !halfop <nick>",
+ "dehalfop": "remove halfop. usage: !dehalfop <nick>",
+ "lock": "lock the channel. usage: !lock [m|i] (default: +mi)",
+ "unlock": "unlock the channel. usage: !unlock [m|i] (default: -mi)",
+}
+
+CONFIG = {
+ "autoop": "manage auto-op list for this channel. usage: !autoop <add|del|list> [nick|mask]",
+ "autovoice": "manage auto-voice list for this channel. usage: !autovoice <add|del|list> [nick|mask]",
+ "autohalfop": "manage auto-halfop list for this channel. usage: !autohalfop <add|del|list> [nick|mask]",
+ "acl": "manage trusted nicks (global). usage: !acl <add|del|list> [nick]",
+ "whitelist": "manage global protection bypass list. usage: !whitelist <add|del|list> [mask]",
+ "blacklist": "manage global ban list. usage: !blacklist <add|del|list> [nick|mask]",
+ "except": "manage per-channel protection bypass list. usage: !except <add|del|list> [mask]",
+ "filter": "manage regex content filters. usage: !filter <add|del|list> [pattern] (del uses index from list)",
+ "badword": "manage word block list. usage: !badword <add|del|list> [word] (del uses index from list)",
+ "set": "set a config key. usage: !set <key> <value> (in channel: per-channel; in PM: global)",
+ "config": "show channel config. usage: !config [#channel]",
+}
+
+TRACK = {
+ "seen": "show when a nick was last seen. usage: !seen <nick>",
+ "info": "show nick summary. usage: !info <nick>",
+ "log": "show recent events for a nick (ACL/owner only). usage: !log <nick> [limit]",
+}
+
+IDLE = {
+ "idle": "show idle time or toggle idle checking. usage: !idle <nick> | !idle <on|off>",
+ "afk": "reset your idle timer. usage: !afk",
+}
+
+ADMIN = {
+ "join": "join a channel. usage: !join <#channel>",
+ "part": "leave a channel. usage: !part [#channel]",
+ "chans": "list channels the bot is in. usage: !chans",
+}
+
+_ALL = {**MOD, **CONFIG, **TRACK, **IDLE, **ADMIN}
+
+TOPIC_MAP = {
+ "mod": (MOD_TOPIC, MOD),
+ "config": (CONFIG_TOPIC, CONFIG),
+ "track": (TRACK_TOPIC, TRACK),
+ "idle": (IDLE_TOPIC, IDLE),
+ "admin": (ADMIN_TOPIC, ADMIN),
+}
+
+
+def lookup(args):
+ """Return a help string for args (list of strings) or None if not found.
+
+ lookup([]) -> TOPICS
+ lookup(["mod"]) -> MOD_TOPIC
+ lookup(["kick"]) -> MOD["kick"]
+ lookup(["mod","kick"]) -> MOD["kick"]
+ """
+ if not args:
+ return TOPICS
+ first = args[0].lower()
+ if first in TOPIC_MAP:
+ if len(args) == 1:
+ return TOPIC_MAP[first][0]
+ cmd = args[1].lower()
+ return TOPIC_MAP[first][1].get(cmd)
+ return _ALL.get(first)
diff --git a/osterman/idle.py b/osterman/idle.py
new file mode 100644
index 0000000..c80d0d0
--- /dev/null
+++ b/osterman/idle.py
@@ -0,0 +1,137 @@
+"""
+Sopel plugin: osterman - anti-idle
+
+Warns idle users via NOTICE, kicks if they stay idle past the kick threshold.
+Idle state is tracked per channel. Thresholds are in minutes; set to 0
+to disable warn or kick.
+
+Degrades gracefully by privilege level:
+ halfop or op - warn and kick
+ none - warn only (cannot kick)
+
+ !afk - reset your own idle timer
+ !idle on|off - enable/disable idle checking (ACL/owner; per channel if in channel, global if PM)
+ !idle <nick> - show how long a nick has been idle in this channel
+"""
+
+import json
+import os
+import time
+
+from sopel import plugin
+
+
+def _bot_has_halfop(bot, channel):
+ chan = bot.channels.get(channel)
+ if not chan:
+ return False
+ return chan.privileges.get(bot.nick, 0) >= plugin.HALFOP
+
+
+def _chan_cfg(bot, channel):
+ global_cfg = bot.memory.get("os_db", {}).get("config", {})
+ chan_cfg = (bot.memory.get("os_db", {})
+ .get("channels", {})
+ .get(channel.lower(), {})
+ .get("config", {}))
+ return {**global_cfg, **chan_cfg}
+
+
+@plugin.interval(60)
+def _idle_tick(bot):
+ now = time.time()
+ last = bot.memory.get("os_last", {})
+
+ for channel_name, channel in list(bot.channels.items()):
+ cfg = _chan_cfg(bot, channel_name)
+ warn_min = cfg.get("idle_warn_min", 45)
+ kick_min = cfg.get("idle_kick_min", 60)
+
+ if warn_min <= 0 and kick_min <= 0:
+ continue
+
+ can_kick = _bot_has_halfop(bot, channel_name)
+
+ for nick in list(channel.users):
+ if nick == bot.nick:
+ continue
+ idle_sec = now - last.get((channel_name, nick), now)
+ idle_min = idle_sec / 60
+
+ if kick_min > 0 and idle_min >= kick_min:
+ if can_kick:
+ bot.kick(channel_name, nick, f"idle for {int(idle_min)}m")
+ last.pop((channel_name, nick), None)
+ else:
+ bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} (cannot kick without halfop)", nick)
+ elif warn_min > 0 and idle_min >= warn_min:
+ bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} - say something or get kicked", nick)
+
+
+@plugin.command("afk")
+@plugin.require_chanmsg
+def cmd_afk(bot, trigger):
+ channel = str(trigger.sender)
+ bot.memory.setdefault("os_last", {})[(channel, trigger.nick)] = time.time()
+ bot.notice("idle timer reset", trigger.nick)
+
+
+def _is_owner(bot, trigger):
+ return trigger.nick == bot.settings.core.owner
+
+
+def _is_acl(bot, trigger):
+ return trigger.nick in bot.memory.get("os_db", {}).get("acl", [])
+
+
+@plugin.command("idle", "id")
+def cmd_idle(bot, trigger):
+ arg = (trigger.group(2) or "").strip().lower()
+ in_chan = str(trigger.sender).startswith("#")
+
+ if arg in ("on", "off"):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ db = bot.memory.get("os_db", {})
+ if in_chan:
+ channel = str(trigger.sender)
+ cfg = (db.setdefault("channels", {})
+ .setdefault(channel.lower(), {})
+ .setdefault("config", {}))
+ else:
+ cfg = db.setdefault("config", {})
+
+ if arg == "off":
+ cfg["idle_warn_min"] = 0
+ cfg["idle_kick_min"] = 0
+ bot.notice("idle checking disabled", trigger.nick)
+ else:
+ cfg["idle_warn_min"] = 45
+ cfg["idle_kick_min"] = 60
+ bot.notice("idle checking enabled (warn 45m, kick 60m)", trigger.nick)
+
+ path = bot.memory.get("os_db_path", "/var/osterman/db.json")
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as fp:
+ json.dump(db, fp, indent=2)
+ return
+
+ target = arg or trigger.nick
+ if not in_chan:
+ bot.notice("use !idle in a channel to check idle time", trigger.nick)
+ return
+ channel = str(trigger.sender)
+ last = bot.memory.get("os_last", {}).get((channel, target))
+ if last is None:
+ bot.say(f"{target}: no idle data", trigger.sender)
+ return
+ diff = int(time.time() - last)
+ if diff < 60:
+ idle_str = f"{diff}s"
+ elif diff < 3600:
+ idle_str = f"{diff // 60}m"
+ elif diff < 86400:
+ idle_str = f"{diff // 3600}h {(diff % 3600) // 60}m"
+ else:
+ idle_str = f"{diff // 86400}d {(diff % 86400) // 3600}h"
+ bot.say(f"{target} idle: {idle_str}", trigger.sender)
diff --git a/osterman/osterman.cfg b/osterman/osterman.cfg
new file mode 100644
index 0000000..9288194
--- /dev/null
+++ b/osterman/osterman.cfg
@@ -0,0 +1,23 @@
+[core]
+nick = osterman
+host = 127.0.0.1
+port = 6667
+use_ssl = false
+verify_ssl = false
+owner = gumx
+password = REPLACEME
+prefix = !
+channels = #gumx
+extra = /home/ahmed/scripts/osterman
+logdir = /var/log/osterman
+logging_level = INFO
+timeout = 120
+enable =
+ protect
+ idle
+ commands
+ tracking
+ help
+
+[osterman]
+db_path = /var/osterman/db.json
diff --git a/osterman/protect.py b/osterman/protect.py
new file mode 100644
index 0000000..d677173
--- /dev/null
+++ b/osterman/protect.py
@@ -0,0 +1,486 @@
+"""
+Sopel plugin: osterman - channel protection core
+
+Automatic enforcement (all configurable at runtime via !set):
+ - Flood control flood_threshold, flood_window_sec
+ - Caps filter caps_filter, caps_percent, caps_min_len
+ - Repeat filter repeat_filter, repeat_threshold, repeat_window_sec
+ - Content filter regex list via !filter
+ - Bad word list word list via !badword
+ - Clone detection clone_limit
+ - Join flood join_flood_filter, join_flood_count, join_flood_window
+ - Nick flood nick_flood_filter, nick_flood_count, nick_flood_window
+ - Persistent ban reapply
+ - Blacklist global instant-ban on join
+ - Auto-mode auto +o/+h/+v on join (per channel)
+ - Takeover mitigation re-op ACL nicks that get deopped
+ - Greet greet
+
+Degrades gracefully by privilege level:
+ op - full: kick, ban, re-op, flood ban
+ halfop - partial: kick only (no bans, no re-op)
+ none - passive: last_seen tracking only
+
+Config is per-channel with global defaults as fallback.
+ Global: acl, whitelist, blacklist, config defaults
+ Per-channel: config overrides, bans, filters, badwords, exceptions, auto-modes
+"""
+
+import fnmatch
+import json
+import logging
+import os
+import re
+import threading
+import time
+
+from sopel import plugin
+from sopel.config.types import FilenameAttribute, StaticSection
+
+log = logging.getLogger(__name__)
+
+_DB_DEFAULTS = {
+ "config": {
+ "flood_threshold": 5,
+ "flood_window_sec": 10,
+ "caps_filter": 1,
+ "caps_percent": 70,
+ "caps_min_len": 10,
+ "repeat_filter": 1,
+ "repeat_threshold": 3,
+ "repeat_window_sec": 30,
+ "clone_limit": 2,
+ "join_flood_filter": 1,
+ "join_flood_count": 5,
+ "join_flood_window": 10,
+ "nick_flood_filter": 1,
+ "nick_flood_count": 3,
+ "nick_flood_window": 60,
+ "badword_action": "kick",
+ "tempban_max_min": 60,
+ "idle_warn_min": 45,
+ "idle_kick_min": 60,
+ "log_limit": 6,
+ "greet": 1,
+ },
+ "acl": [],
+ "whitelist": ["*!*@services.dal.net"],
+ "blacklist": [],
+ "exceptions": [],
+ "channels": {},
+}
+
+
+class OstermanSection(StaticSection):
+ db_path = FilenameAttribute("db_path", relative=False,
+ default="/var/osterman/db.json")
+
+
+def _load_db(path):
+ if os.path.exists(path):
+ with open(path) as f:
+ data = json.load(f)
+ data.setdefault("channels", {})
+ data.setdefault("blacklist", [])
+ return data
+ db = {
+ k: (dict(v) if isinstance(v, dict) else list(v))
+ for k, v in _DB_DEFAULTS.items()
+ }
+ _save_db(path, db)
+ return db
+
+
+def _save_db(path, db):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as f:
+ json.dump(db, f, indent=2)
+
+
+def setup(bot):
+ bot.config.define_section("osterman", OstermanSection, validate=False)
+ path = bot.config.osterman.db_path
+
+ bot.memory["os_db_path"] = path
+ bot.memory["os_lock"] = threading.Lock()
+ bot.memory["os_flood"] = {}
+ bot.memory["os_repeat"] = {}
+ bot.memory["os_join_flood"] = {}
+ bot.memory["os_nick_flood"] = {}
+ bot.memory["os_last"] = {}
+
+ try:
+ bot.memory["os_db"] = _load_db(path)
+ except Exception as exc:
+ log.error("osterman: failed to load db from %s: %s - using defaults", path, exc)
+ bot.memory["os_db"] = {
+ k: (dict(v) if isinstance(v, dict) else list(v))
+ for k, v in _DB_DEFAULTS.items()
+ }
+
+
+# --- DB helpers ---
+
+def _global_db(bot):
+ return bot.memory["os_db"]
+
+
+def _chan_db(bot, channel):
+ return bot.memory["os_db"]["channels"].setdefault(channel.lower(), {})
+
+
+def _cfg(bot, channel):
+ global_cfg = bot.memory["os_db"].get("config", {})
+ channel_cfg = _chan_db(bot, channel).get("config", {})
+ return {**global_cfg, **channel_cfg}
+
+
+def _save(bot):
+ _save_db(bot.memory["os_db_path"], bot.memory["os_db"])
+
+
+# --- Privilege helpers ---
+
+def _hostmask(trigger):
+ return f"{trigger.nick}!{trigger.user}@{trigger.host}"
+
+
+def _bot_privs(bot, channel):
+ chan = bot.channels.get(channel)
+ if not chan:
+ return 0
+ return chan.privileges.get(bot.nick, 0)
+
+
+def _bot_has_op(bot, channel):
+ return _bot_privs(bot, channel) >= plugin.OP
+
+
+def _bot_has_halfop(bot, channel):
+ return _bot_privs(bot, channel) >= plugin.HALFOP
+
+
+# --- Policy helpers ---
+
+def _is_whitelisted(bot, hostmask):
+ return any(fnmatch.fnmatch(hostmask, p)
+ for p in bot.memory["os_db"].get("whitelist", []))
+
+
+def _is_blacklisted(bot, hostmask):
+ return any(fnmatch.fnmatch(hostmask, p)
+ for p in bot.memory["os_db"].get("blacklist", []))
+
+
+def _is_excepted(bot, hostmask, channel=None):
+ # Check per-channel exceptions
+ if channel:
+ per_chan = _chan_db(bot, channel).get("exceptions", [])
+ if any(fnmatch.fnmatch(hostmask, p) for p in per_chan):
+ return True
+ # Legacy: check global exceptions list for backward compat
+ global_exc = bot.memory["os_db"].get("exceptions", [])
+ return any(fnmatch.fnmatch(hostmask, p) for p in global_exc)
+
+
+def _is_exempt(bot, hostmask, channel=None):
+ return _is_whitelisted(bot, hostmask) or _is_excepted(bot, hostmask, channel)
+
+
+def _is_banned(bot, hostmask, channel):
+ global_bans = bot.memory["os_db"].get("bans", [])
+ channel_bans = _chan_db(bot, channel).get("bans", [])
+ return any(fnmatch.fnmatch(hostmask, b["mask"])
+ for b in global_bans + channel_bans)
+
+
+def _matches_list(hostmask, entries):
+ """Return True if hostmask matches any nick or hostmask pattern in entries."""
+ for entry in entries:
+ if "!" in entry or "@" in entry or "*" in entry or "?" in entry:
+ if fnmatch.fnmatch(hostmask, entry):
+ return True
+ else:
+ nick = hostmask.split("!")[0]
+ if nick.lower() == entry.lower():
+ return True
+ return False
+
+
+# --- Event handlers ---
+
+@plugin.event("JOIN")
+@plugin.require_chanmsg
+def on_bot_join(bot, trigger):
+ if trigger.nick != bot.nick:
+ return
+ channel = str(trigger.sender)
+ if channel.lower() not in bot.memory["os_db"].get("channels", {}):
+ _chan_db(bot, channel)
+ _save(bot)
+
+
+@plugin.event("JOIN")
+@plugin.require_chanmsg
+def on_join(bot, trigger):
+ if trigger.nick == bot.nick:
+ return
+
+ channel = str(trigger.sender)
+ hm = _hostmask(trigger)
+ has_op = _bot_has_op(bot, channel)
+ has_halfop = _bot_has_halfop(bot, channel)
+
+ # Blacklist: global instant-ban, checked before whitelist
+ if _is_blacklisted(bot, hm):
+ if has_op:
+ bot.write(["MODE", channel, "+b", hm])
+ bot.write(["KICK", channel, trigger.nick, "blacklisted"])
+ elif has_halfop:
+ bot.write(["KICK", channel, trigger.nick, "blacklisted"])
+ return
+
+ if _is_exempt(bot, hm, channel):
+ # Still apply auto-modes for whitelisted/excepted users
+ _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop)
+ return
+
+ if _is_banned(bot, hm, channel):
+ if has_op:
+ bot.write(["MODE", channel, "+b", hm])
+ bot.write(["KICK", channel, trigger.nick, "banned"])
+ elif has_halfop:
+ bot.write(["KICK", channel, trigger.nick, "banned"])
+ return
+
+ if not has_halfop:
+ return
+
+ cfg = _cfg(bot, channel)
+
+ # Clone detection
+ limit = cfg.get("clone_limit", 2)
+ host = trigger.host
+ chan = bot.channels.get(channel)
+ if chan:
+ clones = [u for u in chan.users
+ if u != trigger.nick and chan.users[u].host == host]
+ if len(clones) >= limit:
+ bot.write(["KICK", channel, trigger.nick, f"clone limit ({limit}) exceeded"])
+ return
+
+ # Join flood detection
+ if cfg.get("join_flood_filter", 1):
+ jf_count = cfg.get("join_flood_count", 5)
+ jf_window = cfg.get("join_flood_window", 10)
+ now = time.time()
+ jf_key = (channel, host)
+ times = bot.memory["os_join_flood"].setdefault(jf_key, [])
+ times.append(now)
+ bot.memory["os_join_flood"][jf_key] = [t for t in times if now - t <= jf_window]
+ if len(bot.memory["os_join_flood"][jf_key]) >= jf_count:
+ mask = f"*!*@{host}"
+ bot.write(["KICK", channel, trigger.nick, "join flood"])
+ if has_op:
+ bot.write(["MODE", channel, "+b", mask])
+ bot.memory["os_join_flood"][jf_key] = []
+ return
+
+ # Auto-modes applied after all checks pass
+ _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop)
+
+
+def _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop):
+ cdb = _chan_db(bot, channel)
+
+ if has_op:
+ autoop = cdb.get("autoop", [])
+ if _matches_list(hm, autoop):
+ bot.write(["MODE", channel, "+o", trigger.nick])
+ return
+
+ autohalfop = cdb.get("autohalfop", [])
+ if _matches_list(hm, autohalfop):
+ bot.write(["MODE", channel, "+h", trigger.nick])
+ return
+
+ if has_halfop or has_op:
+ autovoice = cdb.get("autovoice", [])
+ if _matches_list(hm, autovoice):
+ bot.write(["MODE", channel, "+v", trigger.nick])
+
+
+@plugin.event("MODE")
+def on_mode(bot, trigger):
+ if not trigger.args or len(trigger.args) < 2:
+ return
+
+ channel = trigger.args[0]
+ mode_str = trigger.args[1]
+ targets = list(trigger.args[2:])
+
+ if channel not in bot.channels:
+ return
+ if not _bot_has_op(bot, channel):
+ return
+
+ acl = _global_db(bot).get("acl", [])
+ adding = True
+ t_idx = 0
+
+ for ch in mode_str:
+ if ch == "+":
+ adding = True
+ elif ch == "-":
+ adding = False
+ elif ch == "o":
+ if t_idx < len(targets):
+ target = targets[t_idx]
+ t_idx += 1
+ if not adding and target in acl:
+ bot.write(["MODE", channel, "+o", target])
+
+
+@plugin.event("NICK")
+def on_nick_flood(bot, trigger):
+ if trigger.nick == bot.nick:
+ return
+
+ hm = _hostmask(trigger)
+ if _is_exempt(bot, hm):
+ return
+
+ now = time.time()
+ host_key = f"{trigger.user}@{trigger.host}"
+
+ global_cfg = _global_db(bot).get("config", {})
+ if not global_cfg.get("nick_flood_filter", 1):
+ return
+
+ nf_count = global_cfg.get("nick_flood_count", 3)
+ nf_window = global_cfg.get("nick_flood_window", 60)
+
+ times = bot.memory["os_nick_flood"].setdefault(host_key, [])
+ times.append(now)
+ bot.memory["os_nick_flood"][host_key] = [t for t in times if now - t <= nf_window]
+
+ if len(bot.memory["os_nick_flood"][host_key]) >= nf_count:
+ new_nick = trigger.args[0] if trigger.args else None
+ if not new_nick:
+ return
+ for chan_name in list(bot.channels):
+ if new_nick in bot.channels[chan_name].users:
+ if _bot_has_halfop(bot, chan_name):
+ bot.write(["KICK", chan_name, new_nick, "nick flood"])
+ if _bot_has_op(bot, chan_name):
+ bot.write(["MODE", chan_name, "+b", hm])
+ bot.memory["os_nick_flood"][host_key] = []
+
+
+@plugin.rule(".*")
+@plugin.require_chanmsg
+def on_message(bot, trigger):
+ nick = trigger.nick
+ if nick == bot.nick:
+ return
+
+ channel = str(trigger.sender)
+ hm = _hostmask(trigger)
+ now = time.time()
+
+ bot.memory["os_last"][(channel, nick)] = now
+
+ if _is_exempt(bot, hm, channel):
+ return
+
+ has_op = _bot_has_op(bot, channel)
+ has_halfop = _bot_has_halfop(bot, channel)
+
+ if not has_halfop:
+ return
+
+ with bot.memory["os_lock"]:
+ cfg = _cfg(bot, channel)
+ cdb = _chan_db(bot, channel)
+ text = trigger.group(0) or ""
+
+ # Regex content filter
+ for pattern in cdb.get("filters", []):
+ try:
+ if re.search(pattern, text, re.IGNORECASE):
+ bot.write(["KICK", channel, nick, "content filter"])
+ return
+ except re.error:
+ pass
+
+ # Bad word list
+ text_lower = text.lower()
+ for word in cdb.get("badwords", []):
+ if word in text_lower:
+ action = cfg.get("badword_action", "kick")
+ if action == "warn":
+ bot.notice("watch your language", nick)
+ else:
+ bot.write(["KICK", channel, nick, "bad word"])
+ return
+
+ # Caps filter
+ if cfg.get("caps_filter", 1):
+ letters = [c for c in text if c.isalpha()]
+ if len(letters) >= cfg.get("caps_min_len", 10):
+ upper_pct = sum(1 for c in letters if c.isupper()) / len(letters) * 100
+ if upper_pct >= cfg.get("caps_percent", 70):
+ bot.write(["KICK", channel, nick, "caps"])
+ return
+
+ # Repeat filter
+ if cfg.get("repeat_filter", 1):
+ rep_threshold = cfg.get("repeat_threshold", 3)
+ rep_window = cfg.get("repeat_window_sec", 30)
+ msg_hash = hash(text.strip().lower())
+ history = bot.memory["os_repeat"].setdefault((channel, nick), [])
+ history.append((msg_hash, now))
+ bot.memory["os_repeat"][(channel, nick)] = [
+ (h, t) for h, t in history if now - t <= rep_window
+ ]
+ same = sum(1 for h, _ in bot.memory["os_repeat"][(channel, nick)]
+ if h == msg_hash)
+ if same >= rep_threshold:
+ bot.write(["KICK", channel, nick, "repeat"])
+ bot.memory["os_repeat"][(channel, nick)] = []
+ return
+
+ # Flood control
+ threshold = cfg.get("flood_threshold", 5)
+ window = cfg.get("flood_window_sec", 10)
+ times = bot.memory["os_flood"].setdefault((channel, nick), [])
+ times.append(now)
+ bot.memory["os_flood"][(channel, nick)] = [
+ t for t in times if now - t <= window
+ ]
+ if len(bot.memory["os_flood"][(channel, nick)]) > threshold:
+ bot.write(["KICK", channel, nick, "flood"])
+ if has_op:
+ bot.write(["MODE", channel, "+b", hm])
+ bot.memory["os_flood"][(channel, nick)] = []
+
+
+@plugin.event("PART")
+@plugin.event("QUIT")
+@plugin.event("NICK")
+def on_gone(bot, trigger):
+ nick = trigger.nick
+ host_key = f"{trigger.user}@{trigger.host}"
+
+ if trigger.event == "PART":
+ channel = str(trigger.sender)
+ for cache in ("os_last", "os_flood", "os_repeat"):
+ bot.memory.get(cache, {}).pop((channel, nick), None)
+ else:
+ for cache in ("os_last", "os_flood", "os_repeat"):
+ mem = bot.memory.get(cache, {})
+ keys = [k for k in mem if isinstance(k, tuple) and k[1] == nick]
+ for k in keys:
+ del mem[k]
+
+ bot.memory.get("os_nick_flood", {}).pop(host_key, None)
diff --git a/osterman/tracking.py b/osterman/tracking.py
new file mode 100644
index 0000000..fa5696d
--- /dev/null
+++ b/osterman/tracking.py
@@ -0,0 +1,414 @@
+"""
+Sopel plugin: osterman - user tracking
+
+Records join, part, quit, kick, ban, nick events to SQLite.
+Derives db path from os_db_path set by protect.py.
+
+On JOIN: sends a NOTICE greeting to the joining user.
+ - First-time joiner: "Welcome to #channel, nick!"
+ - Returning user: "Welcome back, nick!"
+ Controlled per channel via !set greet 1|0
+
+ !seen <nick> - when was nick last seen
+ !info / !i <nick> - summary: first seen, known nicks, kick/ban count
+ !log / !l <nick> - last N events (ACL/owner, PMed)
+"""
+
+import datetime
+import os
+import sqlite3
+import time
+
+from sopel import plugin
+
+
+def _track_path(bot):
+ base = bot.memory.get("os_db_path", "/var/osterman/db.json")
+ return os.path.join(os.path.dirname(base), "track.db")
+
+
+def _connect(bot):
+ return sqlite3.connect(_track_path(bot))
+
+
+def setup(bot):
+ path = _track_path(bot)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with sqlite3.connect(path) as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ nick TEXT NOT NULL,
+ type TEXT NOT NULL,
+ channel TEXT,
+ detail TEXT,
+ timestamp INTEGER NOT NULL
+ )
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_nick ON events(nick COLLATE NOCASE)"
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_ts ON events(timestamp DESC)"
+ )
+ conn.commit()
+
+
+def _log(bot, nick, type_, channel, detail):
+ with _connect(bot) as conn:
+ conn.execute(
+ "INSERT INTO events (nick, type, channel, detail, timestamp)"
+ " VALUES (?,?,?,?,?)",
+ (nick, type_, channel, detail, int(time.time()))
+ )
+ conn.commit()
+
+
+def _fmt_ts(ts):
+ return datetime.datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
+
+
+def _fmt_ago(ts):
+ diff = int(time.time() - ts)
+ if diff < 60:
+ return "just now"
+ elif diff < 3600:
+ return f"{diff // 60}m ago"
+ elif diff < 86400:
+ return f"{diff // 3600}h ago"
+ elif diff < 604800:
+ return f"{diff // 86400}d ago"
+ else:
+ return f"{diff // 604800}w ago"
+
+
+def _fmt_idle(seconds):
+ seconds = int(seconds)
+ if seconds < 60:
+ return f"{seconds}s"
+ elif seconds < 3600:
+ return f"{seconds // 60}m"
+ elif seconds < 86400:
+ return f"{seconds // 3600}h"
+ else:
+ return f"{seconds // 86400}d"
+
+
+def _greet_enabled(bot, channel):
+ global_cfg = bot.memory.get("os_db", {}).get("config", {})
+ chan_cfg = (bot.memory.get("os_db", {})
+ .get("channels", {})
+ .get(channel.lower(), {})
+ .get("config", {}))
+ return bool({**global_cfg, **chan_cfg}.get("greet", 1))
+
+
+# --- Event handlers ---
+
+@plugin.event("JOIN")
+@plugin.require_chanmsg
+def on_join(bot, trigger):
+ if trigger.nick == bot.nick:
+ return
+
+ nick = trigger.nick
+ channel = str(trigger.sender)
+
+ # Greeting check BEFORE logging so first-timers get the right message
+ if _greet_enabled(bot, channel):
+ try:
+ with _connect(bot) as conn:
+ seen = conn.execute(
+ "SELECT 1 FROM events WHERE nick = ? COLLATE NOCASE LIMIT 1",
+ (nick,)
+ ).fetchone()
+ if seen:
+ bot.notice(f"Welcome back, {nick}!", nick)
+ else:
+ bot.notice(f"Welcome to {channel}, {nick}!", nick)
+ except Exception:
+ pass
+
+ _log(bot, nick, "join", channel, None)
+
+
+@plugin.event("PART")
+@plugin.require_chanmsg
+def on_part(bot, trigger):
+ if trigger.nick == bot.nick:
+ return
+ reason = trigger.args[1] if len(trigger.args) > 1 else None
+ _log(bot, trigger.nick, "part", str(trigger.sender), reason)
+
+
+@plugin.event("QUIT")
+def on_quit(bot, trigger):
+ if trigger.nick == bot.nick:
+ return
+ reason = trigger.args[0] if trigger.args else None
+ _log(bot, trigger.nick, "quit", None, reason)
+
+
+@plugin.event("KICK")
+def on_kick(bot, trigger):
+ channel = str(trigger.sender)
+ kicker = trigger.nick
+ victim = trigger.args[1] if len(trigger.args) > 1 else None
+ reason = trigger.args[2] if len(trigger.args) > 2 else None
+ if not victim:
+ return
+ detail = f"by {kicker}"
+ if reason:
+ detail += f": {reason}"
+ _log(bot, victim, "kick", channel, detail)
+
+
+@plugin.event("NICK")
+def on_nick(bot, trigger):
+ old_nick = trigger.nick
+ new_nick = trigger.args[0] if trigger.args else None
+ if not new_nick or old_nick == bot.nick:
+ return
+ _log(bot, old_nick, "nick", None, new_nick)
+
+
+@plugin.event("MODE")
+def on_mode_ban(bot, trigger):
+ if not trigger.args or len(trigger.args) < 2:
+ return
+ channel = trigger.args[0]
+ mode_str = trigger.args[1]
+ targets = list(trigger.args[2:])
+ actor = trigger.nick
+ adding = True
+ t_idx = 0
+
+ if not channel.startswith("#"):
+ return
+
+ for ch in mode_str:
+ if ch == "+":
+ adding = True
+ elif ch == "-":
+ adding = False
+ elif ch == "b":
+ if t_idx < len(targets):
+ mask = targets[t_idx]
+ t_idx += 1
+ if adding:
+ nick_key = mask.split("!")[0] if "!" in mask and not mask.startswith("*") else mask
+ _log(bot, nick_key, "ban", channel, f"{mask} by {actor}")
+ elif ch == "q":
+ if t_idx < len(targets):
+ mask = targets[t_idx]
+ t_idx += 1
+ action = "mute" if adding else "unmute"
+ nick_key = mask.split("!")[0] if "!" in mask and not mask.startswith("*") else mask
+ _log(bot, nick_key, action, channel, f"{mask} by {actor}")
+ elif ch in ("m", "i"):
+ action = f"lock+{ch}" if adding else f"unlock-{ch}"
+ _log(bot, actor, action, channel, None)
+
+
+# --- Helpers shared by commands ---
+
+def _is_owner(bot, trigger):
+ return trigger.nick == bot.settings.core.owner
+
+
+def _is_acl(bot, trigger):
+ return trigger.nick in bot.memory.get("os_db", {}).get("acl", [])
+
+
+# --- Commands ---
+
+@plugin.command("seen")
+def cmd_seen(bot, trigger):
+ args = (trigger.group(2) or "").split()
+ in_chan = str(trigger.sender).startswith("#")
+ authed = _is_owner(bot, trigger) or _is_acl(bot, trigger)
+
+ if not args:
+ bot.notice("usage: !seen <nick>", trigger.nick)
+ return
+
+ target = args[0]
+
+ if in_chan:
+ channel = str(trigger.sender)
+ chan = bot.channels.get(channel)
+ if chan and target in chan.users:
+ last = bot.memory.get("os_last", {}).get((channel, target))
+ idle_str = f", idle {_fmt_idle(time.time() - last)}" if last else ""
+ bot.say(f"{target} is here{idle_str}", trigger.sender)
+ return
+ with _connect(bot) as conn:
+ row = conn.execute(
+ "SELECT type, timestamp FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND channel = ?"
+ " ORDER BY timestamp DESC LIMIT 1",
+ (target, channel)
+ ).fetchone()
+ if not row:
+ bot.say(f"{target}: not seen in {channel}", trigger.sender)
+ return
+ type_, ts = row
+ bot.say(f"{target} last seen {_fmt_ago(ts)} ({type_})", trigger.sender)
+
+ elif authed:
+ for chan_name, chan in bot.channels.items():
+ if target in chan.users:
+ last = bot.memory.get("os_last", {}).get((chan_name, target))
+ idle_str = f", idle {_fmt_idle(time.time() - last)}" if last else ""
+ bot.notice(f"{target} is in {chan_name}{idle_str}", trigger.nick)
+ return
+ with _connect(bot) as conn:
+ row = conn.execute(
+ "SELECT type, channel, timestamp FROM events"
+ " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT 1",
+ (target,)
+ ).fetchone()
+ if not row:
+ bot.notice(f"{target}: never seen", trigger.nick)
+ return
+ type_, chan_name, ts = row
+ parts = [f"{target} last seen {_fmt_ago(ts)}"]
+ if chan_name:
+ parts.append(f"in {chan_name}")
+ parts.append(f"({type_})")
+ bot.notice(" ".join(parts), trigger.nick)
+
+ else:
+ bot.notice("use !seen in a channel", trigger.nick)
+
+
+@plugin.command("info", "i")
+def cmd_info(bot, trigger):
+ args = (trigger.group(2) or "").split()
+ in_chan = str(trigger.sender).startswith("#")
+ authed = _is_owner(bot, trigger) or _is_acl(bot, trigger)
+
+ if not args:
+ bot.notice("usage: !info <nick>", trigger.nick)
+ return
+
+ if not in_chan and not authed:
+ bot.notice("use !info in a channel", trigger.nick)
+ return
+
+ target = args[0]
+ channel = str(trigger.sender) if in_chan else None
+
+ with _connect(bot) as conn:
+ if channel:
+ span = conn.execute(
+ "SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND channel = ?",
+ (target, channel)
+ ).fetchone()
+ else:
+ span = conn.execute(
+ "SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM events"
+ " WHERE nick = ? COLLATE NOCASE",
+ (target,)
+ ).fetchone()
+
+ if not span or span[0] is None:
+ bot.notice(f"{target}: no records", trigger.nick)
+ return
+
+ first_ts, last_ts, total = span
+
+ if channel:
+ last_row = conn.execute(
+ "SELECT type, channel, detail FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND channel = ?"
+ " ORDER BY timestamp DESC LIMIT 1",
+ (target, channel)
+ ).fetchone()
+ kb_count = conn.execute(
+ "SELECT COUNT(*) FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND channel = ? AND type IN ('kick','ban')",
+ (target, channel)
+ ).fetchone()[0]
+ else:
+ last_row = conn.execute(
+ "SELECT type, channel, detail FROM events"
+ " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT 1",
+ (target,)
+ ).fetchone()
+ kb_count = conn.execute(
+ "SELECT COUNT(*) FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND type IN ('kick','ban')",
+ (target,)
+ ).fetchone()[0]
+
+ newnicks = [r[0] for r in conn.execute(
+ "SELECT DISTINCT detail FROM events"
+ " WHERE nick = ? COLLATE NOCASE AND type = 'nick' AND detail IS NOT NULL",
+ (target,)
+ ).fetchall()]
+
+ oldnicks = [r[0] for r in conn.execute(
+ "SELECT DISTINCT nick FROM events"
+ " WHERE detail = ? COLLATE NOCASE AND type = 'nick'",
+ (target,)
+ ).fetchall()]
+
+ bot.say(
+ f"{target}: first seen {_fmt_ago(first_ts)}, last {_fmt_ago(last_ts)}, {total} events",
+ trigger.nick
+ )
+
+ if last_row:
+ type_, chan_name, detail = last_row
+ last_str = type_
+ if chan_name:
+ last_str += f" in {chan_name}"
+ if detail:
+ last_str += f" ({detail})"
+ bot.say(f"last: {last_str}", trigger.nick)
+
+ all_nicks = sorted(set(newnicks + oldnicks) - {target})
+ if all_nicks:
+ bot.say(f"also known as: {', '.join(all_nicks[:6])}", trigger.nick)
+
+ if kb_count:
+ bot.say(f"kicked/banned: {kb_count}x", trigger.nick)
+
+
+@plugin.command("log", "l")
+def cmd_log(bot, trigger):
+ if not _is_owner(bot, trigger) and not _is_acl(bot, trigger):
+ return
+ args = (trigger.group(2) or "").split()
+ if not args:
+ bot.say("usage: !log <nick> [limit]", trigger.nick)
+ return
+ target = args[0]
+
+ cfg = bot.memory.get("os_db", {}).get("config", {})
+ limit = cfg.get("log_limit", 6)
+ if len(args) > 1:
+ try:
+ limit = max(1, min(int(args[1]), 50))
+ except ValueError:
+ pass
+
+ with _connect(bot) as conn:
+ rows = conn.execute(
+ "SELECT type, channel, detail, timestamp FROM events"
+ " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT ?",
+ (target, limit)
+ ).fetchall()
+
+ if not rows:
+ bot.say(f"{target}: no records", trigger.nick)
+ return
+
+ bot.say(f"-- {target} ({len(rows)} events) --", trigger.nick)
+ for type_, channel, detail, ts in rows:
+ content = channel or ""
+ if detail:
+ content = f"{content} {detail}".strip()
+ bot.say(f"[{_fmt_ts(ts)}] {type_:<6} {content}", trigger.nick)