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:
user
2026-02-15 02:24:56 +01:00
parent 36b21e2463
commit f96224afb1
9 changed files with 408 additions and 18 deletions

View File

@@ -76,7 +76,7 @@ async def cmd_uptime(bot, message):
await bot.reply(message, f"up {' '.join(parts)}")
@command("load", help="Hot-load a plugin: !load <name>")
@command("load", help="Hot-load a plugin: !load <name>", admin=True)
async def cmd_load(bot, message):
"""Load a new plugin from the plugins directory."""
parts = message.text.split(None, 2)
@@ -91,7 +91,7 @@ async def cmd_load(bot, message):
await bot.reply(message, f"Failed to load plugin: {reason}")
@command("reload", help="Reload a plugin: !reload <name>")
@command("reload", help="Reload a plugin: !reload <name>", admin=True)
async def cmd_reload(bot, message):
"""Unload and reload a plugin, picking up file changes."""
parts = message.text.split(None, 2)
@@ -106,7 +106,7 @@ async def cmd_reload(bot, message):
await bot.reply(message, f"Failed to reload plugin: {reason}")
@command("unload", help="Unload a plugin: !unload <name>")
@command("unload", help="Unload a plugin: !unload <name>", admin=True)
async def cmd_unload(bot, message):
"""Unload a plugin, removing all its handlers."""
parts = message.text.split(None, 2)
@@ -132,3 +132,34 @@ async def cmd_plugins(bot, message):
counts[handler.plugin] += 1
parts = [f"{name} ({counts[name]})" for name in sorted(counts)]
await bot.reply(message, f"Plugins: {', '.join(parts)}")
@command("whoami", help="Show your hostmask and admin status")
async def cmd_whoami(bot, message):
"""Display the sender's hostmask and permission level."""
prefix = message.prefix or "unknown"
is_admin = bot._is_admin(message)
is_oper = message.prefix in bot._opers if message.prefix else False
tags = []
if is_admin:
tags.append("admin")
else:
tags.append("user")
if is_oper:
tags.append("IRCOP")
await bot.reply(message, f"{prefix} [{', '.join(tags)}]")
@command("admins", help="Show configured admin patterns and detected opers", admin=True)
async def cmd_admins(bot, message):
"""Display admin hostmask patterns and known IRC operators."""
parts = []
if bot._admins:
parts.append(f"Patterns: {', '.join(bot._admins)}")
else:
parts.append("Patterns: (none)")
if bot._opers:
parts.append(f"Opers: {', '.join(sorted(bot._opers))}")
else:
parts.append("Opers: (none)")
await bot.reply(message, " | ".join(parts))