feat: initial implementation
Asyncio IRC bot with decorator-based plugin system. Zero external dependencies, Python 3.11+. - IRC protocol: message parsing, formatting, async TCP/TLS connection - Plugin system: @command and @event decorators, file-based loading - Bot orchestrator: connect, dispatch, reconnect, nick recovery - CLI: argparse entry point with TOML config - Built-in plugins: ping, help, version, echo - 28 unit tests for parser and plugin system
This commit is contained in:
100
tests/test_irc.py
Normal file
100
tests/test_irc.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Tests for IRC message parsing and formatting."""
|
||||
|
||||
from derp.irc import format_msg, parse
|
||||
|
||||
|
||||
class TestParse:
|
||||
"""IRC message parser tests."""
|
||||
|
||||
def test_privmsg(self):
|
||||
msg = parse(":nick!user@host PRIVMSG #channel :hello world")
|
||||
assert msg.prefix == "nick!user@host"
|
||||
assert msg.nick == "nick"
|
||||
assert msg.command == "PRIVMSG"
|
||||
assert msg.params == ["#channel", "hello world"]
|
||||
assert msg.target == "#channel"
|
||||
assert msg.text == "hello world"
|
||||
assert msg.is_channel is True
|
||||
|
||||
def test_privmsg_pm(self):
|
||||
msg = parse(":nick!user@host PRIVMSG bot :hello")
|
||||
assert msg.target == "bot"
|
||||
assert msg.is_channel is False
|
||||
|
||||
def test_ping(self):
|
||||
msg = parse("PING :server.example.com")
|
||||
assert msg.prefix is None
|
||||
assert msg.nick is None
|
||||
assert msg.command == "PING"
|
||||
assert msg.params == ["server.example.com"]
|
||||
|
||||
def test_join(self):
|
||||
msg = parse(":nick!user@host JOIN #channel")
|
||||
assert msg.command == "JOIN"
|
||||
assert msg.nick == "nick"
|
||||
assert msg.target == "#channel"
|
||||
|
||||
def test_numeric(self):
|
||||
msg = parse(":server 001 bot :Welcome to the IRC Network")
|
||||
assert msg.command == "001"
|
||||
assert msg.prefix == "server"
|
||||
assert msg.nick is None
|
||||
assert msg.params == ["bot", "Welcome to the IRC Network"]
|
||||
|
||||
def test_nick_in_use(self):
|
||||
msg = parse(":server 433 * derp :Nickname is already in use")
|
||||
assert msg.command == "433"
|
||||
assert msg.params[1] == "derp"
|
||||
|
||||
def test_empty_trailing(self):
|
||||
msg = parse(":nick!user@host PRIVMSG #channel :")
|
||||
assert msg.text == ""
|
||||
|
||||
def test_no_prefix(self):
|
||||
msg = parse("NOTICE AUTH :*** Looking up your hostname")
|
||||
assert msg.prefix is None
|
||||
assert msg.command == "NOTICE"
|
||||
assert msg.params == ["AUTH", "*** Looking up your hostname"]
|
||||
|
||||
def test_server_prefix_no_nick(self):
|
||||
msg = parse(":irc.server.net 372 bot :- Message of the day")
|
||||
assert msg.prefix == "irc.server.net"
|
||||
assert msg.nick is None
|
||||
|
||||
def test_command_case(self):
|
||||
msg = parse(":nick!u@h privmsg #ch :test")
|
||||
assert msg.command == "PRIVMSG"
|
||||
|
||||
def test_multiple_colons_in_trailing(self):
|
||||
msg = parse(":nick!u@h PRIVMSG #ch :url: https://example.com")
|
||||
assert msg.text == "url: https://example.com"
|
||||
|
||||
def test_part_with_reason(self):
|
||||
msg = parse(":nick!u@h PART #channel :leaving")
|
||||
assert msg.command == "PART"
|
||||
assert msg.target == "#channel"
|
||||
assert msg.text == "leaving"
|
||||
|
||||
|
||||
class TestFormat:
|
||||
"""IRC message formatting tests."""
|
||||
|
||||
def test_simple_command(self):
|
||||
assert format_msg("NICK", "derp") == "NICK :derp"
|
||||
|
||||
def test_command_with_trailing(self):
|
||||
assert format_msg("PRIVMSG", "#channel", "hello world") == \
|
||||
"PRIVMSG #channel :hello world"
|
||||
|
||||
def test_join(self):
|
||||
assert format_msg("JOIN", "#channel") == "JOIN :#channel"
|
||||
|
||||
def test_no_params(self):
|
||||
assert format_msg("QUIT") == "QUIT"
|
||||
|
||||
def test_user_registration(self):
|
||||
result = format_msg("USER", "derp", "0", "*", "derp IRC bot")
|
||||
assert result == "USER derp 0 * :derp IRC bot"
|
||||
|
||||
def test_pong(self):
|
||||
assert format_msg("PONG", "server.example.com") == "PONG :server.example.com"
|
||||
128
tests/test_plugin.py
Normal file
128
tests/test_plugin.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for the plugin system."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from derp.plugin import PluginRegistry, command, event
|
||||
|
||||
|
||||
class TestDecorators:
|
||||
"""Test that decorators mark functions correctly."""
|
||||
|
||||
def test_command_decorator(self):
|
||||
@command("test", help="a test command")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_command == "test"
|
||||
assert handler._derp_help == "a test command"
|
||||
|
||||
def test_event_decorator(self):
|
||||
@event("join")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_event == "JOIN"
|
||||
|
||||
def test_event_decorator_case(self):
|
||||
@event("PRIVMSG")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_event == "PRIVMSG"
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Test the plugin registry."""
|
||||
|
||||
def test_register_command(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
registry.register_command("test", handler, help="test help")
|
||||
assert "test" in registry.commands
|
||||
assert registry.commands["test"].help == "test help"
|
||||
|
||||
def test_register_event(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
registry.register_event("PRIVMSG", handler)
|
||||
assert "PRIVMSG" in registry.events
|
||||
assert len(registry.events["PRIVMSG"]) == 1
|
||||
|
||||
def test_multiple_event_handlers(self):
|
||||
registry = PluginRegistry()
|
||||
|
||||
async def handler_a(bot, msg):
|
||||
pass
|
||||
|
||||
async def handler_b(bot, msg):
|
||||
pass
|
||||
|
||||
registry.register_event("JOIN", handler_a)
|
||||
registry.register_event("JOIN", handler_b)
|
||||
assert len(registry.events["JOIN"]) == 2
|
||||
|
||||
def test_load_plugin_file(self, tmp_path: Path):
|
||||
plugin_code = textwrap.dedent("""\
|
||||
from derp.plugin import command, event
|
||||
|
||||
@command("greet", help="Say hello")
|
||||
async def cmd_greet(bot, msg):
|
||||
pass
|
||||
|
||||
@event("JOIN")
|
||||
async def on_join(bot, msg):
|
||||
pass
|
||||
""")
|
||||
plugin_file = tmp_path / "greet.py"
|
||||
plugin_file.write_text(plugin_code)
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_plugin(plugin_file)
|
||||
|
||||
assert "greet" in registry.commands
|
||||
assert "JOIN" in registry.events
|
||||
|
||||
def test_load_directory(self, tmp_path: Path):
|
||||
for name in ("a.py", "b.py"):
|
||||
(tmp_path / name).write_text(textwrap.dedent(f"""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("{name[0]}", help="{name}")
|
||||
async def cmd(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_directory(tmp_path)
|
||||
|
||||
assert "a" in registry.commands
|
||||
assert "b" in registry.commands
|
||||
|
||||
def test_skip_underscore_files(self, tmp_path: Path):
|
||||
(tmp_path / "__init__.py").write_text("")
|
||||
(tmp_path / "_private.py").write_text("x = 1\n")
|
||||
(tmp_path / "valid.py").write_text(textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("valid")
|
||||
async def cmd(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_directory(tmp_path)
|
||||
|
||||
assert "valid" in registry.commands
|
||||
assert len(registry.commands) == 1
|
||||
|
||||
def test_load_missing_directory(self, tmp_path: Path):
|
||||
registry = PluginRegistry()
|
||||
registry.load_directory(tmp_path / "nonexistent")
|
||||
assert len(registry.commands) == 0
|
||||
Reference in New Issue
Block a user