diff --git a/config/bouncer.example.toml b/config/bouncer.example.toml index 15840a8..c1a6ee8 100644 --- a/config/bouncer.example.toml +++ b/config/bouncer.example.toml @@ -3,6 +3,17 @@ bind = "127.0.0.1" port = 6667 password = "changeme" +# PING watchdog -- detect stale server connections +# ping_interval = 120 # seconds of silence before sending PING +# ping_timeout = 30 # seconds to wait for PONG after PING + +# Push notifications -- alerts when no clients are attached +# notify_url = "" # ntfy or webhook URL (empty = disabled) +# notify_on_highlight = true +# notify_on_privmsg = true +# notify_cooldown = 60 # min seconds between notifications +# notify_proxy = false # route notifications through SOCKS5 + [bouncer.backlog] max_messages = 10000 replay_on_connect = true diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index d0533e8..8663c54 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -137,6 +137,34 @@ SASL EXTERNAL (cert + creds) > SASL PLAIN (creds) > NickServ IDENTIFY 5s -> 10s -> 30s -> 60s -> 120s -> 300s (cap) ``` +## PING Watchdog + +Detects stale connections where TCP stays open but server stops responding. + +```toml +ping_interval = 120 # silence before PING (seconds) +ping_timeout = 30 # wait for PONG (seconds) +``` + +Total detection time: `ping_interval + ping_timeout` (default 150s). + +## server-time (IRCv3) + +Automatic -- no config needed. Timestamps injected on all messages. +Backlog replay includes original timestamps. + +## Push Notifications + +```toml +notify_url = "https://ntfy.sh/my-topic" # ntfy or generic webhook +notify_on_highlight = true # channel mentions +notify_on_privmsg = true # private messages +notify_cooldown = 60 # rate limit (seconds) +notify_proxy = false # use SOCKS5 for notifications +``` + +Only fires when no clients are attached. + ## Config Skeleton ```toml @@ -148,6 +176,9 @@ probation_seconds / nick_timeout / rejoin_delay backoff_steps / http_timeout email_poll_interval / email_max_polls / email_request_timeout cert_validity_days +ping_interval / ping_timeout # PING watchdog +notify_url / notify_on_highlight / notify_on_privmsg +notify_cooldown / notify_proxy # push notifications [bouncer.backlog] max_messages / replay_on_connect @@ -197,7 +228,8 @@ src/bouncer/ cert.py # client certificate generation + management captcha.py # hCaptcha solver via NoCaptchaAI commands.py # 25 bouncer control commands (/msg *bouncer) - router.py # message routing + backlog trigger + notify.py # push notifications (ntfy/webhook) + router.py # message routing + backlog trigger + server-time server.py # TCP listener backlog.py # SQLite store/replay/prune ``` diff --git a/docs/USAGE.md b/docs/USAGE.md index 5ed438f..5770d4d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -171,6 +171,83 @@ replay_on_connect = true # set false to disable replay Stored commands: `PRIVMSG`, `NOTICE`, `TOPIC`, `KICK`, `MODE`. +## PING Watchdog + +The bouncer sends periodic PING messages to detect stale server connections +(socket open but no data flowing). If no data is received within the configured +interval, a PING is sent. If the server doesn't respond within the timeout, +the connection is dropped and a reconnect is scheduled. + +```toml +[bouncer] +ping_interval = 120 # seconds of silence before sending PING +ping_timeout = 30 # seconds to wait for PONG after PING +``` + +The watchdog starts automatically when a network enters the READY state. +Any received data (not just PONG) resets the timer. + +## IRCv3 server-time + +The bouncer requests the `server-time` IRCv3 capability on every connection. +When enabled by the server, timestamps on incoming messages are preserved and +forwarded to clients. When the server does not provide a timestamp, the bouncer +injects one using the current UTC time. + +Backlog replay also includes timestamps from when messages were originally +stored, so clients that support `server-time` see accurate times on replayed +messages. + +No client configuration is needed -- timestamps appear automatically if the +client supports IRCv3 message tags. + +## Push Notifications + +When no IRC clients are connected to the bouncer, highlights and private +messages can trigger push notifications via [ntfy](https://ntfy.sh) or a +generic webhook. + +### Setup + +```toml +[bouncer] +notify_url = "https://ntfy.sh/my-bouncer-topic" +notify_on_highlight = true # mentions of your nick in channels +notify_on_privmsg = true # private messages +notify_cooldown = 60 # min seconds between notifications +notify_proxy = false # route notifications through SOCKS5 +``` + +### ntfy Example + +```toml +notify_url = "https://ntfy.sh/my-secret-topic" +``` + +Install the ntfy app on your phone and subscribe to the topic. Notifications +include the sender, target, and message text. + +### Generic Webhook + +Any URL that does not contain `ntfy` in the hostname is treated as a generic +webhook. The bouncer POSTs JSON: + +```json +{ + "network": "libera", + "sender": "user", + "target": "#channel", + "text": "hey mynick, check this out" +} +``` + +### Behavior + +- Notifications only fire when **no clients** are attached +- The cooldown prevents notification floods (one per `notify_cooldown` seconds) +- When `notify_proxy = true`, notification requests are routed through the + configured SOCKS5 proxy + ## Configuration Reference ```toml @@ -199,6 +276,17 @@ email_request_timeout = 20 # per-request timeout for email APIs # Certificate generation cert_validity_days = 3650 # client cert validity (~10 years) +# PING watchdog +ping_interval = 120 # seconds of silence before sending PING +ping_timeout = 30 # seconds to wait for PONG after PING + +# Push notifications +notify_url = "" # ntfy or webhook URL (empty = disabled) +notify_on_highlight = true # notify on nick mentions +notify_on_privmsg = true # notify on private messages +notify_cooldown = 60 # min seconds between notifications +notify_proxy = false # route notifications through SOCKS5 + [bouncer.backlog] max_messages = 10000 # per network, 0 = unlimited replay_on_connect = true # replay missed messages on client connect diff --git a/src/bouncer/config.py b/src/bouncer/config.py index 5cbd8ab..e29bd46 100644 --- a/src/bouncer/config.py +++ b/src/bouncer/config.py @@ -79,6 +79,17 @@ class BouncerConfig: # Certificate generation cert_validity_days: int = 3650 + # PING watchdog + ping_interval: int = 120 # seconds of silence before sending PING + ping_timeout: int = 30 # seconds to wait for PONG after PING + + # Push notifications + notify_url: str = "" # ntfy/webhook URL (empty = disabled) + notify_on_highlight: bool = True + notify_on_privmsg: bool = True + notify_cooldown: int = 60 # min seconds between notifications + notify_proxy: bool = False # route notifications through SOCKS5 + @dataclass(slots=True) class Config: @@ -114,6 +125,13 @@ def load(path: Path) -> Config: email_max_polls=bouncer_raw.get("email_max_polls", 30), email_request_timeout=bouncer_raw.get("email_request_timeout", 20), cert_validity_days=bouncer_raw.get("cert_validity_days", 3650), + ping_interval=bouncer_raw.get("ping_interval", 120), + ping_timeout=bouncer_raw.get("ping_timeout", 30), + notify_url=bouncer_raw.get("notify_url", ""), + notify_on_highlight=bouncer_raw.get("notify_on_highlight", True), + notify_on_privmsg=bouncer_raw.get("notify_on_privmsg", True), + notify_cooldown=bouncer_raw.get("notify_cooldown", 60), + notify_proxy=bouncer_raw.get("notify_proxy", False), ) proxy_raw = raw.get("proxy", {}) diff --git a/src/bouncer/network.py b/src/bouncer/network.py index 518c7ea..a72d162 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -7,6 +7,7 @@ import base64 import hashlib import logging import random +import time from enum import Enum, auto from pathlib import Path from typing import Callable @@ -215,6 +216,9 @@ class Network: self._read_task: asyncio.Task[None] | None = None self._reconnect_task: asyncio.Task[None] | None = None self._probation_task: asyncio.Task[None] | None = None + self._ping_task: asyncio.Task[None] | None = None + # PING watchdog state + self._last_recv: float = 0.0 # Transient nick used during registration/probation self._connect_nick: str = "" # Visible hostname reported by server @@ -236,6 +240,9 @@ class Network: self._sasl_pass: str = "" self._sasl_mechanism: str = "" # "EXTERNAL" or "PLAIN" self._sasl_complete: asyncio.Event = asyncio.Event() + # IRCv3 capability negotiation + self._caps_pending: int = 0 + self._server_time: bool = False # URL for manual verification (e.g. OFTC captcha) self._verify_url: str = "" @@ -244,6 +251,10 @@ class Network: if self.on_status: self.on_status(self.cfg.name, text) + @property + def server_time(self) -> bool: + return self._server_time + @property def connected(self) -> bool: return self.state not in (State.DISCONNECTED, State.CONNECTING) @@ -264,7 +275,11 @@ class Network: async def stop(self) -> None: """Disconnect and stop reconnection.""" self._running = False - for task in (self._read_task, self._reconnect_task, self._probation_task, self._verify_task): + tasks = ( + self._read_task, self._reconnect_task, self._probation_task, + self._verify_task, self._ping_task, + ) + for task in tasks: if task and not task.done(): task.cancel() await self._disconnect() @@ -296,6 +311,8 @@ class Network: self._sasl_pass = "" self._sasl_mechanism = "" self._sasl_complete = asyncio.Event() + self._caps_pending = 0 + self._server_time = False # Check for stored creds to decide SASL strategy use_sasl = False @@ -336,12 +353,17 @@ class Network: ) self.state = State.REGISTERING + # Always request server-time capability + await self.send_raw("CAP", "REQ", "server-time") + self._caps_pending += 1 + if use_sasl: self._status( f"connected, authenticating as {self._connect_nick}" f" (SASL {self._sasl_mechanism})" ) await self.send_raw("CAP", "REQ", "sasl") + self._caps_pending += 1 else: self._status(f"connected, registering as {self._connect_nick}") @@ -369,6 +391,9 @@ class Network: if self._probation_task and not self._probation_task.done(): self._probation_task.cancel() self._probation_task = None + if self._ping_task and not self._ping_task.done(): + self._ping_task.cancel() + self._ping_task = None if self._writer and not self._writer.is_closing(): try: self._writer.close() @@ -405,6 +430,7 @@ class Network: try: while self._running and self.state != State.DISCONNECTED: data = await self._reader.read(4096) + self._last_recv = time.monotonic() if not data: log.warning("[%s] server closed connection", self.cfg.name) break @@ -472,6 +498,29 @@ class Network: self._status(f"registering cert fingerprint for {nick}") await self.send_raw("PRIVMSG", "NickServ", f"CERT ADD {fp}") + async def _ping_watchdog(self) -> None: + """Send PING if no data received within ping_interval; disconnect on timeout.""" + interval = self.bouncer_cfg.ping_interval + timeout = self.bouncer_cfg.ping_timeout + try: + while self._running and self.state == State.READY: + await asyncio.sleep(interval) + if self.state != State.READY or not self._running: + break + elapsed = time.monotonic() - self._last_recv + if elapsed >= interval: + await self.send_raw("PING", "bouncer") + await asyncio.sleep(timeout) + if time.monotonic() - self._last_recv >= interval + timeout: + log.warning("[%s] ping timeout, reconnecting", self.cfg.name) + self._status("ping timeout, reconnecting") + await self._disconnect() + if self._running: + self._schedule_reconnect() + return + except asyncio.CancelledError: + return + async def _go_ready(self) -> None: """Transition to ready: skip NickServ if SASL succeeded, otherwise register. @@ -482,6 +531,10 @@ class Network: log.info("[%s] ready as %s (host=%s)", self.cfg.name, self.nick, self.visible_host or "unknown") + # Start PING watchdog + self._last_recv = time.monotonic() + self._ping_task = asyncio.create_task(self._ping_watchdog()) + # SASL already authenticated -- skip NickServ entirely if self._sasl_complete.is_set(): self._status(f"ready as {self.nick} (SASL)") @@ -948,6 +1001,16 @@ class Network: ) return True + def _cap_resolved(self) -> bool: + """Decrement pending cap count, return True if all caps are resolved.""" + self._caps_pending = max(0, self._caps_pending - 1) + return self._caps_pending == 0 + + async def _maybe_cap_end(self) -> None: + """Send CAP END if no caps are still pending and SASL is not in-flight.""" + if self._caps_pending <= 0: + await self.send_raw("CAP", "END") + async def _handle(self, msg: IRCMessage) -> None: """Handle an IRC message from the server.""" if msg.command == "PING": @@ -960,21 +1023,34 @@ class Network: log.warning("[%s] server ERROR: %s", self.cfg.name, reason) return - # --- SASL capability negotiation --- + # --- IRCv3 capability negotiation --- if msg.command == "CAP" and len(msg.params) >= 3: subcommand = msg.params[1].upper() caps = msg.params[2].strip().lower() - if subcommand == "ACK" and "sasl" in caps: - log.info("[%s] SASL capability acknowledged, using %s", - self.cfg.name, self._sasl_mechanism) - await self.send_raw("AUTHENTICATE", self._sasl_mechanism or "PLAIN") - elif subcommand == "NAK" and "sasl" in caps: - log.warning("[%s] SASL not supported by server", self.cfg.name) - self._status("SASL not supported, falling back") - self._sasl_nick = "" - self._sasl_pass = "" - self._sasl_mechanism = "" - await self.send_raw("CAP", "END") + if subcommand == "ACK": + if "server-time" in caps: + self._server_time = True + log.info("[%s] server-time capability enabled", self.cfg.name) + self._cap_resolved() + if "sasl" in caps: + log.info("[%s] SASL capability acknowledged, using %s", + self.cfg.name, self._sasl_mechanism) + # Don't decrement yet -- SASL auth flow will resolve this cap + await self.send_raw("AUTHENTICATE", self._sasl_mechanism or "PLAIN") + return + await self._maybe_cap_end() + elif subcommand == "NAK": + if "server-time" in caps: + log.info("[%s] server-time not supported", self.cfg.name) + self._cap_resolved() + if "sasl" in caps: + log.warning("[%s] SASL not supported by server", self.cfg.name) + self._status("SASL not supported, falling back") + self._sasl_nick = "" + self._sasl_pass = "" + self._sasl_mechanism = "" + self._cap_resolved() + await self._maybe_cap_end() return if msg.command == "AUTHENTICATE" and msg.params and msg.params[0] == "+": @@ -1002,7 +1078,8 @@ class Network: # it while we're still in capability negotiation (before K-line) if self._sasl_mechanism == "PLAIN" and self.data_dir: await self._register_cert_fingerprint() - await self.send_raw("CAP", "END") + self._cap_resolved() + await self._maybe_cap_end() return if msg.command in ("902", "904", "905"): @@ -1021,12 +1098,14 @@ class Network: self._sasl_nick = "" self._sasl_pass = "" self._sasl_mechanism = "" - await self.send_raw("CAP", "END") + self._cap_resolved() + await self._maybe_cap_end() return if msg.command in ("906", "908"): # ERR_SASLABORTED / RPL_SASLMECHS - await self.send_raw("CAP", "END") + self._cap_resolved() + await self._maybe_cap_end() return if msg.command == "001": diff --git a/src/bouncer/notify.py b/src/bouncer/notify.py new file mode 100644 index 0000000..8915f21 --- /dev/null +++ b/src/bouncer/notify.py @@ -0,0 +1,134 @@ +"""Push notifications for highlights and private messages.""" + +from __future__ import annotations + +import logging +import time +from urllib.parse import urlparse + +import aiohttp + +from bouncer.config import BouncerConfig, ProxyConfig + +log = logging.getLogger(__name__) + + +class Notifier: + """Sends push notifications when no clients are attached.""" + + def __init__(self, cfg: BouncerConfig, proxy_cfg: ProxyConfig) -> None: + self._url = cfg.notify_url + self._on_highlight = cfg.notify_on_highlight + self._on_privmsg = cfg.notify_on_privmsg + self._cooldown = cfg.notify_cooldown + self._use_proxy = cfg.notify_proxy + self._proxy_cfg = proxy_cfg + self._last_sent: float = 0.0 + + @property + def enabled(self) -> bool: + return bool(self._url) + + def should_notify( + self, + nick: str, + target: str, + text: str, + own_nick: str, + ) -> bool: + """Check if this message warrants a notification.""" + if not self.enabled: + return False + if time.monotonic() - self._last_sent < self._cooldown: + return False + is_pm = not target.startswith(("#", "&", "+", "!")) + is_highlight = own_nick.lower() in text.lower() + if is_pm and self._on_privmsg: + return True + if is_highlight and self._on_highlight: + return True + return False + + async def send( + self, + network: str, + sender: str, + target: str, + text: str, + ) -> None: + """Fire notification. Auto-detects ntfy vs generic webhook.""" + try: + connector = None + if self._use_proxy: + from aiohttp_socks import ProxyConnector + + connector = ProxyConnector.from_url( + f"socks5://{self._proxy_cfg.host}:{self._proxy_cfg.port}", + ) + + async with aiohttp.ClientSession(connector=connector) as session: + if self._is_ntfy(): + await self._send_ntfy(session, network, sender, target, text) + else: + await self._send_webhook(session, network, sender, target, text) + + self._last_sent = time.monotonic() + except Exception: + log.exception("notification send failed") + + def _is_ntfy(self) -> bool: + """Check if the URL looks like an ntfy endpoint.""" + hostname = urlparse(self._url).hostname or "" + return "ntfy" in hostname + + async def _send_ntfy( + self, + session: aiohttp.ClientSession, + network: str, + sender: str, + target: str, + text: str, + ) -> None: + """Send notification via ntfy (POST plain text with headers).""" + title = f"{sender} on {target}/{network}" + headers = { + "Title": title, + "Tags": "speech_balloon", + } + async with session.post( + self._url, + data=text.encode(), + headers=headers, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status >= 400: + body = await resp.text() + log.warning("ntfy returned %d: %s", resp.status, body[:200]) + else: + log.info("ntfy notification sent: %s -> %s", sender, target) + + async def _send_webhook( + self, + session: aiohttp.ClientSession, + network: str, + sender: str, + target: str, + text: str, + ) -> None: + """Send notification via generic webhook (POST JSON).""" + payload = { + "network": network, + "sender": sender, + "target": target, + "text": text, + } + async with session.post( + self._url, + json=payload, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status >= 400: + body = await resp.text() + log.warning("webhook returned %d: %s", resp.status, body[:200]) + else: + log.info("webhook notification sent: %s -> %s", sender, target) diff --git a/src/bouncer/router.py b/src/bouncer/router.py index be4a5f0..dc3002d 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING @@ -12,6 +13,7 @@ from bouncer.config import Config, NetworkConfig, ProxyConfig from bouncer.irc import IRCMessage from bouncer.namespace import decode_target, encode_message from bouncer.network import Network +from bouncer.notify import Notifier if TYPE_CHECKING: from bouncer.client import Client @@ -90,6 +92,7 @@ class Router: self.networks: dict[str, Network] = {} self.clients: list[Client] = [] self._lock = asyncio.Lock() + self._notifier = Notifier(config.bouncer, config.proxy) def _proxy_for(self, net_cfg: NetworkConfig) -> ProxyConfig: """Return the effective proxy config for a network.""" @@ -244,6 +247,25 @@ class Router: if _suppress(msg): return + # Inject server-time tag if not present + if "time" not in msg.tags: + msg.tags["time"] = datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ) + + # Push notification when no clients are attached + if not self.clients and self._notifier.enabled: + if msg.command == "PRIVMSG" and msg.prefix and len(msg.params) >= 2: + sender_nick = msg.prefix.split("!")[0] + target = msg.params[0] + text = msg.params[1] + network = self.networks.get(network_name) + own_nick = network.nick if network else "" + if self._notifier.should_notify(sender_nick, target, text, own_nick): + asyncio.create_task( + self._notifier.send(network_name, sender_nick, target, text) + ) + # Namespace and forward to all clients (per-client: own nicks -> client nick) own_nicks = self.get_own_nicks() for client in self.clients: @@ -267,10 +289,14 @@ class Router: own_nicks = self.get_own_nicks() for entry in entries: + ts = datetime.fromtimestamp(entry.timestamp, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ) msg = IRCMessage( command=entry.command, params=[entry.target, entry.content], prefix=entry.sender, + tags={"time": ts}, ) if _suppress(msg): continue diff --git a/tests/test_network.py b/tests/test_network.py index 1057ca1..cec136a 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -4,8 +4,8 @@ from __future__ import annotations import asyncio import base64 -import hashlib import random +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -13,13 +13,10 @@ import pytest from bouncer.config import BouncerConfig, NetworkConfig, ProxyConfig from bouncer.irc import IRCMessage, parse from bouncer.network import ( - Network, - State, - _BIGRAMS, - _GENERIC_IDENTS, - _GENERIC_REALNAMES, _STARTERS, _VOWELS, + Network, + State, _markov_word, _nick_for_host, _password_for_host, @@ -28,7 +25,6 @@ from bouncer.network import ( _seeded_markov, ) - # -- helpers ----------------------------------------------------------------- def _cfg(name: str = "testnet", host: str = "irc.test.net", port: int = 6697, @@ -854,9 +850,11 @@ class TestConnect: user_sent = any(b"USER " in c for c in calls) assert nick_sent assert user_sent - # Should NOT have sent CAP REQ sasl - cap_sent = any(b"CAP REQ" in c for c in calls) - assert not cap_sent + # Should have sent CAP REQ server-time but NOT CAP REQ sasl + cap_server_time = any(b"CAP REQ server-time" in c for c in calls) + cap_sasl = any(b"CAP REQ sasl" in c for c in calls) + assert cap_server_time + assert not cap_sasl @pytest.mark.asyncio async def test_connect_with_sasl_plain(self) -> None: @@ -1201,3 +1199,206 @@ class TestReadLoop: await net._read_loop() assert cb.call_count == 2 + + +# -- PING watchdog ----------------------------------------------------------- + +class TestPingWatchdog: + @pytest.mark.asyncio + async def test_timeout_triggers_disconnect(self) -> None: + """Stale connection triggers disconnect + reconnect.""" + net = _net(bouncer_cfg=_bouncer(ping_interval=0, ping_timeout=0)) + net.state = State.READY + net._running = True + net._last_recv = time.monotonic() - 1000 # stale + + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + + with patch.object(net, "_disconnect", new_callable=AsyncMock) as mock_disc: + with patch.object(net, "_schedule_reconnect") as mock_recon: + await net._ping_watchdog() + + mock_disc.assert_awaited_once() + mock_recon.assert_called_once() + + @pytest.mark.asyncio + async def test_healthy_connection_stays_alive(self) -> None: + """Fresh data during timeout window prevents disconnect.""" + net = _net(bouncer_cfg=_bouncer(ping_interval=10, ping_timeout=5)) + net.state = State.READY + net._running = True + + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + + # Control time progression: stale at first, then fresh after PING + clock = [100.0] # start at T=100 + + def fake_monotonic() -> float: + return clock[0] + + original_sleep = asyncio.sleep + call_count = 0 + + async def fake_sleep(delay: float) -> None: + nonlocal call_count + call_count += 1 + if call_count == 1: + # After interval sleep: time advanced, data is stale -> PING sent + clock[0] = 120.0 + net._last_recv = 100.0 # stale (20s > interval=10) + elif call_count == 2: + # During timeout wait: simulate PONG received (fresh data) + clock[0] = 122.0 + net._last_recv = 122.0 # fresh + elif call_count == 3: + # Next interval sleep: exit loop + net.state = State.DISCONNECTED + await original_sleep(0) + + with patch("time.monotonic", side_effect=fake_monotonic): + with patch("asyncio.sleep", side_effect=fake_sleep): + with patch.object(net, "_disconnect", new_callable=AsyncMock) as mock_disc: + await net._ping_watchdog() + + # PING was sent, but fresh data arrived -- no disconnect + ping_sent = any(b"PING" in c.args[0] for c in writer.write.call_args_list) + assert ping_sent + mock_disc.assert_not_awaited() + + @pytest.mark.asyncio + async def test_watchdog_cancelled_on_disconnect(self) -> None: + net = _net() + net.state = State.READY + net._running = True + net._last_recv = time.monotonic() + + task = MagicMock() + task.done.return_value = False + net._ping_task = task + + await net._disconnect() + task.cancel.assert_called_once() + assert net._ping_task is None + + @pytest.mark.asyncio + async def test_ping_task_started_in_go_ready(self) -> None: + net = _net(bouncer_cfg=_bouncer(probation_seconds=0, ping_interval=999)) + net.state = State.PROBATION + net._running = True + net._sasl_complete.set() + + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + bl = _mock_backlog() + net.backlog = bl + + await net._go_ready() + assert net._ping_task is not None + # Cancel it so test doesn't leak + net._ping_task.cancel() + try: + await net._ping_task + except asyncio.CancelledError: + pass + + @pytest.mark.asyncio + async def test_ping_task_in_stop(self) -> None: + """stop() cancels the ping task.""" + net = _net() + net._running = True + ping_task = MagicMock() + ping_task.done.return_value = False + net._ping_task = ping_task + + await net.stop() + # cancel() is called in both stop() and _disconnect() + assert ping_task.cancel.call_count >= 1 + + +# -- IRCv3 CAP negotiation (server-time) ------------------------------------ + +class TestCapServerTime: + @pytest.mark.asyncio + async def test_server_time_ack_sets_flag(self) -> None: + net = _net() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + net._caps_pending = 1 + + await net._handle(_msg(":server CAP * ACK :server-time")) + assert net._server_time is True + + @pytest.mark.asyncio + async def test_server_time_nak_handled(self) -> None: + net = _net() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + net._caps_pending = 1 + + await net._handle(_msg(":server CAP * NAK :server-time")) + assert net._server_time is False + + @pytest.mark.asyncio + async def test_combined_sasl_and_server_time(self) -> None: + """Both caps requested: ACK server-time + ACK sasl.""" + net = _net() + net._sasl_mechanism = "PLAIN" + net._sasl_nick = "nick" + net._sasl_pass = "pass" + net._caps_pending = 2 + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + + # ACK server-time first + await net._handle(_msg(":server CAP * ACK :server-time")) + assert net._server_time is True + # Should NOT have sent CAP END yet (SASL still pending) + cap_end_calls = [c for c in writer.write.call_args_list + if b"CAP END" in c.args[0]] + assert len(cap_end_calls) == 0 + + # ACK sasl starts AUTHENTICATE flow + await net._handle(_msg(":server CAP * ACK :sasl")) + writer.write.assert_called_with(b"AUTHENTICATE PLAIN\r\n") + + # SASL success resolves the last cap + await net._handle(_msg(":server 903 nick :SASL authentication successful")) + cap_end_calls = [c for c in writer.write.call_args_list + if b"CAP END" in c.args[0]] + assert len(cap_end_calls) == 1 + + @pytest.mark.asyncio + async def test_cap_end_after_all_resolved(self) -> None: + """CAP END sent only after all caps are resolved.""" + net = _net() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + net._writer = writer + net._caps_pending = 1 # only server-time, no SASL + + await net._handle(_msg(":server CAP * ACK :server-time")) + cap_end_calls = [c for c in writer.write.call_args_list + if b"CAP END" in c.args[0]] + assert len(cap_end_calls) == 1 + + @pytest.mark.asyncio + async def test_server_time_property(self) -> None: + net = _net() + assert net.server_time is False + net._server_time = True + assert net.server_time is True diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..ec16d58 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,185 @@ +"""Tests for push notification module.""" + +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bouncer.config import BouncerConfig, ProxyConfig +from bouncer.notify import Notifier + +# -- helpers ----------------------------------------------------------------- + +def _cfg(**overrides: object) -> BouncerConfig: + defaults: dict[str, object] = { + "notify_url": "https://ntfy.sh/bouncer", + "notify_on_highlight": True, + "notify_on_privmsg": True, + "notify_cooldown": 60, + "notify_proxy": False, + } + defaults.update(overrides) + return BouncerConfig(**defaults) # type: ignore[arg-type] + + +def _proxy() -> ProxyConfig: + return ProxyConfig(host="127.0.0.1", port=1080) + + +def _notifier(**overrides: object) -> Notifier: + return Notifier(_cfg(**overrides), _proxy()) + + +# -- enabled ----------------------------------------------------------------- + +class TestEnabled: + def test_enabled_with_url(self) -> None: + n = _notifier(notify_url="https://ntfy.sh/test") + assert n.enabled is True + + def test_disabled_without_url(self) -> None: + n = _notifier(notify_url="") + assert n.enabled is False + + +# -- should_notify ----------------------------------------------------------- + +class TestShouldNotify: + def test_pm_triggers(self) -> None: + n = _notifier() + assert n.should_notify("sender", "mynick", "hello", "mynick") is True + + def test_highlight_triggers(self) -> None: + n = _notifier() + assert n.should_notify("sender", "#channel", "hey mynick!", "mynick") is True + + def test_highlight_case_insensitive(self) -> None: + n = _notifier() + assert n.should_notify("sender", "#channel", "hey MYNICK!", "mynick") is True + + def test_normal_channel_msg_does_not_trigger(self) -> None: + n = _notifier() + assert n.should_notify("sender", "#channel", "hello world", "mynick") is False + + def test_disabled_does_not_trigger(self) -> None: + n = _notifier(notify_url="") + assert n.should_notify("sender", "mynick", "hello", "mynick") is False + + def test_pm_disabled(self) -> None: + n = _notifier(notify_on_privmsg=False) + assert n.should_notify("sender", "mynick", "hello", "mynick") is False + + def test_highlight_disabled(self) -> None: + n = _notifier(notify_on_highlight=False) + assert n.should_notify("sender", "#channel", "hey mynick!", "mynick") is False + + def test_cooldown_respected(self) -> None: + n = _notifier(notify_cooldown=60) + n._last_sent = time.monotonic() # just sent + assert n.should_notify("sender", "mynick", "hello", "mynick") is False + + def test_cooldown_expired(self) -> None: + n = _notifier(notify_cooldown=60) + n._last_sent = time.monotonic() - 120 # expired + assert n.should_notify("sender", "mynick", "hello", "mynick") is True + + def test_channel_prefixes(self) -> None: + """Targets starting with #, &, +, ! are channels, not PMs.""" + n = _notifier() + for prefix in ("#", "&", "+", "!"): + target = f"{prefix}channel" + assert n.should_notify("sender", target, "hello", "mynick") is False + + +# -- _is_ntfy --------------------------------------------------------------- + +class TestIsNtfy: + def test_ntfy_sh(self) -> None: + n = _notifier(notify_url="https://ntfy.sh/mytopic") + assert n._is_ntfy() is True + + def test_self_hosted_ntfy(self) -> None: + n = _notifier(notify_url="https://ntfy.example.com/mytopic") + assert n._is_ntfy() is True + + def test_generic_webhook(self) -> None: + n = _notifier(notify_url="https://hooks.example.com/webhook") + assert n._is_ntfy() is False + + +# -- send -------------------------------------------------------------------- + +class TestSend: + @pytest.mark.asyncio + async def test_ntfy_sends_post(self) -> None: + n = _notifier(notify_url="https://ntfy.sh/bouncer") + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.post = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session): + await n.send("libera", "user", "#test", "hello world") + + mock_session.post.assert_called_once() + call_kwargs = mock_session.post.call_args + assert call_kwargs[1]["data"] == b"hello world" + assert "Title" in call_kwargs[1]["headers"] + assert n._last_sent > 0 + + @pytest.mark.asyncio + async def test_webhook_sends_json(self) -> None: + n = _notifier(notify_url="https://hooks.example.com/webhook") + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.post = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session): + await n.send("libera", "user", "#test", "hello world") + + call_kwargs = mock_session.post.call_args + assert call_kwargs[1]["json"]["network"] == "libera" + assert call_kwargs[1]["json"]["sender"] == "user" + + @pytest.mark.asyncio + async def test_proxy_connector_used(self) -> None: + n = _notifier(notify_url="https://ntfy.sh/test", notify_proxy=True) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.post = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("bouncer.notify.aiohttp.ClientSession", return_value=mock_session): + with patch("bouncer.notify.Notifier._send_ntfy", new_callable=AsyncMock): + with patch("aiohttp_socks.ProxyConnector.from_url") as mock_proxy: + mock_proxy.return_value = MagicMock() + await n.send("libera", "user", "#test", "hello") + mock_proxy.assert_called_once_with("socks5://127.0.0.1:1080") + + @pytest.mark.asyncio + async def test_send_error_does_not_raise(self) -> None: + """Send errors are logged, not propagated.""" + n = _notifier(notify_url="https://ntfy.sh/test") + with patch("bouncer.notify.aiohttp.ClientSession", side_effect=Exception("boom")): + await n.send("libera", "user", "#test", "hello") # should not raise diff --git a/tests/test_router.py b/tests/test_router.py new file mode 100644 index 0000000..bcf83b8 --- /dev/null +++ b/tests/test_router.py @@ -0,0 +1,932 @@ +"""Tests for message router.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig +from bouncer.irc import IRCMessage +from bouncer.router import BACKLOG_COMMANDS, Router, _suppress + +# -- helpers ----------------------------------------------------------------- + +def _net_cfg(name: str = "libera", host: str = "irc.libera.chat", + port: int = 6697, tls: bool = True, + proxy_host: str | None = None, + proxy_port: int | None = None) -> NetworkConfig: + return NetworkConfig( + name=name, host=host, port=port, tls=tls, + proxy_host=proxy_host, proxy_port=proxy_port, + ) + + +def _config(*net_cfgs: NetworkConfig) -> Config: + nets = {n.name: n for n in net_cfgs} if net_cfgs else { + "libera": _net_cfg("libera"), + } + return Config( + bouncer=BouncerConfig(), + proxy=ProxyConfig(host="127.0.0.1", port=1080), + networks=nets, + ) + + +def _backlog() -> AsyncMock: + bl = AsyncMock() + bl.get_last_seen = AsyncMock(return_value=0) + bl.replay = AsyncMock(return_value=[]) + bl.mark_seen = AsyncMock() + return bl + + +def _mock_network(name: str = "libera", nick: str = "botnick", + connected: bool = True, channels: set[str] | None = None, + topics: dict[str, str] | None = None, + names: dict[str, set[str]] | None = None) -> MagicMock: + net = MagicMock() + net.cfg.name = name + net.nick = nick + net.connected = connected + net.channels = channels or set() + net.topics = topics or {} + net.names = names or {} + net.send = AsyncMock() + net.stop = AsyncMock() + net.start = AsyncMock() + return net + + +def _mock_client(nick: str = "testuser") -> MagicMock: + client = MagicMock() + client.nick = nick + client.write = MagicMock() + return client + + +def _msg(command: str, params: list[str] | None = None, + prefix: str | None = None, tags: dict | None = None) -> IRCMessage: + return IRCMessage( + command=command, + params=params or [], + prefix=prefix, + tags=tags or {}, + ) + + +# -- _suppress --------------------------------------------------------------- + +class TestSuppress: + def test_suppresses_welcome_numerics(self) -> None: + for num in ("001", "002", "003", "004", "005"): + assert _suppress(_msg(num, ["nick", "text"])) is True + + def test_suppresses_motd(self) -> None: + for num in ("375", "372", "376", "422"): + assert _suppress(_msg(num, ["nick", "text"])) is True + + def test_suppresses_lusers(self) -> None: + for num in ("250", "251", "252", "253", "254", "255", "265", "266"): + assert _suppress(_msg(num, ["nick", "text"])) is True + + def test_suppresses_uid_and_visiblehost(self) -> None: + assert _suppress(_msg("042", ["nick", "UID"])) is True + assert _suppress(_msg("396", ["nick", "host"])) is True + + def test_suppresses_nick_in_use(self) -> None: + assert _suppress(_msg("433", ["*", "nick", "in use"])) is True + + def test_suppresses_server_notice(self) -> None: + msg = _msg("NOTICE", ["nick", "server message"], prefix="server.example.com") + assert _suppress(msg) is True + + def test_passes_user_notice(self) -> None: + msg = _msg("NOTICE", ["nick", "hello"], prefix="user!ident@host") + assert _suppress(msg) is False + + def test_suppresses_connection_notice_star(self) -> None: + msg = _msg("NOTICE", ["*", "Looking up your hostname..."]) + assert _suppress(msg) is True + + def test_suppresses_connection_notice_auth(self) -> None: + msg = _msg("NOTICE", ["AUTH", "*** Checking Ident"]) + assert _suppress(msg) is True + + def test_suppresses_ctcp_reply_in_notice(self) -> None: + msg = _msg("NOTICE", ["nick", "\x01VERSION mIRC\x01"], prefix="user!i@h") + assert _suppress(msg) is True + + def test_suppresses_ctcp_in_privmsg(self) -> None: + msg = _msg("PRIVMSG", ["nick", "\x01VERSION\x01"], prefix="user!i@h") + assert _suppress(msg) is True + + def test_passes_action_in_privmsg(self) -> None: + msg = _msg("PRIVMSG", ["#ch", "\x01ACTION waves\x01"], prefix="user!i@h") + assert _suppress(msg) is False + + def test_suppresses_user_mode(self) -> None: + msg = _msg("MODE", ["nick", "+i"]) + assert _suppress(msg) is True + + def test_passes_channel_mode(self) -> None: + msg = _msg("MODE", ["#channel", "+o", "nick"]) + assert _suppress(msg) is False + + def test_passes_normal_privmsg(self) -> None: + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h") + assert _suppress(msg) is False + + def test_passes_join(self) -> None: + msg = _msg("JOIN", ["#test"], prefix="user!i@h") + assert _suppress(msg) is False + + def test_passes_part(self) -> None: + msg = _msg("PART", ["#test"], prefix="user!i@h") + assert _suppress(msg) is False + + def test_passes_kick(self) -> None: + msg = _msg("KICK", ["#test", "nick", "reason"], prefix="op!i@h") + assert _suppress(msg) is False + + def test_passes_topic(self) -> None: + msg = _msg("TOPIC", ["#test", "new topic"], prefix="user!i@h") + assert _suppress(msg) is False + + +# -- Router._proxy_for ------------------------------------------------------ + +class TestProxyFor: + def test_default_proxy(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + proxy = router._proxy_for(_net_cfg()) + assert proxy.host == "127.0.0.1" + assert proxy.port == 1080 + + def test_per_network_proxy_override(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + net = _net_cfg(proxy_host="10.0.0.1", proxy_port=9050) + proxy = router._proxy_for(net) + assert proxy.host == "10.0.0.1" + assert proxy.port == 9050 + + def test_per_network_proxy_inherits_port(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + net = _net_cfg(proxy_host="10.0.0.1") + proxy = router._proxy_for(net) + assert proxy.host == "10.0.0.1" + assert proxy.port == 1080 # from global config + + +# -- Router init and network management -------------------------------------- + +class TestNetworkManagement: + def test_network_names_empty(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + assert router.network_names() == [] + + def test_get_network_none(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + assert router.get_network("nonexistent") is None + + def test_get_network_found(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + assert router.get_network("libera") is net + + def test_network_names(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + router.networks["libera"] = _mock_network("libera") + router.networks["oftc"] = _mock_network("oftc") + assert sorted(router.network_names()) == ["libera", "oftc"] + + def test_get_own_nicks(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + router.networks["libera"] = _mock_network("libera", nick="lnick") + router.networks["oftc"] = _mock_network("oftc", nick="onick") + nicks = router.get_own_nicks() + assert nicks == {"libera": "lnick", "oftc": "onick"} + + @pytest.mark.asyncio + async def test_add_network(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + net_cfg = _net_cfg("hackint", host="irc.hackint.org") + + with patch("bouncer.router.Network") as MockNet: + mock_instance = MagicMock() + mock_instance.start = AsyncMock() + MockNet.return_value = mock_instance + result = await router.add_network(net_cfg) + + assert "hackint" in router.networks + assert result is mock_instance + + @pytest.mark.asyncio + async def test_remove_network(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + result = await router.remove_network("libera") + assert result is True + assert "libera" not in router.networks + net.stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_remove_network_not_found(self) -> None: + cfg = _config() + router = Router(cfg, _backlog()) + result = await router.remove_network("nonexistent") + assert result is False + + +# -- Client attach/detach --------------------------------------------------- + +class TestClientAttachDetach: + @pytest.mark.asyncio + async def test_attach_adds_client(self) -> None: + router = Router(_config(), _backlog()) + client = _mock_client() + await router.attach_all(client) + assert client in router.clients + + @pytest.mark.asyncio + async def test_detach_removes_client(self) -> None: + router = Router(_config(), _backlog()) + client = _mock_client() + await router.attach_all(client) + await router.detach_all(client) + assert client not in router.clients + + @pytest.mark.asyncio + async def test_detach_missing_client_no_error(self) -> None: + router = Router(_config(), _backlog()) + client = _mock_client() + await router.detach_all(client) # should not raise + + @pytest.mark.asyncio + async def test_multiple_clients(self) -> None: + router = Router(_config(), _backlog()) + c1 = _mock_client("user1") + c2 = _mock_client("user2") + await router.attach_all(c1) + await router.attach_all(c2) + assert len(router.clients) == 2 + + await router.detach_all(c1) + assert len(router.clients) == 1 + assert c2 in router.clients + + +# -- stop_networks ----------------------------------------------------------- + +class TestStopNetworks: + @pytest.mark.asyncio + async def test_stops_all(self) -> None: + router = Router(_config(), _backlog()) + n1 = _mock_network("libera") + n2 = _mock_network("oftc") + router.networks = {"libera": n1, "oftc": n2} + + await router.stop_networks() + n1.stop.assert_awaited_once() + n2.stop.assert_awaited_once() + + +# -- route_client_message (outbound: client -> network) ---------------------- + +class TestRouteClientMessage: + @pytest.mark.asyncio + async def test_privmsg_to_channel(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "hello"]) + await router.route_client_message(msg) + + net.send.assert_awaited_once() + sent = net.send.call_args[0][0] + assert sent.command == "PRIVMSG" + assert sent.params == ["#test", "hello"] + + @pytest.mark.asyncio + async def test_privmsg_to_nick(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["user123/libera", "hi there"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params == ["user123", "hi there"] + + @pytest.mark.asyncio + async def test_no_namespace_dropped(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test", "hello"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_unknown_network_dropped(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/fakenet", "hello"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_disconnected_network_dropped(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera", connected=False) + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "hello"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_empty_params_ignored(self) -> None: + router = Router(_config(), _backlog()) + msg = _msg("PRIVMSG") + await router.route_client_message(msg) # should not raise + + @pytest.mark.asyncio + async def test_join_single_channel(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("JOIN", ["#dev/libera"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.command == "JOIN" + assert sent.params == ["#dev"] + + @pytest.mark.asyncio + async def test_join_comma_separated_same_network(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("JOIN", ["#a/libera,#b/libera"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params[0] == "#a,#b" + + @pytest.mark.asyncio + async def test_join_comma_separated_multi_network(self) -> None: + router = Router(_config(), _backlog()) + n1 = _mock_network("libera") + n2 = _mock_network("oftc") + router.networks = {"libera": n1, "oftc": n2} + + msg = _msg("JOIN", ["#a/libera,#b/oftc"]) + await router.route_client_message(msg) + + assert n1.send.await_count == 1 + assert n2.send.await_count == 1 + s1 = n1.send.call_args[0][0] + s2 = n2.send.call_args[0][0] + assert s1.params[0] == "#a" + assert s2.params[0] == "#b" + + @pytest.mark.asyncio + async def test_part(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PART", ["#dev/libera", "leaving"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.command == "PART" + assert sent.params == ["#dev", "leaving"] + + @pytest.mark.asyncio + async def test_kick(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("KICK", ["#test/libera", "baduser/libera", "bye"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.command == "KICK" + assert sent.params == ["#test", "baduser", "bye"] + + @pytest.mark.asyncio + async def test_kick_no_namespace_dropped(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("KICK", ["#test", "baduser"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_invite(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("INVITE", ["user/libera", "#test/libera"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.command == "INVITE" + assert sent.params == ["user", "#test"] + + @pytest.mark.asyncio + async def test_invite_network_from_either_param(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + # Network suffix only on the nick, not the channel + msg = _msg("INVITE", ["user/libera", "#test"]) + await router.route_client_message(msg) + net.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_mode_channel(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("MODE", ["#test/libera", "+o", "nick"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params == ["#test", "+o", "nick"] + + @pytest.mark.asyncio + async def test_who(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("WHO", ["#test/libera"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params == ["#test"] + + @pytest.mark.asyncio + async def test_notice(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("NOTICE", ["user/libera", "you've been warned"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params == ["user", "you've been warned"] + + @pytest.mark.asyncio + async def test_topic(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("TOPIC", ["#test/libera", "new topic"]) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.params == ["#test", "new topic"] + + @pytest.mark.asyncio + async def test_preserves_tags(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "hi"], + tags={"label": "abc"}) + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.tags == {"label": "abc"} + + @pytest.mark.asyncio + async def test_preserves_prefix(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "hi"], prefix="me!u@h") + await router.route_client_message(msg) + + sent = net.send.call_args[0][0] + assert sent.prefix == "me!u@h" + + +# -- _dispatch (inbound: network -> clients) --------------------------------- + +class TestDispatch: + @pytest.mark.asyncio + async def test_delivers_to_all_clients(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera", nick="bot") + router.networks["libera"] = net + c1 = _mock_client("user1") + c2 = _mock_client("user2") + router.clients = [c1, c2] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="sender!u@h") + await router._dispatch("libera", msg) + + assert c1.write.call_count == 1 + assert c2.write.call_count == 1 + + @pytest.mark.asyncio + async def test_suppressed_not_delivered(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera") + client = _mock_client() + router.clients = [client] + + msg = _msg("001", ["nick", "Welcome"]) + await router._dispatch("libera", msg) + client.write.assert_not_called() + + @pytest.mark.asyncio + async def test_namespaces_channel(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + router.clients = [client] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h") + await router._dispatch("libera", msg) + + written = client.write.call_args[0][0] + assert b"#test/libera" in written + + @pytest.mark.asyncio + async def test_namespaces_prefix(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + router.clients = [client] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="sender!i@h") + await router._dispatch("libera", msg) + + written = client.write.call_args[0][0] + assert b"sender/libera" in written + + @pytest.mark.asyncio + async def test_own_nick_rewritten_to_client_nick(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("clientnick") + router.clients = [client] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="bot!i@h") + await router._dispatch("libera", msg) + + written = client.write.call_args[0][0] + assert b"clientnick" in written + assert b"bot/libera" not in written + + @pytest.mark.asyncio + async def test_client_write_error_handled(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + bad_client = _mock_client() + bad_client.write.side_effect = ConnectionResetError + good_client = _mock_client() + router.clients = [bad_client, good_client] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h") + await router._dispatch("libera", msg) + + # Bad client raised, but good client still received + good_client.write.assert_called_once() + + @pytest.mark.asyncio + async def test_no_clients_no_error(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera") + router.clients = [] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h") + await router._dispatch("libera", msg) # should not raise + + +# -- _on_network_status ------------------------------------------------------ + +class TestOnNetworkStatus: + def test_broadcasts_to_all_clients(self) -> None: + router = Router(_config(), _backlog()) + c1 = _mock_client() + c2 = _mock_client() + router.clients = [c1, c2] + + router._on_network_status("libera", "connection stable") + + assert c1.write.call_count == 1 + assert c2.write.call_count == 1 + written = c1.write.call_args[0][0] + assert b"[libera] connection stable" in written + assert b"bouncer" in written # prefix + + def test_client_error_does_not_propagate(self) -> None: + router = Router(_config(), _backlog()) + bad = _mock_client() + bad.write.side_effect = ConnectionResetError + good = _mock_client() + router.clients = [bad, good] + + router._on_network_status("libera", "test") + good.write.assert_called_once() + + +# -- _on_network_message (sync -> async bridge) ----------------------------- + +class TestOnNetworkMessage: + @pytest.mark.asyncio + async def test_creates_dispatch_task(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera") + + with patch.object(router, "_dispatch", new_callable=AsyncMock) as mock_disp: + msg = _msg("PRIVMSG", ["#test", "hi"], prefix="u!i@h") + router._on_network_message("libera", msg) + # Let the task run + await asyncio.sleep(0) + + mock_disp.assert_awaited_once_with("libera", msg) + + +# -- _replay_backlog --------------------------------------------------------- + +class TestReplayBacklog: + @pytest.mark.asyncio + async def test_empty_backlog(self) -> None: + bl = _backlog() + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client() + + await router._replay_backlog(client, "libera") + client.write.assert_not_called() + + @pytest.mark.asyncio + async def test_replays_messages(self) -> None: + bl = _backlog() + entry1 = MagicMock() + entry1.id = 1 + entry1.command = "PRIVMSG" + entry1.target = "#test" + entry1.content = "hello" + entry1.sender = "user!i@h" + entry2 = MagicMock() + entry2.id = 2 + entry2.command = "PRIVMSG" + entry2.target = "#test" + entry2.content = "world" + entry2.sender = "other!i@h" + bl.replay.return_value = [entry1, entry2] + + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + + await router._replay_backlog(client, "libera") + + assert client.write.call_count == 2 + # Verify namespaced + first = client.write.call_args_list[0][0][0] + assert b"#test/libera" in first + # Should mark last seen + bl.mark_seen.assert_awaited_once_with("libera", 2) + + @pytest.mark.asyncio + async def test_replays_since_last_seen(self) -> None: + bl = _backlog() + bl.get_last_seen.return_value = 42 + + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client() + + await router._replay_backlog(client, "libera") + bl.replay.assert_awaited_once_with("libera", since_id=42) + + @pytest.mark.asyncio + async def test_suppressed_skipped_during_replay(self) -> None: + bl = _backlog() + entry = MagicMock() + entry.id = 1 + entry.command = "NOTICE" + entry.target = "nick" + entry.content = "\x01VERSION mIRC\x01" + entry.sender = "server.example.com" # no '!' -> server notice + bl.replay.return_value = [entry] + + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client() + + await router._replay_backlog(client, "libera") + client.write.assert_not_called() + # Still marks seen even if all suppressed + bl.mark_seen.assert_awaited_once_with("libera", 1) + + @pytest.mark.asyncio + async def test_client_error_stops_replay(self) -> None: + bl = _backlog() + entry1 = MagicMock() + entry1.id = 1 + entry1.command = "PRIVMSG" + entry1.target = "#test" + entry1.content = "msg1" + entry1.sender = "user!i@h" + entry2 = MagicMock() + entry2.id = 2 + entry2.command = "PRIVMSG" + entry2.target = "#test" + entry2.content = "msg2" + entry2.sender = "user!i@h" + bl.replay.return_value = [entry1, entry2] + + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client() + client.write.side_effect = [ConnectionResetError, None] + + await router._replay_backlog(client, "libera") + # Should have stopped after first error + assert client.write.call_count == 1 + + +# -- BACKLOG_COMMANDS constant ----------------------------------------------- + +class TestBacklogCommands: + def test_expected_commands(self) -> None: + assert "PRIVMSG" in BACKLOG_COMMANDS + assert "NOTICE" in BACKLOG_COMMANDS + assert "TOPIC" in BACKLOG_COMMANDS + assert "KICK" in BACKLOG_COMMANDS + assert "MODE" in BACKLOG_COMMANDS + + def test_join_not_in_backlog(self) -> None: + assert "JOIN" not in BACKLOG_COMMANDS + assert "PART" not in BACKLOG_COMMANDS + assert "QUIT" not in BACKLOG_COMMANDS + + +# -- server-time tag injection ----------------------------------------------- + +class TestServerTimeDispatch: + @pytest.mark.asyncio + async def test_injects_time_tag_when_missing(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + router.clients = [client] + + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h") + await router._dispatch("libera", msg) + + assert "time" in msg.tags + # Verify ISO8601 format + assert msg.tags["time"].endswith("Z") + assert "T" in msg.tags["time"] + + @pytest.mark.asyncio + async def test_preserves_existing_time_tag(self) -> None: + router = Router(_config(), _backlog()) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + router.clients = [client] + + original_time = "2025-01-15T12:00:00.000000Z" + msg = _msg("PRIVMSG", ["#test", "hello"], prefix="user!i@h", + tags={"time": original_time}) + await router._dispatch("libera", msg) + + assert msg.tags["time"] == original_time + + +class TestServerTimeReplay: + @pytest.mark.asyncio + async def test_replay_injects_timestamp(self) -> None: + bl = _backlog() + entry = MagicMock() + entry.id = 1 + entry.command = "PRIVMSG" + entry.target = "#test" + entry.content = "hello" + entry.sender = "user!i@h" + entry.timestamp = 1705320000.0 # 2024-01-15T12:00:00Z + bl.replay.return_value = [entry] + + router = Router(_config(), bl) + router.networks["libera"] = _mock_network("libera", nick="bot") + client = _mock_client("me") + + await router._replay_backlog(client, "libera") + + written = client.write.call_args[0][0] + # The time tag should be in the wire format + assert b"time=" in written + + +# -- push notifications ------------------------------------------------------ + +class TestNotifications: + @pytest.mark.asyncio + async def test_notification_triggered_on_pm_no_clients(self) -> None: + cfg = _config() + cfg.bouncer.notify_url = "https://ntfy.sh/test" + router = Router(cfg, _backlog()) + net = _mock_network("libera", nick="bot") + router.networks["libera"] = net + router.clients = [] # no clients + + with patch.object(router._notifier, "should_notify", return_value=True): + with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send: + msg = _msg("PRIVMSG", ["bot", "hello bot"], prefix="user!i@h") + await router._dispatch("libera", msg) + # Let fire-and-forget task run + await asyncio.sleep(0) + + mock_send.assert_awaited_once_with("libera", "user", "bot", "hello bot") + + @pytest.mark.asyncio + async def test_notification_not_triggered_with_clients(self) -> None: + cfg = _config() + cfg.bouncer.notify_url = "https://ntfy.sh/test" + router = Router(cfg, _backlog()) + net = _mock_network("libera", nick="bot") + router.networks["libera"] = net + client = _mock_client() + router.clients = [client] + + with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send: + msg = _msg("PRIVMSG", ["bot", "hello bot"], prefix="user!i@h") + await router._dispatch("libera", msg) + await asyncio.sleep(0) + + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_notification_respects_should_notify(self) -> None: + cfg = _config() + cfg.bouncer.notify_url = "https://ntfy.sh/test" + router = Router(cfg, _backlog()) + net = _mock_network("libera", nick="bot") + router.networks["libera"] = net + router.clients = [] + + with patch.object(router._notifier, "should_notify", return_value=False): + with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send: + msg = _msg("PRIVMSG", ["#channel", "random msg"], prefix="user!i@h") + await router._dispatch("libera", msg) + await asyncio.sleep(0) + + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_notification_disabled_skips(self) -> None: + cfg = _config() + cfg.bouncer.notify_url = "" # disabled + router = Router(cfg, _backlog()) + net = _mock_network("libera", nick="bot") + router.networks["libera"] = net + router.clients = [] + + with patch.object(router._notifier, "send", new_callable=AsyncMock) as mock_send: + msg = _msg("PRIVMSG", ["bot", "hello"], prefix="user!i@h") + await router._dispatch("libera", msg) + await asyncio.sleep(0) + + mock_send.assert_not_awaited()