From 0d7c46a4bbace75904e12f2216e2669391c5a961 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:30:55 +0200 Subject: [PATCH 1/2] colour names using WeeChat's own nick colours --- pyproject.toml | 2 +- rrc.py | 47 ++++- rrc_helper/__init__.py | 2 +- rrc_helper/session.py | 2 +- tests/fake_weechat.py | 73 +++++++- tests/test_script_colors.py | 332 ++++++++++++++++++++++++++++++++++ tests/test_script_events.py | 55 ++++-- tests/test_script_nicklist.py | 9 +- 8 files changed, 495 insertions(+), 27 deletions(-) create mode 100644 tests/test_script_colors.py diff --git a/pyproject.toml b/pyproject.toml index 38a549e..919bf7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "weechat-rrc" -version = "0.1.0" +version = "0.1.1" description = "WeeChat plugin for Reticulum Relay Chat (RRC)" readme = "README.md" requires-python = ">=3.11" diff --git a/rrc.py b/rrc.py index efdba2d..64a78fa 100644 --- a/rrc.py +++ b/rrc.py @@ -39,7 +39,7 @@ SCRIPT_NAME = "rrc" SCRIPT_AUTHOR = "Afri Blank (@l5yth)" -SCRIPT_VERSION = "0.1.0" +SCRIPT_VERSION = "0.1.1" SCRIPT_LICENSE = "Apache-2.0" SCRIPT_DESC = "Reticulum Relay Chat (RRC) client" @@ -253,6 +253,31 @@ def speaker(event: dict) -> str: return clean(event.get("nick")) or short(event.get("src", "")) +def coloured(identity: str, name: str) -> str: + """Return *name* wrapped in the colour WeeChat assigns to *identity*. + + The colour is keyed on the identity hash, never on the displayed name, so + it follows the person through a rename and an impostor who takes somebody's + nickname does not take their colour with it (``SPEC.md`` D21). WeeChat + computes it: the user's own ``weechat.color.chat_nick_colors`` palette and + ``weechat.look.nick_color_hash`` algorithm apply here exactly as they do in + the irc plugin, and nothing in this repository selects or hashes a colour + (``SPEC.md`` D20). + + The trailing reset is load-bearing. Without it the speaker's colour runs on + past the name into the message body, which is hub-supplied text that must + never carry formatting the plugin did not choose (``SPEC.md`` D22). + + Args: + identity: The sender's full identity hash, as hex. + name: The already-sanitised display name to wrap. + + Returns: + The name with a leading colour code and a trailing reset. + """ + return weechat.info_get("nick_color", identity) + name + weechat.color("reset") + + class Connection: """One hub: its helper process, its buffers, and its event handling.""" @@ -548,7 +573,13 @@ def refresh_nicklist(self, room: str) -> None: self.members.get(room, {}).items(), key=lambda item: item[1] or item[0] ): weechat.nicklist_add_nick( - pointer, "", nick or short(identity), "bar_fg", "", "bar_fg", 1 + pointer, + "", + nick or short(identity), + weechat.info_get("nick_color_name", identity), + "", + "bar_fg", + 1, ) def resolve(self, token: str) -> str | None: @@ -671,7 +702,7 @@ def _ev_join(self, event: dict) -> None: continue weechat.prnt( self.room_buffer(room), - f"-->\t{nick or short(identity)} joined {room}", + f"-->\t{coloured(identity, nick or short(identity))} joined {room}", ) def _ev_part(self, event: dict) -> None: @@ -684,7 +715,7 @@ def _ev_part(self, event: dict) -> None: # Likewise, only announce a departure for somebody we still list. if identity not in self.members.get(room, {}): continue - name = clean(event.get("nick")) or short(identity) + name = coloured(identity, clean(event.get("nick")) or short(identity)) weechat.prnt(self.rooms[room], f"<--\t{name} left {room}") self.drop_member(room, identity) @@ -698,7 +729,7 @@ def _ev_chat(self, event: dict) -> None: ) target = self.room_buffer(clean(room)) if room else self.buffer body = clean(event.get("body")) - name = speaker(event) + name = coloured(clean(event.get("src")), speaker(event)) kind = event.get("kind") if kind == "notice": self.learn_members(body) @@ -716,7 +747,11 @@ def _ev_direct(self, event: dict) -> None: nick = clean(event.get("nick")) if nick: weechat.buffer_set(buffer, "short_name", nick) - weechat.prnt(buffer, f"{nick or short(identity)}\t{clean(event.get('body'))}") + weechat.prnt( + buffer, + f"{coloured(identity, nick or short(identity))}" + f"\t{clean(event.get('body'))}", + ) def _ev_pong(self, event: dict) -> None: """Record the measured round-trip time.""" diff --git a/rrc_helper/__init__.py b/rrc_helper/__init__.py index 7ad682e..cfc3b53 100644 --- a/rrc_helper/__init__.py +++ b/rrc_helper/__init__.py @@ -26,4 +26,4 @@ outside the standard library. """ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/rrc_helper/session.py b/rrc_helper/session.py index dbe6bf1..d823dbb 100644 --- a/rrc_helper/session.py +++ b/rrc_helper/session.py @@ -40,7 +40,7 @@ #: Client name and version advertised in the ``HELLO`` body. CLIENT_NAME = "weechat-rrc" -CLIENT_VERSION = "0.1.0" +CLIENT_VERSION = "0.1.1" #: Capabilities this client advertises. Resource transfer is deliberately #: absent: ``EX1`` says a client that does not want resources should simply not diff --git a/tests/fake_weechat.py b/tests/fake_weechat.py index 821b7f5..24c9bdb 100644 --- a/tests/fake_weechat.py +++ b/tests/fake_weechat.py @@ -70,6 +70,7 @@ def __init__(self): self.config = {} self.unhooked = [] self.counter = 0 + self.nick_colors = {} def reset(self): """Forget everything, as if WeeChat had just started.""" @@ -235,13 +236,68 @@ def config_get_plugin(option): return state.config.get(option, "") +#: Colour names handed out by :func:`info_get`, in assignment order. Real +#: WeeChat picks from ``weechat.color.chat_nick_colors``; the exact names do not +#: matter here, only that they are distinct and stable. +NICK_COLORS = ( + "cyan", + "magenta", + "green", + "brown", + "lightblue", + "lightcyan", + "lightmagenta", + "lightgreen", + "31", + "35", + "38", + "40", + "49", + "63", + "70", + "80", +) + + def color(name): - """Return an empty string; colour codes only add noise in tests.""" - return "" + """Return a colour code shaped like the one real WeeChat returns. + + WeeChat answers ``color("reset")`` with ``0x1C`` and a colour name with a + ``0x19``-prefixed code. An earlier version of this stand-in returned ``""`` + for everything, on the reasoning that colour codes are noise in tests. That + was true until the script began emitting colour itself: against an empty + fake, no code ever reaches a rendered line, so every colour assertion in the + suite passes without testing anything and the C8 injection check passes for + the wrong reason. + """ + if name == "reset": + return "\x1c" + return "\x19" + name + + +def _nick_color_name(key): + """Return a stable colour name for *key*, assigned on first sight. + + Deliberately **not** a model of WeeChat's djb2 hashing. It guarantees that + distinct keys get distinct colours, which real WeeChat does not — its + palette is finite and collisions are certain (``SPEC.md`` D21). Tests may + therefore rely on "different identity, different colour" here, and must not + assert anything about collisions, which are a property of the real + implementation and not of this one. + """ + if not key: + return "default" # what real WeeChat answers for an empty nick + if key not in state.nick_colors: + state.nick_colors[key] = NICK_COLORS[len(state.nick_colors) % len(NICK_COLORS)] + return state.nick_colors[key] def info_get(name, arguments): """Return a plausible value for the few info keys the script uses.""" + if name == "nick_color_name": + return _nick_color_name(arguments) + if name == "nick_color": + return "\x19" + _nick_color_name(arguments) return {"weechat_dir": "/home/user/.config/weechat"}.get(name, "") @@ -253,8 +309,17 @@ def nicklist_add_group(buffer, parent, name, group_color, visible): def nicklist_add_nick(buffer, group, name, nick_color, prefix, prefix_color, visible): - """Add a nick to a buffer's nicklist.""" - state.buffers[buffer].nicks[name] = {"group": group, "prefix": prefix} + """Add a nick to a buffer's nicklist. + + The colour argument is recorded, not discarded: it is the whole of what + nickname colouring changes about the nicklist, so a test that cannot see it + cannot check it (``SPEC.md`` D19). + """ + state.buffers[buffer].nicks[name] = { + "group": group, + "prefix": prefix, + "color": nick_color, + } return state.pointer("n") diff --git a/tests/test_script_colors.py b/tests/test_script_colors.py new file mode 100644 index 0000000..3f06310 --- /dev/null +++ b/tests/test_script_colors.py @@ -0,0 +1,332 @@ +# SPDX-FileCopyrightText: 2026 Afri Blank (@l5yth) +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright © 2026 Afri Blank (@l5yth) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Nickname colours (ACCEPTANCE N1-N6, SPEC D19-D22). + +Colour is keyed on the identity hash rather than the advisory nickname, so it +follows the person through a rename and an impostor taking a nickname does not +take the colour with it. WeeChat computes every colour; nothing here does. +""" + +from __future__ import annotations + +ALICE = "1f5a80f61a6194267cf6b6df6a954adb" +BOB = "aabbccddeeff00112233445566778899" + +#: WeeChat's own formatting bytes: colour, attribute, escape, reset. +COLOUR, ATTRIBUTE, ESCAPE, RESET = "\x19", "\x1a", "\x1b", "\x1c" + + +# -- the oracle itself --------------------------------------------------- + + +def test_the_fake_is_realistic_enough_to_test_against(wee): + """The stand-in must return real colour shapes, or nothing below tests. + + Both functions once returned "". Against that fake no colour code ever + reaches a rendered line, so every assertion in this module would pass while + proving nothing, and the C8 injection check would pass for the wrong + reason. This guards the guard. + """ + weechat, rrc = wee + assert weechat.color("reset") == RESET + first = weechat.info_get("nick_color", ALICE) + second = weechat.info_get("nick_color", BOB) + assert first.startswith(COLOUR), "a colour code must carry WeeChat's 0x19" + assert second.startswith(COLOUR) + assert first != second, "distinct identities must get distinct colours" + assert weechat.info_get("nick_color", ALICE) == first, "colour must be stable" + assert weechat.info_get("nick_color_name", ALICE) == first[1:] + + +def deliver(rrc, connection, process, *events): + """Push *events* through the helper pipe and let the script read them.""" + process.emit(*events) + rrc.rrc_stdout_cb(connection.name, "0") + + +def room_lines(weechat, connection, room="#general"): + """Return the raw lines printed to *room*, colour codes intact.""" + return weechat.state.buffers[connection.rooms[room]].lines + + +def say(rrc, connection, process, src, body="hi", nick=None, kind="msg"): + """Deliver one room message from *src* and return the line it rendered.""" + event = {"op": "chat", "kind": kind, "room": "#general", "src": src, "body": body} + if nick is not None: + event["nick"] = nick + deliver(rrc, connection, process, event) + + +# -- N1: every place a person is named ------------------------------------ + + +def test_the_nicklist_surface_carries_the_identity_colour(connected): + """The colour argument is WeeChat's answer, not the old literal bar_fg.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": [ALICE]}, + ) + entry = weechat.state.buffers[connection.rooms["#general"]].nicks[ALICE[:8]] + assert entry["color"] == weechat.info_get("nick_color_name", ALICE) + assert entry["color"] != "bar_fg" + + +def test_the_message_and_action_surfaces_colour_the_speaker(connected): + """A MSG and an ACTION both name their speaker in that person's colour.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, "hi", nick="alice") + say(rrc, connection, process, ALICE, "waves", nick="alice", kind="action") + code = weechat.info_get("nick_color", ALICE) + rendered = room_lines(weechat, connection) + assert rendered[-2].startswith(code + "alice" + RESET) + assert rendered[-1].startswith(" *\t" + code + "alice" + RESET) + + +def test_the_join_and_part_surfaces_colour_the_name(connected): + """Arrival and departure lines colour the person they are about.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": []}, + {"op": "join", "room": "#general", "members": [BOB], "nick": "bob"}, + {"op": "part", "room": "#general", "members": [BOB], "nick": "bob"}, + ) + wrapped = weechat.info_get("nick_color", BOB) + "bob" + RESET + rendered = room_lines(weechat, connection) + assert f"-->\t{wrapped} joined #general" in rendered + assert f"<--\t{wrapped} left #general" in rendered + + +def test_the_private_buffer_surface_colours_the_sender(connected): + """A direct message names its sender in that sender's colour.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "direct", "src": ALICE, "nick": "a", "body": "p"}, + ) + line = weechat.state.buffers[connection.dms[ALICE]].lines[-1] + assert line == weechat.info_get("nick_color", ALICE) + "a" + RESET + "\t" + "p" + + +# -- N2: WeeChat computes it, keyed on the full hash ---------------------- + + +def test_weechat_computes_it_from_the_full_hash_not_the_short_one(connected): + """The key is the 32-hex identity, not the 8-hex form and not the nick. + + Real WeeChat maps the full and short forms of one hash to different + colours, so the choice has to be pinned rather than left to whichever + string happens to be at hand (SPEC D20). + """ + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, nick="bob") + line = room_lines(weechat, connection)[-1] + assert line.startswith(weechat.info_get("nick_color", ALICE)) + assert not line.startswith(weechat.info_get("nick_color", ALICE[:8])) + assert not line.startswith(weechat.info_get("nick_color", "bob")) + + +def test_weechat_computes_the_value_and_it_is_used_verbatim(connected): + """Whatever info_get returns is emitted unchanged, never post-processed.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, "hi", nick="alice") + code = weechat.info_get("nick_color", ALICE) + assert room_lines(weechat, connection)[-1] == code + "alice" + RESET + "\thi" + + +# -- N3: the colour follows the person ------------------------------------ + + +def test_a_late_nickname_leaves_the_identity_colour_untouched(connected): + """A name arriving after the join must not make the colour jump. + + This is the ordinary case, not an edge one: the automatic /who (SPEC D15) + relabels members moments after every join. + """ + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": [ALICE]}, + ) + before = weechat.state.buffers[connection.rooms["#general"]].nicks[ALICE[:8]][ + "color" + ] + deliver( + rrc, + connection, + process, + { + "op": "chat", + "kind": "notice", + "src": "0" * 32, + "body": f"members in #general: alice ({ALICE[:12]})", + }, + ) + after = weechat.state.buffers[connection.rooms["#general"]].nicks["alice"]["color"] + assert after == before + + +def test_two_members_sharing_an_identity_nickname_differ_in_colour(connected): + """An impostor taking a nickname does not take the colour with it.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, "one", nick="alice") + say(rrc, connection, process, BOB, "two", nick="alice") + first, second = room_lines(weechat, connection)[-2:] + assert first.startswith(weechat.info_get("nick_color", ALICE)) + assert second.startswith(weechat.info_get("nick_color", BOB)) + assert first.split(RESET)[0] != second.split(RESET)[0] + + +def test_one_identity_keeps_one_colour_across_every_surface(connected): + """Nicklist, message line and private buffer agree on a person's colour.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": [ALICE]}, + ) + say(rrc, connection, process, ALICE, "hi", nick="alice") + deliver( + rrc, + connection, + process, + {"op": "direct", "src": ALICE, "nick": "alice", "body": "p"}, + ) + nicklist = weechat.state.buffers[connection.rooms["#general"]].nicks["alice"][ + "color" + ] + assert nicklist == weechat.info_get("nick_color_name", ALICE) + code = weechat.info_get("nick_color", ALICE) + assert room_lines(weechat, connection)[-1].startswith(code) + assert weechat.state.buffers[connection.dms[ALICE]].lines[-1].startswith(code) + + +# -- N4: the rendered shape ------------------------------------------------ + + +def test_the_line_shape_is_code_then_name_then_reset_then_body(connected): + """\\t, in that order, with nothing between.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, "hello there", nick="alice") + line = room_lines(weechat, connection)[-1] + code = weechat.info_get("nick_color", ALICE) + assert line.index(code) == 0 + assert line.index(RESET) == len(code) + len("alice") + assert line[line.index(RESET) + 1] == "\t" + + +def test_the_body_shape_carries_no_formatting_at_all(connected): + """A missing reset would tint the message text with the speaker's colour.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, ALICE, "hello there", nick="alice") + body = room_lines(weechat, connection)[-1].split("\t", 1)[1] + assert body == "hello there" + for byte in (COLOUR, ATTRIBUTE, ESCAPE, RESET): + assert byte not in body + + +# -- N5: injection --------------------------------------------------------- + + +def test_injection_of_a_colour_or_reset_by_a_hub_nickname_fails(connected): + """A hub must not close our colour early and own the rest of the line. + + The nickname here carries both a raw colour code and a raw reset. Both are + stripped, so the line holds exactly one of each and both are ours + (SPEC D22, ACCEPTANCE C8 as amended). + """ + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say( + rrc, + connection, + process, + ALICE, + body="body" + COLOUR + "F31" + RESET + "tail", + nick="ev" + COLOUR + "il" + RESET + "admin", + ) + line = room_lines(weechat, connection)[-1] + assert len(room_lines(weechat, connection)) == 2, "an extra line was forged" + assert line.count(COLOUR) == 1 + assert line.count(RESET) == 1 + assert line.startswith(weechat.info_get("nick_color", ALICE)) + assert COLOUR not in line.split("\t", 1)[1] + assert RESET not in line.split("\t", 1)[1] + + +# -- N6: unknown or absent identity --------------------------------------- + + +def test_an_unknown_identity_still_renders_exactly_one_line(connected): + """Somebody who never joined still gets a colour and a line.""" + weechat, rrc, connection, process = connected + deliver( + rrc, connection, process, {"op": "joined", "room": "#general", "members": []} + ) + say(rrc, connection, process, BOB, "hi") + assert len(room_lines(weechat, connection)) == 2 + assert room_lines(weechat, connection)[-1].startswith( + weechat.info_get("nick_color", BOB) + ) + + +def test_an_unknown_or_absent_identity_raises_nothing(connected): + """A message with no src renders rather than failing. + + Real WeeChat answers info_get("nick_color_name", "") with "default", so + there is no error path here and none may be invented. + """ + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": []}, + {"op": "chat", "kind": "msg", "room": "#general", "body": "hi"}, + ) + assert len(room_lines(weechat, connection)) == 2 + assert room_lines(weechat, connection)[-1].endswith("\thi") diff --git a/tests/test_script_events.py b/tests/test_script_events.py index 4e03f98..a2679a9 100644 --- a/tests/test_script_events.py +++ b/tests/test_script_events.py @@ -33,6 +33,19 @@ #: free of them. NUL, TAB, ESC = chr(0), chr(9), chr(27) +#: WeeChat's colour and reset markers, which the plugin now emits itself. +COLOUR, ATTRIBUTE, RESET = chr(25), chr(26), chr(28) + + +def coloured(weechat, identity, name): + """Return *name* as the script renders it: colour code, name, reset. + + Built from the fake's own answers rather than written out, so a test says + "the colour WeeChat gave this identity" instead of pinning a literal that + would have to change with the palette (SPEC D20). + """ + return weechat.info_get("nick_color", identity) + name + weechat.color("reset") + def deliver(rrc, connection, process, *events): """Push *events* down the helper pipe and let the script read them.""" @@ -226,8 +239,8 @@ def test_others_joining_and_leaving_are_shown(connected): {"op": "part", "room": "#general", "members": [PEER], "nick": "bob"}, ) text = "\n".join(lines(weechat, connection.rooms["#general"])) - assert "bob joined #general" in text - assert "bob left #general" in text + assert coloured(weechat, PEER, "bob") + " joined #general" in text + assert coloured(weechat, PEER, "bob") + " left #general" in text def test_a_member_without_a_nickname_shows_a_short_hash(connected): @@ -268,16 +281,17 @@ def test_own_part_is_reported(connected): @pytest.mark.parametrize( - "kind,body,expected", + "kind,body,template", [ - ("msg", "hello", "bob" + TAB + "hello"), - ("action", "waves", " *" + TAB + "bob waves"), - ("notice", "fyi", "--" + TAB + "bob: fyi"), + ("msg", "hello", "{name}" + TAB + "hello"), + ("action", "waves", " *" + TAB + "{name} waves"), + ("notice", "fyi", "--" + TAB + "{name}: fyi"), ], ) -def test_each_chat_kind_renders_distinctly(connected, kind, body, expected): +def test_each_chat_kind_renders_distinctly(connected, kind, body, template): """A message, an emote and a notice are visually distinguishable.""" weechat, rrc, connection, process = connected + expected = template.format(name=coloured(weechat, PEER, "bob")) deliver( rrc, connection, @@ -315,7 +329,7 @@ def test_a_speaker_without_a_nickname_shows_a_short_hash(connected): process, {"op": "chat", "kind": "msg", "room": "#general", "src": PEER, "body": "hi"}, ) - expected = PEER[:8] + TAB + "hi" + expected = coloured(weechat, PEER, PEER[:8]) + TAB + "hi" assert expected in raw_lines(weechat, connection.rooms["#general"]) @@ -359,7 +373,7 @@ def test_a_direct_message_opens_a_private_buffer(connected): assert buffer is not None assert buffer.properties["localvar_set_type"] == "private" assert buffer.properties["short_name"] == "alice" - assert "alice" + TAB + "psst" in buffer.lines + assert coloured(weechat, PEER, "alice") + TAB + "psst" in buffer.lines def test_direct_messages_reuse_one_buffer_per_peer(connected): @@ -383,7 +397,7 @@ def test_a_direct_message_without_a_nickname_uses_the_hash(connected): weechat, rrc, connection, process = connected deliver(rrc, connection, process, {"op": "direct", "src": PEER, "body": "hi"}) buffer = weechat.state.buffers[connection.dms[PEER]] - assert PEER[:8] + TAB + "hi" in buffer.lines + assert coloured(weechat, PEER, PEER[:8]) + TAB + "hi" in buffer.lines # -- buffer input and closing ---------------------------------------------- @@ -489,6 +503,12 @@ def test_newlines_and_weechat_colour_codes_are_stripped(connected, hostile): WeeChat reads 0x19, 0x1A, 0x1B and 0x1C as colour and attribute markers, so a hub could otherwise recolour or restyle a line it did not own. + + Since nickname colours landed the plugin emits 0x19 and 0x1C itself, so + absence is no longer the right test. This asserts **provenance**: every + formatting byte in the line is one the plugin chose, derived from the + Link-authenticated identity hash, and the hub-supplied span carries none + (SPEC D22, ACCEPTANCE C8 as amended). """ weechat, rrc, connection, process = connected deliver( @@ -506,5 +526,16 @@ def test_newlines_and_weechat_colour_codes_are_stripped(connected, hostile): ) rendered = raw_lines(weechat, connection.rooms["#general"]) assert len(rendered) == 1, "a newline forged an extra buffer line" - for code in (10, 25, 26, 27, 28): - assert chr(code) not in rendered[0] + line = rendered[0] + + # Exactly the plugin's own formatting, and no more of it. + assert line.startswith(coloured(weechat, PEER, "bob") + TAB) + assert line.count(COLOUR) == 1, "a second colour code reached the line" + assert line.count(RESET) == 1, "a second reset reached the line" + assert ATTRIBUTE not in line, "the plugin never emits an attribute marker" + assert ESC not in line, "the plugin never emits an escape" + + # The hub-supplied span carries none of it. + body = line.split(TAB, 1)[1] + for byte in (chr(10), COLOUR, ATTRIBUTE, ESC, RESET): + assert byte not in body, "hub text kept a formatting byte" diff --git a/tests/test_script_nicklist.py b/tests/test_script_nicklist.py index 0e5d1ec..625655b 100644 --- a/tests/test_script_nicklist.py +++ b/tests/test_script_nicklist.py @@ -29,6 +29,11 @@ BOB = "aabbccddeeff00112233445566778899" +def coloured(weechat, identity, name): + """Return *name* as the script renders it: colour code, name, reset.""" + return weechat.info_get("nick_color", identity) + name + weechat.color("reset") + + def nicks(weechat, connection, room): """Return the nicknames currently shown in *room*'s nicklist.""" return set(weechat.state.buffers[connection.rooms[room]].nicks) @@ -274,7 +279,7 @@ def test_a_repeated_join_is_announced_once(connected): {"op": "join", "room": "#general", "members": [BOB], "nick": "bob"}, ) text = "\n".join(weechat.state.buffers[connection.rooms["#general"]].text) - assert text.count("bob joined") == 1 + assert text.count(coloured(weechat, BOB, "bob") + " joined") == 1 assert nicks(weechat, connection, "#general") == {"bob"} @@ -305,7 +310,7 @@ def test_a_rejoin_after_a_part_is_announced(connected): {"op": "join", "room": "#general", "members": [BOB], "nick": "bob"}, ) text = "\n".join(weechat.state.buffers[connection.rooms["#general"]].text) - assert text.count("bob joined") == 1 + assert text.count(coloured(weechat, BOB, "bob") + " joined") == 1 assert text.count("left") == 1 From 3669367e8a483d31ca3615dd70a94084b1168dc0 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:46:12 +0200 Subject: [PATCH 2/2] colour your own outgoing direct message too --- rrc.py | 10 ++++++++-- tests/test_script_colors.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/rrc.py b/rrc.py index 64a78fa..7539d27 100644 --- a/rrc.py +++ b/rrc.py @@ -639,7 +639,10 @@ def on_event(self, event: dict) -> None: def _ev_identity(self, event: dict) -> None: """Record and show the identity this session presents to the hub.""" - self.identity = event.get("hash", "") + # Helper-sourced, so not hostile — but it is now a colour key as well + # as display text, and every other displayed value is cleaned. Being + # the one exception is not worth the reader's second look. + self.identity = clean(event.get("hash")) self.display(f"your identity is {self.identity}", "--") def _ev_state(self, event: dict) -> None: @@ -876,9 +879,12 @@ def rrc_input_cb(data: str, buffer: str, text: str) -> int: return weechat.WEECHAT_RC_OK if target.startswith(DM_PREFIX): connection.direct(target[len(DM_PREFIX) :], text) + # Your own echo is a fifth place a person is named, so it is coloured + # like the other four (SPEC.md D19) — keyed on your identity, which is + # what makes your name look the same here as it does to everyone else. weechat.prnt( connection.dms[target[len(DM_PREFIX) :]], - f"{connection.nick or 'you'}\t{text}", + f"{coloured(connection.identity, connection.nick or 'you')}\t{text}", ) return weechat.WEECHAT_RC_OK connection.say(target, text) diff --git a/tests/test_script_colors.py b/tests/test_script_colors.py index 3f06310..fd147f1 100644 --- a/tests/test_script_colors.py +++ b/tests/test_script_colors.py @@ -49,7 +49,10 @@ def test_the_fake_is_realistic_enough_to_test_against(wee): assert second.startswith(COLOUR) assert first != second, "distinct identities must get distinct colours" assert weechat.info_get("nick_color", ALICE) == first, "colour must be stable" - assert weechat.info_get("nick_color_name", ALICE) == first[1:] + # Deliberately NOT asserted: that the code equals 0x19 + the colour name. + # Real WeeChat 4.10 answers "lightblue" and "\x19F10" for one key, so + # pinning that relationship would make this guard itself unrealistic. + assert weechat.info_get("nick_color_name", ALICE), "a colour name is needed" def deliver(rrc, connection, process, *events): @@ -198,8 +201,15 @@ def test_a_late_nickname_leaves_the_identity_colour_untouched(connected): assert after == before -def test_two_members_sharing_an_identity_nickname_differ_in_colour(connected): - """An impostor taking a nickname does not take the colour with it.""" +def test_two_members_sharing_an_identity_nickname_are_keyed_separately(connected): + """An impostor taking a nickname is still looked up as themselves. + + What is asserted is that each line carries **that sender's own** colour, + looked up per identity. Deliberately not asserted: that the two colours + differ. WeeChat's palette is finite and collisions are certain, so two + identities may legitimately share a colour (SPEC.md D21) — that would be a + property of the stand-in fake, not of the behaviour under test. + """ weechat, rrc, connection, process = connected deliver( rrc, connection, process, {"op": "joined", "room": "#general", "members": []} @@ -209,7 +219,6 @@ def test_two_members_sharing_an_identity_nickname_differ_in_colour(connected): first, second = room_lines(weechat, connection)[-2:] assert first.startswith(weechat.info_get("nick_color", ALICE)) assert second.startswith(weechat.info_get("nick_color", BOB)) - assert first.split(RESET)[0] != second.split(RESET)[0] def test_one_identity_keeps_one_colour_across_every_surface(connected): @@ -330,3 +339,19 @@ def test_an_unknown_or_absent_identity_raises_nothing(connected): ) assert len(room_lines(weechat, connection)) == 2 assert room_lines(weechat, connection)[-1].endswith("\thi") + + +def test_the_own_echo_surface_colours_your_own_name(connected): + """Your own outgoing direct message names you in your own colour. + + A fifth place a person is named, and the one where getting it wrong is + least visible: the hub does not echo direct messages, so this line is + written locally, and an uncoloured one would show you in a different colour + from the one everybody else sees you in. + """ + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + dm = connection.dm_buffer(BOB) + rrc.rrc_input_cb(f"{connection.name}/@{BOB}", dm, "psst") + line = weechat.state.buffers[dm].lines[-1] + assert line == weechat.info_get("nick_color", ALICE) + "afri" + RESET + "\tpsst"