aboutsummaryrefslogtreecommitdiffstats
path: root/osterman/commands.py
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/commands.py
init: mostly vibed
Diffstat (limited to 'osterman/commands.py')
-rw-r--r--osterman/commands.py875
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")
+
+