feat: add admin/owner permission system
Hostmask-based admin controls with automatic IRCOP detection via WHO. Permission enforcement in the central dispatch path denies restricted commands to non-admins. Includes !whoami and !admins commands, marks load/reload/unload as admin-only. Also lands previously-implemented SASL PLAIN auth, token-bucket rate limiting, and CTCP VERSION/TIME/PING responses that were staged but uncommitted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
160
src/derp/bot.py
160
src/derp/bot.py
@@ -3,10 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import fnmatch
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from derp import __version__
|
||||
from derp.irc import IRCConnection, Message, format_msg, parse
|
||||
from derp.plugin import Handler, PluginRegistry
|
||||
|
||||
@@ -16,6 +20,30 @@ RECONNECT_DELAY = 30
|
||||
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Token bucket rate limiter for outgoing messages."""
|
||||
|
||||
def __init__(self, rate: float, burst: int) -> None:
|
||||
self._rate = rate
|
||||
self._burst = burst
|
||||
self._tokens = float(burst)
|
||||
self._last = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self) -> None:
|
||||
"""Wait until a token is available."""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
self._tokens = min(self._burst, self._tokens + (now - self._last) * self._rate)
|
||||
self._last = now
|
||||
if self._tokens < 1:
|
||||
wait = (1 - self._tokens) / self._rate
|
||||
await asyncio.sleep(wait)
|
||||
self._tokens = 0
|
||||
else:
|
||||
self._tokens -= 1
|
||||
|
||||
|
||||
class Bot:
|
||||
"""IRC bot: ties connection, config, and plugins together."""
|
||||
|
||||
@@ -33,6 +61,14 @@ class Bot:
|
||||
self._running = False
|
||||
self._started: float = time.monotonic()
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
||||
# Rate limiter: default 2 msg/sec, burst of 5
|
||||
rate_cfg = config.get("bot", {})
|
||||
self._bucket = _TokenBucket(
|
||||
rate=rate_cfg.get("rate_limit", 2.0),
|
||||
burst=rate_cfg.get("rate_burst", 5),
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Connect, register, join channels, and enter the main loop."""
|
||||
@@ -56,13 +92,71 @@ class Bot:
|
||||
await self.conn.close()
|
||||
|
||||
async def _register(self) -> None:
|
||||
"""Send NICK/USER registration to the server."""
|
||||
"""Send NICK/USER registration, with optional SASL PLAIN."""
|
||||
srv = self.config["server"]
|
||||
sasl_user = srv.get("sasl_user", "")
|
||||
sasl_pass = srv.get("sasl_pass", "")
|
||||
|
||||
if sasl_user and sasl_pass:
|
||||
await self._sasl_auth(sasl_user, sasl_pass)
|
||||
|
||||
if srv.get("password"):
|
||||
await self.conn.send(format_msg("PASS", srv["password"]))
|
||||
await self.conn.send(format_msg("NICK", self.nick))
|
||||
await self.conn.send(format_msg("USER", srv["user"], "0", "*", srv["realname"]))
|
||||
|
||||
async def _sasl_auth(self, user: str, password: str) -> None:
|
||||
"""Perform SASL PLAIN authentication during registration."""
|
||||
await self.conn.send("CAP REQ :sasl")
|
||||
|
||||
# Wait for CAP ACK or NAK
|
||||
while True:
|
||||
line = await self.conn.readline()
|
||||
if line is None:
|
||||
log.error("connection closed during SASL negotiation")
|
||||
return
|
||||
msg = parse(line)
|
||||
if msg.command == "CAP" and len(msg.params) >= 3:
|
||||
sub = msg.params[1].upper()
|
||||
if sub == "ACK" and "sasl" in msg.params[-1].lower():
|
||||
break
|
||||
if sub == "NAK":
|
||||
log.warning("server rejected SASL capability")
|
||||
await self.conn.send("CAP END")
|
||||
return
|
||||
|
||||
# Send AUTHENTICATE PLAIN
|
||||
await self.conn.send("AUTHENTICATE PLAIN")
|
||||
|
||||
# Wait for AUTHENTICATE +
|
||||
while True:
|
||||
line = await self.conn.readline()
|
||||
if line is None:
|
||||
return
|
||||
msg = parse(line)
|
||||
if msg.command == "AUTHENTICATE" and msg.params and msg.params[0] == "+":
|
||||
break
|
||||
|
||||
# Send credentials: base64(user\0user\0pass)
|
||||
payload = f"{user}\x00{user}\x00{password}"
|
||||
encoded = base64.b64encode(payload.encode("utf-8")).decode("ascii")
|
||||
await self.conn.send(f"AUTHENTICATE {encoded}")
|
||||
|
||||
# Wait for 903 (success) or 904 (failure)
|
||||
while True:
|
||||
line = await self.conn.readline()
|
||||
if line is None:
|
||||
return
|
||||
msg = parse(line)
|
||||
if msg.command == "903":
|
||||
log.info("SASL authentication successful")
|
||||
break
|
||||
if msg.command in ("904", "905", "906"):
|
||||
log.error("SASL authentication failed: %s", msg.params[-1] if msg.params else "")
|
||||
break
|
||||
|
||||
await self.conn.send("CAP END")
|
||||
|
||||
async def _loop(self) -> None:
|
||||
"""Read and dispatch messages until disconnect."""
|
||||
while self._running:
|
||||
@@ -80,11 +174,22 @@ class Bot:
|
||||
await self.conn.send(format_msg("PONG", msg.params[0] if msg.params else ""))
|
||||
return
|
||||
|
||||
# RPL_WELCOME (001) — join channels
|
||||
# RPL_WELCOME (001) — join channels and WHO for oper detection
|
||||
if msg.command == "001":
|
||||
self.nick = msg.params[0] if msg.params else self.nick
|
||||
for channel in self.config["bot"]["channels"]:
|
||||
await self.join(channel)
|
||||
await self.conn.send(format_msg("WHO", channel))
|
||||
|
||||
# RPL_WHOREPLY (352) — detect IRC operators
|
||||
if msg.command == "352" and len(msg.params) >= 7:
|
||||
_chan, user, host, _server, nick, flags = msg.params[1:7]
|
||||
if "*" in flags:
|
||||
self._opers.add(f"{nick}!{user}@{host}")
|
||||
|
||||
# QUIT — remove departed nicks from oper set
|
||||
if msg.command == "QUIT" and msg.prefix:
|
||||
self._opers.discard(msg.prefix)
|
||||
|
||||
# Nick already in use (433) — append underscore
|
||||
if msg.command == "433":
|
||||
@@ -92,6 +197,11 @@ class Bot:
|
||||
await self.conn.send(format_msg("NICK", self.nick))
|
||||
return
|
||||
|
||||
# CTCP queries (PRIVMSG with \x01...\x01 wrapping)
|
||||
if msg.command == "PRIVMSG" and msg.text and msg.text.startswith("\x01"):
|
||||
self._spawn(self._handle_ctcp(msg), name="ctcp")
|
||||
return
|
||||
|
||||
# Dispatch to event handlers (fire-and-forget)
|
||||
event_type = msg.command
|
||||
for handler in self.registry.events.get(event_type, []):
|
||||
@@ -101,6 +211,44 @@ class Bot:
|
||||
if msg.command == "PRIVMSG" and msg.text:
|
||||
self._dispatch_command(msg)
|
||||
|
||||
async def _handle_ctcp(self, msg: Message) -> None:
|
||||
"""Respond to CTCP VERSION, TIME, and PING queries."""
|
||||
text = msg.text.strip("\x01")
|
||||
parts = text.split(None, 1)
|
||||
ctcp_cmd = parts[0].upper() if parts else ""
|
||||
ctcp_arg = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if not msg.nick:
|
||||
return
|
||||
|
||||
if ctcp_cmd == "VERSION":
|
||||
reply = f"\x01VERSION derp {__version__}\x01"
|
||||
elif ctcp_cmd == "TIME":
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
reply = f"\x01TIME {now}\x01"
|
||||
elif ctcp_cmd == "PING":
|
||||
reply = f"\x01PING {ctcp_arg}\x01"
|
||||
else:
|
||||
return
|
||||
|
||||
await self.conn.send(format_msg("NOTICE", msg.nick, reply))
|
||||
log.debug("CTCP %s reply to %s", ctcp_cmd, msg.nick)
|
||||
|
||||
def _is_admin(self, msg: Message) -> bool:
|
||||
"""Check if the message sender is a bot admin.
|
||||
|
||||
Returns True if the sender is a known IRC operator or matches
|
||||
a configured hostmask pattern (fnmatch-style).
|
||||
"""
|
||||
if not msg.prefix:
|
||||
return False
|
||||
if msg.prefix in self._opers:
|
||||
return True
|
||||
for pattern in self._admins:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _dispatch_command(self, msg: Message) -> None:
|
||||
"""Check if a PRIVMSG is a bot command and spawn it."""
|
||||
text = msg.text
|
||||
@@ -119,6 +267,11 @@ class Bot:
|
||||
name=f"cmd:{cmd_name}:ambiguous")
|
||||
return
|
||||
|
||||
if handler.admin and not self._is_admin(msg):
|
||||
deny = f"Permission denied: {self.prefix}{cmd_name} requires admin"
|
||||
self._spawn(self.reply(msg, deny), name=f"cmd:{cmd_name}:denied")
|
||||
return
|
||||
|
||||
self._spawn(self._run_command(handler, cmd_name, msg), name=f"cmd:{cmd_name}")
|
||||
|
||||
async def _run_command(self, handler: Handler, cmd_name: str, msg: Message) -> None:
|
||||
@@ -157,8 +310,9 @@ class Bot:
|
||||
# -- Public API for plugins --
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
"""Send a PRIVMSG to a target (channel or nick)."""
|
||||
"""Send a PRIVMSG to a target (channel or nick), rate-limited."""
|
||||
for line in text.split("\n"):
|
||||
await self._bucket.acquire()
|
||||
await self.conn.send(format_msg("PRIVMSG", target, line))
|
||||
|
||||
async def reply(self, msg: Message, text: str) -> None:
|
||||
|
||||
@@ -14,11 +14,16 @@ DEFAULTS: dict = {
|
||||
"user": "derp",
|
||||
"realname": "derp IRC bot",
|
||||
"password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_pass": "",
|
||||
},
|
||||
"bot": {
|
||||
"prefix": "!",
|
||||
"channels": ["#test"],
|
||||
"plugins_dir": "plugins",
|
||||
"rate_limit": 2.0,
|
||||
"rate_burst": 5,
|
||||
"admins": [],
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
|
||||
@@ -21,9 +21,10 @@ class Handler:
|
||||
callback: Callable
|
||||
help: str = ""
|
||||
plugin: str = ""
|
||||
admin: bool = False
|
||||
|
||||
|
||||
def command(name: str, help: str = "") -> Callable:
|
||||
def command(name: str, help: str = "", admin: bool = False) -> Callable:
|
||||
"""Decorator to register an async function as a bot command.
|
||||
|
||||
Usage::
|
||||
@@ -31,11 +32,16 @@ def command(name: str, help: str = "") -> Callable:
|
||||
@command("ping", help="Check if the bot is alive")
|
||||
async def cmd_ping(bot, message):
|
||||
await bot.reply(message, "pong")
|
||||
|
||||
@command("reload", help="Reload a plugin", admin=True)
|
||||
async def cmd_reload(bot, message):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
func._derp_command = name # type: ignore[attr-defined]
|
||||
func._derp_help = help # type: ignore[attr-defined]
|
||||
func._derp_admin = admin # type: ignore[attr-defined]
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -68,11 +74,13 @@ class PluginRegistry:
|
||||
self._paths: dict[str, Path] = {}
|
||||
|
||||
def register_command(self, name: str, callback: Callable, help: str = "",
|
||||
plugin: str = "") -> None:
|
||||
plugin: str = "", admin: bool = False) -> None:
|
||||
"""Register a command handler."""
|
||||
if name in self.commands:
|
||||
log.warning("command '%s' already registered, overwriting", name)
|
||||
self.commands[name] = Handler(name=name, callback=callback, help=help, plugin=plugin)
|
||||
self.commands[name] = Handler(
|
||||
name=name, callback=callback, help=help, plugin=plugin, admin=admin,
|
||||
)
|
||||
log.debug("registered command: %s (%s)", name, plugin)
|
||||
|
||||
def register_event(self, event_type: str, callback: Callable, plugin: str = "") -> None:
|
||||
@@ -93,6 +101,7 @@ class PluginRegistry:
|
||||
obj._derp_command, obj,
|
||||
help=getattr(obj, "_derp_help", ""),
|
||||
plugin=plugin_name,
|
||||
admin=getattr(obj, "_derp_admin", False),
|
||||
)
|
||||
count += 1
|
||||
if hasattr(obj, "_derp_event"):
|
||||
|
||||
Reference in New Issue
Block a user