Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 49 additions & 8 deletions rrc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -608,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:
Expand Down Expand Up @@ -671,7 +705,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:
Expand All @@ -684,7 +718,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)

Expand All @@ -698,7 +732,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)
Expand All @@ -716,7 +750,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."""
Expand Down Expand Up @@ -841,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)
Expand Down
2 changes: 1 addition & 1 deletion rrc_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@
outside the standard library.
"""

__version__ = "0.1.0"
__version__ = "0.1.1"
2 changes: 1 addition & 1 deletion rrc_helper/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 69 additions & 4 deletions tests/fake_weechat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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, "")


Expand All @@ -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")


Expand Down
Loading
Loading