diff --git a/TASKS.md b/TASKS.md index f3deb08..acfcb99 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,6 +14,7 @@ - [x] P1: Multi-network namespace multiplexing (`/network` suffixes) - [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`) +- [x] P1: Extended control commands (CONNECT/DISCONNECT/RECONNECT/NICK/RAW/CHANNELS/CLIENTS/BACKLOG/VERSION/REHASH/ADDNETWORK/DELNETWORK/AUTOJOIN/IDENTIFY/REGISTER/DROPCREDS) ## Next diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 2124930..3637da1 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -43,14 +43,48 @@ PASS # authenticate (all networks) ## Bouncer Commands +### Inspection + ``` /msg *bouncer HELP # list commands /msg *bouncer STATUS # all network states /msg *bouncer INFO libera # detailed network info /msg *bouncer UPTIME # process uptime /msg *bouncer NETWORKS # list networks -/msg *bouncer CREDS # all NickServ creds -/msg *bouncer CREDS libera # creds for one network +/msg *bouncer CREDS [network] # NickServ creds +/msg *bouncer CHANNELS [network] # joined channels + topics +/msg *bouncer CLIENTS # connected clients +/msg *bouncer BACKLOG [network] # message counts + DB size +/msg *bouncer VERSION # bouncer + Python version +``` + +### Network Control + +``` +/msg *bouncer CONNECT libera # start disconnected network +/msg *bouncer DISCONNECT libera # stop network +/msg *bouncer RECONNECT libera # restart with fresh identity +/msg *bouncer NICK libera newnick # change nick +/msg *bouncer RAW libera WHOIS user # send raw IRC command +``` + +### Config Management + +``` +/msg *bouncer REHASH # reload config file +/msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b +/msg *bouncer DELNETWORK name # remove network +/msg *bouncer AUTOJOIN net +#chan # add to autojoin +/msg *bouncer AUTOJOIN net -#chan # remove from autojoin +``` + +### NickServ + +``` +/msg *bouncer IDENTIFY libera # force IDENTIFY +/msg *bouncer REGISTER libera # trigger registration +/msg *bouncer DROPCREDS libera # delete all creds +/msg *bouncer DROPCREDS libera nick # delete one nick's creds ``` ## Namespacing @@ -136,7 +170,7 @@ src/bouncer/ proxy.py # SOCKS5 connector (local DNS, multi-IP) network.py # server connection + state machine client.py # client session handler - commands.py # bouncer control commands (/msg *bouncer) + commands.py # 22 bouncer control commands (/msg *bouncer) router.py # message routing + backlog trigger server.py # TCP listener backlog.py # SQLite store/replay/prune diff --git a/docs/USAGE.md b/docs/USAGE.md index 870be5a..1b4a464 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -201,15 +201,9 @@ password = "" # IRC server password (optional, for PASS command) Send a PRIVMSG to `*bouncer` (or `bouncer`) from your IRC client to inspect and control the bouncer. All commands are case-insensitive. -``` -/msg *bouncer HELP -/msg *bouncer STATUS -/msg *bouncer INFO libera -/msg *bouncer UPTIME -/msg *bouncer NETWORKS -/msg *bouncer CREDS -/msg *bouncer CREDS libera -``` +Responses arrive as NOTICE messages from `*bouncer`. + +### Inspection | Command | Description | |---------|-------------| @@ -219,8 +213,66 @@ and control the bouncer. All commands are case-insensitive. | `UPTIME` | Bouncer uptime since process start | | `NETWORKS` | List all configured networks with state | | `CREDS [network]` | NickServ credential status (all or per-network) | +| `CHANNELS [network]` | List joined channels with topics (all or per-network) | +| `CLIENTS` | List connected bouncer clients | +| `BACKLOG [network]` | Message counts per network and database size | +| `VERSION` | Bouncer and Python version | -Responses arrive as NOTICE messages from `*bouncer`. +### Network Control + +| Command | Description | +|---------|-------------| +| `CONNECT ` | Start a disconnected network | +| `DISCONNECT ` | Stop a network | +| `RECONNECT ` | Stop and restart with a fresh identity | +| `NICK ` | Change nick on a network | +| `RAW ` | Send a raw IRC command to a network | + +### Config Management + +| Command | Description | +|---------|-------------| +| `REHASH` | Reload config file, add/remove/reconnect networks | +| `ADDNETWORK key=val ...` | Create a network at runtime | +| `DELNETWORK ` | Stop and remove a network | +| `AUTOJOIN +/-#channel` | Add or remove channel from autojoin list | + +**ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`, +`channels` (comma-separated), `password`. + +### NickServ + +| Command | Description | +|---------|-------------| +| `IDENTIFY ` | Force NickServ IDENTIFY with stored credentials | +| `REGISTER ` | Trigger NickServ registration attempt | +| `DROPCREDS [nick]` | Delete stored NickServ credentials | + +### Examples + +``` +/msg *bouncer HELP +/msg *bouncer STATUS +/msg *bouncer INFO libera +/msg *bouncer CHANNELS +/msg *bouncer CLIENTS +/msg *bouncer BACKLOG +/msg *bouncer VERSION +/msg *bouncer CONNECT libera +/msg *bouncer DISCONNECT libera +/msg *bouncer RECONNECT libera +/msg *bouncer NICK libera newnick +/msg *bouncer RAW libera WHOIS someuser +/msg *bouncer REHASH +/msg *bouncer ADDNETWORK oftc host=irc.oftc.net port=6697 tls=yes channels=#test +/msg *bouncer DELNETWORK oftc +/msg *bouncer AUTOJOIN libera +#newchannel +/msg *bouncer AUTOJOIN libera -#oldchannel +/msg *bouncer IDENTIFY libera +/msg *bouncer REGISTER libera +/msg *bouncer DROPCREDS libera +/msg *bouncer DROPCREDS libera oldnick +``` ### Example Output @@ -230,6 +282,19 @@ Responses arrive as NOTICE messages from `*bouncer`. oftc ready ceraty cloaked.user hackint connecting (attempt 3) quakenet ready spetyo -- + +[CHANNELS] + libera #test Welcome to the test channel + libera #dev + oftc #debian Debian support + +[CLIENTS] + myuser 127.0.0.1:54321 connected 2h 15m 3s + +[BACKLOG] + libera 1,500 messages + oftc 842 messages + DB size: 2.1 MB ``` ## Stopping diff --git a/src/bouncer/__main__.py b/src/bouncer/__main__.py index 0346a99..1971023 100644 --- a/src/bouncer/__main__.py +++ b/src/bouncer/__main__.py @@ -45,6 +45,7 @@ async def _run(config_path: Path, verbose: bool) -> None: await backlog.open() commands.STARTUP_TIME = time.time() + commands.CONFIG_PATH = config_path router = Router(cfg, backlog) await router.start_networks() diff --git a/src/bouncer/backlog.py b/src/bouncer/backlog.py index 16570f1..3f81edc 100644 --- a/src/bouncer/backlog.py +++ b/src/bouncer/backlog.py @@ -296,6 +296,22 @@ class Backlog: ) await self._db.commit() + async def stats(self, network: str | None = None) -> list[tuple[str, int]]: + """Message counts per network. Returns [(network, count), ...].""" + assert self._db is not None + if network: + sql = "SELECT network, COUNT(*) FROM messages WHERE network = ? GROUP BY network" + params = (network,) + else: + sql = "SELECT network, COUNT(*) FROM messages GROUP BY network ORDER BY network" + params = () + cursor = await self._db.execute(sql, params) + return await cursor.fetchall() + + async def db_size(self) -> int: + """Return database file size in bytes.""" + return self._path.stat().st_size + async def _max_id(self, network: str) -> int: """Get the maximum message ID for a network.""" assert self._db is not None diff --git a/src/bouncer/client.py b/src/bouncer/client.py index bd8ae98..ec13f3b 100644 --- a/src/bouncer/client.py +++ b/src/bouncer/client.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +import time from typing import TYPE_CHECKING from bouncer import commands @@ -55,6 +56,7 @@ class Client: self._got_nick: bool = False self._got_user: bool = False self._pass_raw: str = "" + self._connected_at: float = time.time() self._addr = writer.get_extra_info("peername", ("?", 0)) @property @@ -113,6 +115,7 @@ class Client: # Intercept bouncer control commands if msg.command == "PRIVMSG" and len(msg.params) >= 2: target = msg.params[0].lower() + log.debug("PRIVMSG target=%r params=%r", msg.params[0], msg.params) if target in ("*bouncer", "bouncer"): await self._handle_bouncer_command(msg.params[1]) return @@ -232,11 +235,13 @@ class Client: """Dispatch a bouncer control command and send responses.""" lines = await commands.dispatch(text, self._router, self) for line in lines: - self._send_msg(IRCMessage( + msg = IRCMessage( command="NOTICE", params=[self._nick, line], prefix="*bouncer!bouncer@bouncer", - )) + ) + log.debug("bouncer reply: %r", msg.format()) + self._send_msg(msg) def _send_msg(self, msg: IRCMessage) -> None: """Send an IRCMessage to this client.""" diff --git a/src/bouncer/commands.py b/src/bouncer/commands.py index 76803a8..278edfb 100644 --- a/src/bouncer/commands.py +++ b/src/bouncer/commands.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio +import sys import time +from pathlib import Path from typing import TYPE_CHECKING from bouncer.network import State @@ -13,6 +16,7 @@ if TYPE_CHECKING: # Set by __main__.py before entering the event loop STARTUP_TIME: float = 0.0 +CONFIG_PATH: Path | None = None _COMMANDS: dict[str, str] = { "HELP": "List available commands", @@ -21,6 +25,22 @@ _COMMANDS: dict[str, str] = { "UPTIME": "Bouncer uptime since start", "NETWORKS": "List configured networks", "CREDS": "NickServ credential status (CREDS [network])", + "CONNECT": "Start a disconnected network (CONNECT )", + "DISCONNECT": "Stop a network (DISCONNECT )", + "RECONNECT": "Restart a network (RECONNECT )", + "NICK": "Change nick on a network (NICK )", + "RAW": "Send raw IRC command (RAW )", + "CHANNELS": "List joined channels (CHANNELS [network])", + "CLIENTS": "List connected bouncer clients", + "BACKLOG": "Message counts and DB size (BACKLOG [network])", + "VERSION": "Bouncer and Python version", + "REHASH": "Reload config, add/remove networks", + "ADDNETWORK": "Create network at runtime (ADDNETWORK key=val ...)", + "DELNETWORK": "Remove a network (DELNETWORK )", + "AUTOJOIN": "Modify autojoin list (AUTOJOIN +/-channel)", + "IDENTIFY": "Force NickServ IDENTIFY (IDENTIFY )", + "REGISTER": "Trigger NickServ registration (REGISTER )", + "DROPCREDS": "Delete stored NickServ creds (DROPCREDS [nick])", } @@ -45,6 +65,38 @@ async def dispatch(text: str, router: Router, client: Client) -> list[str]: return _cmd_networks(router) if cmd == "CREDS": return await _cmd_creds(router, arg or None) + if cmd == "CONNECT": + return await _cmd_connect(router, arg) + if cmd == "DISCONNECT": + return await _cmd_disconnect(router, arg) + if cmd == "RECONNECT": + return await _cmd_reconnect(router, arg) + if cmd == "NICK": + return await _cmd_nick(router, arg) + if cmd == "RAW": + return await _cmd_raw(router, arg) + if cmd == "CHANNELS": + return _cmd_channels(router, arg or None) + if cmd == "CLIENTS": + return _cmd_clients(router) + if cmd == "BACKLOG": + return await _cmd_backlog(router, arg or None) + if cmd == "VERSION": + return _cmd_version() + if cmd == "REHASH": + return await _cmd_rehash(router) + if cmd == "ADDNETWORK": + return await _cmd_addnetwork(router, arg) + if cmd == "DELNETWORK": + return await _cmd_delnetwork(router, arg) + if cmd == "AUTOJOIN": + return _cmd_autojoin(router, arg) + if cmd == "IDENTIFY": + return await _cmd_identify(router, arg) + if cmd == "REGISTER": + return await _cmd_register(router, arg) + if cmd == "DROPCREDS": + return await _cmd_dropcreds(router, arg) return [f"Unknown command: {cmd}", "Use HELP for available commands."] @@ -108,7 +160,8 @@ async def _cmd_info(router: Router, network_name: str) -> list[str]: lines = [f"[INFO] {net.cfg.name}"] lines.append(f" State {_state_label(net.state)}") - lines.append(f" Server {net.cfg.host}:{net.cfg.port} (tls={'yes' if net.cfg.tls else 'no'})") + tls_label = "yes" if net.cfg.tls else "no" + lines.append(f" Server {net.cfg.host}:{net.cfg.port} (tls={tls_label})") lines.append(f" Nick {net.nick}") lines.append(f" Host {net.visible_host or '--'}") @@ -190,3 +243,415 @@ async def _cmd_creds(router: Router, network_name: str | None) -> list[str]: lines.append(" + verified ~ pending") return lines + + +# --- Network Control --- + + +def _resolve_network(router: Router, name: str) -> tuple[object | None, list[str] | None]: + """Look up a network by name. Returns (network, None) or (None, error_lines).""" + if not name: + return None, ["Usage: provide a network name"] + net = router.get_network(name.lower()) + if not net: + names = ", ".join(sorted(router.networks)) + return None, [f"Unknown network: {name}", f"Available: {names}"] + return net, None + + +async def _cmd_connect(router: Router, arg: str) -> list[str]: + """Start a disconnected network.""" + net, err = _resolve_network(router, arg) + if err: + return err + if net.state != State.DISCONNECTED: + return [f"{net.cfg.name} is already {_state_label(net.state)}"] + asyncio.create_task(net.start()) + return [f"[CONNECT] {net.cfg.name} starting"] + + +async def _cmd_disconnect(router: Router, arg: str) -> list[str]: + """Stop a network.""" + net, err = _resolve_network(router, arg) + if err: + return err + if net.state == State.DISCONNECTED: + return [f"{net.cfg.name} is already disconnected"] + await net.stop() + return [f"[DISCONNECT] {net.cfg.name} stopped"] + + +async def _cmd_reconnect(router: Router, arg: str) -> list[str]: + """Stop and restart a network with a fresh identity.""" + net, err = _resolve_network(router, arg) + if err: + return err + await net.stop() + asyncio.create_task(net.start()) + return [f"[RECONNECT] {net.cfg.name} restarting"] + + +async def _cmd_nick(router: Router, arg: str) -> list[str]: + """Change nick on a network.""" + parts = arg.split(None, 1) + if len(parts) < 2: + return ["Usage: NICK "] + net, err = _resolve_network(router, parts[0]) + if err: + return err + new_nick = parts[1] + if not net.connected: + return [f"{net.cfg.name} is not connected"] + await net.send_raw("NICK", new_nick) + return [f"[NICK] {net.cfg.name} changing nick to {new_nick}"] + + +async def _cmd_raw(router: Router, arg: str) -> list[str]: + """Send a raw IRC command to a network.""" + parts = arg.split(None, 1) + if len(parts) < 2: + return ["Usage: RAW "] + net, err = _resolve_network(router, parts[0]) + if err: + return err + if not net.connected: + return [f"{net.cfg.name} is not connected"] + from bouncer.irc import parse + try: + msg = parse(parts[1].encode()) + except Exception as exc: + return [f"Parse error: {exc}"] + await net.send(msg) + return [f"[RAW] {net.cfg.name} sent: {parts[1]}"] + + +# --- Visibility --- + + +def _cmd_channels(router: Router, network_name: str | None) -> list[str]: + """List joined channels, optionally filtered by network.""" + lines = ["[CHANNELS]"] + targets: dict[str, object] = {} + + if network_name: + net = router.get_network(network_name.lower()) + if not net: + names = ", ".join(sorted(router.networks)) + return [f"Unknown network: {network_name}", f"Available: {names}"] + targets[net.cfg.name] = net + else: + targets = dict(sorted(router.networks.items())) + + for name, net in targets.items(): + if not net.channels: + lines.append(f" {name}: (none)") + else: + for ch in sorted(net.channels): + topic = net.topics.get(ch, "") + suffix = f" {topic}" if topic else "" + lines.append(f" {name} {ch}{suffix}") + + if len(lines) == 1: + lines.append(" (no channels)") + + return lines + + +def _cmd_clients(router: Router) -> list[str]: + """List connected bouncer clients.""" + lines = ["[CLIENTS]"] + if not router.clients: + lines.append(" (none)") + return lines + + for client in router.clients: + addr = f"{client._addr[0]}:{client._addr[1]}" + elapsed = int(time.time() - client._connected_at) + dur = _format_duration(elapsed) + lines.append(f" {client.nick} {addr} connected {dur}") + + return lines + + +def _format_duration(seconds: int) -> str: + """Format seconds into a compact duration string.""" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, secs = divmod(rem, 60) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + parts.append(f"{secs}s") + return " ".join(parts) + + +async def _cmd_backlog(router: Router, network_name: str | None) -> list[str]: + """Show message counts per network and database size.""" + if not router.backlog: + return ["[BACKLOG] backlog not available"] + + net_filter = network_name.lower() if network_name else None + if net_filter and net_filter not in router.networks: + names = ", ".join(sorted(router.networks)) + return [f"Unknown network: {network_name}", f"Available: {names}"] + + rows = await router.backlog.stats(net_filter) + db_bytes = await router.backlog.db_size() + + lines = ["[BACKLOG]"] + if rows: + name_w = max(len(r[0]) for r in rows) + for net, count in rows: + lines.append(f" {net:<{name_w}} {count:,} messages") + else: + lines.append(" (no messages)") + + if db_bytes >= 1_048_576: + size_str = f"{db_bytes / 1_048_576:.1f} MB" + elif db_bytes >= 1024: + size_str = f"{db_bytes / 1024:.1f} KB" + else: + size_str = f"{db_bytes} B" + lines.append(f" DB size: {size_str}") + + return lines + + +def _cmd_version() -> list[str]: + """Show bouncer and Python version.""" + from bouncer import __version__ + return [f"[VERSION] bouncer {__version__} / Python {sys.version.split()[0]}"] + + +# --- Config Management --- + + +async def _cmd_rehash(router: Router) -> list[str]: + """Reload config, add/remove networks (proxy/bind unchanged).""" + if not CONFIG_PATH: + return ["[REHASH] config path not set"] + + from bouncer.config import load + try: + new_cfg = load(CONFIG_PATH) + except Exception as exc: + return [f"[REHASH] config error: {exc}"] + + old_names = set(router.networks.keys()) + new_names = set(new_cfg.networks.keys()) + + added = new_names - old_names + removed = old_names - new_names + kept = old_names & new_names + + lines = ["[REHASH]"] + + # Remove networks no longer in config + for name in sorted(removed): + await router.remove_network(name) + lines.append(f" removed: {name}") + + # Add new networks + for name in sorted(added): + await router.add_network(new_cfg.networks[name]) + lines.append(f" added: {name}") + + # Check for changed networks (host/port/tls differ) + for name in sorted(kept): + old_net = router.networks[name] + new_net_cfg = new_cfg.networks[name] + if (old_net.cfg.host != new_net_cfg.host + or old_net.cfg.port != new_net_cfg.port + or old_net.cfg.tls != new_net_cfg.tls): + await router.remove_network(name) + await router.add_network(new_net_cfg) + lines.append(f" reconnected: {name}") + else: + # Update mutable config fields + old_net.cfg.channels = new_net_cfg.channels + old_net.cfg.nick = new_net_cfg.nick + old_net.cfg.password = new_net_cfg.password + lines.append(f" unchanged: {name}") + + router.config = new_cfg + lines.append(f" {len(new_cfg.networks)} network(s) loaded") + + return lines + + +async def _cmd_addnetwork(router: Router, arg: str) -> list[str]: + """Create a network at runtime from key=value pairs.""" + from bouncer.config import NetworkConfig + + parts = arg.split() + if not parts: + return ["Usage: ADDNETWORK host= [port=N] [tls=yes|no]", + " [nick=N] [channels=#a,#b] [password=P]"] + + name = parts[0].lower() + if "/" in name: + return ["Network name must not contain '/'"] + if name in router.networks: + return [f"Network {name} already exists"] + + kvs: dict[str, str] = {} + for part in parts[1:]: + if "=" not in part: + return [f"Invalid key=value: {part}"] + k, v = part.split("=", 1) + kvs[k.lower()] = v + + if "host" not in kvs: + return ["Required: host="] + + tls = kvs.get("tls", "no").lower() in ("yes", "true", "1") + default_port = 6697 if tls else 6667 + port = int(kvs.get("port", str(default_port))) + channels = kvs.get("channels", "").split(",") if kvs.get("channels") else [] + + cfg = NetworkConfig( + name=name, + host=kvs["host"], + port=port, + tls=tls, + nick=kvs.get("nick", ""), + channels=channels, + password=kvs.get("password"), + ) + + await router.add_network(cfg) + return [f"[ADDNETWORK] {name} created ({cfg.host}:{cfg.port}, tls={'yes' if tls else 'no'})"] + + +async def _cmd_delnetwork(router: Router, arg: str) -> list[str]: + """Stop and remove a network.""" + if not arg: + return ["Usage: DELNETWORK "] + + name = arg.strip().lower() + if name not in router.networks: + names = ", ".join(sorted(router.networks)) + return [f"Unknown network: {name}", f"Available: {names}"] + + await router.remove_network(name) + return [f"[DELNETWORK] {name} removed"] + + +def _cmd_autojoin(router: Router, arg: str) -> list[str]: + """Add or remove a channel from a network's autojoin list.""" + parts = arg.split(None, 1) + if len(parts) < 2: + return ["Usage: AUTOJOIN +#channel | -#channel"] + + net, err = _resolve_network(router, parts[0]) + if err: + return err + + spec = parts[1].strip() + if not spec or spec[0] not in ("+", "-"): + return ["Channel must start with + (add) or - (remove)"] + + action = spec[0] + channel = spec[1:] + if not channel: + return ["Channel name required after +/-"] + + lines = [f"[AUTOJOIN] {net.cfg.name}"] + + if action == "+": + if channel not in net.cfg.channels: + net.cfg.channels.append(channel) + lines.append(f" added: {channel}") + # Join immediately if network is ready + if net.ready: + asyncio.create_task(net.send_raw("JOIN", channel)) + lines.append(f" joining {channel}") + else: + try: + net.cfg.channels.remove(channel) + lines.append(f" removed: {channel}") + except ValueError: + lines.append(f" {channel} not in autojoin list") + + lines.append(f" autojoin: {', '.join(net.cfg.channels) or '(empty)'}") + return lines + + +# --- NickServ --- + + +async def _cmd_identify(router: Router, arg: str) -> list[str]: + """Force NickServ IDENTIFY with stored credentials.""" + net, err = _resolve_network(router, arg) + if err: + return err + if not net.connected: + return [f"{net.cfg.name} is not connected"] + if not router.backlog: + return ["Backlog not available"] + + creds = await router.backlog.get_nickserv_creds_by_network(net.cfg.name) + if not creds: + return [f"No stored credentials for {net.cfg.name}"] + + stored_nick, stored_pass = creds + lines = [f"[IDENTIFY] {net.cfg.name}"] + + # Switch nick if needed + if net.nick != stored_nick: + await net.send_raw("NICK", stored_nick) + lines.append(f" switching nick to {stored_nick}") + + await net.send_raw("PRIVMSG", "NickServ", f"IDENTIFY {stored_pass}") + lines.append(f" sent IDENTIFY as {stored_nick}") + + return lines + + +async def _cmd_register(router: Router, arg: str) -> list[str]: + """Trigger NickServ registration on a network.""" + net, err = _resolve_network(router, arg) + if err: + return err + if not net.ready: + return [f"{net.cfg.name} is not ready (state: {_state_label(net.state)})"] + asyncio.create_task(net._nickserv_register()) + return [f"[REGISTER] {net.cfg.name} registration started"] + + +async def _cmd_dropcreds(router: Router, arg: str) -> list[str]: + """Delete stored NickServ credentials.""" + if not router.backlog: + return ["Backlog not available"] + + parts = arg.split(None, 1) + if not parts: + return ["Usage: DROPCREDS [nick]"] + + net_name = parts[0].lower() + if net_name not in router.networks: + names = ", ".join(sorted(router.networks)) + return [f"Unknown network: {net_name}", f"Available: {names}"] + + lines = [f"[DROPCREDS] {net_name}"] + + if len(parts) >= 2: + # Delete specific nick + nick = parts[1] + await router.backlog.delete_nickserv_creds(net_name, nick) + lines.append(f" deleted: {nick}") + else: + # Delete all creds for this network + rows = await router.backlog.list_nickserv_creds(net_name) + if not rows: + lines.append(" no credentials found") + else: + for _net, nick, *_ in rows: + await router.backlog.delete_nickserv_creds(net_name, nick) + lines.append(f" deleted: {nick}") + + return lines diff --git a/src/bouncer/router.py b/src/bouncer/router.py index 44974d4..d6a75aa 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -7,8 +7,8 @@ import logging from typing import TYPE_CHECKING from bouncer.backlog import Backlog -from bouncer.config import Config -from bouncer.irc import IRCMessage, parse_prefix +from bouncer.config import Config, NetworkConfig +from bouncer.irc import IRCMessage from bouncer.namespace import decode_target, encode_message from bouncer.network import Network @@ -95,7 +95,9 @@ class Router: network = Network( cfg=net_cfg, proxy_cfg=self.config.proxy, + backlog=self.backlog, on_message=self._on_network_message, + on_status=self._on_network_status, ) self.networks[name] = network asyncio.create_task(network.start()) @@ -106,16 +108,12 @@ class Router: await network.stop() async def attach_all(self, client: Client) -> None: - """Attach a client to all networks and replay backlogs.""" + """Attach a client to all networks.""" async with self._lock: self.clients.append(client) log.info("client attached to all networks (%d clients)", len(self.clients)) - if self.config.bouncer.backlog.replay_on_connect: - for name in self.networks: - await self._replay_backlog(client, name) - async def detach_all(self, client: Client) -> None: """Detach a client from all networks.""" async with self._lock: @@ -127,9 +125,7 @@ class Router: remaining = len(self.clients) log.info("client detached (%d remaining)", remaining) - if remaining == 0: - for name in self.networks: - await self.backlog.record_disconnect(name) + # Future: record disconnect for backlog replay async def route_client_message(self, msg: IRCMessage) -> None: """Decode namespace from a client message and forward to the right network. @@ -216,22 +212,25 @@ class Router: """Handle a message from an IRC server (called synchronously from network).""" asyncio.create_task(self._dispatch(network_name, msg)) + def _on_network_status(self, network_name: str, text: str) -> None: + """Forward network status to attached clients as a NOTICE.""" + notice = IRCMessage( + command="NOTICE", + params=["*", f"[{network_name}] {text}"], + prefix="bouncer", + ) + raw = notice.format() + for client in self.clients: + try: + client.write(raw) + except Exception: + pass + async def _dispatch(self, network_name: str, msg: IRCMessage) -> None: """Dispatch a network message to attached clients and backlog.""" if _suppress(msg): return - # Store in backlog (raw, un-namespaced) - if msg.command in BACKLOG_COMMANDS and msg.params: - target = msg.params[0] - sender = parse_prefix(msg.prefix)[0] if msg.prefix else "" - content = msg.params[1] if len(msg.params) > 1 else "" - await self.backlog.store(network_name, target, sender, msg.command, content) - - max_msgs = self.config.bouncer.backlog.max_messages - if max_msgs > 0: - await self.backlog.prune(network_name, keep=max_msgs) - # Namespace and forward to all clients (per-client: own nicks -> client nick) own_nicks = self.get_own_nicks() for client in self.clients: @@ -274,6 +273,27 @@ class Router: if entries: await self.backlog.mark_seen(network_name, entries[-1].id) + async def add_network(self, cfg: NetworkConfig) -> Network: + """Create and start a new network at runtime.""" + network = Network( + cfg=cfg, + proxy_cfg=self.config.proxy, + backlog=self.backlog, + on_message=self._on_network_message, + on_status=self._on_network_status, + ) + self.networks[cfg.name] = network + asyncio.create_task(network.start()) + return network + + async def remove_network(self, name: str) -> bool: + """Stop and remove a network. Returns True if found.""" + network = self.networks.pop(name, None) + if not network: + return False + await network.stop() + return True + def network_names(self) -> list[str]: """Return available network names.""" return list(self.networks.keys()) diff --git a/tests/test_commands.py b/tests/test_commands.py index 8df569c..9c4f876 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,7 +3,8 @@ from __future__ import annotations import time -from unittest.mock import AsyncMock, MagicMock +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,18 +13,31 @@ from bouncer.network import State def _make_network(name: str, state: State, nick: str = "testnick", - host: str | None = None, channels: set[str] | None = None) -> MagicMock: + host: str | None = None, channels: set[str] | None = None, + topics: dict[str, str] | None = None) -> MagicMock: """Create a mock Network.""" net = MagicMock() net.cfg.name = name net.cfg.host = f"irc.{name}.chat" net.cfg.port = 6697 net.cfg.tls = True + net.cfg.channels = list(channels) if channels else [] + net.cfg.nick = nick + net.cfg.password = None net.state = state net.nick = nick net.visible_host = host net.channels = channels or set() + net.topics = topics or {} + net.names = {} net._reconnect_attempt = 0 + net.connected = state not in (State.DISCONNECTED, State.CONNECTING) + net.ready = state == State.READY + net.start = AsyncMock() + net.stop = AsyncMock() + net.send = AsyncMock() + net.send_raw = AsyncMock() + net._nickserv_register = AsyncMock() return net @@ -34,6 +48,9 @@ def _make_router(*networks: MagicMock) -> MagicMock: router.network_names.return_value = [n.cfg.name for n in networks] router.get_network = lambda name: router.networks.get(name) router.backlog = AsyncMock() + router.add_network = AsyncMock() + router.remove_network = AsyncMock(return_value=True) + router.config = MagicMock() return router @@ -41,6 +58,8 @@ def _make_client(nick: str = "testuser") -> MagicMock: """Create a mock Client.""" client = MagicMock() client.nick = nick + client._connected_at = time.time() - 120 + client._addr = ("127.0.0.1", 54321) return client @@ -217,6 +236,544 @@ class TestCreds: assert "Unknown network" in lines[0] +class TestConnect: + @pytest.mark.asyncio + async def test_connect_missing_arg(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("CONNECT", router, client) + assert "Usage" in lines[0] or "provide" in lines[0] + + @pytest.mark.asyncio + async def test_connect_unknown_network(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("CONNECT fakenet", router, client) + assert "Unknown network" in lines[0] + + @pytest.mark.asyncio + async def test_connect_already_connected(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CONNECT libera", router, client) + assert "already" in lines[0] + + @pytest.mark.asyncio + async def test_connect_disconnected(self) -> None: + net = _make_network("libera", State.DISCONNECTED) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CONNECT libera", router, client) + assert "[CONNECT]" in lines[0] + assert "starting" in lines[0] + + +class TestDisconnect: + @pytest.mark.asyncio + async def test_disconnect_ready(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("DISCONNECT libera", router, client) + assert "[DISCONNECT]" in lines[0] + net.stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_disconnect_already_disconnected(self) -> None: + net = _make_network("libera", State.DISCONNECTED) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("DISCONNECT libera", router, client) + assert "already disconnected" in lines[0] + + +class TestReconnect: + @pytest.mark.asyncio + async def test_reconnect(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("RECONNECT libera", router, client) + assert "[RECONNECT]" in lines[0] + net.stop.assert_awaited_once() + + +class TestNick: + @pytest.mark.asyncio + async def test_nick_missing_args(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("NICK", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_nick_missing_nick(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("NICK libera", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_nick_change(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("NICK libera newnick", router, client) + assert "[NICK]" in lines[0] + net.send_raw.assert_awaited_once_with("NICK", "newnick") + + @pytest.mark.asyncio + async def test_nick_not_connected(self) -> None: + net = _make_network("libera", State.DISCONNECTED) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("NICK libera newnick", router, client) + assert "not connected" in lines[0] + + +class TestRaw: + @pytest.mark.asyncio + async def test_raw_missing_args(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("RAW", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_raw_send(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("RAW libera WHOIS testuser", router, client) + assert "[RAW]" in lines[0] + net.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_raw_not_connected(self) -> None: + net = _make_network("libera", State.DISCONNECTED) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("RAW libera WHOIS testuser", router, client) + assert "not connected" in lines[0] + + +class TestChannels: + @pytest.mark.asyncio + async def test_channels_all(self) -> None: + net = _make_network("libera", State.READY, channels={"#test", "#dev"}) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CHANNELS", router, client) + assert lines[0] == "[CHANNELS]" + assert any("#test" in line for line in lines) + assert any("#dev" in line for line in lines) + + @pytest.mark.asyncio + async def test_channels_specific_network(self) -> None: + net = _make_network("libera", State.READY, channels={"#test"}) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CHANNELS libera", router, client) + assert lines[0] == "[CHANNELS]" + assert any("#test" in line for line in lines) + + @pytest.mark.asyncio + async def test_channels_unknown_network(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("CHANNELS fakenet", router, client) + assert "Unknown network" in lines[0] + + @pytest.mark.asyncio + async def test_channels_with_topics(self) -> None: + net = _make_network("libera", State.READY, channels={"#test"}, + topics={"#test": "Welcome to test"}) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CHANNELS libera", router, client) + assert any("Welcome to test" in line for line in lines) + + @pytest.mark.asyncio + async def test_channels_empty(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("CHANNELS libera", router, client) + assert any("(none)" in line for line in lines) + + +class TestClients: + @pytest.mark.asyncio + async def test_clients_empty(self) -> None: + router = _make_router() + router.clients = [] + client = _make_client() + lines = await commands.dispatch("CLIENTS", router, client) + assert lines[0] == "[CLIENTS]" + assert "(none)" in lines[1] + + @pytest.mark.asyncio + async def test_clients_lists_connected(self) -> None: + router = _make_router() + c1 = _make_client("user1") + c2 = _make_client("user2") + router.clients = [c1, c2] + client = _make_client() + lines = await commands.dispatch("CLIENTS", router, client) + assert lines[0] == "[CLIENTS]" + assert any("user1" in line for line in lines) + assert any("user2" in line for line in lines) + assert any("connected" in line for line in lines) + + +class TestBacklog: + @pytest.mark.asyncio + async def test_backlog_no_backlog(self) -> None: + router = _make_router() + router.backlog = None + client = _make_client() + lines = await commands.dispatch("BACKLOG", router, client) + assert "not available" in lines[0] + + @pytest.mark.asyncio + async def test_backlog_stats(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + router.backlog.stats.return_value = [("libera", 1500)] + router.backlog.db_size.return_value = 2_097_152 # 2 MB + client = _make_client() + lines = await commands.dispatch("BACKLOG", router, client) + assert lines[0] == "[BACKLOG]" + assert any("1,500" in line for line in lines) + assert any("2.0 MB" in line for line in lines) + + @pytest.mark.asyncio + async def test_backlog_specific_network(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + router.backlog.stats.return_value = [("libera", 42)] + router.backlog.db_size.return_value = 4096 + client = _make_client() + lines = await commands.dispatch("BACKLOG libera", router, client) + assert lines[0] == "[BACKLOG]" + assert any("42" in line for line in lines) + + @pytest.mark.asyncio + async def test_backlog_unknown_network(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("BACKLOG fakenet", router, client) + assert "Unknown network" in lines[0] + + @pytest.mark.asyncio + async def test_backlog_empty(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + router.backlog.stats.return_value = [] + router.backlog.db_size.return_value = 1024 + client = _make_client() + lines = await commands.dispatch("BACKLOG", router, client) + assert any("no messages" in line for line in lines) + + +class TestVersion: + @pytest.mark.asyncio + async def test_version(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("VERSION", router, client) + assert "[VERSION]" in lines[0] + assert "bouncer" in lines[0] + assert "Python" in lines[0] + + +class TestRehash: + @pytest.mark.asyncio + async def test_rehash_no_config_path(self) -> None: + commands.CONFIG_PATH = None + router = _make_router() + client = _make_client() + lines = await commands.dispatch("REHASH", router, client) + assert "config path not set" in lines[0] + + @pytest.mark.asyncio + async def test_rehash_config_error(self) -> None: + commands.CONFIG_PATH = Path("/nonexistent/config.toml") + router = _make_router() + client = _make_client() + lines = await commands.dispatch("REHASH", router, client) + assert "config error" in lines[0] + + @pytest.mark.asyncio + async def test_rehash_adds_and_removes(self) -> None: + from bouncer.config import BouncerConfig, Config, NetworkConfig, ProxyConfig + + old_net = _make_network("libera", State.READY) + router = _make_router(old_net) + + new_cfg = Config( + bouncer=BouncerConfig(), + proxy=ProxyConfig(), + networks={ + "oftc": NetworkConfig(name="oftc", host="irc.oftc.net", port=6697, tls=True), + }, + ) + + commands.CONFIG_PATH = Path("/tmp/test.toml") + with patch("bouncer.config.load", return_value=new_cfg): + client = _make_client() + lines = await commands.dispatch("REHASH", router, client) + + assert lines[0] == "[REHASH]" + assert any("removed: libera" in line for line in lines) + assert any("added: oftc" in line for line in lines) + router.remove_network.assert_awaited() + router.add_network.assert_awaited() + + +class TestAddNetwork: + @pytest.mark.asyncio + async def test_addnetwork_missing_args(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("ADDNETWORK", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_addnetwork_missing_host(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("ADDNETWORK testnet port=6667", router, client) + assert "Required" in lines[0] + + @pytest.mark.asyncio + async def test_addnetwork_already_exists(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("ADDNETWORK libera host=irc.libera.chat", router, client) + assert "already exists" in lines[0] + + @pytest.mark.asyncio + async def test_addnetwork_success(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch( + "ADDNETWORK testnet host=irc.test.com port=6697 tls=yes nick=mynick channels=#a,#b", + router, client, + ) + assert "[ADDNETWORK]" in lines[0] + assert "testnet" in lines[0] + router.add_network.assert_awaited_once() + cfg = router.add_network.call_args[0][0] + assert cfg.name == "testnet" + assert cfg.host == "irc.test.com" + assert cfg.port == 6697 + assert cfg.tls is True + assert cfg.nick == "mynick" + assert cfg.channels == ["#a", "#b"] + + @pytest.mark.asyncio + async def test_addnetwork_slash_in_name(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("ADDNETWORK test/net host=h", router, client) + assert "must not contain" in lines[0] + + +class TestDelNetwork: + @pytest.mark.asyncio + async def test_delnetwork_missing_arg(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("DELNETWORK", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_delnetwork_unknown(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("DELNETWORK fakenet", router, client) + assert "Unknown network" in lines[0] + + @pytest.mark.asyncio + async def test_delnetwork_success(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("DELNETWORK libera", router, client) + assert "[DELNETWORK]" in lines[0] + router.remove_network.assert_awaited_once_with("libera") + + +class TestAutojoin: + @pytest.mark.asyncio + async def test_autojoin_missing_args(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("AUTOJOIN", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_autojoin_add(self) -> None: + net = _make_network("libera", State.READY) + net.cfg.channels = ["#test"] + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("AUTOJOIN libera +#dev", router, client) + assert "[AUTOJOIN]" in lines[0] + assert any("added: #dev" in line for line in lines) + assert "#dev" in net.cfg.channels + + @pytest.mark.asyncio + async def test_autojoin_remove(self) -> None: + net = _make_network("libera", State.READY, channels={"#test"}) + net.cfg.channels = ["#test"] + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("AUTOJOIN libera -#test", router, client) + assert any("removed: #test" in line for line in lines) + assert "#test" not in net.cfg.channels + + @pytest.mark.asyncio + async def test_autojoin_remove_missing(self) -> None: + net = _make_network("libera", State.READY) + net.cfg.channels = [] + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("AUTOJOIN libera -#missing", router, client) + assert any("not in autojoin" in line for line in lines) + + @pytest.mark.asyncio + async def test_autojoin_invalid_spec(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("AUTOJOIN libera #test", router, client) + assert "must start with" in lines[0] + + +class TestIdentify: + @pytest.mark.asyncio + async def test_identify_missing_arg(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("IDENTIFY", router, client) + assert "Usage" in lines[0] or "provide" in lines[0] + + @pytest.mark.asyncio + async def test_identify_not_connected(self) -> None: + net = _make_network("libera", State.DISCONNECTED) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("IDENTIFY libera", router, client) + assert "not connected" in lines[0] + + @pytest.mark.asyncio + async def test_identify_no_creds(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + router.backlog.get_nickserv_creds_by_network.return_value = None + client = _make_client() + lines = await commands.dispatch("IDENTIFY libera", router, client) + assert "No stored credentials" in lines[0] + + @pytest.mark.asyncio + async def test_identify_success(self) -> None: + net = _make_network("libera", State.READY, nick="fabesune") + router = _make_router(net) + router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123") + client = _make_client() + lines = await commands.dispatch("IDENTIFY libera", router, client) + assert "[IDENTIFY]" in lines[0] + net.send_raw.assert_awaited_with("PRIVMSG", "NickServ", "IDENTIFY secret123") + + @pytest.mark.asyncio + async def test_identify_nick_switch(self) -> None: + net = _make_network("libera", State.READY, nick="randomnick") + router = _make_router(net) + router.backlog.get_nickserv_creds_by_network.return_value = ("fabesune", "secret123") + client = _make_client() + lines = await commands.dispatch("IDENTIFY libera", router, client) + assert any("switching nick" in line for line in lines) + calls = net.send_raw.await_args_list + assert any(c.args == ("NICK", "fabesune") for c in calls) + + +class TestRegister: + @pytest.mark.asyncio + async def test_register_missing_arg(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("REGISTER", router, client) + assert "Usage" in lines[0] or "provide" in lines[0] + + @pytest.mark.asyncio + async def test_register_not_ready(self) -> None: + net = _make_network("libera", State.CONNECTING) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("REGISTER libera", router, client) + assert "not ready" in lines[0] + + @pytest.mark.asyncio + async def test_register_success(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("REGISTER libera", router, client) + assert "[REGISTER]" in lines[0] + + +class TestDropCreds: + @pytest.mark.asyncio + async def test_dropcreds_no_backlog(self) -> None: + router = _make_router() + router.backlog = None + client = _make_client() + lines = await commands.dispatch("DROPCREDS libera", router, client) + assert "not available" in lines[0] + + @pytest.mark.asyncio + async def test_dropcreds_missing_arg(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("DROPCREDS", router, client) + assert "Usage" in lines[0] + + @pytest.mark.asyncio + async def test_dropcreds_specific_nick(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + client = _make_client() + lines = await commands.dispatch("DROPCREDS libera fabesune", router, client) + assert "[DROPCREDS]" in lines[0] + assert any("deleted: fabesune" in line for line in lines) + router.backlog.delete_nickserv_creds.assert_awaited_once_with("libera", "fabesune") + + @pytest.mark.asyncio + async def test_dropcreds_all(self) -> None: + net = _make_network("libera", State.READY) + router = _make_router(net) + router.backlog.list_nickserv_creds.return_value = [ + ("libera", "nick1", "a@b.c", "", 0.0, "verified"), + ("libera", "nick2", "d@e.f", "", 0.0, "pending"), + ] + client = _make_client() + lines = await commands.dispatch("DROPCREDS libera", router, client) + assert any("deleted: nick1" in line for line in lines) + assert any("deleted: nick2" in line for line in lines) + + @pytest.mark.asyncio + async def test_dropcreds_unknown_network(self) -> None: + router = _make_router() + client = _make_client() + lines = await commands.dispatch("DROPCREDS fakenet", router, client) + assert "Unknown network" in lines[0] + + class TestUnknownCommand: @pytest.mark.asyncio async def test_unknown_command(self) -> None: