diff --git a/src/bouncer/client.py b/src/bouncer/client.py index cee8674..7ddfd30 100644 --- a/src/bouncer/client.py +++ b/src/bouncer/client.py @@ -56,6 +56,11 @@ class Client: self._pass_raw: str = "" self._addr = writer.get_extra_info("peername", ("?", 0)) + @property + def nick(self) -> str: + """The nick the client registered with.""" + return self._nick + async def handle(self) -> None: """Main client session loop.""" log.info("client connected from %s", self._addr) @@ -161,9 +166,8 @@ class Client: async def _send_welcome(self) -> None: """Send IRC welcome sequence and channel state from all networks.""" server_name = "bouncer" - # Pick a representative nick for the welcome numerics - own_nicks = self._router.get_own_nicks() - nick = next(iter(own_nicks.values()), self._nick) + nick = self._nick + self_prefix = f"{nick}!{self._user}@bouncer" networks = ", ".join(self._router.network_names()) self._send_msg(IRCMessage( @@ -185,15 +189,14 @@ class Client: # 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 + # Synthetic JOIN from client's own nick so irssi opens the window self._send_msg(IRCMessage( command="JOIN", params=[ns_channel], - prefix=net_nick, + prefix=self_prefix, )) # Topic diff --git a/src/bouncer/namespace.py b/src/bouncer/namespace.py index bb5730d..61fc97c 100644 --- a/src/bouncer/namespace.py +++ b/src/bouncer/namespace.py @@ -28,17 +28,31 @@ def decode_channel(namespaced: str) -> tuple[str, str | 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.""" +def encode_nick( + nick: str, + network: str, + own_nicks: dict[str, str], + client_nick: str | None = None, +) -> str: + """Suffix a nick unless it belongs to us on *any* network. + + If *client_nick* is set, own nicks are rewritten to match the client's + registered nick so IRC clients recognise them as "self". + """ if nick in own_nicks.values(): - return nick + return client_nick if client_nick else nick return f"{nick}{SEPARATOR}{network}" -def encode_prefix(prefix: str, network: str, own_nicks: dict[str, str]) -> str: +def encode_prefix( + prefix: str, + network: str, + own_nicks: dict[str, str], + client_nick: str | None = None, +) -> str: """Namespace the nick portion of ``nick!user@host``.""" nick, user, host = parse_prefix(prefix) - enc = encode_nick(nick, network, own_nicks) + enc = encode_nick(nick, network, own_nicks, client_nick=client_nick) if user and host: return f"{enc}!{user}@{host}" if user: @@ -66,16 +80,22 @@ def _is_channel(target: str) -> bool: return target.startswith(("#", "&", "+", "!")) -def encode_message(msg: IRCMessage, network: str, own_nicks: dict[str, str]) -> IRCMessage: +def encode_message( + msg: IRCMessage, + network: str, + own_nicks: dict[str, str], + client_nick: str | None = None, +) -> 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) + - Own nicks rewritten to *client_nick* when set - 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) + prefix = encode_prefix(prefix, network, own_nicks, client_nick=client_nick) params = list(msg.params) @@ -91,13 +111,15 @@ def encode_message(msg: IRCMessage, network: str, own_nicks: dict[str, str]) -> while bare and bare[0] in "@+%~&": mode_prefix += bare[0] bare = bare[1:] - encoded.append(mode_prefix + encode_nick(bare, network, own_nicks)) + encoded.append(mode_prefix + encode_nick( + bare, network, own_nicks, client_nick=client_nick, + )) 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) + params[0] = encode_nick(new_nick, network, own_nicks, client_nick=client_nick) else: # Generic: namespace channel-like params[0] diff --git a/src/bouncer/router.py b/src/bouncer/router.py index 2092394..e46680f 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -176,13 +176,14 @@ class Router: if max_msgs > 0: await self.backlog.prune(network_name, keep=max_msgs) - # Namespace and forward to all clients + # Namespace and forward to all clients (per-client: own nicks -> client nick) 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) + namespaced = encode_message( + msg, network_name, own_nicks, client_nick=client.nick, + ) + client.write(namespaced.format()) except Exception: log.exception("failed to write to client") @@ -203,7 +204,9 @@ class Router: params=[entry.target, entry.content], prefix=entry.sender, ) - namespaced = encode_message(msg, network_name, own_nicks) + namespaced = encode_message( + msg, network_name, own_nicks, client_nick=client.nick, + ) try: client.write(namespaced.format()) except Exception: diff --git a/tests/test_namespace.py b/tests/test_namespace.py index be7c5c2..21dacbc 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -44,6 +44,12 @@ class TestEncodeNick: # Own nick on another network still shown bare assert encode_nick("mybot2", "libera", OWN) == "mybot2" + def test_own_nick_rewritten_to_client_nick(self): + assert encode_nick("mybot", "libera", OWN, client_nick="tester") == "tester" + + def test_foreign_nick_unaffected_by_client_nick(self): + assert encode_nick("user123", "libera", OWN, client_nick="tester") == "user123/libera" + class TestEncodePrefix: def test_full_prefix(self): @@ -141,3 +147,22 @@ class TestEncodeMessage: ) out = encode_message(msg, "libera", OWN) assert out.raw is None # raw must not carry over + + def test_own_prefix_rewritten_to_client_nick(self): + msg = IRCMessage( + command="PRIVMSG", + params=["#test", "hello"], + prefix="mybot!ident@host", + ) + out = encode_message(msg, "libera", OWN, client_nick="tester") + assert out.prefix == "tester!ident@host" + + def test_namreply_own_nick_rewritten(self): + msg = IRCMessage( + command="353", + params=["mybot", "=", "#test", "@op mybot"], + ) + out = encode_message(msg, "libera", OWN, client_nick="tester") + names = out.params[3].split() + assert names[0] == "@op/libera" + assert names[1] == "tester"