feat: multi-network namespace multiplexing

Multiplex all networks onto a single client connection using /network
suffixes on channels and nicks. PASS is now just the password (no
network prefix). Channels appear as #channel/network, foreign nicks as
nick/network, own nicks stay bare.

New namespace.py module with pure encode/decode functions. Router
tracks clients globally (not per-network), namespaces messages before
delivery. Client attaches to all networks on connect, sends synthetic
JOIN/TOPIC/NAMES for every channel across all networks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-20 19:03:58 +01:00
parent ab7603f638
commit 8cc57a7af4
13 changed files with 512 additions and 131 deletions

View File

@@ -35,11 +35,12 @@ DISCONNECTED -> CONNECTING -> REGISTERING -> PROBATION (15s) -> READY
|--------|---------------|
| `irc.py` | IRC protocol parser/formatter (RFC 2812 subset) |
| `config.py` | TOML configuration loading and validation |
| `namespace.py` | `/network` suffix encode/decode for multi-network multiplexing |
| `proxy.py` | SOCKS5 async connector with local DNS + multi-IP failover |
| `network.py` | Server connection state machine, stealth registration |
| `server.py` | TCP listener accepting IRC client connections |
| `client.py` | Per-client session, PASS/NICK/USER handshake |
| `router.py` | Message routing between clients and networks |
| `client.py` | Per-client session, PASS/NICK/USER handshake, multi-network attach |
| `router.py` | Namespaced message routing between clients and networks |
| `backlog.py` | SQLite message storage and replay |
### Key Decisions

View File

@@ -4,12 +4,12 @@ IRC bouncer with SOCKS5 proxy support and persistent message backlog.
## Features
- Connect to multiple IRC networks simultaneously
- **Multi-network multiplexing**: single client connection sees all networks via `/network` suffixes
- All outbound connections routed through SOCKS5 proxy
- Stealth connect: registers with a random pronounceable nick and generic identity
- Probation window: waits 15s after registration to detect K-lines before revealing real nick
- Persistent message backlog (SQLite) with replay on reconnect
- Multiple clients can attach to the same network session
- Multiple clients can attach simultaneously
- Password authentication
- TLS support for IRC server connections
- Automatic reconnection with exponential backoff
@@ -32,10 +32,18 @@ bouncer -c config/bouncer.toml -v
From your IRC client, connect to `127.0.0.1:6667` with:
```
PASS networkname:yourpassword
PASS yourpassword
```
Where `networkname` matches a `[networks.NAME]` section in your config.
A single connection gives you all configured networks. Channels and nicks
appear with a `/network` suffix:
```
Client sees: Server sends/receives:
#libera/libera <-> #libera (on libera network)
#debian/oftc <-> #debian (on oftc network)
user123/libera <-> user123 (on libera network)
```
## How It Works

View File

@@ -15,6 +15,7 @@
- [x] Stealth connect (random markov-generated identity)
- [x] Probation window (K-line detection before revealing nick)
- [x] Verified end-to-end on Libera.Chat via SOCKS5
- [x] Multi-network namespace multiplexing (`/network` suffixes)
## v0.2.0

View File

@@ -11,6 +11,7 @@
- [x] P1: Integration testing with live IRC server (Libera.Chat)
- [x] P1: Verified SOCKS5 proxy connectivity end-to-end
- [x] P1: Documentation update
- [x] P1: Multi-network namespace multiplexing (`/network` suffixes)
## Next

View File

@@ -11,11 +11,16 @@ replay_on_connect = true
host = "127.0.0.1"
port = 1080
# Client PASS is just the password (no network prefix).
# A single client connection sees all networks via /network suffixes:
# #libera/libera, #debian/oftc, user123/libera
#
# Registration uses a random nick and generic ident/realname.
# After surviving the probation window (no k-line), the bouncer
# derives a stable nick from the exit endpoint hostname. The same
# exit IP always produces the same nick across reconnects.
# Set nick to override (optional, used as fallback only).
# Network names must not contain '/' (reserved for namespace separator).
[networks.libera]
host = "irc.libera.chat"

View File

@@ -38,8 +38,21 @@ make clean # rm .venv, build artifacts
## Client Auth
```
PASS <network>:<password> # select network + authenticate
PASS <password> # use first network
PASS <password> # authenticate (all networks)
```
## Namespacing
```
#channel/network # channel on a specific network
nick/network # foreign nick on a specific network
own-nick # own nicks shown without suffix
```
```
/msg #libera/libera hello # send to #libera on libera network
/join #test/oftc # join #test on oftc
/join #a/libera,#b/oftc # comma-separated, different networks
```
## Connection States
@@ -107,6 +120,7 @@ src/bouncer/
cli.py # argparse
config.py # TOML loader
irc.py # IRC message parse/format
namespace.py # /network encode/decode for multiplexing
proxy.py # SOCKS5 connector (local DNS, multi-IP)
network.py # server connection + state machine
client.py # client session handler

View File

@@ -82,59 +82,78 @@ Configure your IRC client to connect to the bouncer:
|---------|-------|
| Server | `127.0.0.1` |
| Port | `6667` (or as configured) |
| Password | `networkname:yourpassword` |
| Password | `yourpassword` |
### Password Format
```
PASS <network>:<password>
PASS <password>
```
- `network` -- matches a `[networks.NAME]` section in config
- `password` -- the `bouncer.password` value from config
If you omit the network prefix (`PASS yourpassword`), the first configured
network is used.
The password is the `bouncer.password` value from config. A single connection
automatically attaches to **all** configured networks.
### Client Examples
**irssi:**
```
/connect -password libera:mypassword 127.0.0.1 6667
/connect -password mypassword 127.0.0.1 6667
```
**weechat:**
```
/server add bouncer 127.0.0.1/6667 -password=libera:mypassword
/server add bouncer 127.0.0.1/6667 -password=mypassword
/connect bouncer
```
**hexchat:**
Set server password to `libera:mypassword` in the network settings.
Set server password to `mypassword` in the network settings.
## Multiple Networks
## Multi-Network Namespacing
Define multiple `[networks.*]` sections in the config. Each gets its own
persistent server connection through the SOCKS5 proxy.
Connect your client with the appropriate network prefix:
All configured networks are multiplexed onto a single client connection. Channels
and nicks carry a `/network` suffix so you can tell which network they belong to:
```
PASS libera:mypassword # connects to [networks.libera]
PASS oftc:mypassword # connects to [networks.oftc]
Client sees: Server wire:
#libera/libera <-> #libera (on libera network)
#debian/oftc <-> #debian (on oftc network)
user123/libera <-> user123 (on libera network)
```
Multiple clients can attach to the same network simultaneously. All receive
the same messages in real time.
### Rules
- **Channels**: `#channel/network` in client, `#channel` on wire
- **Foreign nicks**: `nick/network` in client, `nick` on wire
- **Own nicks**: shown without suffix (prevents client confusion)
- **Sending messages**: include the `/network` suffix in the target
```
/msg #libera/libera hello -> sends "hello" to #libera on libera network
/join #test/oftc -> joins #test on oftc network
/msg user123/libera hi -> private message to user123 on libera
```
### Comma-Separated JOIN/PART
Targets can span networks:
```
/join #a/libera,#b/oftc -> joins #a on libera AND #b on oftc
```
Multiple clients can attach simultaneously. All receive the same namespaced
messages in real time.
## What Clients Receive on Connect
When a client authenticates and attaches to a network:
When a client authenticates:
1. **Backlog replay** -- missed messages since last disconnect
2. **Synthetic welcome** -- 001-004 numeric replies from the bouncer
3. **Channel state** -- TOPIC and NAMES for each joined channel
1. **Backlog replay** -- missed messages (namespaced) from all networks
2. **Synthetic welcome** -- 001-004 numeric replies listing all networks
3. **Channel state** -- synthetic JOIN, TOPIC, and NAMES for every joined
channel across all networks (all namespaced with `/network` suffix)
## Backlog

View File

@@ -7,9 +7,9 @@ import logging
from typing import TYPE_CHECKING
from bouncer.irc import IRCMessage, parse
from bouncer.namespace import encode_channel
if TYPE_CHECKING:
from bouncer.network import Network
from bouncer.router import Router
log = logging.getLogger(__name__)
@@ -45,8 +45,6 @@ class Client:
self._writer = writer
self._router = router
self._password = password
self._network_name: str | None = None
self._network: Network | None = None
self._nick: str = "*"
self._user: str = "unknown"
self._realname: str = ""
@@ -106,8 +104,8 @@ class Client:
if msg.command == "QUIT":
return
if msg.command in FORWARD_COMMANDS and self._network_name:
await self._router.client_to_network(self._network_name, msg)
if msg.command in FORWARD_COMMANDS:
await self._router.route_client_message(msg)
async def _handle_registration(self, msg: IRCMessage) -> None:
"""Handle PASS/NICK/USER registration sequence."""
@@ -137,17 +135,11 @@ class Client:
await self._complete_registration()
async def _complete_registration(self) -> None:
"""Validate credentials and attach to network."""
# Parse PASS: "network:password" or just "password" (use first network)
network_name: str | None = None
password: str = ""
"""Validate credentials and attach to all networks."""
password = self._pass_raw if self._got_pass else ""
if self._got_pass and ":" in self._pass_raw:
network_name, password = self._pass_raw.split(":", 1)
elif self._got_pass:
password = self._pass_raw
else:
self._send_error("Password required (PASS network:password)")
if not password:
self._send_error("Password required (PASS <password>)")
self._writer.close()
return
@@ -156,46 +148,27 @@ class Client:
self._writer.close()
return
# Resolve network
if not network_name:
# Default to first configured network
names = self._router.network_names()
if names:
network_name = names[0]
if not network_name:
self._send_error("No network specified and none configured")
self._writer.close()
return
self._authenticated = True
self._registered = True
self._network_name = network_name
# Attach to network
self._network = await self._router.attach(self, network_name)
if not self._network:
self._send_error(f"Unknown network: {network_name}")
self._writer.close()
return
# Attach to all networks at once
await self._router.attach_all(self)
log.info(
"client %s authenticated for network %s (nick=%s)",
self._addr, network_name, self._nick,
)
log.info("client %s authenticated (nick=%s)", self._addr, self._nick)
# Send synthetic welcome
await self._send_welcome()
async def _send_welcome(self) -> None:
"""Send IRC welcome sequence and channel state to client."""
assert self._network is not None
"""Send IRC welcome sequence and channel state from all networks."""
server_name = "bouncer"
nick = self._network.nick
# Pick a representative nick for the welcome numerics
own_nicks = self._router.get_own_nicks()
nick = next(iter(own_nicks.values()), self._nick)
networks = ", ".join(self._router.network_names())
self._send_msg(IRCMessage(
command=RPL_WELCOME, prefix=server_name,
params=[nick, f"Welcome to bouncer ({self._network_name})"],
params=[nick, f"Welcome to bouncer (networks: {networks})"],
))
self._send_msg(IRCMessage(
command=RPL_YOURHOST, prefix=server_name,
@@ -210,28 +183,39 @@ class Client:
params=[nick, server_name, "bouncer-0.1", "o", "o"],
))
# Send channel state for joined channels
for channel in self._network.channels:
# Topic
topic = self._network.topics.get(channel, "")
if topic:
# Send namespaced channel state from every network
for net_name, network in sorted(self._router.networks.items()):
net_nick = network.nick
for channel in sorted(network.channels):
ns_channel = encode_channel(channel, net_name)
# Synthetic JOIN so the client knows we're in this channel
self._send_msg(IRCMessage(
command=RPL_TOPIC, prefix=server_name,
params=[nick, channel, topic],
command="JOIN",
params=[ns_channel],
prefix=net_nick,
))
# Names
names = self._network.names.get(channel, set())
if names:
name_str = " ".join(sorted(names))
self._send_msg(IRCMessage(
command=RPL_NAMREPLY, prefix=server_name,
params=[nick, "=", channel, name_str],
))
self._send_msg(IRCMessage(
command=RPL_ENDOFNAMES, prefix=server_name,
params=[nick, channel, "End of /NAMES list"],
))
# Topic
topic = network.topics.get(channel, "")
if topic:
self._send_msg(IRCMessage(
command=RPL_TOPIC, prefix=server_name,
params=[nick, ns_channel, topic],
))
# Names
names = network.names.get(channel, set())
if names:
name_str = " ".join(sorted(names))
self._send_msg(IRCMessage(
command=RPL_NAMREPLY, prefix=server_name,
params=[nick, "=", ns_channel, name_str],
))
self._send_msg(IRCMessage(
command=RPL_ENDOFNAMES, prefix=server_name,
params=[nick, ns_channel, "End of /NAMES list"],
))
def _send_msg(self, msg: IRCMessage) -> None:
"""Send an IRCMessage to this client."""
@@ -242,9 +226,8 @@ class Client:
self._send_msg(IRCMessage(command="ERROR", params=[text]))
async def _cleanup(self) -> None:
"""Detach from network and close connection."""
"""Detach from all networks and close connection."""
log.info("client disconnected from %s", self._addr)
if self._network_name:
await self._router.detach(self, self._network_name)
await self._router.detach_all(self)
if not self._writer.is_closing():
self._writer.close()

View File

@@ -105,4 +105,11 @@ def load(path: Path) -> Config:
if not networks:
raise ValueError("at least one network must be configured")
for name in networks:
if "/" in name:
raise ValueError(
f"network name {name!r} must not contain '/' "
"(reserved for namespace separator)"
)
return Config(bouncer=bouncer, proxy=proxy, networks=networks)

112
src/bouncer/namespace.py Normal file
View File

@@ -0,0 +1,112 @@
"""Network namespace encoding/decoding for multi-network multiplexing.
Channels and nicks are suffixed with /network so a single client connection
can see traffic from all networks at once.
Client sees: Wire:
#libera/libera <-> #libera (on libera network)
user123/libera <-> user123 (on libera network)
"""
from __future__ import annotations
from bouncer.irc import IRCMessage, parse_prefix
SEPARATOR = "/"
def encode_channel(channel: str, network: str) -> str:
"""Suffix a channel with its network name. ``#ch`` -> ``#ch/net``."""
return f"{channel}{SEPARATOR}{network}"
def decode_channel(namespaced: str) -> tuple[str, str | None]:
"""Split ``#ch/net`` -> ``('#ch', 'net')``. No separator returns ``(channel, None)``."""
idx = namespaced.rfind(SEPARATOR)
if idx < 0:
return namespaced, None
return namespaced[:idx], namespaced[idx + 1:]
def encode_nick(nick: str, network: str, own_nicks: dict[str, str]) -> str:
"""Suffix a nick unless it belongs to us on *any* network."""
if nick in own_nicks.values():
return nick
return f"{nick}{SEPARATOR}{network}"
def encode_prefix(prefix: str, network: str, own_nicks: dict[str, str]) -> str:
"""Namespace the nick portion of ``nick!user@host``."""
nick, user, host = parse_prefix(prefix)
enc = encode_nick(nick, network, own_nicks)
if user and host:
return f"{enc}!{user}@{host}"
if user:
return f"{enc}!{user}"
if host:
return f"{enc}@{host}"
return enc
def decode_target(target: str) -> tuple[str, str | None]:
"""Decode a namespaced target (channel or nick).
``#ch/net`` -> ``('#ch', 'net')``
``nick/net`` -> ``('nick', 'net')``
``#ch`` -> ``('#ch', None)``
"""
idx = target.rfind(SEPARATOR)
if idx < 0:
return target, None
return target[:idx], target[idx + 1:]
def _is_channel(target: str) -> bool:
"""Check if a target looks like a channel name."""
return target.startswith(("#", "&", "+", "!"))
def encode_message(msg: IRCMessage, network: str, own_nicks: dict[str, str]) -> IRCMessage:
"""Namespace an entire IRC message for delivery to a client.
- Channels in params get ``/network`` suffix
- Prefix nick gets ``/network`` suffix (unless it's our own)
- Special handling for 353 (NAMREPLY nick list) and NICK (new nick in trailing)
"""
prefix = msg.prefix
if prefix:
prefix = encode_prefix(prefix, network, own_nicks)
params = list(msg.params)
if msg.command == "353" and len(params) >= 4:
# RPL_NAMREPLY: params = [nick, "=", #channel, "nick1 @nick2 +nick3"]
params[2] = encode_channel(params[2], network)
nicks = params[3].split()
encoded = []
for n in nicks:
# Strip mode prefixes (@, +, %, ~, &)
mode_prefix = ""
bare = n
while bare and bare[0] in "@+%~&":
mode_prefix += bare[0]
bare = bare[1:]
encoded.append(mode_prefix + encode_nick(bare, network, own_nicks))
params[3] = " ".join(encoded)
elif msg.command == "NICK" and params:
# NICK: params[0] is the new nick
new_nick = params[0]
params[0] = encode_nick(new_nick, network, own_nicks)
else:
# Generic: namespace channel-like params[0]
if params and _is_channel(params[0]):
params[0] = encode_channel(params[0], network)
return IRCMessage(
command=msg.command,
params=params,
prefix=prefix,
tags=msg.tags,
)

View File

@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from bouncer.backlog import Backlog
from bouncer.config import Config
from bouncer.irc import IRCMessage, parse_prefix
from bouncer.namespace import decode_target, encode_message
from bouncer.network import Network
if TYPE_CHECKING:
@@ -19,6 +20,11 @@ log = logging.getLogger(__name__)
# Commands worth storing in backlog
BACKLOG_COMMANDS = {"PRIVMSG", "NOTICE", "TOPIC", "KICK", "MODE"}
# Commands where params[0] is a target to decode
_TARGET0_COMMANDS = {
"PRIVMSG", "NOTICE", "JOIN", "PART", "MODE", "TOPIC", "NAMES", "WHO", "WHOIS",
}
class Router:
"""Central message hub linking clients to networks."""
@@ -27,7 +33,7 @@ class Router:
self.config = config
self.backlog = backlog
self.networks: dict[str, Network] = {}
self.clients: dict[str, list[Client]] = {} # network_name -> clients
self.clients: list[Client] = []
self._lock = asyncio.Lock()
async def start_networks(self) -> None:
@@ -39,7 +45,6 @@ class Router:
on_message=self._on_network_message,
)
self.networks[name] = network
self.clients[name] = []
asyncio.create_task(network.start())
async def stop_networks(self) -> None:
@@ -47,67 +52,135 @@ class Router:
for network in self.networks.values():
await network.stop()
async def attach(self, client: Client, network_name: str) -> Network | None:
"""Attach a client to a network. Returns the network or None if not found."""
if network_name not in self.networks:
return None
async def attach_all(self, client: Client) -> None:
"""Attach a client to all networks and replay backlogs."""
async with self._lock:
self.clients[network_name].append(client)
self.clients.append(client)
network = self.networks[network_name]
client_count = len(self.clients[network_name])
log.info("client attached to %s (%d clients)", network_name, client_count)
log.info("client attached to all networks (%d clients)", len(self.clients))
# Replay backlog
if self.config.bouncer.backlog.replay_on_connect:
await self._replay_backlog(client, network_name)
for name in self.networks:
await self._replay_backlog(client, name)
return network
async def detach(self, client: Client, network_name: str) -> None:
"""Detach a client from a network."""
async def detach_all(self, client: Client) -> None:
"""Detach a client from all networks."""
async with self._lock:
if network_name in self.clients:
try:
self.clients[network_name].remove(client)
except ValueError:
pass
try:
self.clients.remove(client)
except ValueError:
pass
remaining = len(self.clients.get(network_name, []))
log.info("client detached from %s (%d remaining)", network_name, remaining)
remaining = len(self.clients)
log.info("client detached (%d remaining)", remaining)
if remaining == 0:
await self.backlog.record_disconnect(network_name)
for name in self.networks:
await self.backlog.record_disconnect(name)
async def client_to_network(self, network_name: str, msg: IRCMessage) -> None:
"""Forward a client command to the network."""
async def route_client_message(self, msg: IRCMessage) -> None:
"""Decode namespace from a client message and forward to the right network.
The target in params[0] (or params[1] for some commands) carries a
``/network`` suffix that tells us where to route.
"""
if not msg.params:
return
if msg.command == "KICK" and len(msg.params) >= 2:
# KICK #channel/net nick/net :reason
raw_chan, net = decode_target(msg.params[0])
raw_nick, _ = decode_target(msg.params[1])
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_chan, raw_nick] + msg.params[2:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command == "INVITE" and len(msg.params) >= 2:
# INVITE nick/net #channel/net
raw_nick, net1 = decode_target(msg.params[0])
raw_chan, net2 = decode_target(msg.params[1])
net = net1 or net2
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_nick, raw_chan] + msg.params[2:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command in ("JOIN", "PART") and msg.params:
# May be comma-separated: JOIN #a/net1,#b/net2
targets = msg.params[0].split(",")
by_network: dict[str, list[str]] = {}
for t in targets:
raw, net = decode_target(t)
if net:
by_network.setdefault(net, []).append(raw)
for net, chans in by_network.items():
fwd = IRCMessage(
command=msg.command,
params=[",".join(chans)] + msg.params[1:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
if msg.command in _TARGET0_COMMANDS and msg.params:
raw_target, net = decode_target(msg.params[0])
if not net:
return
fwd = IRCMessage(
command=msg.command,
params=[raw_target] + msg.params[1:],
prefix=msg.prefix,
tags=msg.tags,
)
await self._send_to_network(net, fwd)
return
async def _send_to_network(self, network_name: str, msg: IRCMessage) -> None:
"""Forward a message to a specific network."""
network = self.networks.get(network_name)
if network and network.connected:
await network.send(msg)
def get_own_nicks(self) -> dict[str, str]:
"""Return ``{network_name: current_nick}`` for all networks."""
return {name: net.nick for name, net in self.networks.items()}
def _on_network_message(self, network_name: str, msg: IRCMessage) -> None:
"""Handle a message from an IRC server (called synchronously from network)."""
asyncio.create_task(self._dispatch(network_name, msg))
async def _dispatch(self, network_name: str, msg: IRCMessage) -> None:
"""Dispatch a network message to attached clients and backlog."""
# Store in backlog for relevant commands
# 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)
# Prune if configured
max_msgs = self.config.bouncer.backlog.max_messages
if max_msgs > 0:
await self.backlog.prune(network_name, keep=max_msgs)
# Forward to all attached clients (prefer raw bytes from server)
clients = self.clients.get(network_name, [])
data = msg.raw if msg.raw else msg.format()
for client in clients:
# Namespace and forward to all clients
own_nicks = self.get_own_nicks()
namespaced = encode_message(msg, network_name, own_nicks)
data = namespaced.format()
for client in self.clients:
try:
client.write(data)
except Exception:
@@ -123,19 +196,20 @@ class Router:
log.info("replaying %d messages for %s", len(entries), network_name)
own_nicks = self.get_own_nicks()
for entry in entries:
msg = IRCMessage(
command=entry.command,
params=[entry.target, entry.content],
prefix=entry.sender,
)
namespaced = encode_message(msg, network_name, own_nicks)
try:
client.write(msg.format())
client.write(namespaced.format())
except Exception:
log.exception("failed to replay to client")
break
# Mark the latest as seen
if entries:
await self.backlog.mark_seen(network_name, entries[-1].id)

View File

@@ -97,6 +97,19 @@ password = "x"
with pytest.raises(ValueError, match="at least one network"):
load(_write_config(config))
def test_slash_in_network_name_raises(self):
config = """\
[bouncer]
password = "x"
[proxy]
[networks."lib/era"]
host = "irc.example.com"
"""
with pytest.raises(ValueError, match="must not contain '/'"):
load(_write_config(config))
def test_tls_default_port(self):
config = """\
[bouncer]

143
tests/test_namespace.py Normal file
View File

@@ -0,0 +1,143 @@
"""Tests for network namespace encoding/decoding."""
from bouncer.irc import IRCMessage
from bouncer.namespace import (
decode_channel,
decode_target,
encode_channel,
encode_message,
encode_nick,
encode_prefix,
)
OWN = {"libera": "mybot", "oftc": "mybot2"}
class TestEncodeChannel:
def test_basic(self):
assert encode_channel("#libera", "libera") == "#libera/libera"
def test_preserves_prefix(self):
assert encode_channel("&local", "net") == "&local/net"
class TestDecodeChannel:
def test_basic(self):
assert decode_channel("#libera/libera") == ("#libera", "libera")
def test_no_separator(self):
assert decode_channel("#libera") == ("#libera", None)
def test_channel_with_slash_in_name(self):
# Edge: channel name itself contains slash -- rfind picks the last one
assert decode_channel("#a/b/net") == ("#a/b", "net")
class TestEncodeNick:
def test_foreign_nick(self):
assert encode_nick("user123", "libera", OWN) == "user123/libera"
def test_own_nick_bare(self):
assert encode_nick("mybot", "libera", OWN) == "mybot"
def test_own_nick_other_network(self):
# Own nick on another network still shown bare
assert encode_nick("mybot2", "libera", OWN) == "mybot2"
class TestEncodePrefix:
def test_full_prefix(self):
result = encode_prefix("user!ident@host", "libera", OWN)
assert result == "user/libera!ident@host"
def test_own_prefix(self):
result = encode_prefix("mybot!ident@host", "libera", OWN)
assert result == "mybot!ident@host"
def test_nick_only(self):
result = encode_prefix("server.example.com", "libera", OWN)
assert result == "server.example.com/libera"
class TestDecodeTarget:
def test_channel(self):
assert decode_target("#libera/libera") == ("#libera", "libera")
def test_nick(self):
assert decode_target("user123/libera") == ("user123", "libera")
def test_bare(self):
assert decode_target("#libera") == ("#libera", None)
class TestEncodeMessage:
def test_privmsg_channel(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
assert out.prefix == "user/libera!ident@host"
def test_privmsg_own_prefix(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="mybot!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.prefix == "mybot!ident@host"
def test_namreply(self):
msg = IRCMessage(
command="353",
params=["mybot", "=", "#test", "@op +voice regular mybot"],
)
out = encode_message(msg, "libera", OWN)
assert out.params[2] == "#test/libera"
names = out.params[3].split()
assert names[0] == "@op/libera"
assert names[1] == "+voice/libera"
assert names[2] == "regular/libera"
assert names[3] == "mybot" # own nick stays bare
def test_nick_change(self):
msg = IRCMessage(
command="NICK",
params=["newnick"],
prefix="oldnick!user@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "newnick/libera"
assert out.prefix == "oldnick/libera!user@host"
def test_join(self):
msg = IRCMessage(
command="JOIN",
params=["#test"],
prefix="user!ident@host",
)
out = encode_message(msg, "libera", OWN)
assert out.params[0] == "#test/libera"
def test_non_channel_target_untouched(self):
msg = IRCMessage(
command="PRIVMSG",
params=["someuser", "hi"],
prefix="other!ident@host",
)
out = encode_message(msg, "libera", OWN)
# Private message to a nick -- params[0] is not a channel, left as-is
assert out.params[0] == "someuser"
def test_no_raw_preserved(self):
msg = IRCMessage(
command="PRIVMSG",
params=["#test", "hello"],
prefix="user!ident@host",
raw=b":user!ident@host PRIVMSG #test :hello\r\n",
)
out = encode_message(msg, "libera", OWN)
assert out.raw is None # raw must not carry over