feat: add 16 extended bouncer control commands
Network control (CONNECT, DISCONNECT, RECONNECT, NICK, RAW), visibility (CHANNELS, CLIENTS, BACKLOG, VERSION), config management (REHASH, ADDNETWORK, DELNETWORK, AUTOJOIN), and NickServ operations (IDENTIFY, REGISTER, DROPCREDS). Total command count: 22. Adds stats()/db_size() to Backlog, add_network()/remove_network() to Router, and _connected_at timestamp to Client. 74 command tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
TASKS.md
1
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
|
||||
|
||||
|
||||
@@ -43,14 +43,48 @@ PASS <password> # 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
|
||||
|
||||
@@ -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 <network>` | Start a disconnected network |
|
||||
| `DISCONNECT <network>` | Stop a network |
|
||||
| `RECONNECT <network>` | Stop and restart with a fresh identity |
|
||||
| `NICK <network> <nick>` | Change nick on a network |
|
||||
| `RAW <network> <command>` | Send a raw IRC command to a network |
|
||||
|
||||
### Config Management
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `REHASH` | Reload config file, add/remove/reconnect networks |
|
||||
| `ADDNETWORK <name> key=val ...` | Create a network at runtime |
|
||||
| `DELNETWORK <name>` | Stop and remove a network |
|
||||
| `AUTOJOIN <network> +/-#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 <network>` | Force NickServ IDENTIFY with stored credentials |
|
||||
| `REGISTER <network>` | Trigger NickServ registration attempt |
|
||||
| `DROPCREDS <network> [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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 <network>)",
|
||||
"DISCONNECT": "Stop a network (DISCONNECT <network>)",
|
||||
"RECONNECT": "Restart a network (RECONNECT <network>)",
|
||||
"NICK": "Change nick on a network (NICK <network> <nick>)",
|
||||
"RAW": "Send raw IRC command (RAW <network> <command>)",
|
||||
"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 <name> key=val ...)",
|
||||
"DELNETWORK": "Remove a network (DELNETWORK <name>)",
|
||||
"AUTOJOIN": "Modify autojoin list (AUTOJOIN <network> +/-channel)",
|
||||
"IDENTIFY": "Force NickServ IDENTIFY (IDENTIFY <network>)",
|
||||
"REGISTER": "Trigger NickServ registration (REGISTER <network>)",
|
||||
"DROPCREDS": "Delete stored NickServ creds (DROPCREDS <network> [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 <network> <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 <network> <irc command>"]
|
||||
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 <name> host=<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=<hostname>"]
|
||||
|
||||
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>"]
|
||||
|
||||
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 <network> +#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 <network> [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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user