diff --git a/rrc.py b/rrc.py index 7539d27..062a1a6 100644 --- a/rrc.py +++ b/rrc.py @@ -61,6 +61,48 @@ RRC_VENV = "~/.local/share/weechat/rrc-venv/bin/python" FALLBACK_PYTHONS = ("python3", RRC_VENV, "~/.venv/bin/python") +#: One type tag per rendered line, from the vocabulary fixed by ``SPEC.md`` +#: D26. WeeChat exposes tags to ``/filter``, to ``hook_print`` and to +#: ``hotlist_max_level_nicks``, so these names are a public interface: renaming +#: one silently breaks a user's filters. +TAG_STATUS = "rrc_status" +TAG_JOIN = "rrc_join" +TAG_PART = "rrc_part" +TAG_MSG = "rrc_msg" +TAG_ACTION = "rrc_action" +TAG_NOTICE = "rrc_notice" +TAG_DIRECT = "rrc_direct" + +#: WeeChat's own notify tags, which decide whether a line puts its buffer into +#: the hotlist and at which level (``SPEC.md`` D23). A line carrying none of +#: them is scored ``low``, which is exactly what join, part and status lines +#: want, so their absence is the value rather than an omission. +NOTIFY_MESSAGE = "notify_message" +NOTIFY_PRIVATE = "notify_private" + +#: WeeChat's own tag for a line that must not be able to highlight, whatever +#: it happens to contain. +NO_HIGHLIGHT = "no_highlight" + +#: The conversational content of each kind of buffer, for the +#: ``highlight_tags_restrict`` property (``SPEC.md`` D25). WeeChat refuses to +#: highlight a line carrying none of these, which makes join lines, part lines +#: and every status line structurally incapable of mentioning the user rather +#: than individually excused. That matters because the plugin quotes the user's +#: own mention keys back at them routinely -- "your identity is " and +#: "you are now known as " each contain one verbatim. +RESTRICT_ROOM = "rrc_msg,rrc_action,rrc_notice" +RESTRICT_PRIVATE = "rrc_direct" +RESTRICT_HUB = "rrc_notice" + +#: Tags for a line the user wrote themselves. The hub echoes every message back +#: to its author, so without these your own words would light your own buffer +#: and highlight you for typing your own name. Verified against WeeChat 4.10: +#: neither the buffer's ``nick`` local variable nor the ``self_msg`` tag +#: suppresses a self-highlight, and only ``notify_none`` with ``no_highlight`` +#: does. ``self_msg`` is kept because scripts hook it, not because it works. +SELF_TAGS = ("notify_none", NO_HIGHLIGHT, "self_msg") + #: A hub notice listing a room's members, as rrcd answers "/who". The room is #: taken from the text because such a notice may carry no room field. MEMBER_LIST = re.compile(r"^members in (\S+):\s*(.+)$", re.DOTALL) @@ -224,7 +266,7 @@ def helper_directory() -> str: def log(message: str) -> None: """Print a script-level message on WeeChat's core buffer.""" - weechat.prnt("", f"{SCRIPT_NAME}: {message}") + show("", f"{SCRIPT_NAME}: {message}", TAG_STATUS) def clean(text: object, fallback: str = "") -> str: @@ -239,6 +281,42 @@ def clean(text: object, fallback: str = "") -> str: return "".join(ch for ch in text if ch.isprintable() or ch == " ") +def tag_value(text: object) -> str: + """Return *text* reduced to something safe to interpolate into a tag. + + WeeChat's tag list is comma-separated, so a value spliced into it can close + one tag and open another. This is not theoretical: a tag list built from a + nickname containing a comma was verified against WeeChat 4.10 to yield a + real, effective ``notify_highlight``. Unsanitised, a hub could choose its + own notify level, forge ``no_log`` to keep a line out of the user's log + files, or forge ``no_filter`` to make one unhideable (``SPEC.md`` D26). + + Commas and whitespace are removed on top of :func:`clean`, so what remains + cannot be read as a separator by WeeChat or as a second word by anything + parsing the tag. Two names that differ only in spacing therefore collide + here, which costs nothing: the ``nick_`` tag is a convenience, and the + ``rrc_id_`` tag beside it is the authoritative one (``SPEC.md`` D21, D26). + """ + return "".join(ch for ch in clean(text) if ch != "," and not ch.isspace()) + + +def mention_key(text: object) -> str: + """Return *text* reduced to something safe to seed as a highlight word. + + WeeChat's ``highlight_words`` is a comma-separated list whose entries may + contain ``*`` as a wildcard, so a hub-confirmed nickname reaching it + unsanitised could match every line in every room -- a hub answering + ``/nick`` with ``*`` would decide when the user's terminal beeps. The + nickname is advisory and hub-supplied (``SPEC.md`` D13), which makes this + the same class of problem as a formatting byte inside a colour code: a hub + string entering a construct the plugin chose (``SPEC.md`` D24). + + :func:`tag_value` already removes the separator and whitespace, so only the + wildcard is left to strip. + """ + return "".join(ch for ch in tag_value(text) if ch != "*") + + def short(identity_hash: str) -> str: """Return an abbreviated identity hash, for use when no nickname exists.""" return identity_hash[:8] if identity_hash else "?" @@ -278,6 +356,64 @@ def coloured(identity: str, name: str) -> str: return weechat.info_get("nick_color", identity) + name + weechat.color("reset") +def line_tags(kind: str, *notify: str, src: str = "", name: str = "") -> str: + """Return the comma-separated tag string for one buffer line. + + A line with a sender carries both ``nick_`` and ``rrc_id_``, + deliberately. ``nick_`` is the conventional tag every WeeChat script, + ``/filter`` recipe and ``hotlist_max_level_nicks`` entry expects, and + ``rrc_id_`` is the authoritative one: this is D21's hint-versus-proof split + expressed in tags, so a user who mutes ``nick_bob`` mutes whoever currently + calls themselves bob, and one who keys on ``rrc_id_`` mutes a person. + + Args: + kind: The line's type tag, one of the ``TAG_*`` vocabulary. + notify: WeeChat's own tags for this line, if any. Passing none is + meaningful: WeeChat scores a line with no ``notify_`` tag as + ``low``, which is the level activity lines want. + src: The sender's full identity hash, if the line has a sender. + name: The sender's display name, if the line has a sender. + + Returns: + The tag string to hand to ``weechat.prnt_date_tags``. + """ + tags = [kind, *notify] + # Sanitised, then dropped if nothing survives: an empty "nick_" tag would + # be a tag the plugin did not mean to emit. + for prefix, value in (("nick_", name), ("rrc_id_", src)): + safe = tag_value(value) + if safe: + tags.append(prefix + safe) + return ",".join(tags) + + +def show( + buffer: str, text: str, kind: str, *notify: str, src: str = "", name: str = "" +) -> None: + """Print *text* to *buffer*, tagged so WeeChat can score it. + + Every line this plugin renders goes through here. WeeChat reads the tags to + decide whether the buffer enters the hotlist and at which of its four + levels, and therefore in which of the user's ``weechat.color.status_data_*`` + colours the status bar shows it (``SPEC.md`` D23). Nothing here picks a + colour or a hotlist entry; it states what kind of line this is and leaves + the rest to WeeChat. + + The date is ``0``, which WeeChat reads as "now". + + Args: + buffer: The buffer to print to; empty prints to WeeChat's core buffer. + text: The already-rendered line, prefix and tab included. + kind: The line's type tag, one of the ``TAG_*`` vocabulary. + notify: WeeChat's own tags for this line, if any. + src: The sender's full identity hash, if the line has a sender. + name: The sender's display name, if the line has a sender. + """ + weechat.prnt_date_tags( + buffer, 0, line_tags(kind, *notify, src=src, name=name), text + ) + + class Connection: """One hub: its helper process, its buffers, and its event handling.""" @@ -295,6 +431,9 @@ def __init__(self, name: str, hub_hash: str, nick: str = "") -> None: self.rooms: dict[str, str] = {} self.members: dict[str, dict[str, str]] = {} self.dms: dict[str, str] = {} + #: The mention keys currently published on every buffer we own, so the + #: previous set can be retired by name when one of them changes. + self.seeded: list[str] = [] self.hooks: list[str] = [] self._reader = None # Reuse the buffer if one is already open under this name. Closing a @@ -310,6 +449,77 @@ def __init__(self, name: str, hub_hash: str, nick: str = "") -> None: weechat.buffer_set(self.buffer, "title", f"RRC hub {hub_hash}") weechat.buffer_set(self.buffer, "localvar_set_type", "server") weechat.buffer_set(self.buffer, "localvar_set_server", name) + self.prepare(self.buffer, RESTRICT_HUB) + self.publish_mentions() + + # -- mentions --------------------------------------------------------- + + def all_buffers(self) -> list[str]: + """Return every buffer this connection owns.""" + return [self.buffer, *self.rooms.values(), *self.dms.values()] + + def mention_keys(self) -> list[str]: + """Return the strings that mean "you" on this connection. + + Three of them (``SPEC.md`` D24): the full identity hash, its short form + -- which is what renders when there is no nickname, so it is what a + person can see and type -- and the advisory nickname. Each is + sanitised, and any that reduces to nothing is dropped rather than + seeded as an empty word. + """ + keys: list[str] = [] + forms = ( + self.identity, + short(self.identity) if self.identity else "", + self.nick, + ) + for candidate in forms: + key = mention_key(candidate) + if key and key not in keys: + keys.append(key) + return keys + + def prepare(self, buffer: str, restrict: str) -> None: + """Give *buffer* its two highlight rules: what may mention, and with what. + + Args: + buffer: The buffer to prepare. + restrict: The tags whose lines may highlight there, one of the + ``RESTRICT_*`` lists. + """ + weechat.buffer_set(buffer, "highlight_tags_restrict", restrict) + self.seed_mentions(buffer) + + def seed_mentions(self, buffer: str) -> None: + """Give one buffer the mention keys already in force. + + Called when a buffer is opened. The ``irc`` plugin can seed at creation + alone because it knows the user's nickname before the buffer exists; + here the identity arrives from the helper after the hub buffer is + already open, so creation-time seeding is only half the job and + :meth:`publish_mentions` does the other half. + """ + if self.seeded: + weechat.buffer_set(buffer, "highlight_words_add", ",".join(self.seeded)) + + def publish_mentions(self) -> None: + """Bring every buffer into line after a mention key changed. + + Words are added and retired by name rather than by assigning + ``highlight_words`` wholesale, so a word the user added themselves with + ``/buffer setauto highlight_words_add`` is never discarded + (``SPEC.md`` D24). WeeChat does the matching; this only publishes the + strings to match on. + """ + keys = self.mention_keys() + if keys == self.seeded: + return + for buffer in self.all_buffers(): + if self.seeded: + weechat.buffer_set(buffer, "highlight_words_del", ",".join(self.seeded)) + if keys: + weechat.buffer_set(buffer, "highlight_words_add", ",".join(keys)) + self.seeded = keys # -- process lifecycle ------------------------------------------------ @@ -404,7 +614,7 @@ def send(self, command: dict) -> None: def display(self, text: str, prefix: str = "") -> None: """Print a line on the hub's server buffer.""" - weechat.prnt(self.buffer, f"{prefix}\t{text}") + show(self.buffer, f"{prefix}\t{text}", TAG_STATUS) def room_buffer(self, room: str) -> str: """Return the buffer for *room*, creating it if necessary.""" @@ -414,6 +624,7 @@ def room_buffer(self, room: str) -> str: existing = weechat.buffer_search("python", name) if existing: self.rooms[room] = existing + self.prepare(existing, RESTRICT_ROOM) return existing pointer = weechat.buffer_new( f"{SCRIPT_NAME}.{self.name}.{room}", @@ -427,6 +638,7 @@ def room_buffer(self, room: str) -> str: weechat.buffer_set(pointer, "localvar_set_server", self.name) weechat.buffer_set(pointer, "localvar_set_channel", room) weechat.buffer_set(pointer, "nicklist", "1") + self.prepare(pointer, RESTRICT_ROOM) self.rooms[room] = pointer return pointer @@ -486,6 +698,7 @@ def dm_buffer(self, identity: str) -> str: existing = weechat.buffer_search("python", name) if existing: self.dms[identity] = existing + self.prepare(existing, RESTRICT_PRIVATE) return existing pointer = weechat.buffer_new( f"{SCRIPT_NAME}.{self.name}.{short(identity)}", @@ -498,6 +711,7 @@ def dm_buffer(self, identity: str) -> str: weechat.buffer_set(pointer, "localvar_set_type", "private") weechat.buffer_set(pointer, "localvar_set_server", self.name) weechat.buffer_set(pointer, "title", f"direct messages with {identity}") + self.prepare(pointer, RESTRICT_PRIVATE) self.dms[identity] = pointer return pointer @@ -643,6 +857,9 @@ def _ev_identity(self, event: dict) -> None: # 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")) + # The identity arrives after the hub buffer is already open, which is + # why seeding cannot be a creation-time job only (SPEC.md D24). + self.publish_mentions() self.display(f"your identity is {self.identity}", "--") def _ev_state(self, event: dict) -> None: @@ -676,7 +893,7 @@ def _ev_joined(self, event: dict) -> None: self.members[room] = {} for identity in members: self.note_member(room, clean(identity)) - weechat.prnt(buffer, f"--\tjoined {room} ({len(members)} present)") + show(buffer, f"--\tjoined {room} ({len(members)} present)", TAG_JOIN) # Asked after the join line so the hub's answer reads as a reply to it. # This runs on a re-JOIN after an outage too, which is when the # nicklist has just been rebuilt from hashes and needs it most. @@ -686,7 +903,7 @@ def _ev_parted(self, event: dict) -> None: """Report that we left a room.""" room = clean(event.get("room")) if room in self.rooms: - weechat.prnt(self.rooms[room], f"--\tleft {room}") + show(self.rooms[room], f"--\tleft {room}", TAG_PART) self.members.pop(room, None) def _ev_join(self, event: dict) -> None: @@ -703,9 +920,12 @@ def _ev_join(self, event: dict) -> None: self.note_member(room, identity, nick) if already_present: continue - weechat.prnt( + show( self.room_buffer(room), f"-->\t{coloured(identity, nick or short(identity))} joined {room}", + TAG_JOIN, + src=identity, + name=nick or short(identity), ) def _ev_part(self, event: dict) -> None: @@ -718,8 +938,15 @@ 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 = coloured(identity, clean(event.get("nick")) or short(identity)) - weechat.prnt(self.rooms[room], f"<--\t{name} left {room}") + who = clean(event.get("nick")) or short(identity) + name = coloured(identity, who) + show( + self.rooms[room], + f"<--\t{name} left {room}", + TAG_PART, + src=identity, + name=who, + ) self.drop_member(room, identity) def _ev_chat(self, event: dict) -> None: @@ -732,16 +959,44 @@ def _ev_chat(self, event: dict) -> None: ) target = self.room_buffer(clean(room)) if room else self.buffer body = clean(event.get("body")) - name = coloured(clean(event.get("src")), speaker(event)) + src = clean(event.get("src")) + who = speaker(event) + name = coloured(src, who) kind = event.get("kind") - if kind == "notice": - self.learn_members(body) + # A /who reply names everybody in the room, so it carries the user's own + # nickname and short hash by construction -- and D15 makes one arrive + # after every join and every rejoin. A roster is not somebody addressing + # you, so it is exempted below. Nothing is correlated with the request + # that provoked it: this recognises a line by its own shape, which + # learn_members already does to harvest the names (SPEC.md D25). + roster = kind == "notice" and self.learn_members(body) + if src and src == self.identity: + # The hub returns every message to its author, so the user's own + # words arrive as ordinary inbound traffic. Verified against WeeChat + # 4.10: neither the buffer's nick local variable nor the self_msg + # tag suppresses a self-highlight, so both are said explicitly. + level: tuple[str, ...] = SELF_TAGS + else: + # A notice with no room lands on the server buffer, which is where + # MOTDs and member listings arrive: hub chatter is activity, not a + # message. It can still reach highlight level if it names the user, + # and WeeChat decides that, not this. + level = () if kind == "notice" and not room else (NOTIFY_MESSAGE,) + if roster: + level += (NO_HIGHLIGHT,) if kind == "action": - weechat.prnt(target, f" *\t{name} {body}") + show(target, f" *\t{name} {body}", TAG_ACTION, *level, src=src, name=who) elif kind == "notice": - weechat.prnt(target, f"--\t{body}" if not room else f"--\t{name}: {body}") + show( + target, + f"--\t{body}" if not room else f"--\t{name}: {body}", + TAG_NOTICE, + *level, + src=src, + name=who, + ) else: - weechat.prnt(target, f"{name}\t{body}") + show(target, f"{name}\t{body}", TAG_MSG, *level, src=src, name=who) def _ev_direct(self, event: dict) -> None: """Show a direct message in a private buffer for its sender.""" @@ -750,10 +1005,14 @@ def _ev_direct(self, event: dict) -> None: nick = clean(event.get("nick")) if nick: weechat.buffer_set(buffer, "short_name", nick) - weechat.prnt( + show( buffer, f"{coloured(identity, nick or short(identity))}" f"\t{clean(event.get('body'))}", + TAG_DIRECT, + NOTIFY_PRIVATE, + src=identity, + name=nick or short(identity), ) def _ev_pong(self, event: dict) -> None: @@ -764,6 +1023,7 @@ def _ev_pong(self, event: dict) -> None: def _ev_nick(self, event: dict) -> None: """Record a confirmed nickname change.""" self.nick = clean(event.get("nick")) + self.publish_mentions() self.display(f"you are now known as {self.nick}", "--") @@ -882,9 +1142,13 @@ def rrc_input_cb(data: str, buffer: str, text: str) -> int: # 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( + show( connection.dms[target[len(DM_PREFIX) :]], f"{coloured(connection.identity, connection.nick or 'you')}\t{text}", + TAG_DIRECT, + *SELF_TAGS, + src=connection.identity, + name=connection.nick or "you", ) return weechat.WEECHAT_RC_OK connection.say(target, text) diff --git a/tests/fake_weechat.py b/tests/fake_weechat.py index 24c9bdb..4fbae12 100644 --- a/tests/fake_weechat.py +++ b/tests/fake_weechat.py @@ -46,7 +46,10 @@ def __init__(self, name, input_cb, input_data, close_cb, close_data): self.close_cb = close_cb self.close_data = close_data self.properties = {} + self.sets = [] + self.highlight_words = [] self.lines = [] + self.tags = [] self.nicks = {} self.groups = {} self.closed = False @@ -56,6 +59,19 @@ def text(self): """Return every line printed to this buffer, prefixes stripped.""" return [line.split("\t", 1)[-1] for line in self.lines] + def tags_for(self, needle): + """Return the tags of the last printed line containing *needle*. + + Tags are what WeeChat scores a line by, so a test that cannot see them + cannot check the hotlist level at all (``SPEC.md`` D23). Returned split + on commas, which is how WeeChat itself parses the string, so that a + forged separator shows up as two tags rather than hiding inside one. + """ + for line, tags in reversed(list(zip(self.lines, self.tags))): + if needle in line: + return tags.split(",") if tags else [] + raise LookupError(f"no line containing {needle!r}") + class State: """Everything the fake recorded, reset between tests.""" @@ -71,6 +87,7 @@ def __init__(self): self.unhooked = [] self.counter = 0 self.nick_colors = {} + self.core_tags = [] def reset(self): """Forget everything, as if WeeChat had just started.""" @@ -127,10 +144,23 @@ def register(name, author, version, license_, description, shutdown, charset): def prnt(buffer, message): """Append *message* to *buffer*, or to the core buffer when empty.""" + prnt_date_tags(buffer, 0, "", message) + + +def prnt_date_tags(buffer, date, tags, message): + """Append *message* to *buffer* along with the tags WeeChat would score it by. + + The tag string is recorded rather than discarded. Real WeeChat decides from + these whether the buffer enters the hotlist and at which level, so a fake + that dropped them would let every check in the hotlist suite pass while + proving nothing — the same trap the colour fake fell into by returning "". + """ if not buffer: state.core.append(message) + state.core_tags.append(tags) return state.buffers[buffer].lines.append(message) + state.buffers[buffer].tags.append(tags) def buffer_new(name, input_cb, input_data, close_cb, close_data): @@ -149,8 +179,30 @@ def buffer_new(name, input_cb, input_data, close_cb, close_data): def buffer_set(buffer, prop, value): - """Record a buffer property.""" - state.buffers[buffer].properties[prop] = value + """Record a buffer property, modelling the highlight-word list properly. + + ``highlight_words_add`` and ``highlight_words_del`` are not properties, they + are operations on one: a fake that stored them as if they were would let a + wholesale assignment of ``highlight_words`` pass for an add, which is the + distinction ``SPEC.md`` D24 turns on. Behaviour matched to WeeChat 4.10, + probed directly: adding dedupes, deleting an absent word is a no-op, and + adding an empty string does nothing. + """ + buf = state.buffers[buffer] + buf.sets.append((prop, value)) + if prop in ("highlight_words_add", "highlight_words_del"): + for word in value.split(","): + if not word: + continue + if prop.endswith("_add"): + if word not in buf.highlight_words: + buf.highlight_words.append(word) + elif word in buf.highlight_words: + buf.highlight_words.remove(word) + return + if prop == "highlight_words": + buf.highlight_words = [word for word in value.split(",") if word] + buf.properties[prop] = value def buffer_get_string(buffer, prop): @@ -160,6 +212,8 @@ def buffer_get_string(buffer, prop): return "" if prop in ("name", "short_name"): return buf.properties.get(prop, buf.name) + if prop == "highlight_words": + return ",".join(buf.highlight_words) return buf.properties.get(prop, "") diff --git a/tests/test_script_commands.py b/tests/test_script_commands.py index 7e5f77f..953dd37 100644 --- a/tests/test_script_commands.py +++ b/tests/test_script_commands.py @@ -46,6 +46,28 @@ def test_registration_seeds_configuration_defaults(wee): assert weechat.config_is_set_plugin(option) +def test_configuration_options_are_exactly_the_documented_set(wee): + """No feature may add an option without this failing (``SPEC.md`` D27). + + The check beside this one iterates ``DEFAULTS`` and asserts each member was + seeded, which is a tautology with respect to that dict's membership: it + passes just as happily with a sixth option in it. Pinning the set with + ``==`` is what actually holds the line, so a decision to ship no option of + our own cannot be quietly reversed. + """ + weechat, rrc = wee + rrc.main() + documented = { + "helper.python", + "identity.path", + "reconnect", + "autojoin", + "who_on_join", + } + assert set(rrc.DEFAULTS) == documented + assert set(weechat.state.config) == documented, "main() seeded something else" + + def test_registration_does_nothing_if_weechat_refuses(wee, monkeypatch): """A refused registration installs no hooks.""" weechat, rrc = wee diff --git a/tests/test_script_hotlist.py b/tests/test_script_hotlist.py new file mode 100644 index 0000000..17b164c --- /dev/null +++ b/tests/test_script_hotlist.py @@ -0,0 +1,650 @@ +# 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. +"""Hotlist activity levels (ACCEPTANCE H1-H6, SPEC D23-D27). + +Before this feature every line the script printed carried no tags, so WeeChat +scored all of them ``low`` and the status bar showed one undifferentiated grey +whether somebody had joined, said something, or said your name. WeeChat scores +them itself from the tags asserted here; nothing in the script picks a hotlist +entry or a colour. +""" + +from __future__ import annotations + +ALICE = "1f5a80f61a6194267cf6b6df6a954adb" +BOB = "aabbccddeeff00112233445566778899" + +#: Every type tag the vocabulary allows (``SPEC.md`` D26). +TYPE_TAGS = { + "rrc_status", + "rrc_join", + "rrc_part", + "rrc_msg", + "rrc_action", + "rrc_notice", + "rrc_direct", +} + + +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(weechat, connection, name="#general"): + """Return the recorded buffer for a room.""" + return weechat.state.buffers[connection.rooms[name]] + + +def notify_of(tags): + """Return the WeeChat notify tags present in *tags*.""" + return [tag for tag in tags if tag.startswith("notify_")] + + +def types_of(tags): + """Return the plugin type tags present in *tags*.""" + return [tag for tag in tags if tag in TYPE_TAGS] + + +def joined(rrc, connection, process, members=()): + """Confirm our own join of #general so the room buffer exists.""" + deliver( + rrc, + connection, + process, + {"op": "joined", "room": "#general", "members": list(members)}, + ) + + +def say( + rrc, + connection, + process, + src=ALICE, + body="hi", + kind="msg", + room_name="#general", + nick="alice", +): + """Deliver one inbound chat event.""" + event = {"op": "chat", "kind": kind, "src": src, "body": body, "nick": nick} + if room_name is not None: + event["room"] = room_name + deliver(rrc, connection, process, event) + + +# -- the oracle itself ---------------------------------------------------- + + +def test_the_fake_records_tags_it_is_given(wee): + """The stand-in must keep the tags, or nothing below tests anything. + + Tags are the whole of what this feature adds: WeeChat reads them to decide + the hotlist level. A fake that accepted ``prnt_date_tags`` and dropped its + third argument would let every assertion in this module pass while proving + nothing, exactly as the colour fake once did by returning "". + """ + weechat, rrc = wee + pointer = weechat.buffer_new("probe", "", "", "", "") + weechat.prnt_date_tags(pointer, 0, "rrc_msg,notify_message", "alice\thello") + weechat.prnt(pointer, "untagged\tline") + buffer = weechat.state.buffers[pointer] + assert buffer.tags_for("hello") == ["rrc_msg", "notify_message"] + assert buffer.tags_for("untagged") == [] + # The older suites read this; tagging must not move where text lands. + assert buffer.text == ["hello", "line"] + + +# -- H1: every line carries a type tag and the right notify level --------- + + +def test_a_room_message_is_scored_at_message_level(connected): + """A MSG in a room is what "someone is talking" means.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process) + tags = room(weechat, connection).tags_for("hi") + assert types_of(tags) == ["rrc_msg"] + assert notify_of(tags) == ["notify_message"] + + +def test_an_action_is_scored_at_message_level(connected): + """An ACTION is a person talking about themselves; still a message.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="waves", kind="action") + tags = room(weechat, connection).tags_for("waves") + assert types_of(tags) == ["rrc_action"] + assert notify_of(tags) == ["notify_message"] + + +def test_a_room_notice_is_scored_at_message_level(connected): + """A NOTICE addressed to a room is somebody talking to that room.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="build is green", kind="notice") + tags = room(weechat, connection).tags_for("build is green") + assert types_of(tags) == ["rrc_notice"] + assert notify_of(tags) == ["notify_message"] + + +def test_a_hub_wide_notice_stays_at_activity_level(connected): + """A room-less notice is hub chatter, not somebody addressing a room. + + This is where rrcd puts MOTDs and /who member listings, and SPEC D15 makes + this plugin ask for a /who after every confirmed join and after every room + of a post-outage rejoin. Scoring it as a message would light the hub buffer + on traffic the plugin generated for itself. + """ + weechat, rrc, connection, process = connected + say(rrc, connection, process, body="motd: be nice", kind="notice", room_name=None) + tags = weechat.state.buffers[connection.buffer].tags_for("motd: be nice") + assert types_of(tags) == ["rrc_notice"] + assert notify_of(tags) == [], "a room-less notice must not reach message level" + + +def test_a_received_direct_message_is_scored_at_private_level(connected): + """A DM is addressed to you personally, so it earns its own level.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "direct", "src": BOB, "nick": "bob", "body": "psst"}, + ) + tags = weechat.state.buffers[connection.dms[BOB]].tags_for("psst") + assert types_of(tags) == ["rrc_direct"] + assert notify_of(tags) == ["notify_private"] + + +def test_a_join_and_a_part_stay_at_activity_level(connected): + """Coming and going is activity; it is never a message.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + deliver( + rrc, + connection, + process, + {"op": "join", "room": "#general", "members": [BOB], "nick": "bob"}, + {"op": "part", "room": "#general", "members": [BOB], "nick": "bob"}, + ) + buffer = room(weechat, connection) + assert types_of(buffer.tags_for("joined #general")) == ["rrc_join"] + assert notify_of(buffer.tags_for("joined #general")) == [] + assert types_of(buffer.tags_for("left #general")) == ["rrc_part"] + assert notify_of(buffer.tags_for("left #general")) == [] + + +def test_our_own_join_and_part_stay_at_activity_level(connected): + """Your own arrival and departure are activity too.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + deliver(rrc, connection, process, {"op": "parted", "room": "#general"}) + buffer = room(weechat, connection) + assert types_of(buffer.tags_for("(0 present)")) == ["rrc_join"] + assert notify_of(buffer.tags_for("(0 present)")) == [] + assert types_of(buffer.tags_for("left #general")) == ["rrc_part"] + assert notify_of(buffer.tags_for("left #general")) == [] + + +def test_a_plugin_status_line_stays_at_activity_level(connected): + """Lines the plugin writes about itself never claim to be messages.""" + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "state", "state": "up"}, + {"op": "pong", "lag_ms": 42}, + {"op": "error", "message": "hub said no"}, + ) + buffer = weechat.state.buffers[connection.buffer] + for needle in ("up", "lag is 42ms", "hub said no"): + assert types_of(buffer.tags_for(needle)) == ["rrc_status"] + assert notify_of(buffer.tags_for(needle)) == [] + + +def test_a_core_buffer_line_is_scored_at_activity_level(wee): + """Even the core-buffer diagnostic carries a type tag, so /filter reaches it.""" + weechat, rrc = wee + rrc.log("something happened") + assert weechat.state.core_tags[-1] == "rrc_status" + + +def test_every_line_carries_one_type_tag_and_at_most_one_notify_level(connected): + """The vocabulary is exhaustive and exclusive across every surface. + + A line with two type tags, or two notify tags, would make its hotlist level + depend on WeeChat's tag ordering rather than on this plugin's intent. + """ + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process) + say(rrc, connection, process, body="waves", kind="action") + say(rrc, connection, process, body="notice me", kind="notice") + say(rrc, connection, process, body="hub says", kind="notice", room_name=None) + deliver( + rrc, + connection, + process, + {"op": "join", "room": "#general", "members": [BOB], "nick": "bob"}, + {"op": "part", "room": "#general", "members": [BOB], "nick": "bob"}, + {"op": "direct", "src": BOB, "nick": "bob", "body": "psst"}, + {"op": "state", "state": "up"}, + ) + seen = set() + for buffer in weechat.state.buffers.values(): + for tags in buffer.tags: + parsed = tags.split(",") if tags else [] + assert len(types_of(parsed)) == 1, f"one type tag per line: {parsed}" + assert len(notify_of(parsed)) <= 1, f"one notify tag at most: {parsed}" + seen.update(types_of(parsed)) + assert seen == TYPE_TAGS, f"surfaces not exercised: {TYPE_TAGS - seen}" + + +# -- H4: your own words never notify you ---------------------------------- + + +def test_your_own_direct_message_echo_is_kept_out_of_the_hotlist(connected): + """The hub does not echo direct messages, so the script writes that line. + + Being the plugin's own rendering of the user's own words, it must not put + the buffer in the hotlist and must not be able to highlight: verified + against WeeChat 4.10, only notify_none with no_highlight achieves that. + """ + weechat, rrc, connection, process = connected + deliver( + rrc, + connection, + process, + {"op": "identity", "hash": ALICE}, + {"op": "direct", "src": BOB, "nick": "bob", "body": "psst"}, + ) + rrc.rrc_input_cb(f"{connection.name}/{rrc.DM_PREFIX}{BOB}", "", "hello there") + tags = weechat.state.buffers[connection.dms[BOB]].tags_for("hello there") + assert types_of(tags) == ["rrc_direct"] + assert notify_of(tags) == ["notify_none"] + assert "no_highlight" in tags + assert "self_msg" in tags + + +# -- H5: a hub cannot forge a tag ----------------------------------------- + + +def test_a_line_with_a_sender_carries_both_the_name_and_the_identity_tag(connected): + """The conventional tag and the authoritative one, together (SPEC D26).""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, nick="alice") + tags = room(weechat, connection).tags_for("hi") + assert f"nick_alice" in tags + assert f"rrc_id_{ALICE}" in tags + + +def test_a_line_with_no_sender_carries_neither(connected): + """A status line is nobody talking, so it names nobody.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "state", "state": "up"}) + tags = weechat.state.buffers[connection.buffer].tags_for("up") + assert not [t for t in tags if t.startswith(("nick_", "rrc_id_"))] + + +def test_two_senders_sharing_a_nickname_are_told_apart_by_the_identity_tag(connected): + """A nickname is advisory and collides; an identity hash does not. + + This is what makes the identity tag worth its extra bytes: muting nick_bob + mutes whoever currently answers to bob, while muting the identity tag mutes + one person through any rename or impersonation (SPEC D21, D26). + """ + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, src=ALICE, body="first", nick="bob") + say(rrc, connection, process, src=BOB, body="second", nick="bob") + buffer = room(weechat, connection) + assert "nick_bob" in buffer.tags_for("first") + assert "nick_bob" in buffer.tags_for("second") + assert f"rrc_id_{ALICE}" in buffer.tags_for("first") + assert f"rrc_id_{BOB}" in buffer.tags_for("second") + + +def test_injection_of_a_notify_tag_by_a_hub_nickname_fails(connected): + """A comma in a nickname must not become a tag separator. + + Reproduced against WeeChat 4.10 before this feature was designed: a tag + list built by interpolating a nickname containing a comma yielded a real, + effective notify_highlight, promoting the line to notify_level=3. + """ + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="payload", nick="bob,notify_highlight") + tags = room(weechat, connection).tags_for("payload") + assert notify_of(tags) == ["notify_message"], "the hub chose a notify level" + assert "notify_highlight" not in tags + assert types_of(tags) == ["rrc_msg"] + + +def test_injection_of_a_logging_or_filter_tag_by_a_hub_nickname_fails(connected): + """no_log would hide a line from the user's logs; no_filter would pin it.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="payload", nick="eve,no_log,no_filter") + tags = room(weechat, connection).tags_for("payload") + assert "no_log" not in tags + assert "no_filter" not in tags + assert tags == [ + "rrc_msg", + "notify_message", + "nick_eveno_logno_filter", + f"rrc_id_{ALICE}", + ] + + +def test_injection_by_whitespace_in_a_hub_nickname_fails(connected): + """Whitespace is a separator to anything else parsing the tag string.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="payload", nick="bob smith") + tags = room(weechat, connection).tags_for("payload") + assert "nick_bobsmith" in tags + assert not [t for t in tags if any(ch.isspace() for ch in t)] + + +def test_injection_cannot_reach_the_identity_tag_either(connected): + """The identity tag is hex by construction; it is sanitised regardless. + + SPEC D26 rests the identity tag's safety on a structural argument -- the + helper hex-encodes the hash before it ever reaches this script. Sanitising + it anyway costs nothing and means the claim does not depend on the helper. + """ + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, src="aabb,notify_highlight", body="payload") + tags = room(weechat, connection).tags_for("payload") + assert "notify_highlight" not in tags + assert "rrc_id_aabbnotify_highlight" in tags + + +def test_injection_leaves_the_rendered_line_shape_untouched(connected): + """Tags are a separate argument and never enter the message string. + + Layer C8 and SPEC D22 constrain the rendered line; this asserts the tag + work did not disturb it, from the other side. + """ + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, body="payload", nick="bob,notify_highlight") + line = room(weechat, connection).lines[-1] + # Pinned exactly rather than by substring: the hub's nickname legitimately + # contains the text "notify_highlight", so only the whole rendering shows + # that no tag was concatenated into it. + colour = weechat.info_get("nick_color", ALICE) + assert line == f"{colour}bob,notify_highlight\x1c\tpayload" + assert line.count("\n") == 0, "one buffer line" + assert line.count("\x19") == 1, "one colour code, the plugin's own" + assert line.count("\x1c") == 1, "one reset, immediately after the name" + assert "rrc_msg" not in line, "the type tag never enters the message string" + + +# -- H2: WeeChat computes the mention; this plugin says who you are ------- + + +def words(weechat, buffer): + """Return the highlight words WeeChat holds for *buffer*.""" + return weechat.state.buffers[buffer].highlight_words + + +def test_the_three_mention_keys_are_seeded_on_a_room_buffer(connected): + """Full hash, short hash and nickname: the three ways to mean "you".""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + seeded = words(weechat, connection.rooms["#general"]) + assert ALICE in seeded, "the full hash, for anyone who pastes it" + assert ALICE[:8] in seeded, "the short form, which is what actually renders" + assert "afri" in seeded, "the advisory nickname, how a human addresses you" + + +def test_keys_are_added_not_assigned_so_user_words_survive(connected): + """A word the user added themselves must never be discarded (SPEC D24).""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + buffer = connection.rooms["#general"] + weechat.buffer_set(buffer, "highlight_words_add", "deploy") + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + assert "deploy" in words(weechat, buffer), "the user's own word was discarded" + assert ALICE in words(weechat, buffer) + assigned = [ + v for prop, v in weechat.state.buffers[buffer].sets if prop == "highlight_words" + ] + assert assigned == [], "highlight_words was assigned wholesale" + + +def test_new_keys_replace_old_ones_when_the_nickname_changes(connected): + """A rename retires the old key by name and publishes the new one.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + buffer = connection.rooms["#general"] + assert "afri" in words(weechat, buffer) + deliver(rrc, connection, process, {"op": "nick", "nick": "bandit"}) + assert "afri" not in words(weechat, buffer), "the stale nickname still mentions" + assert "bandit" in words(weechat, buffer) + assert ALICE in words(weechat, buffer), "the hash keys survive a rename" + + +def test_keys_reach_buffers_opened_before_the_identity_arrived(connected): + """The hub buffer exists before the helper reports who we are. + + The irc plugin can seed at buffer creation alone because it knows the + nickname first. Here it does not, so creation-time seeding would leave the + hub buffer permanently unable to see the user. + """ + weechat, rrc, connection, process = connected + assert ALICE not in words(weechat, connection.buffer) + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + assert ALICE in words(weechat, connection.buffer) + assert ALICE[:8] in words(weechat, connection.buffer) + + +def test_keys_reach_a_room_buffer_reopened_later(connected): + """A buffer found again rather than created is seeded just the same.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + pointer = connection.rooms["#general"] + connection.rooms.clear() + assert connection.room_buffer("#general") == pointer + assert ALICE in words(weechat, pointer) + + +def test_a_wildcard_in_a_hub_confirmed_nickname_is_stripped_from_the_keys(connected): + """A hub answering /nick with "*" must not highlight every line. + + WeeChat reads "*" in highlight_words as a wildcard, and the nickname is + whatever the hub confirms (SPEC D13), so unsanitised it would let the hub + decide when the user's terminal beeps. + """ + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + deliver(rrc, connection, process, {"op": "nick", "nick": "*"}) + seeded = words(weechat, connection.rooms["#general"]) + assert "*" not in seeded + assert not [word for word in seeded if "*" in word] + assert ALICE in seeded, "the hash keys are unaffected" + + +def test_a_comma_in_a_nickname_cannot_seed_two_keys(connected): + """highlight_words is comma-separated, so a comma would split the key.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + deliver(rrc, connection, process, {"op": "nick", "nick": "eve,root"}) + seeded = words(weechat, connection.rooms["#general"]) + assert seeded == ["everoot"] + + +def test_an_empty_nickname_contributes_no_keys(connected): + """No nickname is not a key; seeding "" would be a word matching nothing.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "nick", "nick": ""}) + joined(rrc, connection, process) + assert words(weechat, connection.rooms["#general"]) == [] + + +def test_unchanged_keys_are_not_republished(connected): + """Re-reporting the same identity must not churn every buffer.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + buffer = weechat.state.buffers[connection.rooms["#general"]] + before = list(buffer.sets) + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + assert buffer.sets == before + + +# -- H3: only hub-supplied conversational content can mention you --------- + + +def restrict(weechat, buffer): + """Return the tags WeeChat will let highlight on *buffer*.""" + return weechat.state.buffers[buffer].properties.get("highlight_tags_restrict", "") + + +def test_a_room_buffer_restricts_highlights_to_conversational_tags(connected): + """Somebody talking can mention you; somebody arriving cannot.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + allowed = restrict(weechat, connection.rooms["#general"]).split(",") + assert allowed == ["rrc_msg", "rrc_action", "rrc_notice"] + assert "rrc_join" not in allowed and "rrc_part" not in allowed + assert "rrc_status" not in allowed + + +def test_a_private_buffer_restricts_highlights_to_direct_messages(connected): + """The only conversational content in a private buffer is the DM itself.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "direct", "src": BOB, "body": "psst"}) + assert restrict(weechat, connection.dms[BOB]) == "rrc_direct" + + +def test_a_hub_buffer_restricts_highlights_to_hub_notices(connected): + """The plugin quotes your own keys at you here, so nothing else may match. + + "your identity is " and "you are now known as " each contain a + mention key verbatim. Without the restriction the plugin would mention the + user on its own status output (SPEC D25). + """ + weechat, rrc, connection, process = connected + assert restrict(weechat, connection.buffer) == "rrc_notice" + + +def test_a_status_line_quoting_your_own_key_is_restricted_from_highlighting(connected): + """The structural proof: the tag such a line carries is not on the list.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + buffer = weechat.state.buffers[connection.buffer] + line = [text for text in buffer.lines if ALICE in text] + assert line, "the identity line really does quote the key back" + assert types_of(buffer.tags_for(ALICE)) == ["rrc_status"] + assert "rrc_status" not in restrict(weechat, connection.buffer) + + +def test_a_member_listing_is_restricted_from_highlighting(connected): + """A roster names everyone, including you, after every single join. + + SPEC D15 makes a /who arrive after every join and every rejoin, so without + this the feature would fire a mention on each one -- precisely the noise it + exists to remove. + """ + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process, members=[ALICE, BOB]) + say( + rrc, + connection, + process, + src=BOB, + kind="notice", + room_name=None, + body=f"members in #general: afri ({ALICE[:8]}), bob ({BOB[:8]})", + ) + tags = weechat.state.buffers[connection.buffer].tags_for("members in #general") + assert "no_highlight" in tags + assert types_of(tags) == ["rrc_notice"] + + +def test_an_ordinary_hub_notice_is_not_restricted_from_highlighting(connected): + """Only the roster shape is exempt; a hub announcement may still reach you.""" + weechat, rrc, connection, process = connected + say( + rrc, + connection, + process, + src=BOB, + kind="notice", + room_name=None, + body="hub restarting in 5 minutes", + ) + tags = weechat.state.buffers[connection.buffer].tags_for("hub restarting") + assert "no_highlight" not in tags + + +# -- H4: your own words never notify you ---------------------------------- + + +def test_your_own_room_message_echoed_by_the_hub_never_notifies_you(connected): + """The hub returns every message to its author, so this arrives inbound.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + say(rrc, connection, process, src=ALICE, body="my own words", nick="afri") + tags = room(weechat, connection).tags_for("my own words") + assert notify_of(tags) == ["notify_none"] + assert "no_highlight" in tags, "typing your own name must not highlight you" + assert "self_msg" in tags + + +def test_your_own_echoed_action_never_notifies_you(connected): + """An ACTION of yours comes back the same way a MSG does.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + say(rrc, connection, process, src=ALICE, body="waves", kind="action") + tags = room(weechat, connection).tags_for("waves") + assert notify_of(tags) == ["notify_none"] + assert "no_highlight" in tags + + +def test_another_persons_message_is_not_treated_as_your_echo(connected): + """The suppression is keyed on the sender, never on the text.""" + weechat, rrc, connection, process = connected + deliver(rrc, connection, process, {"op": "identity", "hash": ALICE}) + joined(rrc, connection, process) + say(rrc, connection, process, src=BOB, body="hello afri", nick="bob") + tags = room(weechat, connection).tags_for("hello afri") + assert notify_of(tags) == ["notify_message"] + assert "no_highlight" not in tags + + +def test_an_echo_arriving_before_the_identity_is_known_is_not_silenced(connected): + """With no identity yet there is nothing to compare against, and no guess.""" + weechat, rrc, connection, process = connected + joined(rrc, connection, process) + say(rrc, connection, process, src="", body="anonymous", nick="ghost") + tags = room(weechat, connection).tags_for("anonymous") + assert notify_of(tags) == ["notify_message"] diff --git a/tests/test_weechat_hotlist.py b/tests/test_weechat_hotlist.py new file mode 100644 index 0000000..9aa147f --- /dev/null +++ b/tests/test_weechat_hotlist.py @@ -0,0 +1,308 @@ +# 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. +"""Hotlist levels as scored by a real WeeChat (ACCEPTANCE H1-H4, SPEC D23-D25). + +The unit suite proves which tags the script emits. It cannot prove what WeeChat +does with them, because the stand-in it asserts against is this repository's own +model of WeeChat. This closes that gap: it loads the real ``rrc.py`` into a real +``weechat-headless``, drives a real ``Connection`` with helper events, and reads +``notify_level`` and ``highlight`` back out of WeeChat's own ``line_data``. + +Every "verified against WeeChat 4.10" claim in ``SPEC.md`` D23-D25 is reproduced +here, so a reviewer can re-run them rather than take them on trust. + +Skipped with a stated reason when ``weechat-headless`` is absent. It never +touches the operator's WeeChat home: ``-d`` points at a temporary one. +""" + +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ME = "1f5a80f61a6194267cf6b6df6a954adb" +PEER = "aabbccddeeff00112233445566778899" +#: A third identity, absent from the initial member list, so that its join +#: is actually announced -- _ev_join stays quiet about somebody already in +#: the nicklist. It arrives under the user's own nickname on purpose. +LATE = "ccddeeff00112233445566778899aabb" + +#: WeeChat's own notify levels, from its user guide: -1 never enters the +#: hotlist, 0 is low ("activity"), 1 message, 2 private, 3 highlight. +NEVER, LOW, MESSAGE, PRIVATE, HIGHLIGHT = -1, 0, 1, 2, 3 + +#: Every type tag the script can emit, less the one no probe event produces: +#: rrc_part needs our own PART to be confirmed, which needs a live helper. +EXPECTED_TAGS = { + "rrc_status", + "rrc_join", + "rrc_msg", + "rrc_action", + "rrc_notice", + "rrc_direct", +} + +PROBE = """ +import json, sys +import weechat + +sys.path.insert(0, {root!r}) +# rrc.py guards main() behind __name__ == "__main__", and WeeChat loads a script +# under that name, so importing it as a module here registers nothing. +import rrc + +ME, PEER, LATE, OUT = {me!r}, {peer!r}, {late!r}, {out!r} + + +def rrc_input_cb(data, buffer, text): + return weechat.WEECHAT_RC_OK + + +def rrc_close_cb(data, buffer): + return weechat.WEECHAT_RC_OK + + +def run(data, remaining): + c = rrc.Connection("probe", "1f8a0102030405060708090a0b0cb3c5", nick="afri") + c.on_event({{"op": "identity", "hash": ME}}) + c.on_event({{"op": "joined", "room": "#general", "members": [ME, PEER]}}) + for event in [ + {{"kind": "msg", "nick": "bob", "body": "ordinary MARK-plain"}}, + {{"kind": "msg", "nick": "bob", "body": "hey afri MARK-nick"}}, + {{"kind": "msg", "nick": "bob", "body": "ping " + ME[:8] + " MARK-short"}}, + {{"kind": "msg", "nick": "bob", "body": "ping " + ME + " MARK-full"}}, + {{"kind": "msg", "nick": "bob", "body": "MARK-substring afrikaans"}}, + {{"kind": "action", "nick": "bob", "body": "waves MARK-action"}}, + {{"kind": "notice", "nick": "bob", "body": "MARK-roomnotice afri"}}, + ]: + event.update({{"op": "chat", "room": "#general", "src": PEER}}) + c.on_event(event) + # The hub returns every message to its author, so this is how your own + # words arrive -- naming yourself, which must still stay silent. + c.on_event({{"op": "chat", "kind": "msg", "room": "#general", "src": ME, + "nick": "afri", "body": "my own afri MARK-echo"}}) + # The arriving peer is deliberately given the user's own nickname: the join + # line then matches a seeded word and must stay grey regardless. + c.on_event({{"op": "join", "room": "#general", "members": [LATE], + "nick": "afri"}}) + c.on_event({{"op": "direct", "src": PEER, "nick": "bob", "body": "MARK-dm"}}) + c.on_event({{"op": "chat", "kind": "notice", "src": PEER, + "body": "MARK-hubnotice from the hub"}}) + # Activity is only the *default* level for hub chatter: a hub announcement + # naming the user must still be able to reach them. + c.on_event({{"op": "chat", "kind": "notice", "src": PEER, + "body": "MARK-hubmention afri your registration expires"}}) + c.on_event({{"op": "chat", "kind": "notice", "src": PEER, + "body": "members in #general: afri (" + ME[:8] + "), bob MARK-roster"}}) + + hb = weechat.hdata_get("buffer") + hls = weechat.hdata_get("lines") + hl = weechat.hdata_get("line") + hd = weechat.hdata_get("line_data") + scored = [] + for buffer in [c.buffer] + list(c.rooms.values()) + list(c.dms.values()): + line = weechat.hdata_pointer( + hls, weechat.hdata_pointer(hb, buffer, "own_lines"), "first_line") + while line: + d = weechat.hdata_pointer(hl, line, "data") + count = weechat.hdata_integer(hd, d, "tags_count") + scored.append({{ + "message": weechat.hdata_string(hd, d, "message"), + "notify": weechat.hdata_char(hd, d, "notify_level"), + "highlight": weechat.hdata_char(hd, d, "highlight"), + "tags": [weechat.hdata_string(hd, d, "%d|tags_array" % i) + for i in range(count)], + }}) + line = weechat.hdata_move(hl, line, 1) + open(OUT, "w").write(json.dumps({{ + "lines": scored, + "highlight_words": weechat.buffer_get_string( + c.rooms["#general"], "highlight_words"), + "restrict": weechat.buffer_get_string( + c.rooms["#general"], "highlight_tags_restrict"), + }})) + weechat.command("", "/quit") + return weechat.WEECHAT_RC_OK + + +weechat.register("probe", "x", "1", "Apache-2.0", "probe", "", "") +weechat.hook_timer(300, 0, 1, "run", "") +""" + + +@pytest.fixture(scope="module") +def scored(tmp_path_factory): + """Return what a real WeeChat made of every line the script printed.""" + binary = shutil.which("weechat-headless") + if binary is None: + pytest.skip("weechat-headless is not installed; hotlist scoring unverifiable") + home = tmp_path_factory.mktemp("wchome") + autoload = home / "python" / "autoload" + autoload.mkdir(parents=True) + out = home / "scored.json" + autoload.joinpath("probe.py").write_text( + PROBE.format(root=str(ROOT), me=ME, peer=PEER, late=LATE, out=str(out)) + ) + try: + subprocess.run( + [binary, "-d", str(home), "--no-connect"], + timeout=90, + capture_output=True, + check=False, + ) + except subprocess.TimeoutExpired: # pragma: no cover - a hung WeeChat + pytest.skip("weechat-headless did not exit; cannot score") + if not out.exists(): # pragma: no cover - the probe failed to run + pytest.skip("the probe script did not run inside WeeChat") + return json.loads(out.read_text()) + + +def mark(scored, token): + """Return the single scored line carrying *token*.""" + hits = [line for line in scored["lines"] if token in line["message"]] + assert len(hits) == 1, f"expected one line marked {token}, found {len(hits)}" + return hits[0] + + +def tagged(scored, tag): + """Return every scored line carrying *tag*.""" + return [line for line in scored["lines"] if tag in line["tags"]] + + +def test_the_probe_really_rendered_every_surface(scored): + """Guard the guard: a partial run could leave a surface silently untested.""" + seen = { + tag + for line in scored["lines"] + for tag in line["tags"] + if tag.startswith("rrc_") and not tag.startswith("rrc_id_") + } + assert seen == EXPECTED_TAGS, f"surfaces missing: {EXPECTED_TAGS - seen}" + assert scored["restrict"] == "rrc_msg,rrc_action,rrc_notice" + + +def test_an_ordinary_room_message_is_scored_at_message_level(scored): + """SPEC D23: a MSG from somebody else is "someone is talking".""" + assert mark(scored, "MARK-plain")["notify"] == MESSAGE + assert mark(scored, "MARK-plain")["highlight"] == 0 + + +def test_an_action_is_scored_at_message_level(scored): + """SPEC D23: an ACTION is a person talking, so it scores like one.""" + assert mark(scored, "MARK-action")["notify"] == MESSAGE + + +def test_a_room_notice_is_scored_at_message_level(scored): + """SPEC D23: a NOTICE addressed to a room is somebody addressing it.""" + assert mark(scored, "MARK-roomnotice")["notify"] >= MESSAGE + + +def test_a_direct_message_is_scored_at_private_level(scored): + """SPEC D23: a DM earns WeeChat's own fourth level.""" + assert mark(scored, "MARK-dm")["notify"] == PRIVATE + + +def test_a_hub_wide_notice_stays_at_activity_level(scored): + """SPEC D23: hub chatter is activity, not a message.""" + assert mark(scored, "MARK-hubnotice")["notify"] == LOW + assert mark(scored, "MARK-hubnotice")["highlight"] == 0 + + +def test_a_hub_notice_naming_you_still_reaches_highlight_level(scored): + """SPEC D23: activity is the default for hub chatter, not a ceiling. + + The hub buffer's highlight_tags_restrict allows rrc_notice precisely so an + announcement that names the user can still reach them; only the roster + shape is exempted, and this is the contrast that proves the exemption is + narrow rather than a blanket silencing of the hub buffer. + """ + assert mark(scored, "MARK-hubmention")["notify"] == HIGHLIGHT + assert mark(scored, "MARK-hubmention")["highlight"] == 1 + + +def test_a_join_line_stays_at_activity_level_even_naming_you(scored): + """SPEC D25: the restriction, doing the work no per-line excuse could. + + The arriving peer carries the user's own nickname, so the line's text + matches a seeded word. rrc_join is not on the room's + highlight_tags_restrict list, so WeeChat cannot promote it anyway. + """ + joins = tagged(scored, "rrc_join") + assert joins, "no join line was rendered, so this would prove nothing" + assert any("afri" in line["message"] for line in joins), "no join names the user" + for line in joins: + assert line["notify"] == LOW + assert line["highlight"] == 0 + + +def test_your_nickname_is_a_mention(scored): + """SPEC D24: WeeChat promotes the line itself, from a word we only seeded.""" + assert mark(scored, "MARK-nick")["notify"] == HIGHLIGHT + assert mark(scored, "MARK-nick")["highlight"] == 1 + + +def test_your_short_identity_hash_is_a_mention(scored): + """SPEC D24: the short form is what renders, so it is what people type.""" + assert mark(scored, "MARK-short")["notify"] == HIGHLIGHT + assert mark(scored, "MARK-short")["highlight"] == 1 + + +def test_your_full_identity_hash_is_a_mention(scored): + """SPEC D24: the authoritative identifier reaches you too.""" + assert mark(scored, "MARK-full")["notify"] == HIGHLIGHT + assert mark(scored, "MARK-full")["highlight"] == 1 + + +def test_your_nickname_inside_a_longer_word_is_not_a_mention(scored): + """SPEC D24: word boundaries are WeeChat's business, and it gets them right.""" + assert mark(scored, "MARK-substring")["notify"] == MESSAGE + assert mark(scored, "MARK-substring")["highlight"] == 0 + + +def test_your_own_echoed_words_never_notify_you(scored): + """SPEC D25: the hub echoes your MSG back, naming you, and it stays silent. + + Neither the buffer's nick local variable nor the self_msg tag suppresses a + self-highlight in WeeChat 4.10, which is why the tags say so explicitly. + """ + assert mark(scored, "MARK-echo")["notify"] == NEVER + assert mark(scored, "MARK-echo")["highlight"] == 0 + + +def test_a_member_listing_naming_you_never_notifies_you(scored): + """SPEC D25: a /who reply arrives after every join and names everyone.""" + assert mark(scored, "MARK-roster")["highlight"] == 0 + assert mark(scored, "MARK-roster")["notify"] == LOW + + +def test_the_plugins_own_status_line_cannot_mention_you(scored): + """SPEC D25: "your identity is " quotes a mention key verbatim.""" + quoting = [line for line in tagged(scored, "rrc_status") if ME in line["message"]] + assert quoting, "no status line quotes the hash, so this would prove nothing" + for line in quoting: + assert line["highlight"] == 0 + assert line["notify"] == LOW + + +def test_the_seeded_words_are_the_three_mention_keys(scored): + """SPEC D24: what the plugin published is what WeeChat holds.""" + assert sorted(scored["highlight_words"].split(",")) == sorted([ME, ME[:8], "afri"])