fix: use client nick for synthetic JOINs and own-nick rewriting

irssi (and other IRC clients) only open a channel window when they see
a JOIN from their own nick. The synthetic JOINs were using the network
nick (e.g. pagumowa) but the client registered as tester -- mismatch.

Three changes:
- Synthetic JOIN prefix is now client_nick!user@bouncer
- 001 welcome uses the client's registered nick
- encode_nick/encode_message accept client_nick param to rewrite own
  nicks from any network to the client's nick, so irssi recognizes
  all self-actions consistently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-20 19:42:10 +01:00
parent 8cc57a7af4
commit 3c6f0bcf19
4 changed files with 73 additions and 20 deletions

View File

@@ -56,6 +56,11 @@ class Client:
self._pass_raw: str = "" self._pass_raw: str = ""
self._addr = writer.get_extra_info("peername", ("?", 0)) 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: async def handle(self) -> None:
"""Main client session loop.""" """Main client session loop."""
log.info("client connected from %s", self._addr) log.info("client connected from %s", self._addr)
@@ -161,9 +166,8 @@ class Client:
async def _send_welcome(self) -> None: async def _send_welcome(self) -> None:
"""Send IRC welcome sequence and channel state from all networks.""" """Send IRC welcome sequence and channel state from all networks."""
server_name = "bouncer" server_name = "bouncer"
# Pick a representative nick for the welcome numerics nick = self._nick
own_nicks = self._router.get_own_nicks() self_prefix = f"{nick}!{self._user}@bouncer"
nick = next(iter(own_nicks.values()), self._nick)
networks = ", ".join(self._router.network_names()) networks = ", ".join(self._router.network_names())
self._send_msg(IRCMessage( self._send_msg(IRCMessage(
@@ -185,15 +189,14 @@ class Client:
# Send namespaced channel state from every network # Send namespaced channel state from every network
for net_name, network in sorted(self._router.networks.items()): for net_name, network in sorted(self._router.networks.items()):
net_nick = network.nick
for channel in sorted(network.channels): for channel in sorted(network.channels):
ns_channel = encode_channel(channel, net_name) 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( self._send_msg(IRCMessage(
command="JOIN", command="JOIN",
params=[ns_channel], params=[ns_channel],
prefix=net_nick, prefix=self_prefix,
)) ))
# Topic # Topic

View File

@@ -28,17 +28,31 @@ def decode_channel(namespaced: str) -> tuple[str, str | None]:
return namespaced[:idx], namespaced[idx + 1:] return namespaced[:idx], namespaced[idx + 1:]
def encode_nick(nick: str, network: str, own_nicks: dict[str, str]) -> str: def encode_nick(
"""Suffix a nick unless it belongs to us on *any* network.""" 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(): if nick in own_nicks.values():
return nick return client_nick if client_nick else nick
return f"{nick}{SEPARATOR}{network}" 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``.""" """Namespace the nick portion of ``nick!user@host``."""
nick, user, host = parse_prefix(prefix) 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: if user and host:
return f"{enc}!{user}@{host}" return f"{enc}!{user}@{host}"
if user: if user:
@@ -66,16 +80,22 @@ def _is_channel(target: str) -> bool:
return target.startswith(("#", "&", "+", "!")) 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. """Namespace an entire IRC message for delivery to a client.
- Channels in params get ``/network`` suffix - Channels in params get ``/network`` suffix
- Prefix nick gets ``/network`` suffix (unless it's our own) - 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) - Special handling for 353 (NAMREPLY nick list) and NICK (new nick in trailing)
""" """
prefix = msg.prefix prefix = msg.prefix
if 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) 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 "@+%~&": while bare and bare[0] in "@+%~&":
mode_prefix += bare[0] mode_prefix += bare[0]
bare = bare[1:] 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) params[3] = " ".join(encoded)
elif msg.command == "NICK" and params: elif msg.command == "NICK" and params:
# NICK: params[0] is the new nick # NICK: params[0] is the new nick
new_nick = params[0] 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: else:
# Generic: namespace channel-like params[0] # Generic: namespace channel-like params[0]

View File

@@ -176,13 +176,14 @@ class Router:
if max_msgs > 0: if max_msgs > 0:
await self.backlog.prune(network_name, keep=max_msgs) 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() own_nicks = self.get_own_nicks()
namespaced = encode_message(msg, network_name, own_nicks)
data = namespaced.format()
for client in self.clients: for client in self.clients:
try: try:
client.write(data) namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
)
client.write(namespaced.format())
except Exception: except Exception:
log.exception("failed to write to client") log.exception("failed to write to client")
@@ -203,7 +204,9 @@ class Router:
params=[entry.target, entry.content], params=[entry.target, entry.content],
prefix=entry.sender, prefix=entry.sender,
) )
namespaced = encode_message(msg, network_name, own_nicks) namespaced = encode_message(
msg, network_name, own_nicks, client_nick=client.nick,
)
try: try:
client.write(namespaced.format()) client.write(namespaced.format())
except Exception: except Exception:

View File

@@ -44,6 +44,12 @@ class TestEncodeNick:
# Own nick on another network still shown bare # Own nick on another network still shown bare
assert encode_nick("mybot2", "libera", OWN) == "mybot2" 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: class TestEncodePrefix:
def test_full_prefix(self): def test_full_prefix(self):
@@ -141,3 +147,22 @@ class TestEncodeMessage:
) )
out = encode_message(msg, "libera", OWN) out = encode_message(msg, "libera", OWN)
assert out.raw is None # raw must not carry over 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"