feat: add IRCv3 cap negotiation, channel management, state persistence
Implement CAP LS 302 flow with configurable ircv3_caps list, replacing the minimal SASL-only registration. Parse IRCv3 message tags (@key=value) with proper value unescaping. Add channel management plugin (kick, ban, unban, topic, mode) and bot API methods. Add SQLite key-value StateStore for plugin state persistence with !state inspection command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
81
plugins/chanmgmt.py
Normal file
81
plugins/chanmgmt.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Channel management: kick, ban, unban, topic, mode."""
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
|
||||
def _require_channel(message):
|
||||
"""Return True if the message originated in a channel."""
|
||||
return message.is_channel
|
||||
|
||||
|
||||
@command("kick", help="Kick a user: !kick <nick> [reason]", admin=True)
|
||||
async def cmd_kick(bot, message):
|
||||
"""Kick a user from the current channel."""
|
||||
if not _require_channel(message):
|
||||
await bot.reply(message, "Must be used in a channel")
|
||||
return
|
||||
parts = message.text.split(None, 2)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !kick <nick> [reason]")
|
||||
return
|
||||
nick = parts[1]
|
||||
reason = parts[2] if len(parts) > 2 else ""
|
||||
await bot.kick(message.target, nick, reason)
|
||||
|
||||
|
||||
@command("ban", help="Ban a hostmask: !ban <mask>", admin=True)
|
||||
async def cmd_ban(bot, message):
|
||||
"""Set +b on a hostmask in the current channel."""
|
||||
if not _require_channel(message):
|
||||
await bot.reply(message, "Must be used in a channel")
|
||||
return
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !ban <mask>")
|
||||
return
|
||||
mask = parts[1]
|
||||
await bot.mode(message.target, "+b", mask)
|
||||
|
||||
|
||||
@command("unban", help="Remove a ban: !unban <mask>", admin=True)
|
||||
async def cmd_unban(bot, message):
|
||||
"""Remove +b from a hostmask in the current channel."""
|
||||
if not _require_channel(message):
|
||||
await bot.reply(message, "Must be used in a channel")
|
||||
return
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !unban <mask>")
|
||||
return
|
||||
mask = parts[1]
|
||||
await bot.mode(message.target, "-b", mask)
|
||||
|
||||
|
||||
@command("topic", help="Set or query channel topic: !topic [text]", admin=True)
|
||||
async def cmd_topic(bot, message):
|
||||
"""Set or query the channel topic."""
|
||||
if not _require_channel(message):
|
||||
await bot.reply(message, "Must be used in a channel")
|
||||
return
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
# Query current topic
|
||||
from derp.irc import format_msg
|
||||
await bot.conn.send(format_msg("TOPIC", message.target))
|
||||
else:
|
||||
await bot.set_topic(message.target, parts[1])
|
||||
|
||||
|
||||
@command("mode", help="Set channel mode: !mode <mode> [args]", admin=True)
|
||||
async def cmd_mode(bot, message):
|
||||
"""Set a mode on the current channel."""
|
||||
if not _require_channel(message):
|
||||
await bot.reply(message, "Must be used in a channel")
|
||||
return
|
||||
parts = message.text.split(None)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !mode <mode> [args]")
|
||||
return
|
||||
mode_str = parts[1]
|
||||
args = parts[2:]
|
||||
await bot.mode(message.target, mode_str, *args)
|
||||
@@ -163,3 +163,57 @@ async def cmd_admins(bot, message):
|
||||
else:
|
||||
parts.append("Opers: (none)")
|
||||
await bot.reply(message, " | ".join(parts))
|
||||
|
||||
|
||||
@command("state", help="Inspect plugin state: !state <list|get|del|clear> ...", admin=True)
|
||||
async def cmd_state(bot, message):
|
||||
"""Manage the plugin state store.
|
||||
|
||||
Usage:
|
||||
!state list <plugin> List keys
|
||||
!state get <plugin> <key> Get a value
|
||||
!state del <plugin> <key> Delete a key
|
||||
!state clear <plugin> Clear all state
|
||||
"""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !state <list|get|del|clear> <plugin> [key]")
|
||||
return
|
||||
|
||||
action = parts[1].lower()
|
||||
plugin = parts[2]
|
||||
|
||||
if action == "list":
|
||||
keys = bot.state.keys(plugin)
|
||||
if keys:
|
||||
await bot.reply(message, f"{plugin}: {', '.join(keys)}")
|
||||
else:
|
||||
await bot.reply(message, f"{plugin}: (no keys)")
|
||||
|
||||
elif action == "get":
|
||||
if len(parts) < 4:
|
||||
await bot.reply(message, "Usage: !state get <plugin> <key>")
|
||||
return
|
||||
key = parts[3]
|
||||
value = bot.state.get(plugin, key)
|
||||
if value is not None:
|
||||
await bot.reply(message, f"{plugin}.{key} = {value}")
|
||||
else:
|
||||
await bot.reply(message, f"{plugin}.{key}: not set")
|
||||
|
||||
elif action == "del":
|
||||
if len(parts) < 4:
|
||||
await bot.reply(message, "Usage: !state del <plugin> <key>")
|
||||
return
|
||||
key = parts[3]
|
||||
if bot.state.delete(plugin, key):
|
||||
await bot.reply(message, f"Deleted {plugin}.{key}")
|
||||
else:
|
||||
await bot.reply(message, f"{plugin}.{key}: not found")
|
||||
|
||||
elif action == "clear":
|
||||
count = bot.state.clear(plugin)
|
||||
await bot.reply(message, f"Cleared {count} key(s) from {plugin}")
|
||||
|
||||
else:
|
||||
await bot.reply(message, "Usage: !state <list|get|del|clear> <plugin> [key]")
|
||||
|
||||
Reference in New Issue
Block a user