From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- osterman/__init__.py | 0 osterman/commands.py | 875 ++++++++++++++++++++++++++++++++++++++++++++++++ osterman/help.py | 30 ++ osterman/helpstrings.py | 93 +++++ osterman/idle.py | 137 ++++++++ osterman/osterman.cfg | 23 ++ osterman/protect.py | 486 +++++++++++++++++++++++++++ osterman/tracking.py | 414 +++++++++++++++++++++++ 8 files changed, 2058 insertions(+) create mode 100644 osterman/__init__.py create mode 100644 osterman/commands.py create mode 100644 osterman/help.py create mode 100644 osterman/helpstrings.py create mode 100644 osterman/idle.py create mode 100644 osterman/osterman.cfg create mode 100644 osterman/protect.py create mode 100644 osterman/tracking.py (limited to 'osterman') diff --git a/osterman/__init__.py b/osterman/__init__.py new file mode 100644 index 0000000..e69de29 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 [reason] - kick + !ban / !b [reason] - ban + kick + !ban list / !b list - view channel ban list (NOTICE) + !bankick / !bk [reason] - ban + kick (explicit) + !unban / !ub - unban + !tempban / !tb [reason] - timed ban + !mute / !mu - quiet (+q) + !unmute / !umu - unquiet (-q) + !voice / !v - give voice + !devoice / !dv - remove voice + !op / !o - give op + !deop / !do - remove op + !halfop / !hop - give halfop + !dehalfop / !dhop - remove halfop + !lock / !lk [m|i] - lock channel + !unlock / !ulk [m|i] - unlock channel + +Auto-mode (ACL / op / owner - per channel): + !autoop / !ao [nick|mask] - auto +o on join + !autovoice / !av [nick|mask] - auto +v on join + !autohalfop / !ahop [nick|mask] - auto +h on join + +Config (ACL / owner): + !blacklist / !bl [nick|mask] - global ban list (all channels) + !acl [nick] - trusted nicks (global) + !whitelist / !wl [mask] - bypass all protection (global) + !except / !ex [mask] - bypass protection (per channel) + !filter / !f [pattern] - regex content filter (per channel) + !badword / !bw [word] - word block list (per channel) + !set / !s - set a config value + !config / !cfg [#channel] - show channel config (NOTICE) + !idle / !id on|off - toggle idle checking + !idle / !id - show idle time + +Tracking: + !seen - last seen time + !info / !i - nick summary + !log / !l [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 [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 [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 [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 ") + 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 [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 ") + 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 ") + 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 ") + 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 ") + 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 ") + 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 ") + 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 ") + 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 ") + 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} [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} [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 [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 [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 [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 [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 [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 [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 [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 [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 [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 (use !filter list for indices)") + else: + _notice(bot, trigger.nick, "usage: !filter [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 [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 (use !badword list for indices)") + else: + _notice(bot, trigger.nick, "usage: !badword [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 - 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 list commands for a topic + !help description and usage for a command + !help 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 or !help to know more" +) + +MOD_TOPIC = "mod: kick, bankick, tempban, ban, unban, mute, unmute, voice, devoice, op, deop, halfop, dehalfop, lock, unlock. type !help for usage" +CONFIG_TOPIC = "config: autoop, autovoice, autohalfop, acl, whitelist, blacklist, except, filter, badword, set, config. type !help for usage" +TRACK_TOPIC = "track: seen, info, log. type !help for usage" +IDLE_TOPIC = "idle: idle, afk. type !help for usage" +ADMIN_TOPIC = "admin: join, part, chans. type !help for usage" + +MOD = { + "kick": "kick a user. usage: !kick [reason]", + "bankick": "ban and kick a user. usage: !bankick [reason]", + "tempban": "timed ban and kick. usage: !tempban [reason]", + "ban": "ban and kick, or list bans. usage: !ban [reason] | !ban list", + "unban": "remove a ban. usage: !unban ", + "mute": "silence a user (+q). usage: !mute ", + "unmute": "remove silence (-q). usage: !unmute ", + "voice": "give voice. usage: !voice ", + "devoice": "remove voice. usage: !devoice ", + "op": "give op. usage: !op ", + "deop": "remove op. usage: !deop ", + "halfop": "give halfop. usage: !halfop ", + "dehalfop": "remove halfop. usage: !dehalfop ", + "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 [nick|mask]", + "autovoice": "manage auto-voice list for this channel. usage: !autovoice [nick|mask]", + "autohalfop": "manage auto-halfop list for this channel. usage: !autohalfop [nick|mask]", + "acl": "manage trusted nicks (global). usage: !acl [nick]", + "whitelist": "manage global protection bypass list. usage: !whitelist [mask]", + "blacklist": "manage global ban list. usage: !blacklist [nick|mask]", + "except": "manage per-channel protection bypass list. usage: !except [mask]", + "filter": "manage regex content filters. usage: !filter [pattern] (del uses index from list)", + "badword": "manage word block list. usage: !badword [word] (del uses index from list)", + "set": "set a config key. usage: !set (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 ", + "info": "show nick summary. usage: !info ", + "log": "show recent events for a nick (ACL/owner only). usage: !log [limit]", +} + +IDLE = { + "idle": "show idle time or toggle idle checking. usage: !idle | !idle ", + "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 - 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 - when was nick last seen + !info / !i - summary: first seen, known nicks, kick/ban count + !log / !l - 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 ", 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 ", 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 [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) -- cgit v1.2.3