From 6446debb524d505d80a9f0949403458c097e4b47 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Fri, 22 May 2026 12:05:15 -0400 Subject: [PATCH] /me added support for rrcd ACTIONs and advisory K_NICK on join/part --- nomadnet/RRC.py | 77 +++++++++++++++++++++++++++++++++- nomadnet/ui/textui/Channels.py | 22 +++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 04e212e..6a58845 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -57,6 +57,7 @@ T_PARTED = 13 T_MSG = 20 T_NOTICE = 21 +T_ACTION = 22 T_PING = 30 T_PONG = 31 @@ -81,6 +82,7 @@ L_MAX_ROOMS_PER_SESSION = 3 L_RATE_LIMIT_MSGS_PER_MINUTE= 4 CAP_RESOURCE_ENVELOPE = 0 +CAP_ACTION = 1 B_RES_ID = 0 B_RES_KIND = 1 @@ -216,6 +218,7 @@ class RRCHub: self.welcomed = False self.hub_name = None self.hub_version = None + self.hub_caps = {} self.motd = None self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES @@ -440,7 +443,10 @@ class RRCHub: body = { B_HELLO_NAME: "nomadnet", B_HELLO_VER: "0.1", - B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, + B_HELLO_CAPS: { + CAP_RESOURCE_ENVELOPE: True, + CAP_ACTION: True, + }, } env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) nick = self.get_effective_nick() @@ -626,6 +632,23 @@ class RRCHub: self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) return mid + def send_action(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("action text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("action too long for hub limit") + env = _make_envelope(T_ACTION, src=self.manager.identity.hash, room=r, body=text) + nick = self.get_effective_nick() + if nick: + env[K_NICK] = nick + mid = env[K_ID] + if isinstance(mid, (bytes, bytearray)): + self._sent_ids.append(bytes(mid)) + self._send_env(env) + self._record_message(RRCMessage("action", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + def _per_room_cap(self): try: v = int(getattr(self.manager.app, "rrc_history_per_room_cap", 0)) @@ -865,6 +888,9 @@ class RRCHub: ver = body.get(B_WELCOME_VER) if isinstance(ver, str): self.hub_version = ver + caps = body.get(B_WELCOME_CAPS) + if isinstance(caps, dict): + self.hub_caps = dict(caps) limits = body.get(B_WELCOME_LIMITS) if isinstance(limits, dict): if L_MAX_NICK_BYTES in limits: @@ -897,6 +923,7 @@ class RRCHub: if isinstance(room, str) and room: r = room.strip().lower() body = env.get(K_BODY) + joiner_nick = env.get(K_NICK) own_hash = self.manager.identity.hash if self.manager.identity is not None else None body_hashes = [] @@ -920,6 +947,13 @@ class RRCHub: if own_hash is not None: members.add(own_hash) + # rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it + # so display_name_for() can render the nick instead of a hash prefix. + if (not self_join) and isinstance(joiner_nick, str) and joiner_nick and len(body_hashes) == 1: + jh = body_hashes[0] + if own_hash is None or jh != own_hash: + self.nicks[jh] = joiner_nick + if self_join: if not silent: self._record_system(r, "You joined #"+r) @@ -946,6 +980,7 @@ class RRCHub: if isinstance(room, str) and room: r = room.strip().lower() body = env.get(K_BODY) + parter_nick = env.get(K_NICK) own_hash = self.manager.identity.hash if self.manager.identity is not None else None body_hashes = [] @@ -957,6 +992,13 @@ class RRCHub: if self_part: self._pending_parts.discard(r) + # rrcd 0.3.2 attaches advisory K_NICK to fanout PARTED; learn it + # before forgetting the member so " left" renders correctly. + if (not self_part) and isinstance(parter_nick, str) and parter_nick and len(body_hashes) == 1: + ph = body_hashes[0] + if own_hash is None or ph != own_hash: + self.nicks[ph] = parter_nick + members = self.members.get(r) if members is not None: for h in body_hashes: @@ -1009,6 +1051,39 @@ class RRCHub: self._record_message(msg) return + if t == T_ACTION: + body = env.get(K_BODY) + room = env.get(K_ROOM) + src = env.get(K_SRC) + nick = env.get(K_NICK) + mid = env.get(K_ID) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash: + if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids: + return + if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick: + with self._lock: + self.nicks[bytes(src)] = nick + if isinstance(room, str) and room: + self.members.setdefault(room.strip().lower(), set()).add(bytes(src)) + if isinstance(body, str): + msg = RRCMessage( + "action", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + nick if isinstance(nick, str) else None, + body, + _now_ms(), + ) + is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + if not is_own: + own_nick = self.get_effective_nick() + pat = _mention_re(own_nick) + if pat is not None and pat.search(body): + msg.mention = True + self._record_message(msg) + return + if t == T_NOTICE: body = env.get(K_BODY) room = env.get(K_ROOM) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index ae4f19c..f9a99f2 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -916,6 +916,7 @@ class RoomWidget(urwid.WidgetWrap): "/join - join a room on this hub", "/part [room] - leave a room (default: current)", "/leave [room] - alias for /part", + "/me - send an action (e.g. /me waves)", "/nick - set your nick on this hub only", "/who [room] - list users (current room if omitted)", "/names [room] - alias for /who", @@ -1015,6 +1016,22 @@ class RoomWidget(urwid.WidgetWrap): self._local_message("error", "Part failed: "+str(e)) return + if cmd == "me": + if not self._require_connected(): + return + if not arg: + self._local_message("error", "Usage: /me ") + return + limit = self.hub.max_msg_body_bytes or 350 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Action too long (max "+str(limit)+" bytes)") + return + try: + self.hub.send_action(self.room, arg) + except Exception as e: + self._local_message("error", "/me failed: "+str(e)) + return + if cmd == "nick": if not arg: cur = self.hub.get_effective_nick() or " unset" @@ -1309,7 +1326,10 @@ def _message_widget(app, hub, m, link_delegate=None): irc_ts = f"`F{t['ts']}" prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" - nick_micron = f"`f{nick_attr}<{sender}>`f " + if m.kind == "action": + nick_micron = f"`f{nick_attr}* {sender}`f " + else: + nick_micron = f"`f{nick_attr}<{sender}>`f " if app.rrc_ui_justify_msgs: prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"]) body_rendered = _render_body(f"{nick_micron}{message_body}", link_delegate=ld, fg=t["text"])