From 0d762ced49ff2a36030a77d1c3b27a06829cc402 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 17:41:38 +0100 Subject: [PATCH] feat: PING watchdog, IRCv3 server-time, push notifications PING watchdog sends PING after configurable silence interval and disconnects on timeout, detecting stale connections that TCP alone misses. IRCv3 server-time capability is requested on every connection; timestamps are injected on dispatch and backlog replay for clients that support message tags. Push notifications via ntfy or generic webhook fire on highlights and PMs when no clients are attached, with configurable cooldown and optional SOCKS5 routing. Co-Authored-By: Claude Opus 4.6 --- config/bouncer.example.toml | 11 + docs/CHEATSHEET.md | 34 +- docs/USAGE.md | 88 ++++ src/bouncer/config.py | 18 + src/bouncer/network.py | 111 ++++- src/bouncer/notify.py | 134 ++++++ src/bouncer/router.py | 26 + tests/test_network.py | 221 ++++++++- tests/test_notify.py | 185 +++++++ tests/test_router.py | 932 ++++++++++++++++++++++++++++++++++++ 10 files changed, 1733 insertions(+), 27 deletions(-) create mode 100644 src/bouncer/notify.py create mode 100644 tests/test_notify.py create mode 100644 tests/test_router.py 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()