feat: add bouncer control commands via /msg *bouncer

Users can now inspect bouncer state and manage it from their IRC client
by sending PRIVMSG to *bouncer (or bouncer). Supported commands:
HELP, STATUS, INFO, UPTIME, NETWORKS, CREDS. Responses arrive as
NOTICE messages. All commands are case-insensitive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 00:10:39 +01:00
parent 532ceb3c3d
commit 6478c514ad
8 changed files with 666 additions and 2 deletions

View File

@@ -13,6 +13,8 @@
- [x] P1: Documentation update - [x] P1: Documentation update
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes) - [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
- [x] P1: Bouncer control commands (`/msg *bouncer STATUS/INFO/UPTIME/NETWORKS/CREDS/HELP`)
## Next ## Next
- [ ] P2: Client-side TLS support - [ ] P2: Client-side TLS support

View File

@@ -41,6 +41,18 @@ make clean # rm .venv, build artifacts
PASS <password> # authenticate (all networks) PASS <password> # authenticate (all networks)
``` ```
## Bouncer Commands
```
/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
```
## Namespacing ## Namespacing
``` ```
@@ -124,6 +136,7 @@ src/bouncer/
proxy.py # SOCKS5 connector (local DNS, multi-IP) proxy.py # SOCKS5 connector (local DNS, multi-IP)
network.py # server connection + state machine network.py # server connection + state machine
client.py # client session handler client.py # client session handler
commands.py # bouncer control commands (/msg *bouncer)
router.py # message routing + backlog trigger router.py # message routing + backlog trigger
server.py # TCP listener server.py # TCP listener
backlog.py # SQLite store/replay/prune backlog.py # SQLite store/replay/prune

View File

@@ -196,6 +196,42 @@ autojoin = true # auto-join channels on ready (default: true)
password = "" # IRC server password (optional, for PASS command) password = "" # IRC server password (optional, for PASS command)
``` ```
## Bouncer Commands
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
```
| Command | Description |
|---------|-------------|
| `HELP` | List available commands |
| `STATUS` | Overview: state, nick, host per network |
| `INFO <network>` | Detailed info for one network (state, server, channels, creds) |
| `UPTIME` | Bouncer uptime since process start |
| `NETWORKS` | List all configured networks with state |
| `CREDS [network]` | NickServ credential status (all or per-network) |
Responses arrive as NOTICE messages from `*bouncer`.
### Example Output
```
[STATUS]
libera ready fabesune user/fabesune
oftc ready ceraty cloaked.user
hackint connecting (attempt 3)
quakenet ready spetyo --
```
## Stopping ## Stopping
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing

View File

@@ -6,8 +6,10 @@ import asyncio
import logging import logging
import signal import signal
import sys import sys
import time
from pathlib import Path from pathlib import Path
from bouncer import commands
from bouncer.backlog import Backlog from bouncer.backlog import Backlog
from bouncer.cli import parse_args from bouncer.cli import parse_args
from bouncer.config import load from bouncer.config import load
@@ -21,7 +23,12 @@ def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO level = logging.DEBUG if verbose else logging.INFO
fmt = "\033[2m%(asctime)s\033[0m %(levelname)-5s \033[38;5;110m%(name)s\033[0m %(message)s" fmt = "\033[2m%(asctime)s\033[0m %(levelname)-5s \033[38;5;110m%(name)s\033[0m %(message)s"
datefmt = "%H:%M:%S" datefmt = "%H:%M:%S"
logging.basicConfig(level=level, format=fmt, datefmt=datefmt) logging.basicConfig(level=level, format=fmt, datefmt=datefmt, force=True)
# Also log to file for container environments
fh = logging.FileHandler("/data/bouncer.log")
fh.setLevel(level)
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(name)s %(message)s", datefmt))
logging.getLogger().addHandler(fh)
async def _run(config_path: Path, verbose: bool) -> None: async def _run(config_path: Path, verbose: bool) -> None:
@@ -37,6 +44,8 @@ async def _run(config_path: Path, verbose: bool) -> None:
backlog = Backlog(db_path) backlog = Backlog(db_path)
await backlog.open() await backlog.open()
commands.STARTUP_TIME = time.time()
router = Router(cfg, backlog) router = Router(cfg, backlog)
await router.start_networks() await router.start_networks()
@@ -71,7 +80,19 @@ def main() -> None:
sys.exit(1) sys.exit(1)
try: try:
asyncio.run(_run(args.config, args.verbose)) if args.cprofile:
import cProfile
prof = cProfile.Profile()
prof.enable()
try:
asyncio.run(_run(args.config, args.verbose))
finally:
prof.disable()
prof.dump_stats(str(args.cprofile))
print(f"cProfile stats written to {args.cprofile}", file=sys.stderr)
else:
asyncio.run(_run(args.config, args.verbose))
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass

View File

@@ -29,8 +29,24 @@ CREATE TABLE IF NOT EXISTS client_state (
last_seen_id INTEGER NOT NULL DEFAULT 0, last_seen_id INTEGER NOT NULL DEFAULT 0,
last_disconnect REAL last_disconnect REAL
); );
CREATE TABLE IF NOT EXISTS nickserv_creds (
network TEXT NOT NULL,
nick TEXT NOT NULL,
password TEXT NOT NULL,
email TEXT NOT NULL,
registered_at REAL NOT NULL,
host TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'verified',
PRIMARY KEY (network, nick)
);
""" """
# Migration: add status column if missing (existing DBs)
_MIGRATIONS = [
"ALTER TABLE nickserv_creds ADD COLUMN status TEXT NOT NULL DEFAULT 'verified'",
]
@dataclass(slots=True) @dataclass(slots=True)
class BacklogEntry: class BacklogEntry:
@@ -57,8 +73,19 @@ class Backlog:
self._db = await aiosqlite.connect(self._path) self._db = await aiosqlite.connect(self._path)
await self._db.executescript(SCHEMA) await self._db.executescript(SCHEMA)
await self._db.commit() await self._db.commit()
await self._run_migrations()
log.debug("backlog database opened: %s", self._path) log.debug("backlog database opened: %s", self._path)
async def _run_migrations(self) -> None:
"""Apply schema migrations, skipping any that have already been applied."""
assert self._db is not None
for sql in _MIGRATIONS:
try:
await self._db.execute(sql)
await self._db.commit()
except Exception:
pass # column/index already exists
async def close(self) -> None: async def close(self) -> None:
"""Close the database connection.""" """Close the database connection."""
if self._db: if self._db:
@@ -141,6 +168,134 @@ class Backlog:
await self._db.commit() await self._db.commit()
return cursor.rowcount # type: ignore[return-value] return cursor.rowcount # type: ignore[return-value]
async def save_nickserv_creds(
self,
network: str,
nick: str,
password: str,
email: str,
host: str,
status: str = "verified",
) -> None:
"""Save NickServ credentials.
Status is 'pending' during registration (email not yet verified)
or 'verified' after successful verification / IDENTIFY.
"""
assert self._db is not None
await self._db.execute(
"INSERT INTO nickserv_creds (network, nick, password, email, registered_at, host, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(network, nick) DO UPDATE SET "
"password = excluded.password, email = excluded.email, "
"registered_at = excluded.registered_at, host = excluded.host, "
"status = excluded.status",
(network, nick, password, email, time.time(), host, status),
)
await self._db.commit()
log.info("saved NickServ creds: %s/%s (host=%s, status=%s)", network, nick, host, status)
async def get_nickserv_creds(
self, network: str, nick: str
) -> tuple[str, str] | None:
"""Get stored NickServ password and email for a nick. Returns (password, email) or None."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT password, email FROM nickserv_creds WHERE network = ? AND nick = ?",
(network, nick),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_nickserv_creds_by_network(
self, network: str,
) -> tuple[str, str] | None:
"""Get most recent verified NickServ nick and password for a network.
Only returns verified credentials (safe for SASL).
Returns (nick, password) or None.
"""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password FROM nickserv_creds "
"WHERE network = ? AND status = 'verified' "
"ORDER BY registered_at DESC LIMIT 1",
(network,),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_nickserv_creds_by_host(
self, network: str, host: str
) -> tuple[str, str] | None:
"""Get stored verified NickServ nick and password by host. Returns (nick, password) or None."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password FROM nickserv_creds "
"WHERE network = ? AND host = ? AND status = 'verified' "
"ORDER BY registered_at DESC LIMIT 1",
(network, host),
)
row = await cursor.fetchone()
return (row[0], row[1]) if row else None
async def get_pending_registration(
self, network: str,
) -> tuple[str, str, str, str] | None:
"""Get a pending (unverified) registration for a network.
Returns (nick, password, email, host) or None.
"""
assert self._db is not None
cursor = await self._db.execute(
"SELECT nick, password, email, host FROM nickserv_creds "
"WHERE network = ? AND status = 'pending' "
"ORDER BY registered_at DESC LIMIT 1",
(network,),
)
row = await cursor.fetchone()
return (row[0], row[1], row[2], row[3]) if row else None
async def mark_nickserv_verified(self, network: str, nick: str) -> None:
"""Promote a pending registration to verified."""
assert self._db is not None
await self._db.execute(
"UPDATE nickserv_creds SET status = 'verified' WHERE network = ? AND nick = ?",
(network, nick),
)
await self._db.commit()
log.info("marked verified: %s/%s", network, nick)
async def list_nickserv_creds(
self, network: str | None = None,
) -> list[tuple[str, str, str, str, float, str]]:
"""List NickServ credentials, optionally filtered by network.
Returns list of (network, nick, email, host, registered_at, status).
"""
assert self._db is not None
if network:
cursor = await self._db.execute(
"SELECT network, nick, email, host, registered_at, status "
"FROM nickserv_creds WHERE network = ? ORDER BY registered_at DESC",
(network,),
)
else:
cursor = await self._db.execute(
"SELECT network, nick, email, host, registered_at, status "
"FROM nickserv_creds ORDER BY network, registered_at DESC",
)
return await cursor.fetchall()
async def delete_nickserv_creds(self, network: str, nick: str) -> None:
"""Remove stored credentials for a nick."""
assert self._db is not None
await self._db.execute(
"DELETE FROM nickserv_creds WHERE network = ? AND nick = ?",
(network, nick),
)
await self._db.commit()
async def _max_id(self, network: str) -> int: async def _max_id(self, network: str) -> int:
"""Get the maximum message ID for a network.""" """Get the maximum message ID for a network."""
assert self._db is not None assert self._db is not None

View File

@@ -6,6 +6,7 @@ import asyncio
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bouncer import commands
from bouncer.irc import IRCMessage, parse from bouncer.irc import IRCMessage, parse
from bouncer.namespace import encode_channel from bouncer.namespace import encode_channel
@@ -109,6 +110,13 @@ class Client:
if msg.command == "QUIT": if msg.command == "QUIT":
return return
# Intercept bouncer control commands
if msg.command == "PRIVMSG" and len(msg.params) >= 2:
target = msg.params[0].lower()
if target in ("*bouncer", "bouncer"):
await self._handle_bouncer_command(msg.params[1])
return
if msg.command in FORWARD_COMMANDS: if msg.command in FORWARD_COMMANDS:
await self._router.route_client_message(msg) await self._router.route_client_message(msg)
@@ -220,6 +228,16 @@ class Client:
params=[nick, ns_channel, "End of /NAMES list"], params=[nick, ns_channel, "End of /NAMES list"],
)) ))
async def _handle_bouncer_command(self, text: str) -> None:
"""Dispatch a bouncer control command and send responses."""
lines = await commands.dispatch(text, self._router, self)
for line in lines:
self._send_msg(IRCMessage(
command="NOTICE",
params=[self._nick, line],
prefix="*bouncer!bouncer@bouncer",
))
def _send_msg(self, msg: IRCMessage) -> None: def _send_msg(self, msg: IRCMessage) -> None:
"""Send an IRCMessage to this client.""" """Send an IRCMessage to this client."""
self.write(msg.format()) self.write(msg.format())

192
src/bouncer/commands.py Normal file
View File

@@ -0,0 +1,192 @@
"""Bouncer control commands via /msg *bouncer."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from bouncer.network import State
if TYPE_CHECKING:
from bouncer.client import Client
from bouncer.router import Router
# Set by __main__.py before entering the event loop
STARTUP_TIME: float = 0.0
_COMMANDS: dict[str, str] = {
"HELP": "List available commands",
"STATUS": "Overview of all networks",
"INFO": "Detailed info for a network (INFO <network>)",
"UPTIME": "Bouncer uptime since start",
"NETWORKS": "List configured networks",
"CREDS": "NickServ credential status (CREDS [network])",
}
async def dispatch(text: str, router: Router, client: Client) -> list[str]:
"""Parse and execute a bouncer command. Returns response lines."""
parts = text.strip().split(None, 1)
if not parts:
return _cmd_help()
cmd = parts[0].upper()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd == "HELP":
return _cmd_help()
if cmd == "STATUS":
return _cmd_status(router)
if cmd == "INFO":
return await _cmd_info(router, arg)
if cmd == "UPTIME":
return _cmd_uptime()
if cmd == "NETWORKS":
return _cmd_networks(router)
if cmd == "CREDS":
return await _cmd_creds(router, arg or None)
return [f"Unknown command: {cmd}", "Use HELP for available commands."]
def _cmd_help() -> list[str]:
"""List available commands."""
lines = ["[HELP]"]
# Align command names
width = max(len(c) for c in _COMMANDS)
for cmd, desc in _COMMANDS.items():
lines.append(f" {cmd:<{width}} {desc}")
return lines
def _state_label(state: State) -> str:
"""Human-readable state label."""
return {
State.DISCONNECTED: "disconnected",
State.CONNECTING: "connecting",
State.REGISTERING: "registering",
State.PROBATION: "probation",
State.READY: "ready",
}.get(state, str(state))
def _cmd_status(router: Router) -> list[str]:
"""Overview: state, nick, host per network."""
lines = ["[STATUS]"]
if not router.networks:
lines.append(" (no networks configured)")
return lines
# Calculate column widths
name_w = max(len(n) for n in router.networks)
state_w = max(len(_state_label(net.state)) for net in router.networks.values())
for name in sorted(router.networks):
net = router.networks[name]
label = _state_label(net.state)
if net.state == State.READY:
host = net.visible_host or "--"
lines.append(f" {name:<{name_w}} {label:<{state_w}} {net.nick:<14} {host}")
elif net.state == State.CONNECTING:
attempt = net._reconnect_attempt
lines.append(f" {name:<{name_w}} {label} (attempt {attempt})")
else:
lines.append(f" {name:<{name_w}} {label}")
return lines
async def _cmd_info(router: Router, network_name: str) -> list[str]:
"""Detailed info for a single network."""
if not network_name:
return ["Usage: INFO <network>"]
net = router.get_network(network_name.lower())
if not net:
names = ", ".join(sorted(router.networks))
return [f"Unknown network: {network_name}", f"Available: {names}"]
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'})")
lines.append(f" Nick {net.nick}")
lines.append(f" Host {net.visible_host or '--'}")
if net.channels:
lines.append(f" Channels {', '.join(sorted(net.channels))}")
else:
lines.append(" Channels (none)")
if router.backlog:
rows = await router.backlog.list_nickserv_creds(net.cfg.name)
if rows:
_net, nick, email, _host, _ts, status = rows[0]
lines.append(f" NickServ {nick} ({status})")
else:
lines.append(" NickServ (no credentials)")
return lines
def _cmd_uptime() -> list[str]:
"""Bouncer uptime since process start."""
if not STARTUP_TIME:
return ["[UPTIME] unknown"]
elapsed = time.time() - STARTUP_TIME
days, rem = divmod(int(elapsed), 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 [f"[UPTIME] {' '.join(parts)}"]
def _cmd_networks(router: Router) -> list[str]:
"""List all configured networks."""
lines = ["[NETWORKS]"]
if not router.networks:
lines.append(" (none)")
return lines
for name in sorted(router.networks):
net = router.networks[name]
label = _state_label(net.state)
lines.append(f" {name} {label}")
return lines
async def _cmd_creds(router: Router, network_name: str | None) -> list[str]:
"""Show stored NickServ credentials and their status."""
if not router.backlog:
return ["[CREDS] 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.list_nickserv_creds(net_filter)
if not rows:
scope = net_filter or "any network"
return [f"[CREDS] no stored credentials for {scope}"]
lines = ["[CREDS]"]
for net, nick, email, host, registered_at, status in rows:
indicator = "+" if status == "verified" else "~"
email_display = email if email else "--"
lines.append(f" {indicator} {net} {nick} {status} {email_display}")
lines.append("")
lines.append(" + verified ~ pending")
return lines

227
tests/test_commands.py Normal file
View File

@@ -0,0 +1,227 @@
"""Tests for bouncer control commands."""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from bouncer import commands
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:
"""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.state = state
net.nick = nick
net.visible_host = host
net.channels = channels or set()
net._reconnect_attempt = 0
return net
def _make_router(*networks: MagicMock) -> MagicMock:
"""Create a mock Router with the given networks."""
router = MagicMock()
router.networks = {n.cfg.name: n for n in networks}
router.network_names.return_value = [n.cfg.name for n in networks]
router.get_network = lambda name: router.networks.get(name)
router.backlog = AsyncMock()
return router
def _make_client(nick: str = "testuser") -> MagicMock:
"""Create a mock Client."""
client = MagicMock()
client.nick = nick
return client
class TestHelp:
@pytest.mark.asyncio
async def test_help_lists_commands(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("HELP", router, client)
assert lines[0] == "[HELP]"
assert any("STATUS" in line for line in lines)
assert any("UPTIME" in line for line in lines)
@pytest.mark.asyncio
async def test_empty_input_shows_help(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("", router, client)
assert lines[0] == "[HELP]"
class TestStatus:
@pytest.mark.asyncio
async def test_status_no_networks(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert lines[0] == "[STATUS]"
assert "(no networks configured)" in lines[1]
@pytest.mark.asyncio
async def test_status_ready_network(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune", host="user/fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert lines[0] == "[STATUS]"
assert "ready" in lines[1]
assert "fabesune" in lines[1]
assert "user/fabesune" in lines[1]
@pytest.mark.asyncio
async def test_status_connecting_shows_attempt(self) -> None:
net = _make_network("hackint", State.CONNECTING)
net._reconnect_attempt = 3
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("STATUS", router, client)
assert "connecting" in lines[1]
assert "attempt 3" in lines[1]
@pytest.mark.asyncio
async def test_status_case_insensitive(self) -> None:
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("status", router, client)
assert lines[0] == "[STATUS]"
class TestInfo:
@pytest.mark.asyncio
async def test_info_missing_arg(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("INFO", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_info_unknown_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("INFO fakenet", router, client)
assert "Unknown network" in lines[0]
assert "libera" in lines[1]
@pytest.mark.asyncio
async def test_info_valid_network(self) -> None:
net = _make_network("libera", State.READY, nick="fabesune",
host="user/fabesune", channels={"#test", "#dev"})
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "fabesune", "test@mail.tm", "user/fabesune", 1700000000.0, "verified"),
]
client = _make_client()
lines = await commands.dispatch("INFO libera", router, client)
assert lines[0] == "[INFO] libera"
assert any("ready" in line for line in lines)
assert any("fabesune" in line for line in lines)
assert any("#dev" in line or "#test" in line for line in lines)
assert any("verified" in line for line in lines)
class TestUptime:
@pytest.mark.asyncio
async def test_uptime(self) -> None:
commands.STARTUP_TIME = time.time() - 3661 # 1h 1m 1s
router = _make_router()
client = _make_client()
lines = await commands.dispatch("UPTIME", router, client)
assert lines[0].startswith("[UPTIME]")
assert "1h" in lines[0]
assert "1m" in lines[0]
assert "1s" in lines[0]
@pytest.mark.asyncio
async def test_uptime_unknown(self) -> None:
commands.STARTUP_TIME = 0.0
router = _make_router()
client = _make_client()
lines = await commands.dispatch("UPTIME", router, client)
assert "unknown" in lines[0]
class TestNetworks:
@pytest.mark.asyncio
async def test_networks_empty(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("NETWORKS", router, client)
assert lines[0] == "[NETWORKS]"
assert "(none)" in lines[1]
@pytest.mark.asyncio
async def test_networks_lists_all(self) -> None:
libera = _make_network("libera", State.READY)
oftc = _make_network("oftc", State.CONNECTING)
router = _make_router(libera, oftc)
client = _make_client()
lines = await commands.dispatch("NETWORKS", router, client)
assert lines[0] == "[NETWORKS]"
assert any("libera" in line and "ready" in line for line in lines[1:])
assert any("oftc" in line and "connecting" in line for line in lines[1:])
class TestCreds:
@pytest.mark.asyncio
async def test_creds_no_backlog(self) -> None:
router = _make_router()
router.backlog = None
client = _make_client()
lines = await commands.dispatch("CREDS", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_creds_empty(self) -> None:
router = _make_router()
router.backlog.list_nickserv_creds.return_value = []
client = _make_client()
lines = await commands.dispatch("CREDS", router, client)
assert "no stored credentials" in lines[0]
@pytest.mark.asyncio
async def test_creds_lists_entries(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
router.backlog.list_nickserv_creds.return_value = [
("libera", "fabesune", "test@mail.tm", "user/fabesune", 1700000000.0, "verified"),
("libera", "oldnick", "old@mail.tm", "old/host", 1699000000.0, "pending"),
]
client = _make_client()
lines = await commands.dispatch("CREDS libera", router, client)
assert lines[0] == "[CREDS]"
assert any("+" in line and "fabesune" in line and "verified" in line for line in lines)
assert any("~" in line and "oldnick" in line and "pending" in line for line in lines)
@pytest.mark.asyncio
async def test_creds_unknown_network(self) -> None:
net = _make_network("libera", State.READY)
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("CREDS fakenet", router, client)
assert "Unknown network" in lines[0]
class TestUnknownCommand:
@pytest.mark.asyncio
async def test_unknown_command(self) -> None:
router = _make_router()
client = _make_client()
lines = await commands.dispatch("FOOBAR", router, client)
assert "Unknown command" in lines[0]
assert "HELP" in lines[1]