feat: initial IRC bouncer implementation
Async Python IRC bouncer with SOCKS5 proxy support, multi-network connections, password auth, and persistent SQLite backlog with replay. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
89
tests/test_backlog.py
Normal file
89
tests/test_backlog.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for SQLite backlog storage."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.backlog import Backlog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def backlog():
|
||||
db_path = Path(tempfile.mktemp(suffix=".db"))
|
||||
bl = Backlog(db_path)
|
||||
await bl.open()
|
||||
yield bl
|
||||
await bl.close()
|
||||
db_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestBacklog:
|
||||
async def test_store_and_replay(self, backlog: Backlog):
|
||||
msg_id = await backlog.store("libera", "#test", "nick", "PRIVMSG", "hello")
|
||||
assert msg_id > 0
|
||||
|
||||
entries = await backlog.replay("libera")
|
||||
assert len(entries) == 1
|
||||
assert entries[0].content == "hello"
|
||||
assert entries[0].sender == "nick"
|
||||
assert entries[0].target == "#test"
|
||||
|
||||
async def test_replay_since_id(self, backlog: Backlog):
|
||||
id1 = await backlog.store("libera", "#test", "a", "PRIVMSG", "first")
|
||||
id2 = await backlog.store("libera", "#test", "b", "PRIVMSG", "second")
|
||||
id3 = await backlog.store("libera", "#test", "c", "PRIVMSG", "third")
|
||||
|
||||
entries = await backlog.replay("libera", since_id=id1)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].id == id2
|
||||
assert entries[1].id == id3
|
||||
|
||||
async def test_replay_empty(self, backlog: Backlog):
|
||||
entries = await backlog.replay("nonexistent")
|
||||
assert entries == []
|
||||
|
||||
async def test_network_isolation(self, backlog: Backlog):
|
||||
await backlog.store("libera", "#test", "a", "PRIVMSG", "libera msg")
|
||||
await backlog.store("oftc", "#test", "b", "PRIVMSG", "oftc msg")
|
||||
|
||||
libera = await backlog.replay("libera")
|
||||
oftc = await backlog.replay("oftc")
|
||||
assert len(libera) == 1
|
||||
assert len(oftc) == 1
|
||||
assert libera[0].content == "libera msg"
|
||||
assert oftc[0].content == "oftc msg"
|
||||
|
||||
async def test_mark_and_get_last_seen(self, backlog: Backlog):
|
||||
assert await backlog.get_last_seen("libera") == 0
|
||||
|
||||
await backlog.mark_seen("libera", 42)
|
||||
assert await backlog.get_last_seen("libera") == 42
|
||||
|
||||
await backlog.mark_seen("libera", 100)
|
||||
assert await backlog.get_last_seen("libera") == 100
|
||||
|
||||
async def test_prune(self, backlog: Backlog):
|
||||
for i in range(10):
|
||||
await backlog.store("libera", "#test", "n", "PRIVMSG", f"msg{i}")
|
||||
|
||||
deleted = await backlog.prune("libera", keep=3)
|
||||
assert deleted == 7
|
||||
|
||||
entries = await backlog.replay("libera")
|
||||
assert len(entries) == 3
|
||||
assert entries[0].content == "msg7"
|
||||
|
||||
async def test_prune_zero_keeps_all(self, backlog: Backlog):
|
||||
for i in range(5):
|
||||
await backlog.store("libera", "#test", "n", "PRIVMSG", f"msg{i}")
|
||||
|
||||
deleted = await backlog.prune("libera", keep=0)
|
||||
assert deleted == 0
|
||||
assert len(await backlog.replay("libera")) == 5
|
||||
|
||||
async def test_record_disconnect(self, backlog: Backlog):
|
||||
await backlog.store("libera", "#test", "n", "PRIVMSG", "msg")
|
||||
await backlog.record_disconnect("libera")
|
||||
last = await backlog.get_last_seen("libera")
|
||||
assert last > 0
|
||||
112
tests/test_config.py
Normal file
112
tests/test_config.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for configuration loading."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bouncer.config import load
|
||||
|
||||
MINIMAL_CONFIG = """\
|
||||
[bouncer]
|
||||
password = "secret"
|
||||
|
||||
[proxy]
|
||||
host = "127.0.0.1"
|
||||
port = 1080
|
||||
|
||||
[networks.test]
|
||||
host = "irc.example.com"
|
||||
"""
|
||||
|
||||
FULL_CONFIG = """\
|
||||
[bouncer]
|
||||
bind = "0.0.0.0"
|
||||
port = 6668
|
||||
password = "hunter2"
|
||||
|
||||
[bouncer.backlog]
|
||||
max_messages = 5000
|
||||
replay_on_connect = false
|
||||
|
||||
[proxy]
|
||||
host = "10.0.0.1"
|
||||
port = 9050
|
||||
|
||||
[networks.libera]
|
||||
host = "irc.libera.chat"
|
||||
port = 6697
|
||||
tls = true
|
||||
nick = "testbot"
|
||||
user = "testuser"
|
||||
realname = "Test Bot"
|
||||
channels = ["#test", "#dev"]
|
||||
autojoin = true
|
||||
|
||||
[networks.oftc]
|
||||
host = "irc.oftc.net"
|
||||
port = 6697
|
||||
tls = true
|
||||
nick = "testbot"
|
||||
channels = ["#debian"]
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(content: str) -> Path:
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False)
|
||||
f.write(content)
|
||||
f.close()
|
||||
return Path(f.name)
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_minimal(self):
|
||||
cfg = load(_write_config(MINIMAL_CONFIG))
|
||||
assert cfg.bouncer.password == "secret"
|
||||
assert cfg.bouncer.bind == "127.0.0.1"
|
||||
assert cfg.bouncer.port == 6667
|
||||
assert cfg.bouncer.backlog.max_messages == 10000
|
||||
assert cfg.proxy.host == "127.0.0.1"
|
||||
assert "test" in cfg.networks
|
||||
net = cfg.networks["test"]
|
||||
assert net.host == "irc.example.com"
|
||||
assert net.port == 6667
|
||||
assert net.tls is False
|
||||
|
||||
def test_full(self):
|
||||
cfg = load(_write_config(FULL_CONFIG))
|
||||
assert cfg.bouncer.bind == "0.0.0.0"
|
||||
assert cfg.bouncer.port == 6668
|
||||
assert cfg.bouncer.backlog.max_messages == 5000
|
||||
assert cfg.bouncer.backlog.replay_on_connect is False
|
||||
assert cfg.proxy.port == 9050
|
||||
assert len(cfg.networks) == 2
|
||||
libera = cfg.networks["libera"]
|
||||
assert libera.tls is True
|
||||
assert libera.port == 6697
|
||||
assert libera.channels == ["#test", "#dev"]
|
||||
assert libera.nick == "testbot"
|
||||
|
||||
def test_no_networks_raises(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
|
||||
[proxy]
|
||||
"""
|
||||
with pytest.raises(ValueError, match="at least one network"):
|
||||
load(_write_config(config))
|
||||
|
||||
def test_tls_default_port(self):
|
||||
config = """\
|
||||
[bouncer]
|
||||
password = "x"
|
||||
|
||||
[proxy]
|
||||
|
||||
[networks.test]
|
||||
host = "irc.example.com"
|
||||
tls = true
|
||||
"""
|
||||
cfg = load(_write_config(config))
|
||||
assert cfg.networks["test"].port == 6697
|
||||
129
tests/test_irc.py
Normal file
129
tests/test_irc.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for IRC message parsing and formatting."""
|
||||
|
||||
from bouncer.irc import IRCMessage, parse, parse_prefix
|
||||
|
||||
|
||||
class TestParse:
|
||||
def test_simple_command(self):
|
||||
msg = parse(b"PING")
|
||||
assert msg.command == "PING"
|
||||
assert msg.params == []
|
||||
assert msg.prefix is None
|
||||
|
||||
def test_command_with_param(self):
|
||||
msg = parse(b"PING :server.example.com")
|
||||
assert msg.command == "PING"
|
||||
assert msg.params == ["server.example.com"]
|
||||
|
||||
def test_privmsg(self):
|
||||
msg = parse(b":nick!user@host PRIVMSG #channel :Hello world")
|
||||
assert msg.prefix == "nick!user@host"
|
||||
assert msg.command == "PRIVMSG"
|
||||
assert msg.params == ["#channel", "Hello world"]
|
||||
|
||||
def test_numeric_reply(self):
|
||||
msg = parse(b":server 001 nick :Welcome to the network")
|
||||
assert msg.prefix == "server"
|
||||
assert msg.command == "001"
|
||||
assert msg.params == ["nick", "Welcome to the network"]
|
||||
|
||||
def test_join(self):
|
||||
msg = parse(b":nick!user@host JOIN #channel")
|
||||
assert msg.command == "JOIN"
|
||||
assert msg.params == ["#channel"]
|
||||
|
||||
def test_nick_user_registration(self):
|
||||
msg = parse(b"NICK mynick")
|
||||
assert msg.command == "NICK"
|
||||
assert msg.params == ["mynick"]
|
||||
|
||||
def test_user_command(self):
|
||||
msg = parse(b"USER myuser 0 * :Real Name")
|
||||
assert msg.command == "USER"
|
||||
assert msg.params == ["myuser", "0", "*", "Real Name"]
|
||||
|
||||
def test_pass_with_network(self):
|
||||
msg = parse(b"PASS libera:secretpass")
|
||||
assert msg.command == "PASS"
|
||||
assert msg.params == ["libera:secretpass"]
|
||||
|
||||
def test_quit_with_message(self):
|
||||
msg = parse(b":nick!user@host QUIT :Gone fishing")
|
||||
assert msg.command == "QUIT"
|
||||
assert msg.params == ["Gone fishing"]
|
||||
|
||||
def test_mode(self):
|
||||
msg = parse(b":nick!user@host MODE #channel +o othernick")
|
||||
assert msg.command == "MODE"
|
||||
assert msg.params == ["#channel", "+o", "othernick"]
|
||||
|
||||
def test_crlf_stripped(self):
|
||||
msg = parse(b"PING :test\r\n")
|
||||
assert msg.command == "PING"
|
||||
assert msg.params == ["test"]
|
||||
|
||||
def test_ircv3_tags(self):
|
||||
msg = parse(b"@time=2024-01-01T00:00:00Z :nick!user@host PRIVMSG #ch :hi")
|
||||
assert msg.tags == {"time": "2024-01-01T00:00:00Z"}
|
||||
assert msg.command == "PRIVMSG"
|
||||
|
||||
def test_ircv3_tag_no_value(self):
|
||||
msg = parse(b"@draft/reply;+example :nick PRIVMSG #ch :test")
|
||||
assert msg.tags == {"draft/reply": None, "+example": None}
|
||||
|
||||
def test_latin1_fallback(self):
|
||||
msg = parse(b":nick PRIVMSG #ch :\xe9\xe8\xea")
|
||||
assert msg.params == ["#ch", "\xe9\xe8\xea"]
|
||||
|
||||
def test_trailing(self):
|
||||
msg = parse(b":nick PRIVMSG #ch :hello world")
|
||||
assert msg.trailing == "hello world"
|
||||
|
||||
def test_trailing_empty(self):
|
||||
msg = parse(b"PING")
|
||||
assert msg.trailing is None
|
||||
|
||||
|
||||
class TestFormat:
|
||||
def test_simple_command(self):
|
||||
msg = IRCMessage(command="PING")
|
||||
assert msg.format() == b"PING\r\n"
|
||||
|
||||
def test_with_params(self):
|
||||
msg = IRCMessage(command="NICK", params=["mynick"])
|
||||
assert msg.format() == b"NICK mynick\r\n"
|
||||
|
||||
def test_with_trailing(self):
|
||||
msg = IRCMessage(command="PRIVMSG", params=["#channel", "Hello world"])
|
||||
assert msg.format() == b"PRIVMSG #channel :Hello world\r\n"
|
||||
|
||||
def test_with_prefix(self):
|
||||
msg = IRCMessage(command="PRIVMSG", params=["#ch", "hi"], prefix="nick!user@host")
|
||||
assert msg.format() == b":nick!user@host PRIVMSG #ch hi\r\n"
|
||||
|
||||
def test_with_tags(self):
|
||||
msg = IRCMessage(
|
||||
command="PRIVMSG",
|
||||
params=["#ch", "hi"],
|
||||
tags={"time": "2024-01-01T00:00:00Z"},
|
||||
)
|
||||
assert msg.format() == b"@time=2024-01-01T00:00:00Z PRIVMSG #ch hi\r\n"
|
||||
|
||||
def test_roundtrip(self):
|
||||
original = b":nick!user@host PRIVMSG #channel :Hello world"
|
||||
msg = parse(original)
|
||||
assert msg.format() == original + b"\r\n"
|
||||
|
||||
|
||||
class TestParsePrefix:
|
||||
def test_full(self):
|
||||
assert parse_prefix("nick!user@host") == ("nick", "user", "host")
|
||||
|
||||
def test_nick_only(self):
|
||||
assert parse_prefix("server.example.com") == ("server.example.com", None, None)
|
||||
|
||||
def test_nick_host(self):
|
||||
assert parse_prefix("nick@host") == ("nick", None, "host")
|
||||
|
||||
def test_nick_user(self):
|
||||
assert parse_prefix("nick!user") == ("nick", "user", None)
|
||||
Reference in New Issue
Block a user