diff options
Diffstat (limited to 'osterman/commands.py')
| -rw-r--r-- | osterman/commands.py | 875 |
1 files changed, 875 insertions, 0 deletions
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") + + |
