From e17d9a8cea71c8311e1a07bf299dcd85fd7dd982 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:26 -0400 Subject: [PATCH 01/14] Initial RRC protocol support and Channels functionality --- nomadnet/NomadNetworkApp.py | 11 + nomadnet/RRC.py | 1069 +++++++++++++++++++++++ nomadnet/ui/TextUI.py | 16 +- nomadnet/ui/textui/Browser.py | 61 ++ nomadnet/ui/textui/Channels.py | 1488 ++++++++++++++++++++++++++++++++ nomadnet/ui/textui/Main.py | 12 +- nomadnet/vendor/cbor.py | 430 +++++++++ 7 files changed, 3084 insertions(+), 3 deletions(-) create mode 100644 nomadnet/RRC.py create mode 100644 nomadnet/ui/textui/Channels.py create mode 100644 nomadnet/vendor/cbor.py diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 62d2086..b0d99e2 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -41,6 +41,13 @@ class NomadNetworkApp: RNS.log("Saving directory...", RNS.LOG_VERBOSE) self.directory.save_to_disk() + if hasattr(self, "rrc") and self.rrc is not None: + try: + self.rrc.save() + self.rrc.shutdown() + except Exception: + pass + if hasattr(self.ui, "restore_ixon"): if self.ui.restore_ixon: try: @@ -301,6 +308,10 @@ class NomadNetworkApp: self.directory = nomadnet.Directory(self) + from nomadnet.RRC import RRCManager + self.rrc = RRCManager(self) + self.rrc.load() + static_peers = [] for static_peer in self.static_peers: try: diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py new file mode 100644 index 0000000..ff3b95b --- /dev/null +++ b/nomadnet/RRC.py @@ -0,0 +1,1069 @@ +import os +import re +import time +import threading +import hashlib +from collections import deque + +import RNS + +from nomadnet.vendor import cbor + + +_MENTION_RE_CACHE = {} + + +def _mention_re(nick): + if not isinstance(nick, str) or not nick: + return None + pat = _MENTION_RE_CACHE.get(nick) + if pat is None: + pat = re.compile(r"(? 32: + _MENTION_RE_CACHE.clear() + _MENTION_RE_CACHE[nick] = pat + return pat + +# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py +RRC_VERSION = 1 + +K_V = 0 +K_T = 1 +K_ID = 2 +K_TS = 3 +K_SRC = 4 +K_ROOM = 5 +K_BODY = 6 +K_NICK = 7 + +T_HELLO = 1 +T_WELCOME = 2 + +T_JOIN = 10 +T_JOINED = 11 +T_PART = 12 +T_PARTED = 13 + +T_MSG = 20 +T_NOTICE = 21 + +T_PING = 30 +T_PONG = 31 + +T_ERROR = 40 + +T_RESOURCE_ENVELOPE = 50 + +B_HELLO_NAME = 0 +B_HELLO_VER = 1 +B_HELLO_CAPS = 2 + +B_WELCOME_HUB = 0 +B_WELCOME_VER = 1 +B_WELCOME_CAPS = 2 +B_WELCOME_LIMITS = 3 + +L_MAX_NICK_BYTES = 0 +L_MAX_ROOM_NAME_BYTES = 1 +L_MAX_MSG_BODY_BYTES = 2 +L_MAX_ROOMS_PER_SESSION = 3 +L_RATE_LIMIT_MSGS_PER_MINUTE= 4 + +CAP_RESOURCE_ENVELOPE = 0 + +B_RES_ID = 0 +B_RES_KIND = 1 +B_RES_SIZE = 2 +B_RES_SHA256 = 3 +B_RES_ENCODING = 4 + +RES_KIND_NOTICE = "notice" +RES_KIND_MOTD = "motd" +RES_KIND_BLOB = "blob" + +DEFAULT_DEST_NAME = "rrc.hub" +DEFAULT_MAX_NICK_BYTES = 32 +DEFAULT_MAX_ROOM_BYTES = 64 +DEFAULT_MAX_MSG_BYTES = 350 +DEFAULT_MAX_ROOMS = 32 +DEFAULT_RATE_PER_MINUTE = 240 + + + + + + +def _now_ms(): + return int(time.time()*1000) + + +def _msg_id(): + return os.urandom(8) + + +def _parse_room_list_notice(text): + if not isinstance(text, str): + return None + stripped = text.strip() + if stripped == "No public rooms registered": + return {} + lines = text.split("\n") + if not lines or not lines[0].lstrip().startswith("Registered public rooms"): + return None + rooms = {} + for line in lines[1:]: + s = line.strip() + if not s: + continue + if " - " in s: + name, topic = s.split(" - ", 1) + rooms[name.strip().lower()] = topic.strip() or None + else: + rooms[s.strip().lstrip("#").lower()] = None + return rooms + + +def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None): + env = { + K_V: RRC_VERSION, + K_T: int(msg_type), + K_ID: mid or _msg_id(), + K_TS: ts or _now_ms(), + K_SRC: src, + } + if room is not None: + env[K_ROOM] = room + if body is not None: + env[K_BODY] = body + if nick is not None and nick != "": + env[K_NICK] = nick + return env + + +class RRCMessage: + def __init__(self, kind, room, src, nick, text, ts): + self.kind = kind + self.room = room + self.src = src + self.nick = nick + self.text = text + self.ts = ts + self.mention = False + + +class RRCHub: + STATUS_DISCONNECTED = 0 + STATUS_CONNECTING = 1 + STATUS_CONNECTED = 2 + STATUS_FAILED = 3 + + def __init__(self, manager, hub_hash, dest_name=None, name=None): + self.manager = manager + self.hub_hash = hub_hash + self.dest_name = dest_name or DEFAULT_DEST_NAME + self.name = name or RNS.prettyhexrep(hub_hash) + + self.link = None + self.status = RRCHub.STATUS_DISCONNECTED + self.status_text = "Disconnected" + self.welcomed = False + self.hub_name = None + self.hub_version = None + + self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES + self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES + self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES + self.max_rooms_per_session = DEFAULT_MAX_ROOMS + self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE + + self.rooms = set() + self.messages = {} + self.notices = [] + self.unread_rooms = set() + self.mention_rooms = set() + self.members = {} + self.nicks = {} + + self.auto_reconnect = False + self.auto_list = False + + self._lock = threading.RLock() + self._resource_expectations = {} + self._sent_ids = deque(maxlen=256) + + self._hello_thread = None + self._stop_hello = threading.Event() + self._manual_disconnect = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._pending_pings = {} + + self.available_rooms = {} + self._silent_list_pending = 0 + + def _log(self, msg, level=None): + if level is None: + level = RNS.LOG_INFO + RNS.log("[RRC "+self.name+"] "+msg, level) + + def add_room(self, room): + room_n = self._normalize_room(room) + with self._lock: + self.rooms.add(room_n) + if room_n not in self.messages: + self.messages[room_n] = [] + self.manager.save() + self.manager._notify_change(self) + return room_n + + def remove_room(self, room): + r = self._normalize_room(room) + with self._lock: + self.rooms.discard(r) + self.messages.pop(r, None) + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.members.pop(r, None) + self.manager.save() + self.manager._notify_change(self) + + def get_members(self, room): + with self._lock: + return list(self.members.get(room, set())) + + def display_name_for(self, peer): + if not isinstance(peer, (bytes, bytearray)): + return "" + ph = bytes(peer) + with self._lock: + nick = self.nicks.get(ph) + if nick: + return nick + return ph.hex()[:10] + + def mark_read(self, room): + r = self._normalize_room(room) + with self._lock: + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self.manager._notify_change(self) + + def _normalize_room(self, room): + r = (room or "").strip().lower() + if not r: + raise ValueError("room must not be empty") + return r + + def _set_status(self, status, text=None): + self.status = status + if text is not None: + self.status_text = text + self.manager._notify_change(self) + + def connect(self): + with self._lock: + if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED): + return + self._manual_disconnect = False + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + self._set_status(RRCHub.STATUS_CONNECTING, "Connecting") + + t = threading.Thread(target=self._connect_worker, daemon=True) + t.start() + + def _connect_worker(self): + try: + timeout_s = 20.0 + if not RNS.Transport.has_path(self.hub_hash): + RNS.Transport.request_path(self.hub_hash) + deadline = time.monotonic() + min(5.0, timeout_s) + while time.monotonic() < deadline: + if RNS.Transport.has_path(self.hub_hash): + break + time.sleep(0.1) + + hub_identity = None + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + hub_identity = RNS.Identity.recall(self.hub_hash) + if hub_identity is not None: + break + time.sleep(0.2) + + if hub_identity is None: + self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown") + return + + app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name) + hub_dest = RNS.Destination( + hub_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + + if hub_dest.hash != self.hub_hash: + self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch") + return + + self._stop_hello.clear() + link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed) + link.set_packet_callback(lambda data, pkt: self._on_packet(data)) + with self._lock: + self.link = link + + except Exception as e: + self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e)) + + def _on_established(self, link): + try: + link.set_resource_strategy(RNS.Link.ACCEPT_APP) + link.set_resource_callback(self._resource_advertised) + link.set_resource_started_callback(self._resource_advertised) + link.set_resource_concluded_callback(self._resource_concluded) + except Exception: + pass + + try: + link.identify(self.manager.identity) + except Exception as e: + self._log("identify failed: "+str(e), RNS.LOG_ERROR) + try: link.teardown() + except Exception: pass + return + + self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO") + + def hello_loop(): + attempts = 0 + while not self._stop_hello.is_set() and not self.welcomed and attempts < 5: + with self._lock: + cur_link = self.link + if cur_link is None or cur_link.status != RNS.Link.ACTIVE: + return + try: + self._send_hello(cur_link) + except Exception as e: + self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR) + attempts += 1 + self._stop_hello.wait(timeout=3.0) + if not self.welcomed and not self._stop_hello.is_set(): + self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout") + try: + with self._lock: + if self.link is not None: + self.link.teardown() + except Exception: + pass + + self._hello_thread = threading.Thread(target=hello_loop, daemon=True) + self._hello_thread.start() + + def _send_hello(self, link): + body = { + B_HELLO_NAME: "nomadnet", + B_HELLO_VER: "0.1", + B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, + } + env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + payload = cbor.encode(env) + RNS.Packet(link, payload).send() + + def _on_closed(self, link): + self._stop_hello.set() + with self._lock: + self.link = None + self.welcomed = False + self.members.clear() + self._resource_expectations.clear() + should_reconnect = self.auto_reconnect and not self._manual_disconnect + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + if should_reconnect: + self._schedule_reconnect() + + def _schedule_reconnect(self): + with self._lock: + self._reconnect_attempts += 1 + backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6))) + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + + def fire(): + with self._lock: + self._reconnect_timer = None + if self._manual_disconnect or not self.auto_reconnect: + return + self._set_status(RRCHub.STATUS_CONNECTING, "Reconnecting (attempt "+str(self._reconnect_attempts)+")") + self.connect() + + self._reconnect_timer = threading.Timer(backoff, fire) + self._reconnect_timer.daemon = True + self._reconnect_timer.start() + self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") + + + + def disconnect(self): + self._stop_hello.set() + with self._lock: + self._manual_disconnect = True + self._reconnect_attempts = 0 + if self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + link = self.link + self.link = None + if link is not None: + try: link.teardown() + except Exception: pass + self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") + + def set_auto_reconnect(self, enabled): + with self._lock: + self.auto_reconnect = bool(enabled) + if not enabled and self._reconnect_timer is not None: + self._reconnect_timer.cancel() + self._reconnect_timer = None + self.manager.save() + self.manager._notify_change(self) + + def set_auto_list(self, enabled): + with self._lock: + self.auto_list = bool(enabled) + self.manager.save() + self.manager._notify_change(self) + + def _packet_would_fit(self, link, payload): + try: + pkt = RNS.Packet(link, payload) + pkt.pack() + return True + except Exception: + return False + + def _send_env(self, env): + with self._lock: + link = self.link + if link is None or link.status != RNS.Link.ACTIVE: + raise RuntimeError("not connected") + payload = cbor.encode(env) + if not self._packet_would_fit(link, payload): + raise RuntimeError("message exceeds link MTU") + RNS.Packet(link, payload).send() + + def join_room(self, room, key=None): + r = self._normalize_room(room) + body = key if (isinstance(key, str) and key) else None + env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + self._send_env(env) + with self._lock: + if r not in self.messages: + self.messages[r] = [] + self.manager._notify_change(self) + + def send_command(self, text, room=None): + if not isinstance(text, str) or not text.startswith("/"): + raise ValueError("command must start with /") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) + nick = self.manager.get_nickname() + if nick: + env[K_NICK] = nick + self._send_env(env) + + def send_ping(self, room=None): + body = os.urandom(8) + env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body) + with self._lock: + now_ms = _now_ms() + self._pending_pings[body] = (now_ms, room) + expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000] + for k in expired: + self._pending_pings.pop(k, None) + self._send_env(env) + return body + + def part_room(self, room): + room_n = self._normalize_room(room) + env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + try: + self._send_env(env) + except Exception: + pass + with self._lock: + self.rooms.discard(room_n) + self.manager.save() + self.manager._notify_change(self) + + def send_message(self, room, text): + r = self._normalize_room(room) + if not isinstance(text, str) or not text.strip(): + raise ValueError("message text must be non-empty") + if len(text.encode("utf-8")) > self.max_msg_body_bytes: + raise ValueError("message too long for hub limit") + env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) + nick = self.manager.get_nickname() + 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("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True) + return mid + + def _record_message(self, msg, local=False): + with self._lock: + buf = self.messages.setdefault(msg.room or "*", []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + if not local and msg.room: + if msg.room != self.manager.active_room_for(self): + self.unread_rooms.add(msg.room) + if msg.mention: + self.mention_rooms.add(msg.room) + self.manager._notify_messages(self, msg) + + def _record_system(self, room, text): + if not room: + return + msg = RRCMessage("system", room, None, None, text, _now_ms()) + with self._lock: + buf = self.messages.setdefault(room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.manager._notify_messages(self, msg) + + def _record_notice(self, msg): + target_room = msg.room + if not target_room: + target_room = self.manager.active_room_for(self) + if target_room: + msg.room = target_room + + with self._lock: + self.notices.append(msg) + if len(self.notices) > 200: + del self.notices[:len(self.notices)-200] + if target_room: + buf = self.messages.setdefault(target_room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + if target_room != self.manager.active_room_for(self): + self.unread_rooms.add(target_room) + self.manager._notify_messages(self, msg) + + def get_messages(self, room): + with self._lock: + buf = list(self.messages.get(room, [])) + return buf + + def _on_packet(self, data): + try: + env = cbor.decode(data) + except Exception as e: + self._log("decode failed: "+str(e), RNS.LOG_DEBUG) + return + if not isinstance(env, dict): + return + try: + t = env.get(K_T) + except Exception: + return + + if t == T_PING: + try: + pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY)) + self._send_env(pong) + except Exception: + pass + return + + if t == T_PONG: + body = env.get(K_BODY) + if isinstance(body, (bytes, bytearray)): + key = bytes(body) + with self._lock: + pending = self._pending_pings.pop(key, None) + if pending is not None: + sent_ms, room = pending + rtt_ms = max(0, _now_ms() - sent_ms) + self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms") + return + + if t == T_WELCOME: + self.welcomed = True + body = env.get(K_BODY) + if isinstance(body, dict): + hub_name = body.get(B_WELCOME_HUB) + if isinstance(hub_name, str): + self.hub_name = hub_name + ver = body.get(B_WELCOME_VER) + if isinstance(ver, str): + self.hub_version = ver + limits = body.get(B_WELCOME_LIMITS) + if isinstance(limits, dict): + if L_MAX_NICK_BYTES in limits: + self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES]) + if L_MAX_ROOM_NAME_BYTES in limits: + self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES]) + if L_MAX_MSG_BODY_BYTES in limits: + self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES]) + if L_MAX_ROOMS_PER_SESSION in limits: + self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION]) + if L_RATE_LIMIT_MSGS_PER_MINUTE in limits: + self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE]) + self._set_status(RRCHub.STATUS_CONNECTED, "Connected") + with self._lock: + self._reconnect_attempts = 0 + self.manager._on_welcome(self) + if self.auto_list: + try: + with self._lock: + self._silent_list_pending += 1 + self.send_command("/list", room=None) + except Exception: + with self._lock: + if self._silent_list_pending > 0: + self._silent_list_pending -= 1 + return + + if t == T_JOINED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + src = env.get(K_SRC) + nick = env.get(K_NICK) + body = env.get(K_BODY) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + self_join = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + with self._lock: + self.rooms.add(r) + if r not in self.messages: + self.messages[r] = [] + members = self.members.setdefault(r, set()) + if isinstance(body, list): + for entry in body: + if isinstance(entry, (bytes, bytearray)): + members.add(bytes(entry)) + if isinstance(src, (bytes, bytearray)): + sb = bytes(src) + if own_hash is None or sb != own_hash: + members.add(sb) + if isinstance(nick, str) and nick: + self.nicks[sb] = nick + if own_hash is not None: + members.add(own_hash) + if self_join: + self._record_system(r, "You joined #"+r) + self.manager.save() + elif isinstance(src, (bytes, bytearray)): + self._record_system(r, self.display_name_for(src)+" joined") + self.manager._notify_change(self) + return + + if t == T_PARTED: + room = env.get(K_ROOM) + if isinstance(room, str) and room: + r = room.strip().lower() + src = env.get(K_SRC) + body = env.get(K_BODY) + own_hash = self.manager.identity.hash if self.manager.identity is not None else None + self_part = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + with self._lock: + members = self.members.get(r) + if isinstance(body, list): + for entry in body: + if isinstance(entry, (bytes, bytearray)) and members is not None: + members.discard(bytes(entry)) + elif isinstance(src, (bytes, bytearray)) and members is not None: + members.discard(bytes(src)) + if self_part: + self.rooms.discard(r) + self.members.pop(r, None) + if self_part: + self.manager.save() + if not self_part and isinstance(src, (bytes, bytearray)): + self._record_system(r, self.display_name_for(src)+" left") + self.manager._notify_change(self) + return + + if t == T_MSG: + 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( + "msg", + 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.manager.get_nickname() + 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) + src = env.get(K_SRC) + if isinstance(body, str): + parsed = _parse_room_list_notice(body) + if parsed is not None: + with self._lock: + self.available_rooms = parsed + silent = self._silent_list_pending > 0 + if silent: + self._silent_list_pending -= 1 + self.manager._notify_change(self) + if silent: + return + msg = RRCMessage( + "notice", + room.strip().lower() if isinstance(room, str) else None, + bytes(src) if isinstance(src, (bytes, bytearray)) else None, + None, + body, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_ERROR: + body = env.get(K_BODY) + room = env.get(K_ROOM) + text = body if isinstance(body, str) else "(error)" + msg = RRCMessage( + "error", + room.strip().lower() if isinstance(room, str) else None, + None, + None, + text, + _now_ms(), + ) + self._record_notice(msg) + return + + if t == T_RESOURCE_ENVELOPE: + body = env.get(K_BODY) + if not isinstance(body, dict): + return + try: + rid = body.get(B_RES_ID) + kind = body.get(B_RES_KIND) + size = body.get(B_RES_SIZE) + sha256 = body.get(B_RES_SHA256) + encoding = body.get(B_RES_ENCODING) + if not isinstance(rid, (bytes, bytearray)): return + if not isinstance(kind, str): return + if not isinstance(size, int) or size <= 0: return + room = env.get(K_ROOM) + with self._lock: + self._resource_expectations[bytes(rid)] = { + "kind": kind, + "size": size, + "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None, + "encoding": encoding if isinstance(encoding, str) else "utf-8", + "room": room.strip().lower() if isinstance(room, str) else None, + "expires": time.monotonic()+30.0, + } + except Exception: + pass + return + + def _resource_advertised(self, resource): + try: + if hasattr(resource, "get_data_size"): + size = resource.get_data_size() + elif hasattr(resource, "total_size"): + size = resource.total_size + else: + size = getattr(resource, "size", 0) + except Exception: + return False + if size > 262144: + return False + return True + + def _resource_concluded(self, resource): + try: + if resource.status != RNS.Resource.COMPLETE: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + return + try: + size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0) + except Exception: + size = 0 + data = None + try: + data = resource.data.read() + finally: + try: + if hasattr(resource, "data") and resource.data: + resource.data.close() + except Exception: + pass + if data is None: + return + + now = time.monotonic() + matched = None + with self._lock: + expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now] + for k in expired: + self._resource_expectations.pop(k, None) + for k, exp in list(self._resource_expectations.items()): + if exp["size"] == len(data): + matched = exp + self._resource_expectations.pop(k, None) + break + + kind = matched["kind"] if matched else RES_KIND_BLOB + room = matched["room"] if matched else None + encoding = matched["encoding"] if matched else "utf-8" + sha = matched["sha256"] if matched else None + if sha is not None: + if hashlib.sha256(data).digest() != sha: + return + if kind in (RES_KIND_NOTICE, RES_KIND_MOTD): + try: + text = data.decode(encoding, errors="replace") + except Exception: + return + msg = RRCMessage("notice", room, None, None, text, _now_ms()) + self._record_notice(msg) + except Exception as e: + self._log("resource handling failed: "+str(e), RNS.LOG_ERROR) + + +class RRCManager: + def __init__(self, app): + self.app = app + self.hubs = [] + self._lock = threading.RLock() + self._change_callback = None + self._message_callback = None + self._active_hub = None + self._active_room = None + self._loaded = False + self._loading = False + self._save_lock = threading.Lock() + + @property + def identity(self): + return self.app.identity + + def get_nickname(self): + try: + n = self.app.peer_settings.get("display_name") + if isinstance(n, str): + return n + except Exception: + pass + return None + + def set_change_callback(self, cb): + self._change_callback = cb + + def set_message_callback(self, cb): + self._message_callback = cb + + def _notify_change(self, hub=None): + try: + if self._change_callback is not None: + self._change_callback(hub) + except Exception: + pass + + def _notify_messages(self, hub, msg): + try: + if self._message_callback is not None: + self._message_callback(hub, msg) + except Exception: + pass + + def _on_welcome(self, hub): + for r in list(hub.rooms): + try: + hub.join_room(r) + except Exception: + pass + + def set_active(self, hub, room): + self._active_hub = hub + self._active_room = room + if hub is not None and room is not None: + hub.mark_read(room) + + def active_room_for(self, hub): + if self._active_hub is hub: + return self._active_room + return None + + def has_unread(self): + with self._lock: + for hub in self.hubs: + if hub.unread_rooms: + return True + return False + + def add_hub(self, hub_hash, dest_name=None, name=None): + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)): + return h + hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name) + self.hubs.append(hub) + self.save() + self._notify_change() + return hub + + def remove_hub(self, hub): + with self._lock: + if hub in self.hubs: + self.hubs.remove(hub) + try: + hub.disconnect() + except Exception: + pass + self.save() + self._notify_change() + + def find_hub(self, hub_hash, dest_name=None): + dn = dest_name or DEFAULT_DEST_NAME + with self._lock: + for h in self.hubs: + if h.hub_hash == hub_hash and h.dest_name == dn: + return h + return None + + def _store_path(self): + return os.path.join(self.app.storagepath, "rrc_hubs") + + def load(self): + if self._loaded: + return + self._loaded = True + path = self._store_path() + if not os.path.isfile(path): + return + self._loading = True + try: + with open(path, "rb") as f: + data = f.read() + obj = cbor.decode(data) + if not isinstance(obj, dict): + return + entries = obj.get("hubs") + if not isinstance(entries, list): + return + for e in entries: + if not isinstance(e, dict): + continue + hh = e.get("hash") + if not isinstance(hh, (bytes, bytearray)): + continue + dn = e.get("dest_name") + nm = e.get("name") + hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None) + rooms = e.get("rooms") + if isinstance(rooms, list): + for r in rooms: + if isinstance(r, str): + hub.add_room(r) + parted = e.get("parted_rooms") + if isinstance(parted, list): + for r in parted: + if isinstance(r, str): + try: + rn = hub._normalize_room(r) + with hub._lock: + hub.messages.setdefault(rn, []) + except Exception: + pass + ar = e.get("auto_reconnect") + if isinstance(ar, bool): + hub.auto_reconnect = ar + al = e.get("auto_list") + if isinstance(al, bool): + hub.auto_list = al + except Exception as e: + RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) + finally: + self._loading = False + + def save(self): + if self._loading: + return + path = self._store_path() + tmp_path = path + ".tmp" + with self._save_lock: + try: + entries = [] + with self._lock: + for h in self.hubs: + joined = set(h.rooms) + parted = set(h.messages.keys()) - joined + entries.append({ + "hash": h.hub_hash, + "dest_name": h.dest_name, + "name": h.name, + "rooms": sorted(joined), + "parted_rooms": sorted(parted), + "auto_reconnect": bool(h.auto_reconnect), + "auto_list": bool(h.auto_list), + }) + data = cbor.encode({"hubs": entries}) + with open(tmp_path, "wb") as f: + f.write(data) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp_path, path) + except Exception as e: + # + # + # + # + try: os.unlink(tmp_path) + except Exception: pass + + def shutdown(self): + for h in list(self.hubs): + try: + h.disconnect() + except Exception: + pass diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 48bd74f..f05ae81 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -59,6 +59,13 @@ THEMES = { ("placeholder", "dark gray", "default", "default", "dark gray", "default"), ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "light green", "default", "default", "#6c5", "default"), + ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"), + ("irc_notice", "yellow", "default", "default", "#fd3", "default"), + ("irc_error", "light red", "default", "default", "#f55", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"), ], }, @@ -103,7 +110,14 @@ THEMES = { ("placeholder", "light gray", "default", "default", "#999", "default"), ("placeholder_text", "light gray", "default", "default", "#999", "default"), ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), - ], + ("irc_ts", "dark gray", "default", "default", "#888", "default"), + ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"), + ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"), + ("irc_notice", "brown", "default", "default", "#a70", "default"), + ("irc_error", "dark red", "default", "default", "#a22", "default"), + ("irc_system", "dark gray", "default", "default", "#888", "default"), + ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"), + ], } } diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index ada2ffe..1994ac9 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -192,6 +192,8 @@ class Browser: return "nomadnetwork.node" elif destination_type == "lxmf": return "lxmf.delivery" + elif destination_type == "rrc": + return "rrc.hub.session" else: return destination_type @@ -250,6 +252,11 @@ class Browser: recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + # rrc://[:]/ URL form + if link_target.startswith("rrc://"): + self.handle_rrc_link(link_target[6:]) + return + components = link_target.split("@") destination_type = None @@ -280,6 +287,10 @@ class Browser: RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG) self.handle_lxmf_link(link_target) + elif destination_type == "rrc.hub.session": + RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG) + self.handle_rrc_link(link_target) + elif destination_type == "partial": if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids) @@ -331,6 +342,56 @@ class Browser: self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + def handle_rrc_link(self, link_target): + try: + if not isinstance(link_target, str): + raise ValueError("invalid RRC link payload") + rest = link_target.strip() + if rest.startswith("/"): + rest = rest[1:] + hub_part, _, room = rest.partition("/") + hex_part, _, dest = hub_part.partition(":") + hex_part = hex_part.strip() + dest = dest.strip() or None + try: + hub_hash = bytes.fromhex(hex_part) + except Exception: + raise ValueError("invalid hub hash") + expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8 + if len(hub_hash) != expected_len: + raise ValueError("hub hash must be "+str(expected_len)+" bytes") + + room = room.strip().lstrip("#").strip() + room_norm = None + if room: + try: + # validate the room name early; pass the raw value through + from nomadnet.RRC import RRCHub as _RRCHubCls # noqa + room_norm = room.lower() + except Exception: + room_norm = None + + existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) + self.app.ui.main_display.show_channels(None) + channels = self.app.ui.main_display.sub_displays.channels_display + + if existing is not None: + channels.update_list() + if room_norm: + channels._select_room(None, (existing, room_norm)) + else: + channels._select_hub(None, existing) + return + + + channels.confirm_new_hub_dialog(hub_hash, dest, room_norm) + + except Exception as e: + RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR) + self.browser_footer = urwid.Text("Could not open RRC link: "+str(e)) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + + def micron_released_focus(self): if self.delegate != None: self.delegate.focus_lists() diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py new file mode 100644 index 0000000..5a9c7f0 --- /dev/null +++ b/nomadnet/ui/textui/Channels.py @@ -0,0 +1,1488 @@ +import collections +import os +import re +import time + +import RNS +import urwid + +import nomadnet +from nomadnet.RRC import RRCHub +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox +from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec + + +class _ChatLinkableText(LinkableText): + def render(self, size, focus=False): + c = urwid.Text.render(self, size, focus) + if focus: + c = urwid.CompositeCanvas(c) + c.cursor = self.get_cursor_coords(size) + if self.delegate is not None: + self.peek_link() + return c + + +_LINK_RE = re.compile( + r"(?P(?(?(? 0 and last_space >= len(chunk) // 2: + chunk = chunk[:last_space] + if not chunk: + chunk = remaining[:1] + chunks.append(chunk.rstrip()) + remaining = remaining[len(chunk):].lstrip() + return chunks + + +def _split_message(text, max_bytes): + if not text: + return [text] + parts = [text] + for _attempt in range(10): + K_guess = max(1, len(parts)) + prefix_bytes = len(("({}/{}) ".format(K_guess, K_guess)).encode("utf-8")) + budget = max_bytes - prefix_bytes + if budget <= 0: + return None + parts = _chunk_by_bytes(text, budget) + if len(parts) == K_guess: + break + K = len(parts) + return ["({}/{}) ".format(i+1, K) + p for i, p in enumerate(parts)] + + +def _scan_mentions(text, own_nick): + if not own_nick or not text: + return + pat = re.compile(r"(?= last_end: + filtered.append(s) + last_end = s[1] + spans = filtered + + if not spans: + return [(body_attr, body)], False + + out = [] + pos = 0 + has_links = False + for start, end, kind, target in spans: + if start > pos: + out.append((body_attr, body[pos:start])) + if kind == "mention": + out.append(("irc_mention", body[start:end])) + else: + base = _LINK_ATTRS[kind] + out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end])) + has_links = True + pos = end + if pos < len(body): + out.append((body_attr, body[pos:])) + return out, has_links + + +def _short_hash(b, n=10): + if isinstance(b, (bytes, bytearray)): + return bytes(b).hex()[:n] + return "?" + + +def _format_ts(ts_ms): + try: + return time.strftime("%H:%M:%S", time.localtime(ts_ms/1000.0)) + except Exception: + return "" + + +class ChannelsListShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + + +class ChannelsRoomShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar") + + +class ChannelsDialogLineBox(urwid.LineBox): + def keypress(self, size, key): + if key == "esc": + if hasattr(self.delegate, "close_dialog"): + self.delegate.close_dialog() + else: + return super(ChannelsDialogLineBox, self).keypress(size, key) + + +class ChannelListEntry(urwid.Text): + _selectable = True + signals = ["click"] + + def keypress(self, size, key): + if self._command_map[key] != urwid.ACTIVATE: + return key + self._emit("click") + + def mouse_event(self, size, event, button, x, y, focus): + if button != 1 or not urwid.util.is_mouse_press(event): + return False + self._emit("click") + return True + + +class ChannelsListArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + elif key == "ctrl a": + self.delegate.join_room_dialog() + elif key == "ctrl r": + self.delegate.connect_selected() + elif key == "ctrl w": + self.delegate.disconnect_selected() + elif key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + elif key == "ctrl e": + self.delegate.edit_hub_dialog() + elif key == "ctrl x": + self.delegate.remove_selected_dialog() + elif key == "tab": + self.delegate.app.ui.main_display.frame.focus_position = "header" + elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): + self.delegate.app.ui.main_display.frame.focus_position = "header" + else: + return super(ChannelsListArea, self).keypress(size, key) + + +class RoomMessageEdit(urwid.Edit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tab_state = None + + def keypress(self, size, key): + if key == "tab": + if self._try_tab_complete(): + return None + return key + self._tab_state = None + if key == "ctrl d": + self.delegate.send_message() + elif key == "ctrl k": + self.set_edit_text("") + elif key == "ctrl l": + self.delegate.leave_room() + elif key == "ctrl u": + self.delegate.toggle_users() + elif key == "up": + y = self.get_cursor_coords(size)[1] + if y == 0: + self.delegate.frame.focus_position = "body" + else: + return super(RoomMessageEdit, self).keypress(size, key) + else: + return super(RoomMessageEdit, self).keypress(size, key) + + def _candidates(self, prefix_lower): + delegate = getattr(self, "delegate", None) + if delegate is None or delegate.hub is None or delegate.room is None: + return [] + members = delegate.hub.get_members(delegate.room) + own_hash = None + try: + if delegate.app.identity is not None: + own_hash = delegate.app.identity.hash + except Exception: + pass + names = set() + for m in members: + if own_hash is not None and m == own_hash: + continue + names.add(delegate.hub.display_name_for(m)) + return sorted([n for n in names if n.lower().startswith(prefix_lower)], + key=str.lower) + + def _try_tab_complete(self): + text = self.get_edit_text() + pos = self.edit_pos + state = self._tab_state + + if state is not None and state.get("cursor_after") == pos: + prefix_lower = state["prefix"] + token_start = state["token_start"] + has_at = state["has_at"] + matches = self._candidates(prefix_lower) + if not matches: + self._tab_state = None + return False + idx = (state["idx"] + 1) % len(matches) + else: + start = pos + while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-"): + start -= 1 + has_at = start > 0 and text[start-1] == "@" + token_start = start - 1 if has_at else start + token = text[start:pos] + if not token: + return False + prefix_lower = token.lower() + matches = self._candidates(prefix_lower) + if not matches: + return False + idx = 0 + + selected = matches[idx] + if has_at: + replacement = "@" + selected + elif token_start == 0: + replacement = selected + ": " + else: + replacement = selected + + new_text = text[:token_start] + replacement + text[pos:] + new_cursor = token_start + len(replacement) + self.set_edit_text(new_text) + self.set_edit_pos(new_cursor) + self._tab_state = { + "prefix": prefix_lower, + "token_start": token_start, + "has_at": has_at, + "cursor_after": new_cursor, + "idx": idx, + } + return True + + +class RoomFrame(urwid.Frame): + def keypress(self, size, key): + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "tab": + if self.focus_position == "body": + self.focus_position = "footer" + else: + self.focus_position = "body" + elif self.focus_position == "body": + if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: + self.focus_position = "footer" + elif key == "up" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.top_is_visible: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + else: + return super(RoomFrame, self).keypress(size, key) + else: + return super(RoomFrame, self).keypress(size, key) + + +class RoomWidget(urwid.WidgetWrap): + USERS_PANE_WIDTH = 22 + + def __init__(self, display, hub, room): + self.display = display + self.hub = hub + self.room = room + self.app = nomadnet.NomadNetworkApp.get_shared_instance() + + self.messagelist = None + self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") + self._update_peer_info() + + editor = RoomMessageEdit(caption="", edit_text="", multiline=True) + editor.delegate = self + self.editor = editor + editor_attr = urwid.AttrMap(editor, "msg_editor") + + self.link_delegate = _ChatLinkDelegate(self.display, self.hub) + self.update_messages() + + self.frame = RoomFrame( + self.messagelist, + header=self.peer_info_widget, + footer=editor_attr, + focus_part="footer", + ) + self.frame.delegate = self + + self.chat_box = urwid.LineBox(self.frame) + self.users_pile = urwid.Pile([urwid.Text("")]) + self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users") + self._refresh_users_pane() + + self.users_visible = self.display.users_visible + self.columns = urwid.Columns([(urwid.WEIGHT, 1, self.chat_box)], dividechars=0, focus_column=0) + self._apply_users_visibility() + super().__init__(self.columns) + + def toggle_users(self): + self.users_visible = not self.users_visible + self.display.users_visible = self.users_visible + self._apply_users_visibility() + + def _apply_users_visibility(self): + if self.users_visible: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)), + ] + else: + self.columns.contents = [ + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + ] + self.columns.focus_position = 0 + + def _refresh_users_pane(self): + g = self.app.ui.glyphs + if self.hub is None or self.room is None: + self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())] + return + members = self.hub.get_members(self.room) + own_hash = self.app.identity.hash if self.app.identity is not None else None + names = [] + for m in members: + if own_hash is not None and m == own_hash: + names.append((self.hub.display_name_for(m), True)) + else: + names.append((self.hub.display_name_for(m), False)) + names.sort(key=lambda x: x[0].lower()) + + rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] + for name, is_self in names: + if is_self: + rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) + else: + rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status")) + if not names: + rows.append(urwid.Text(" (no members)")) + self.users_pile.contents = [(w, self.users_pile.options()) for w in rows] + + def _update_peer_info(self): + if self.hub is None or self.room is None: + self.peer_info_widget.original_widget.set_text("") + return + + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(self.hub.status, "") + + server = "" + if self.hub.hub_name: + server = " "+self.app.ui.glyphs["divider1"]+" "+self.hub.hub_name + if self.hub.hub_version: + server += " v"+self.hub.hub_version + left = " #"+self.room+server+" ("+self.hub.name+")" + right = status_label+" " + self.peer_info_widget.original_widget.set_text(left+" | "+right) + + MAX_RENDERED_MESSAGES = 500 + + def update_messages(self, replace=False): + msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] + widgets = [] + for m in msgs: + widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate)) + + if not widgets: + widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] + self._empty_placeholder = True + else: + self._empty_placeholder = False + + self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1) + self.messagelist.name = "messagelist" + if replace and hasattr(self, "frame"): + self.frame.contents["body"] = (self.messagelist, None) + if hasattr(self, "users_pile"): + self._refresh_users_pane() + + def append_message(self, msg): + if self.messagelist is None: + self.update_messages(replace=True) + return + try: + widget = _message_widget(self.app, self.hub, msg, link_delegate=self.link_delegate) + wrapped = urwid.AttrMap(widget, None) + body = self.messagelist._listbox.body + if getattr(self, "_empty_placeholder", False): + del body[:] + self._empty_placeholder = False + body.append(wrapped) + while len(body) > self.MAX_RENDERED_MESSAGES: + del body[0] + try: + self.messagelist._listbox.set_focus(len(body)-1) + except Exception: + pass + except Exception as e: + RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) + self.update_messages(replace=True) + if hasattr(self, "users_pile"): + self._refresh_users_pane() + + def send_message(self): + text = self.editor.get_edit_text() + if not text.strip(): + return + if text.lstrip().startswith("/"): + self._handle_slash_command(text.lstrip()) + self.editor.set_edit_text("") + return + if self.hub.status != RRCHub.STATUS_CONNECTED: + try: + self.hub.connect() + except Exception: + pass + return + limit = self.hub.max_msg_body_bytes or 350 + if len(text.encode("utf-8")) > limit: + self._open_split_dialog(text, limit) + return + try: + self.hub.send_message(self.room, text) + self.editor.set_edit_text("") + except Exception as e: + RNS.log("Failed to send RRC message: "+str(e), RNS.LOG_ERROR) + + def _open_split_dialog(self, text, limit): + body_bytes = len(text.encode("utf-8")) + parts = _split_message(text, limit) + if not parts: + self._local_message("error", + "Message is "+str(body_bytes)+" bytes but per-message limit is too small to split.") + return + K = len(parts) + preview = parts[0] + if len(preview) > 70: + preview = preview[:70] + "…" + preview = preview.replace("\n", " ").replace("\t", " ") + + error_text = urwid.Text("") + + def cancel(sender): + self.display.close_dialog() + + def send_split(sender): + try: + for p in parts: + self.hub.send_message(self.room, p) + self.editor.set_edit_text("") + self.display.close_dialog() + except Exception as e: + error_text.set_text(("error_text", "Send failed: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" Message is "+str(body_bytes)+" bytes."), + urwid.Text(" Hub limit : "+str(limit)+" bytes per message."), + urwid.Text(""), + urwid.Text(" Split into "+str(K)+" message"+("s" if K != 1 else "")+"."), + urwid.Text(" Preview of part 1:"), + urwid.AttrMap(urwid.Text(" "+preview), "irc_system"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Send Split", on_press=send_split)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel)), + ]) + ]), title="Message Too Long" + ) + dialog.delegate = self.display + self.display._show_dialog_overlay(dialog) + + def _local_message(self, kind, text): + from nomadnet.RRC import RRCMessage + msg = RRCMessage(kind, self.room, None, None, text, int(time.time()*1000)) + with self.hub._lock: + buf = self.hub.messages.setdefault(self.room, []) + buf.append(msg) + if len(buf) > 500: + del buf[:len(buf)-500] + self.hub.manager._notify_messages(self.hub, msg) + # printed /help + SLASH_HELP = [ + "/help - show this list", + "/ping - measure round-trip to hub", + "/list - list public rooms on this hub", + "/join - join a room on this hub", + "/part [room] - leave a room (default: current)", + "/leave [room] - alias for /part", + "/nick - set your display name", + "/who [room] - list users (current room if omitted)", + "/names [room] - alias for /who", + "/clear - clear local messages in this room", + "/connect - connect this hub", + "/disconnect - disconnect this hub", + "/quit - alias for /disconnect", + "", + "Server-side commands (auth enforced by hub):", + "/topic [text] - view or set room topic", + "/mode [+-flags] [arg] - view or set room modes", + "/register - register the current room", + "/unregister - unregister the current room", + "/kick - remove user from room", + "/ban add|del|list [target] - room ban list", + "/invite add|del|list [target] - room invite list", + "/op - grant op", + "/deop - revoke op", + "/voice - grant voice", + "/devoice - revoke voice", + "/kline add|del|list [target] - global ban", + "/stats - server statistics", + "/reload - reload server config", + ] + + # commands that we forward to the server verbatim + SERVER_SLASH_COMMANDS = { + "who", "names", + "topic", "mode", "kick", "kline", + "ban", "invite", "kline", + "op", "deop", "voice", "devoice", + "register", "unregister", + "stats", "reload", + } + + def _require_connected(self): + if self.hub.status != RRCHub.STATUS_CONNECTED: + self._local_message("error", "Not connected to hub") + return False + return True + + def _handle_slash_command(self, text): + parts = text[1:].split(None, 1) + if not parts or not parts[0]: + self._local_message("error", "Empty command") + return + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd == "help": + for line in self.SLASH_HELP: + self._local_message("system", line) + return + + if cmd == "ping": + if not self._require_connected(): + return + try: + self.hub.send_ping(room=self.room) + self._local_message("system", "Ping sent") + except Exception as e: + self._local_message("error", "Ping failed: "+str(e)) + return + + if cmd == "list": + if not self._require_connected(): + return + try: + self.hub.send_command("/list", room=self.room) + except Exception as e: + self._local_message("error", "/list failed: "+str(e)) + return + + if cmd in ("join", "j"): + if not arg: + self._local_message("error", "Usage: /join ") + return + target = arg.lstrip("#").strip() + try: + self.hub.add_room(target) + if self.hub.status == RRCHub.STATUS_CONNECTED: + self.hub.join_room(target) + self.display.update_list() + self.display._select_room(None, (self.hub, target.lower())) + except Exception as e: + self._local_message("error", "Join failed: "+str(e)) + return + + if cmd in ("part", "leave"): + target = (arg.lstrip("#").strip().lower()) if arg else self.room + try: + self.hub.part_room(target) + self.display.update_list() + if target == self.room: + self.display.show_placeholder() + except Exception as e: + self._local_message("error", "Part failed: "+str(e)) + return + + if cmd == "nick": + if not arg: + cur = self.app.rrc.get_nickname() or "(unset)" + self._local_message("system", "Current nick: "+cur) + return + limit = self.hub.max_nick_bytes or 32 + if len(arg.encode("utf-8")) > limit: + self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") + return + try: + self.app.set_display_name(arg) + self._local_message("system", "Nick set to "+arg) + except Exception as e: + self._local_message("error", "Nick change failed: "+str(e)) + return + + if cmd == "clear": + with self.hub._lock: + self.hub.messages[self.room] = [] + self.update_messages(replace=True) + return + + if cmd == "connect": + try: + self.hub.connect() + self._local_message("system", "Connecting...") + except Exception as e: + self._local_message("error", "Connect failed: "+str(e)) + return + + if cmd in ("disconnect", "quit"): + try: + self.hub.disconnect() + except Exception as e: + self._local_message("error", "Disconnect failed: "+str(e)) + return + + if cmd in self.SERVER_SLASH_COMMANDS: + if not self._require_connected(): + return + try: + self.hub.send_command("/"+cmd+(" "+arg if arg else ""), room=self.room) + except Exception as e: + self._local_message("error", "/"+cmd+" failed: "+str(e)) + return + + self._local_message("error", "Unknown command: /"+cmd+" (try /help)") + + def leave_room(self): + try: + self.hub.part_room(self.room) + except Exception: + pass + self.display.update_list() + self.display.show_placeholder() + + +def _ts_prefix(ts_ms): + t = _format_ts(ts_ms) if ts_ms else " " + return ("irc_ts", " ["+t+"] ") + + +class _ChatLinkDelegate: + def __init__(self, display, hub): + self.display = display + self.hub = hub + self.app = display.app + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if target is None: + return + kind, _, payload = target.partition(":") + try: + if kind == "room": + self._open_room(payload) + elif kind == "lxmf": + self._open_lxmf(payload) + elif kind == "page": + self._open_page(payload) + except Exception as e: + RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR) + + def _open_room(self, room): + room = (room or "").strip().lower() + if not room: + return + if room not in self.hub.rooms and self.hub.status == RRCHub.STATUS_CONNECTED: + try: self.hub.join_room(room) + except Exception: pass + self.hub.add_room(room) + self.display.update_list() + self.display._select_room(None, (self.hub, room)) + + def _open_lxmf(self, hash_hex): + try: + bytes.fromhex(hash_hex) + except Exception: + return + from nomadnet.Directory import DirectoryEntry + existing = [c[0] for c in nomadnet.Conversation.conversation_list(self.app)] + if hash_hex not in existing: + display_name = None + try: + data = RNS.Identity.recall_app_data(bytes.fromhex(hash_hex)) + if data is not None: + import LXMF + display_name = LXMF.display_name_from_app_data(data) + except Exception: + pass + try: + self.app.directory.remember(DirectoryEntry(bytes.fromhex(hash_hex), display_name=display_name)) + except Exception: + pass + try: + nomadnet.Conversation(hash_hex, self.app, initiator=True) + except Exception: + pass + conversations = self.app.ui.main_display.sub_displays.conversations_display + conversations.update_conversation_list() + conversations.display_conversation(None, hash_hex) + self.app.ui.main_display.show_conversations(None) + + def _open_page(self, url): + if not url: + return + self.app.ui.main_display.show_network(None) + try: + self.app.ui.main_display.sub_displays.network_display.browser.retrieve_url(url) + except Exception as e: + RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) + + +def _message_widget(app, hub, m, link_delegate=None): + g = app.ui.glyphs + own_nick = None + try: + own_nick = app.rrc.get_nickname() + except Exception: + pass + + if m.kind == "system": + spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_system", " "+g["arrow_r"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + if m.kind == "notice": + spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_notice", " "+g["info"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + if m.kind == "error": + spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) + markup = [_ts_prefix(m.ts), ("irc_error", " "+g["warning"]+" ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + own = False + try: + if hub is not None and m.src is not None and app.identity is not None: + own = bytes(m.src) == app.identity.hash + except Exception: + pass + + if m.nick: + sender = m.nick + elif isinstance(m.src, (bytes, bytearray)): + sender = _short_hash(m.src) + else: + sender = "?" + + nick_attr = "irc_nick_self" if own else "irc_nick_peer" + body = m.text or "" + spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick) + markup = [_ts_prefix(m.ts), (nick_attr, "<"+sender+">"), ("body_text", " ")] + spans + return _wrap_text(markup, link_delegate if has_links else None) + + +def _wrap_text(markup, link_delegate): + if link_delegate is not None: + return _ChatLinkableText(markup, align="left", delegate=link_delegate) + return urwid.Text(markup) + + +class ChannelsDisplay(): + list_width = 0.33 + given_list_width = 36 + + def __init__(self, app): + self.app = app + self.dialog_open = False + self.list_widgets = [] + self.selected_key = None + self.current_room_widget = None + self.users_visible = True + + self._build_listbox() + + self.list_shortcuts = ChannelsListShortcuts(self.app) + self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.shortcuts_display = self.list_shortcuts + + self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) + self.right = self.placeholder + + self.columns_widget = urwid.Columns( + [ + (ChannelsDisplay.given_list_width, self.listbox), + (urwid.WEIGHT, 1, self.right), + ], + dividechars=0, focus_column=0, box_columns=[0], + ) + self.widget = urwid.WidgetPlaceholder(self.columns_widget) + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + self._mention_bell_last = {} + + self.app.rrc.set_change_callback(self._on_rrc_change) + self.app.rrc.set_message_callback(self._on_rrc_message) + + def start(self): + self.update_list() + + def shortcuts(self): + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + focus_path = None + if focus_path and focus_path[0] == 1: + return self.room_shortcuts + return self.list_shortcuts + + def _build_listbox(self): + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + def _compose_list_widgets(self): + widgets = [] + manager = self.app.rrc + + if not manager.hubs: + entry = urwid.AttrMap(urwid.Text("\n No hubs yet. Press Ctrl-N to add one."), "list_unknown") + widgets.append(entry) + self.list_widgets = widgets + return + + g = self.app.ui.glyphs + for hub_idx, hub in enumerate(manager.hubs): + if hub_idx > 0: + spacer = urwid.Text("") + spacer.row_kind = "spacer" + widgets.append(spacer) + if hub.status == RRCHub.STATUS_CONNECTED: + status_glyph = g["check"] + style = "list_trusted" + elif hub.status == RRCHub.STATUS_CONNECTING: + status_glyph = g["info"] + style = "list_unresponsive" + elif hub.status == RRCHub.STATUS_FAILED: + status_glyph = g["cross"] + style = "list_untrusted" + else: + status_glyph = " " + style = "list_unknown" + + entry = ChannelListEntry(status_glyph+" "+hub.name) + urwid.connect_signal(entry, "click", self._select_hub, hub) + attr = urwid.AttrMap(entry, style, "list_focus") + attr.row_kind = "hub" + attr.hub = hub + attr.room = None + widgets.append(attr) + + for room in sorted(list(hub.rooms | set(hub.messages.keys()))): + if not room: + continue + is_joined = room in hub.rooms + mentioned = room in hub.mention_rooms + unread = room in hub.unread_rooms + if mentioned: + marker = g["warning"] + room_style = "irc_mention" + elif unread: + marker = g["unread"] + room_style = "list_unresponsive" + elif not is_joined: + marker = " " + room_style = "list_unknown" + else: + marker = " " + room_style = "list_trusted" if hub.status == RRCHub.STATUS_CONNECTED else "list_unknown" + room_entry = ChannelListEntry(" "+marker+" #"+room) + urwid.connect_signal(room_entry, "click", self._select_room, (hub, room)) + room_attr = urwid.AttrMap(room_entry, room_style, "list_focus") + room_attr.row_kind = "room" + room_attr.hub = hub + room_attr.room = room + widgets.append(room_attr) + + self.list_widgets = widgets + + def update_list(self): + prev_key = self.selected_key + self._compose_list_widgets() + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=lambda a, b: None, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus", + ) + self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels") + self.listbox.delegate = self + + options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width) + if not self.dialog_open: + self.columns_widget.contents[0] = (self.listbox, options) + + if prev_key is not None: + for idx, w in enumerate(self.list_widgets): + key = self._row_key(w) + if key == prev_key: + try: self.ilb.select_item(idx) + except Exception: pass + break + + self._refresh_active_header() + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _row_key(self, w): + if not hasattr(w, "row_kind"): + return None + if w.row_kind == "hub": + return ("hub", w.hub.hub_hash, w.hub.dest_name) + if w.row_kind == "room": + return ("room", w.hub.hub_hash, w.hub.dest_name, w.room) + return None + + def _refresh_active_header(self): + if self.current_room_widget is not None: + try: + self.current_room_widget._update_peer_info() + except Exception: + pass + return + if self.selected_key and self.selected_key[0] == "hub": + for h in self.app.rrc.hubs: + if h.hub_hash == self.selected_key[1] and h.dest_name == self.selected_key[2]: + self._show_hub_info(h) + break + + def _select_hub(self, sender, hub): + self.selected_key = ("hub", hub.hub_hash, hub.dest_name) + self.app.rrc.set_active(hub, None) + self._maybe_autoconnect(hub) + self._show_hub_info(hub) + + def _select_room(self, sender, payload): + hub, room = payload + self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) + self.app.rrc.set_active(hub, room) + self._maybe_autoconnect(hub) + if room not in hub.rooms and hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + self._show_room(hub, room) + + def _maybe_autoconnect(self, hub): + if hub.status in (RRCHub.STATUS_DISCONNECTED, RRCHub.STATUS_FAILED): + try: + hub.connect() + except Exception as e: + RNS.log("Auto-connect failed: "+str(e), RNS.LOG_ERROR) + + def _show_hub_info(self, hub): + g = self.app.ui.glyphs + status_label = { + RRCHub.STATUS_DISCONNECTED: "Disconnected", + RRCHub.STATUS_CONNECTING: "Connecting", + RRCHub.STATUS_CONNECTED: "Connected", + RRCHub.STATUS_FAILED: "Failed", + }.get(hub.status, "") + status_attr = { + RRCHub.STATUS_DISCONNECTED: "list_unknown", + RRCHub.STATUS_CONNECTING: "list_unresponsive", + RRCHub.STATUS_CONNECTED: "connected_status", + RRCHub.STATUS_FAILED: "list_untrusted", + }.get(hub.status, "list_unknown") + + lines = [ + urwid.Text(""), + urwid.Text(" Hub : "+hub.name), + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.AttrMap(urwid.Text(" Status : "+status_label+" ("+hub.status_text+")"), status_attr), + ] + if hub.hub_name: + ver = " v"+str(hub.hub_version) if hub.hub_version else "" + lines.append(urwid.Text(" Server : "+str(hub.hub_name)+ver)) + + ar_glyph = g["check"] if hub.auto_reconnect else g["cross"] + ar_attr = "list_trusted" if hub.auto_reconnect else "list_unknown" + ar_text = "On" if hub.auto_reconnect else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoRcn : "+ar_glyph+" "+ar_text+" (Ctrl-T to toggle)"), ar_attr)) + + al_glyph = g["check"] if hub.auto_list else g["cross"] + al_attr = "list_trusted" if hub.auto_list else "list_unknown" + al_text = "On" if hub.auto_list else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + + lines.append(urwid.Divider(g["divider1"])) + + if hub.status == RRCHub.STATUS_CONNECTED: + lines.append(urwid.Text(" Connected. Use Ctrl-A to add a room.")) + elif hub.status == RRCHub.STATUS_CONNECTING: + lines.append(urwid.AttrMap(urwid.Text(" Connecting..."), "list_unresponsive")) + else: + lines.append(urwid.Text(" Use Ctrl-R to connect.")) + + if hub.rooms: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Joined rooms:")) + for r in sorted(hub.rooms): + entry = ChannelListEntry(" #"+r) + urwid.connect_signal(entry, "click", self._select_room, (hub, r)) + lines.append(urwid.AttrMap(entry, "list_trusted", "list_focus")) + + available = sorted( + (name, topic) for name, topic in hub.available_rooms.items() + if name and name not in hub.rooms + ) + if available: + lines.append(urwid.Divider(g["divider1"])) + lines.append(urwid.Text(" Available rooms:")) + for name, topic in available: + label = " #"+name + if topic: + label += " "+g["arrow_r"]+" "+topic + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self._select_room, (hub, name)) + lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) + + info = urwid.LineBox(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + self.current_room_widget = None + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (info, options) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def show_placeholder(self): + self.current_room_widget = None + self.selected_key = None + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.placeholder, options) + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _show_room(self, hub, room): + widget = RoomWidget(self, hub, room) + self.current_room_widget = widget + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (widget, options) + self.columns_widget.focus_position = 1 + self.shortcuts_display = self.room_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + def _selected_row(self): + item = self.ilb.get_selected_item() + if item is None: + return None + return item + + def connect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.connect() + except Exception as e: + RNS.log("Connect failed: "+str(e), RNS.LOG_ERROR) + + def disconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + try: + item.hub.disconnect() + except Exception: + pass + + def toggle_auto_reconnect_selected(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + item.hub.set_auto_reconnect(not item.hub.auto_reconnect) + if self.current_room_widget is None: + self._show_hub_info(item.hub) + + def remove_selected_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + room = getattr(item, "room", None) + + def confirmed(sender): + self.close_dialog() + if room is not None: + try: hub.part_room(room) + except Exception: pass + hub.remove_room(room) + else: + self.app.rrc.remove_hub(hub) + self.update_list() + self.show_placeholder() + + def dismiss(sender): + self.close_dialog() + + if room is not None: + prompt = "Leave and remove room\n#"+room+"\non hub "+hub.name+"?" + else: + prompt = "Remove hub\n"+hub.name+"\nfrom this client?\n All Message history will be discarded." + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(prompt+"\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss)), + ]) + ]), title="?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def new_hub_dialog(self): + e_hash = urwid.Edit(caption="Hub address : ", edit_text="") + e_name = urwid.Edit(caption="Display name: ", edit_text="") + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hh_text = e_hash.get_edit_text().strip().lower() + if hh_text.startswith("0x"): + hh_text = hh_text[2:] + hh = bytes.fromhex(hh_text) + if len(hh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8: + raise ValueError("Hash length must be "+str(RNS.Reticulum.TRUNCATED_HASHLENGTH//8)+" bytes") + nm = e_name.get_edit_text().strip() or None + self.app.rrc.add_hub(hh, name=nm) + self.close_dialog() + self.update_list() + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + e_hash, + e_name, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Add", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="New Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def confirm_new_hub_dialog(self, hub_hash, dest_name, room): + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + hub = self.app.rrc.add_hub(hub_hash, dest_name=dest_name) + self.close_dialog() + self.update_list() + if room: + self._select_room(None, (hub, room)) + else: + self._select_hub(None, hub) + except Exception as e: + error_text.set_text(("error_text", "Could not add hub: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text(" A page is requesting to open an RRC hub."), + urwid.Text(""), + urwid.Text(" Address : "+hub_hash.hex()), + urwid.Text(" Aspect : "+(dest_name or "rrc.hub")), + urwid.Text(" Room : "+("#"+room if room else "(none)")), + urwid.Text(""), + urwid.AttrMap(urwid.Text( + " Opening will add this hub to your client,"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " ,and connect to it, and reveal your identity hash"), "list_unknown"), + urwid.AttrMap(urwid.Text( + " to the hub operator."), "list_unknown"), + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Open", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]) + ]), title="Open RRC hub?" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def edit_hub_dialog(self): + item = self._selected_row() + if item is None or not hasattr(item, "hub"): + return + hub = item.hub + + e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") + cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) + cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + error_text = urwid.Text("") + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + nm = e_name.get_edit_text().strip() or hub.name + hub.name = nm + hub.set_auto_reconnect(cb_autorcn.get_state()) + hub.set_auto_list(cb_autolist.get_state()) + self.app.rrc.save() + self.close_dialog() + self.update_list() + if self.selected_key and self.selected_key[0] == "hub" and self.selected_key[1] == hub.hub_hash: + self._show_hub_info(hub) + except Exception as e: + error_text.set_text(("error_text", "Could not save: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Address : "+hub.hub_hash.hex()), + urwid.Text(" Server : "+(hub.hub_name or "(unknown until connected)")), + urwid.Divider(self.app.ui.glyphs["divider1"]), + e_name, + urwid.Text(""), + cb_autorcn, + cb_autolist, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Edit Hub" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def join_room_dialog(self): + item = self._selected_row() + hub = None + if item is not None and hasattr(item, "hub"): + hub = item.hub + if hub is None: + if self.app.rrc.hubs: + hub = self.app.rrc.hubs[0] + else: + return + + e_room = urwid.Edit(caption="Room : #", edit_text="") + e_key = urwid.Edit(caption="Key : ", edit_text="", mask="*") + error_text = urwid.Text("") + + key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) + + def update_key_visibility(checkbox, state): + if state: + key_section_placeholder.original_widget = e_key + else: + key_section_placeholder.original_widget = urwid.Text("") + + cb_key = urwid.CheckBox("Keyed room (+k)", state=False, on_state_change=update_key_visibility) + + def dismiss(sender): + self.close_dialog() + + def confirmed(sender): + try: + room = e_room.get_edit_text().strip() + if not room: + raise ValueError("Room name is required") + key = e_key.get_edit_text().strip() if cb_key.get_state() else None + key = key or None + hub.add_room(room) + if hub.status == RRCHub.STATUS_CONNECTED: + hub.join_room(room, key=key) + self.close_dialog() + self.update_list() + self._select_room(None, (hub, room.lower())) + except Exception as e: + error_text.set_text(("error_text", "Could not join: "+str(e))) + + dialog = ChannelsDialogLineBox( + urwid.Pile([ + urwid.Text(" Hub : "+hub.name), + e_room, + cb_key, + key_section_placeholder, + urwid.Text(""), + error_text, + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Join", on_press=confirmed)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)), + ]) + ]), title="Add Room" + ) + dialog.delegate = self + self._show_dialog_overlay(dialog) + + def _show_dialog_overlay(self, dialog): + self.dialog_open = True + overlay = urwid.Overlay( + dialog, + self.columns_widget, + align=urwid.CENTER, + width=(urwid.RELATIVE, 60), + min_width=40, + valign=urwid.MIDDLE, + height=urwid.PACK, + ) + self.widget.original_widget = overlay + + def close_dialog(self): + self.dialog_open = False + self.widget.original_widget = self.columns_widget + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("RRC UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass + + def _on_rrc_change(self, hub): + self._wake(self.update_list) + + def _on_rrc_message(self, hub, msg): + def action(): + is_active = (self.current_room_widget is not None + and self.current_room_widget.hub is hub + and self.current_room_widget.room == msg.room) + if getattr(msg, "mention", False) and not is_active: + self._ring_mention_bell(hub, msg.room) + if is_active: + self.current_room_widget.append_message(msg) + self.update_list() + self._wake(action) + + def _ring_mention_bell(self, hub, room): + key = (hub.hub_hash, room or "") + now = time.monotonic() + last = self._mention_bell_last.get(key, 0.0) + if now - last < 5.0: + return + self._mention_bell_last[key] = now + try: + import sys + sys.stdout.write("\x07") + sys.stdout.flush() + except Exception: + pass diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 34d88c3..9f14c22 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -2,6 +2,7 @@ import RNS from .Network import * from .Conversations import * +from .Channels import * from .Directory import * from .Config import * from .Interfaces import * @@ -15,6 +16,7 @@ class SubDisplays(): self.app = app self.network_display = NetworkDisplay(self.app) self.conversations_display = ConversationsDisplay(self.app) + self.channels_display = ChannelsDisplay(self.app) self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) self.interface_display = InterfaceDisplay(self.app) @@ -103,6 +105,11 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.conversations_display self.update_active_sub_display() + def show_channels(self, user_data): + self.sub_displays.active_display = self.sub_displays.channels_display + self.update_active_sub_display() + self.sub_displays.channels_display.start() + def show_directory(self, user_data): self.sub_displays.active_display = self.sub_displays.directory_display self.update_active_sub_display() @@ -181,6 +188,7 @@ class MenuDisplay(): menu_text = (urwid.PACK, self.menu_indicator) button_network = (11, MenuButton("Network", on_press=handler.show_network)) button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations)) + button_channels = (12, MenuButton("Channels", on_press=handler.show_channels)) button_directory = (13, MenuButton("Directory", on_press=handler.show_directory)) button_map = (7, MenuButton("Map", on_press=handler.show_map)) button_log = (7, MenuButton("Log", on_press=handler.show_log)) @@ -191,9 +199,9 @@ class MenuDisplay(): # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit] + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit] else: - buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit] columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py new file mode 100644 index 0000000..6c63096 --- /dev/null +++ b/nomadnet/vendor/cbor.py @@ -0,0 +1,430 @@ +# https://github.com/brianolson/cbor_py +# Copyright 2014-2015 Brian Olson +# +# 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. + +import datetime +import re +import struct +from io import BytesIO + + +CBOR_TYPE_MASK = 0xE0 # top 3 bits +CBOR_INFO_BITS = 0x1F # low 5 bits + + +CBOR_UINT = 0x00 +CBOR_NEGINT = 0x20 +CBOR_BYTES = 0x40 +CBOR_TEXT = 0x60 +CBOR_ARRAY = 0x80 +CBOR_MAP = 0xA0 +CBOR_TAG = 0xC0 +CBOR_7 = 0xE0 # float and other types + +CBOR_UINT8_FOLLOWS = 24 # 0x18 +CBOR_UINT16_FOLLOWS = 25 # 0x19 +CBOR_UINT32_FOLLOWS = 26 # 0x1a +CBOR_UINT64_FOLLOWS = 27 # 0x1b +CBOR_VAR_FOLLOWS = 31 # 0x1f + +CBOR_BREAK = 0xFF + +CBOR_FALSE = (CBOR_7 | 20) +CBOR_TRUE = (CBOR_7 | 21) +CBOR_NULL = (CBOR_7 | 22) +CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value + +CBOR_FLOAT16 = (CBOR_7 | 25) +CBOR_FLOAT32 = (CBOR_7 | 26) +CBOR_FLOAT64 = (CBOR_7 | 27) + +CBOR_TAG_DATE_STRING = 0 # RFC3339 +CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC +CBOR_TAG_BIGNUM = 2 # big-endian byte string follows +CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows +CBOR_TAG_DECIMAL = 4 +CBOR_TAG_BIGFLOAT = 5 +CBOR_TAG_BASE64URL = 21 +CBOR_TAG_BASE64 = 22 +CBOR_TAG_BASE16 = 23 +CBOR_TAG_CBOR = 24 + +CBOR_TAG_URI = 32 +CBOR_TAG_REGEX = 35 +CBOR_TAG_MIME = 36 +CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7 + +_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM) +_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM) + + +def _dumps_bignum_to_bytearray(val): + out = [] + while val > 0: + out.insert(0, val & 0x0ff) + val = val >> 8 + return bytes(out) + + +def dumps_int(val): + "return bytes representing int val in CBOR" + if val >= 0: + if val <= 23: + return struct.pack('B', val) + if val <= 0x0ff: + return struct.pack('BB', CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val) + if val <= 0x0ffffffffffffffff: + return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + val = -1 - val + return _encode_type_num(CBOR_NEGINT, val) + + +def dumps_float(val): + return struct.pack("!Bd", CBOR_FLOAT64, val) + + +def _encode_type_num(cbor_type, val): + """For some CBOR primary type [0..7] and an auxiliary unsigned number, + return CBOR encoded bytes.""" + assert val >= 0 + if val <= 23: + return struct.pack('B', cbor_type | val) + if val <= 0x0ff: + return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val) + if val <= 0x0ffff: + return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val) + if val <= 0x0ffffffff: + return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val) + if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or + ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))): + return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val) + if cbor_type != CBOR_NEGINT: + raise Exception("value too big for CBOR unsigned number: {0!r}".format(val)) + outb = _dumps_bignum_to_bytearray(val) + return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb + + +def dumps_string(val, is_text=None, is_bytes=None): + if isinstance(val, str): + val = val.encode('utf8') + is_text = True + is_bytes = False + if (is_bytes) or not (is_text == True): + return _encode_type_num(CBOR_BYTES, len(val)) + val + return _encode_type_num(CBOR_TEXT, len(val)) + val + + +def dumps_array(arr, sort_keys=False): + head = _encode_type_num(CBOR_ARRAY, len(arr)) + parts = [dumps(x, sort_keys=sort_keys) for x in arr] + return head + b''.join(parts) + + +def dumps_dict(d, sort_keys=False): + head = _encode_type_num(CBOR_MAP, len(d)) + parts = [head] + if sort_keys: + for k in sorted(d.keys()): + v = d[k] + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + else: + for k, v in d.items(): + parts.append(dumps(k, sort_keys=sort_keys)) + parts.append(dumps(v, sort_keys=sort_keys)) + return b''.join(parts) + + +def dumps_bool(b): + if b: + return struct.pack('B', CBOR_TRUE) + return struct.pack('B', CBOR_FALSE) + + +def dumps_tag(t, sort_keys=False): + return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys) + + +def dumps(ob, sort_keys=False): + if ob is None: + return struct.pack('B', CBOR_NULL) + if isinstance(ob, bool): + return dumps_bool(ob) + if isinstance(ob, (str, bytes, bytearray)): + if isinstance(ob, bytearray): + ob = bytes(ob) + return dumps_string(ob) + if isinstance(ob, (list, tuple)): + return dumps_array(ob, sort_keys=sort_keys) + if isinstance(ob, dict): + return dumps_dict(ob, sort_keys=sort_keys) + if isinstance(ob, float): + return dumps_float(ob) + if isinstance(ob, int): + return dumps_int(ob) + if isinstance(ob, Tag): + return dumps_tag(ob, sort_keys=sort_keys) + raise Exception("don't know how to cbor serialize object of type %s" % type(ob)) + + +def dump(obj, fp, sort_keys=False): + """obj: Python object to serialize. fp: file-like object capable of .write(bytes).""" + blob = dumps(obj, sort_keys=sort_keys) + fp.write(blob) + + +class Tag(object): + def __init__(self, tag=None, value=None): + self.tag = tag + self.value = value + + def __repr__(self): + return "Tag({0!r}, {1!r})".format(self.tag, self.value) + + def __eq__(self, other): + if not isinstance(other, Tag): + return False + return (self.tag == other.tag) and (self.value == other.value) + + def __hash__(self): + return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self) + + +def loads(data): + """Parse CBOR bytes and return Python objects.""" + if data is None: + raise ValueError("got None for buffer to decode in loads") + if isinstance(data, (bytes, bytearray, memoryview)): + fp = BytesIO(bytes(data)) + else: + fp = data + return _loads(fp)[0] + + +def load(fp): + """Parse and return object from fp, a file-like object supporting .read(n).""" + return _loads(fp)[0] + + +_MAX_DEPTH = 100 + + +def _tag_aux(fp, tb): + bytes_read = 1 + tag = tb & CBOR_TYPE_MASK + tag_aux = tb & CBOR_INFO_BITS + if tag_aux <= 23: + aux = tag_aux + elif tag_aux == CBOR_UINT8_FOLLOWS: + data = fp.read(1) + aux = struct.unpack_from("!B", data, 0)[0] + bytes_read += 1 + elif tag_aux == CBOR_UINT16_FOLLOWS: + data = fp.read(2) + aux = struct.unpack_from("!H", data, 0)[0] + bytes_read += 2 + elif tag_aux == CBOR_UINT32_FOLLOWS: + data = fp.read(4) + aux = struct.unpack_from("!I", data, 0)[0] + bytes_read += 4 + elif tag_aux == CBOR_UINT64_FOLLOWS: + data = fp.read(8) + aux = struct.unpack_from("!Q", data, 0)[0] + bytes_read += 8 + else: + assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb) + aux = None + + return tag, tag_aux, aux, bytes_read + + +def _read_byte(fp): + tb = fp.read(1) + if len(tb) == 0: + raise EOFError() + return tb[0] + + +def _loads_var_array(fp, limit, depth, returntags, bytes_read): + ob = [] + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + ob.append(subob) + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_var_map(fp, limit, depth, returntags, bytes_read): + ob = {} + tb = _read_byte(fp) + while tb != CBOR_BREAK: + (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags) + bytes_read += 1 + sub_len + (subv, sub_len) = _loads(fp, limit, depth, returntags) + bytes_read += sub_len + ob[subk] = subv + tb = _read_byte(fp) + return (ob, bytes_read + 1) + + +def _loads_array(fp, limit, depth, returntags, aux, bytes_read): + ob = [] + for _ in range(aux): + subob, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob.append(subob) + return ob, bytes_read + + +def _loads_map(fp, limit, depth, returntags, aux, bytes_read): + ob = {} + for _ in range(aux): + subk, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + subv, subpos = _loads(fp, limit, depth, returntags) + bytes_read += subpos + ob[subk] = subv + return ob, bytes_read + + +def _loads(fp, limit=None, depth=0, returntags=False): + "return (object, bytes read)" + if depth > _MAX_DEPTH: + raise Exception("hit CBOR loads recursion depth limit") + tb = _read_byte(fp) + return _loads_tb(fp, tb, limit, depth, returntags) + + +def _loads_tb(fp, tb, limit=None, depth=0, returntags=False): + # Some special cases of CBOR_7 best handled by special struct.unpack logic here + if tb == CBOR_FLOAT16: + data = fp.read(2) + hibyte, lowbyte = struct.unpack_from("BB", data, 0) + exp = (hibyte >> 2) & 0x1F + mant = ((hibyte & 0x03) << 8) | lowbyte + if exp == 0: + val = mant * (2.0 ** -24) + elif exp == 31: + val = float('Inf') if mant == 0 else float('NaN') + else: + val = (mant + 1024.0) * (2 ** (exp - 25)) + if hibyte & 0x80: + val = -1.0 * val + return (val, 3) + elif tb == CBOR_FLOAT32: + data = fp.read(4) + pf = struct.unpack_from("!f", data, 0) + return (pf[0], 5) + elif tb == CBOR_FLOAT64: + data = fp.read(8) + pf = struct.unpack_from("!d", data, 0) + return (pf[0], 9) + + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + + if tag == CBOR_UINT: + return (aux, bytes_read) + elif tag == CBOR_NEGINT: + return (-1 - aux, bytes_read) + elif tag == CBOR_BYTES: + ob, subpos = loads_bytes(fp, aux) + return (ob, bytes_read + subpos) + elif tag == CBOR_TEXT: + raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT) + ob = raw.decode('utf8') + return (ob, bytes_read + subpos) + elif tag == CBOR_ARRAY: + if aux is None: + return _loads_var_array(fp, limit, depth, returntags, bytes_read) + return _loads_array(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_MAP: + if aux is None: + return _loads_var_map(fp, limit, depth, returntags, bytes_read) + return _loads_map(fp, limit, depth, returntags, aux, bytes_read) + elif tag == CBOR_TAG: + ob, subpos = _loads(fp, limit, depth + 1, returntags) + bytes_read += subpos + if returntags: + ob = Tag(aux, ob) + else: + ob = tagify(ob, aux) + return ob, bytes_read + elif tag == CBOR_7: + if tb == CBOR_TRUE: + return (True, bytes_read) + if tb == CBOR_FALSE: + return (False, bytes_read) + if tb == CBOR_NULL: + return (None, bytes_read) + if tb == CBOR_UNDEFINED: + return (None, bytes_read) + raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb)) + + +def loads_bytes(fp, aux, btag=CBOR_BYTES): + if aux is not None: + ob = fp.read(aux) + return (ob, aux) + chunklist = [] + total_bytes_read = 0 + while True: + tb = fp.read(1)[0] + if tb == CBOR_BREAK: + total_bytes_read += 1 + break + tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb) + assert tag == btag, 'variable length value contains unexpected component' + ob = fp.read(aux) + chunklist.append(ob) + total_bytes_read += bytes_read + aux + return (b''.join(chunklist), total_bytes_read) + + +def _bytes_to_biguint(bs): + out = 0 + for ch in bs: + out = out << 8 + out = out | ch + return out + + +def tagify(ob, aux): + if aux == CBOR_TAG_DATE_STRING: + # RFC3339 date string parsing not implemented; return as Tag. + return Tag(aux, ob) + if aux == CBOR_TAG_DATE_ARRAY: + return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc) + if aux == CBOR_TAG_BIGNUM: + return _bytes_to_biguint(ob) + if aux == CBOR_TAG_NEGBIGNUM: + return -1 - _bytes_to_biguint(ob) + if aux == CBOR_TAG_REGEX: + return re.compile(ob) + return Tag(aux, ob) + + +def encode(obj, sort_keys=False): + return dumps(obj, sort_keys=sort_keys) + + +def decode(data): + return loads(data) From 07ba36a21cf2da2e2eddaf82e282b4da7ab63533 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 11 May 2026 21:16:26 -0400 Subject: [PATCH 02/14] Use K_BODY for join/part events, changes to /nick behavior --- nomadnet/RRC.py | 120 +++++++++++++++++++++++---------- nomadnet/ui/textui/Channels.py | 17 +++-- 2 files changed, 96 insertions(+), 41 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index ff3b95b..3250750 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -201,6 +201,11 @@ class RRCHub: self.available_rooms = {} self._silent_list_pending = 0 + self.nick_override = None + self._pending_joins = set() + self._pending_parts = set() + self._silent_joins = set() + def _log(self, msg, level=None): if level is None: level = RNS.LOG_INFO @@ -369,7 +374,7 @@ class RRCHub: B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True}, } env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick payload = cbor.encode(env) @@ -382,6 +387,9 @@ class RRCHub: self.welcomed = False self.members.clear() self._resource_expectations.clear() + self._pending_joins.clear() + self._pending_parts.clear() + self._silent_joins.clear() should_reconnect = self.auto_reconnect and not self._manual_disconnect self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") if should_reconnect: @@ -439,6 +447,20 @@ class RRCHub: self.manager.save() self.manager._notify_change(self) + def get_effective_nick(self): + if isinstance(self.nick_override, str) and self.nick_override: + return self.nick_override + return self.manager.get_nickname() + + def set_nick_override(self, nick): + with self._lock: + if nick is None or (isinstance(nick, str) and nick == ""): + self.nick_override = None + else: + self.nick_override = str(nick) + self.manager.save() + self.manager._notify_change(self) + def _packet_would_fit(self, link, payload): try: pkt = RNS.Packet(link, payload) @@ -457,13 +479,17 @@ class RRCHub: raise RuntimeError("message exceeds link MTU") RNS.Packet(link, payload).send() - def join_room(self, room, key=None): + def join_room(self, room, key=None, silent=False): r = self._normalize_room(room) body = key if (isinstance(key, str) and key) else None env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick + with self._lock: + self._pending_joins.add(r) + if silent: + self._silent_joins.add(r) self._send_env(env) with self._lock: if r not in self.messages: @@ -474,7 +500,7 @@ class RRCHub: if not isinstance(text, str) or not text.startswith("/"): raise ValueError("command must start with /") env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick self._send_env(env) @@ -494,6 +520,8 @@ class RRCHub: def part_room(self, room): room_n = self._normalize_room(room) env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n) + with self._lock: + self._pending_parts.add(room_n) try: self._send_env(env) except Exception: @@ -510,7 +538,7 @@ class RRCHub: if len(text.encode("utf-8")) > self.max_msg_body_bytes: raise ValueError("message too long for hub limit") env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text) - nick = self.manager.get_nickname() + nick = self.get_effective_nick() if nick: env[K_NICK] = nick mid = env[K_ID] @@ -643,33 +671,40 @@ class RRCHub: room = env.get(K_ROOM) if isinstance(room, str) and room: r = room.strip().lower() - src = env.get(K_SRC) - nick = env.get(K_NICK) body = env.get(K_BODY) own_hash = self.manager.identity.hash if self.manager.identity is not None else None - self_join = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + with self._lock: + self_join = r in self._pending_joins + silent = r in self._silent_joins + if self_join: + self._pending_joins.discard(r) + if silent: + self._silent_joins.discard(r) + self.rooms.add(r) if r not in self.messages: self.messages[r] = [] members = self.members.setdefault(r, set()) - if isinstance(body, list): - for entry in body: - if isinstance(entry, (bytes, bytearray)): - members.add(bytes(entry)) - if isinstance(src, (bytes, bytearray)): - sb = bytes(src) - if own_hash is None or sb != own_hash: - members.add(sb) - if isinstance(nick, str) and nick: - self.nicks[sb] = nick + for h in body_hashes: + members.add(h) if own_hash is not None: members.add(own_hash) + if self_join: - self._record_system(r, "You joined #"+r) + if not silent: + self._record_system(r, "You joined #"+r) self.manager.save() - elif isinstance(src, (bytes, bytearray)): - self._record_system(r, self.display_name_for(src)+" joined") + else: + joiner = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + joiner = body_hashes[0] + if joiner is not None: + self._record_system(r, self.display_name_for(joiner)+" joined") self.manager._notify_change(self) return @@ -677,25 +712,34 @@ class RRCHub: room = env.get(K_ROOM) if isinstance(room, str) and room: r = room.strip().lower() - src = env.get(K_SRC) body = env.get(K_BODY) own_hash = self.manager.identity.hash if self.manager.identity is not None else None - self_part = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash + + body_hashes = [] + if isinstance(body, list): + body_hashes = [bytes(e) for e in body if isinstance(e, (bytes, bytearray))] + with self._lock: + self_part = r in self._pending_parts + if self_part: + self._pending_parts.discard(r) + members = self.members.get(r) - if isinstance(body, list): - for entry in body: - if isinstance(entry, (bytes, bytearray)) and members is not None: - members.discard(bytes(entry)) - elif isinstance(src, (bytes, bytearray)) and members is not None: - members.discard(bytes(src)) + if members is not None: + for h in body_hashes: + members.discard(h) if self_part: self.rooms.discard(r) self.members.pop(r, None) + if self_part: self.manager.save() - if not self_part and isinstance(src, (bytes, bytearray)): - self._record_system(r, self.display_name_for(src)+" left") + else: + parter = None + if len(body_hashes) == 1 and (own_hash is None or body_hashes[0] != own_hash): + parter = body_hashes[0] + if parter is not None: + self._record_system(r, self.display_name_for(parter)+" left") self.manager._notify_change(self) return @@ -725,7 +769,7 @@ class RRCHub: ) is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash if not is_own: - own_nick = self.manager.get_nickname() + own_nick = self.get_effective_nick() pat = _mention_re(own_nick) if pat is not None and pat.search(body): msg.mention = True @@ -918,7 +962,7 @@ class RRCManager: def _on_welcome(self, hub): for r in list(hub.rooms): try: - hub.join_room(r) + hub.join_room(r, silent=True) except Exception: pass @@ -1020,6 +1064,9 @@ class RRCManager: al = e.get("auto_list") if isinstance(al, bool): hub.auto_list = al + no = e.get("nick") + if isinstance(no, str) and no: + hub.nick_override = no except Exception as e: RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) finally: @@ -1037,7 +1084,7 @@ class RRCManager: for h in self.hubs: joined = set(h.rooms) parted = set(h.messages.keys()) - joined - entries.append({ + entry = { "hash": h.hub_hash, "dest_name": h.dest_name, "name": h.name, @@ -1045,7 +1092,10 @@ class RRCManager: "parted_rooms": sorted(parted), "auto_reconnect": bool(h.auto_reconnect), "auto_list": bool(h.auto_list), - }) + } + if isinstance(h.nick_override, str) and h.nick_override: + entry["nick"] = h.nick_override + entries.append(entry) data = cbor.encode({"hubs": entries}) with open(tmp_path, "wb") as f: f.write(data) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 5a9c7f0..abbc3dd 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -566,7 +566,7 @@ class RoomWidget(urwid.WidgetWrap): "/join - join a room on this hub", "/part [room] - leave a room (default: current)", "/leave [room] - alias for /part", - "/nick - set your display name", + "/nick - set your nick on this hub only", "/who [room] - list users (current room if omitted)", "/names [room] - alias for /who", "/clear - clear local messages in this room", @@ -667,16 +667,18 @@ class RoomWidget(urwid.WidgetWrap): if cmd == "nick": if not arg: - cur = self.app.rrc.get_nickname() or "(unset)" - self._local_message("system", "Current nick: "+cur) + cur = self.hub.get_effective_nick() or " unset" + src = "nick: " if (isinstance(self.hub.nick_override, str) and self.hub.nick_override) else "global" + self._local_message("system", "Nick on this hub: "+cur+" ("+src+")") return limit = self.hub.max_nick_bytes or 32 if len(arg.encode("utf-8")) > limit: self._local_message("error", "Nick too long (max "+str(limit)+" bytes)") return try: - self.app.set_display_name(arg) - self._local_message("system", "Nick set to "+arg) + self.hub.set_nick_override(arg) + self._local_message("system", "Nick on this hub set to "+arg+ + " (use /nick with no argument to view)") except Exception as e: self._local_message("error", "Nick change failed: "+str(e)) return @@ -808,7 +810,10 @@ def _message_widget(app, hub, m, link_delegate=None): g = app.ui.glyphs own_nick = None try: - own_nick = app.rrc.get_nickname() + if hub is not None: + own_nick = hub.get_effective_nick() + else: + own_nick = app.rrc.get_nickname() except Exception: pass From deb8c7f55d6bc1fb33e8db49c5a429dc70889473 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 10:42:57 -0400 Subject: [PATCH 03/14] Don't set STATUS_CONNECTING before we've connected (Patch from @pintobyte) --- nomadnet/RRC.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 3250750..5cf3b21 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -273,7 +273,11 @@ class RRCHub: if self._reconnect_timer is not None: self._reconnect_timer.cancel() self._reconnect_timer = None - self._set_status(RRCHub.STATUS_CONNECTING, "Connecting") + if self._reconnect_attempts > 0: + text = "Reconnecting (attempt "+str(self._reconnect_attempts)+")" + else: + text = "Connecting" + self._set_status(RRCHub.STATUS_CONNECTING, text) t = threading.Thread(target=self._connect_worker, daemon=True) t.start() @@ -407,7 +411,6 @@ class RRCHub: self._reconnect_timer = None if self._manual_disconnect or not self.auto_reconnect: return - self._set_status(RRCHub.STATUS_CONNECTING, "Reconnecting (attempt "+str(self._reconnect_attempts)+")") self.connect() self._reconnect_timer = threading.Timer(backoff, fire) From 3187fe296c99ae4506488bacd617d79076a6b9c4 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:08:42 -0400 Subject: [PATCH 04/14] Add HubInfoArea focus shortcuts, fix for pending room joins --- nomadnet/RRC.py | 15 ++++++++++++- nomadnet/ui/textui/Browser.py | 9 +------- nomadnet/ui/textui/Channels.py | 41 +++++++++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 5cf3b21..cd425c9 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -809,9 +809,22 @@ class RRCHub: body = env.get(K_BODY) room = env.get(K_ROOM) text = body if isinstance(body, str) else "(error)" + r = room.strip().lower() if isinstance(room, str) else None + rollback_join = False + if r: + with self._lock: + if r in self._pending_joins: + rollback_join = True + self._pending_joins.discard(r) + self._silent_joins.discard(r) + self._pending_parts.discard(r) + if rollback_join: + self.rooms.discard(r) + if rollback_join: + self.manager.save() msg = RRCMessage( "error", - room.strip().lower() if isinstance(room, str) else None, + r, None, None, text, diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1994ac9..4b3042a 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -362,14 +362,7 @@ class Browser: raise ValueError("hub hash must be "+str(expected_len)+" bytes") room = room.strip().lstrip("#").strip() - room_norm = None - if room: - try: - # validate the room name early; pass the raw value through - from nomadnet.RRC import RRCHub as _RRCHubCls # noqa - room_norm = room.lower() - except Exception: - room_norm = None + room_norm = room.lower() if room else None existing = self.app.rrc.find_hub(hub_hash, dest_name=dest) self.app.ui.main_display.show_channels(None) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index abbc3dd..5fdca96 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -211,6 +211,32 @@ class ChannelsListArea(urwid.LineBox): return super(ChannelsListArea, self).keypress(size, key) +class HubInfoArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl n": + self.delegate.new_hub_dialog() + return None + if key == "ctrl a": + self.delegate.join_room_dialog() + return None + if key == "ctrl r": + self.delegate.connect_selected() + return None + if key == "ctrl w": + self.delegate.disconnect_selected() + return None + if key == "ctrl t": + self.delegate.toggle_auto_reconnect_selected() + return None + if key == "ctrl e": + self.delegate.edit_hub_dialog() + return None + if key == "ctrl x": + self.delegate.remove_selected_dialog() + return None + return super(HubInfoArea, self).keypress(size, key) + + class RoomMessageEdit(urwid.Edit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1052,9 +1078,13 @@ class ChannelsDisplay(): self.selected_key = ("room", hub.hub_hash, hub.dest_name, room) self.app.rrc.set_active(hub, room) self._maybe_autoconnect(hub) - if room not in hub.rooms and hub.status == RRCHub.STATUS_CONNECTED: - try: hub.join_room(room) - except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + if room not in hub.rooms: + if hub.status == RRCHub.STATUS_CONNECTED: + try: hub.join_room(room) + except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR) + else: + try: hub.add_room(room) + except Exception as e: RNS.log("Pending join queue failed: "+str(e), RNS.LOG_ERROR) self._show_room(hub, room) def _maybe_autoconnect(self, hub): @@ -1131,7 +1161,8 @@ class ChannelsDisplay(): urwid.connect_signal(entry, "click", self._select_room, (hub, name)) lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus")) - info = urwid.LineBox(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + info = HubInfoArea(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name) + info.delegate = self self.current_room_widget = None options = self.columns_widget.options(urwid.WEIGHT, 1) self.columns_widget.contents[1] = (info, options) @@ -1295,7 +1326,7 @@ class ChannelsDisplay(): urwid.AttrMap(urwid.Text( " Opening will add this hub to your client,"), "list_unknown"), urwid.AttrMap(urwid.Text( - " ,and connect to it, and reveal your identity hash"), "list_unknown"), + " and reveal your identity hash to the hub"), "list_unknown"), urwid.AttrMap(urwid.Text( " to the hub operator."), "list_unknown"), urwid.Text(""), From 6e0d8b9f4c3540745c71fd5c622a0e210f8b1133 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:27:22 -0400 Subject: [PATCH 05/14] AutoWho to populate nicks in user sidebar. Thanks @pintobyte --- nomadnet/RRC.py | 91 +++++++++++++++++++++++++++++++--- nomadnet/ui/textui/Channels.py | 12 ++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index cd425c9..fa8d088 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -101,6 +101,39 @@ def _msg_id(): return os.urandom(8) +# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" +_WHO_ENTRY_RE = re.compile( + r"(?:^|,\s)" + r"(?:(?P.+?)\s\((?P[0-9a-fA-F]+)\)|(?P[0-9a-fA-F]+))" + r"(?=,\s|$)" +) + + +# hub /who response format: "members in : nick1 (hex12), nick2 (hex12), , ..." +# nicked users carry only a 12-hex prefix of their identity hash; un-nicked users appear as the full hex +def _parse_who_notice(text): + if not isinstance(text, str): + return None + prefix = "members in " + if not text.startswith(prefix): + return None + sep_idx = text.find(": ", len(prefix)) + if sep_idx < 0: + return None + room = text[len(prefix):sep_idx].strip().lower() + if not room: + return None + body = text[sep_idx+2:].strip() + entries = [] + if body and body != "(none)": + for m in _WHO_ENTRY_RE.finditer(body): + if m.group("nick") is not None: + entries.append((m.group("nick").strip(), m.group("np").lower())) + elif m.group("bh") is not None: + entries.append((None, m.group("bh").lower())) + return (room, entries) + + def _parse_room_list_notice(text): if not isinstance(text, str): return None @@ -186,6 +219,7 @@ class RRCHub: self.auto_reconnect = False self.auto_list = False + self.auto_who = False self._lock = threading.RLock() self._resource_expectations = {} @@ -200,6 +234,7 @@ class RRCHub: self.available_rooms = {} self._silent_list_pending = 0 + self._silent_who_rooms = set() self.nick_override = None self._pending_joins = set() @@ -394,6 +429,7 @@ class RRCHub: self._pending_joins.clear() self._pending_parts.clear() self._silent_joins.clear() + self._silent_who_rooms.clear() should_reconnect = self.auto_reconnect and not self._manual_disconnect self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") if should_reconnect: @@ -418,7 +454,6 @@ class RRCHub: self._reconnect_timer.start() self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s") - def disconnect(self): self._stop_hello.set() @@ -435,19 +470,28 @@ class RRCHub: except Exception: pass self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected") - def set_auto_reconnect(self, enabled): + def set_auto_reconnect(self, enabled, save=True): with self._lock: self.auto_reconnect = bool(enabled) if not enabled and self._reconnect_timer is not None: self._reconnect_timer.cancel() self._reconnect_timer = None - self.manager.save() + if save: + self.manager.save() self.manager._notify_change(self) - def set_auto_list(self, enabled): + def set_auto_list(self, enabled, save=True): with self._lock: self.auto_list = bool(enabled) - self.manager.save() + if save: + self.manager.save() + self.manager._notify_change(self) + + def set_auto_who(self, enabled, save=True): + with self._lock: + self.auto_who = bool(enabled) + if save: + self.manager.save() self.manager._notify_change(self) def get_effective_nick(self): @@ -701,6 +745,14 @@ class RRCHub: if self_join: if not silent: self._record_system(r, "You joined #"+r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) self.manager.save() else: joiner = None @@ -794,6 +846,29 @@ class RRCHub: self.manager._notify_change(self) if silent: return + parsed_who = _parse_who_notice(body) + if parsed_who is not None: + who_room, who_entries = parsed_who + with self._lock: + members = self.members.setdefault(who_room, set()) + for nick, hash_hex in who_entries: + try: + hash_bytes = bytes.fromhex(hash_hex) + except Exception: + continue + if nick is None: + members.add(hash_bytes) + continue + for ph in members: + if ph.startswith(hash_bytes): + self.nicks[ph] = nick + break + silent_who = who_room in self._silent_who_rooms + if silent_who: + self._silent_who_rooms.discard(who_room) + self.manager._notify_change(self) + if silent_who: + return msg = RRCMessage( "notice", room.strip().lower() if isinstance(room, str) else None, @@ -1080,6 +1155,9 @@ class RRCManager: al = e.get("auto_list") if isinstance(al, bool): hub.auto_list = al + aw = e.get("auto_who") + if isinstance(aw, bool): + hub.auto_who = aw no = e.get("nick") if isinstance(no, str) and no: hub.nick_override = no @@ -1108,6 +1186,7 @@ class RRCManager: "parted_rooms": sorted(parted), "auto_reconnect": bool(h.auto_reconnect), "auto_list": bool(h.auto_list), + "auto_who": bool(h.auto_who), } if isinstance(h.nick_override, str) and h.nick_override: entry["nick"] = h.nick_override @@ -1120,7 +1199,7 @@ class RRCManager: except Exception: pass os.replace(tmp_path, path) except Exception as e: - # + # # # # diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 5fdca96..52342c7 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1129,6 +1129,11 @@ class ChannelsDisplay(): al_text = "On" if hub.auto_list else "Off" lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr)) + aw_glyph = g["check"] if hub.auto_who else g["cross"] + aw_attr = "list_trusted" if hub.auto_who else "list_unknown" + aw_text = "On" if hub.auto_who else "Off" + lines.append(urwid.AttrMap(urwid.Text(" AutoWho : "+aw_glyph+" "+aw_text+" (Ctrl-E to edit)"), aw_attr)) + lines.append(urwid.Divider(g["divider1"])) if hub.status == RRCHub.STATUS_CONNECTED: @@ -1350,6 +1355,7 @@ class ChannelsDisplay(): e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) + cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) error_text = urwid.Text("") def dismiss(sender): @@ -1359,8 +1365,9 @@ class ChannelsDisplay(): try: nm = e_name.get_edit_text().strip() or hub.name hub.name = nm - hub.set_auto_reconnect(cb_autorcn.get_state()) - hub.set_auto_list(cb_autolist.get_state()) + hub.set_auto_reconnect(cb_autorcn.get_state(), save=False) + hub.set_auto_list(cb_autolist.get_state(), save=False) + hub.set_auto_who(cb_autowho.get_state(), save=False) self.app.rrc.save() self.close_dialog() self.update_list() @@ -1378,6 +1385,7 @@ class ChannelsDisplay(): urwid.Text(""), cb_autorcn, cb_autolist, + cb_autowho, urwid.Text(""), error_text, urwid.Columns([ From 1c117507c48508e8af2e3f8a23f3ef2dc4c3b4dd Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 12:43:48 -0400 Subject: [PATCH 06/14] Change AutoWho regex to match partial hashes --- nomadnet/RRC.py | 4 ++-- nomadnet/ui/textui/Channels.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index fa8d088..bc1766c 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -104,7 +104,7 @@ def _msg_id(): # greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)" _WHO_ENTRY_RE = re.compile( r"(?:^|,\s)" - r"(?:(?P.+?)\s\((?P[0-9a-fA-F]+)\)|(?P[0-9a-fA-F]+))" + r"(?:(?P[0-9a-fA-F]{32})|(?P.+?)\s\((?P[0-9a-fA-F]{12})\))" r"(?=,\s|$)" ) @@ -279,7 +279,7 @@ class RRCHub: nick = self.nicks.get(ph) if nick: return nick - return ph.hex()[:10] + return ph.hex()[:12] def mark_read(self, room): r = self._normalize_room(room) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 52342c7..0e62a7c 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -137,7 +137,7 @@ def _body_markup(body, body_attr="body_text", own_nick=None): return out, has_links -def _short_hash(b, n=10): +def _short_hash(b, n=12): if isinstance(b, (bytes, bytearray)): return bytes(b).hex()[:n] return "?" From 2977d9ed71ca611153fccbabd13bc48597822bc9 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 16:24:42 -0400 Subject: [PATCH 07/14] Fixes --- nomadnet/RRC.py | 16 ++++++++-------- nomadnet/ui/textui/Channels.py | 10 +++++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index bc1766c..982dbe1 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -745,14 +745,14 @@ class RRCHub: if self_join: if not silent: self._record_system(r, "You joined #"+r) - if self.auto_who: - try: - with self._lock: - self._silent_who_rooms.add(r) - self.send_command("/who "+r, room=r) - except Exception: - with self._lock: - self._silent_who_rooms.discard(r) + if self.auto_who: + try: + with self._lock: + self._silent_who_rooms.add(r) + self.send_command("/who "+r, room=r) + except Exception: + with self._lock: + self._silent_who_rooms.discard(r) self.manager.save() else: joiner = None diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 0e62a7c..4043529 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -1503,7 +1503,15 @@ class ChannelsDisplay(): pass def _on_rrc_change(self, hub): - self._wake(self.update_list) + def action(): + self.update_list() + if (self.current_room_widget is not None + and self.current_room_widget.hub is hub): + try: + self.current_room_widget._refresh_users_pane() + except Exception: + pass + self._wake(action) def _on_rrc_message(self, hub, msg): def action(): From 5c76d509d7fb48e6774f45fdcba792af8aabfdea Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Thu, 14 May 2026 17:53:51 -0400 Subject: [PATCH 08/14] Fixes --- nomadnet/Conversation.py | 64 ++++++++++++++++++++++------- nomadnet/ui/textui/Channels.py | 17 ++++++++ nomadnet/ui/textui/Conversations.py | 32 ++++++++------- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index 14844d3..d7039dc 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -1,4 +1,5 @@ import os +import re import RNS import LXMF import shutil @@ -459,22 +460,25 @@ class ConversationMessage: if found_in_fields: names = [] file_atts = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, []) - for att in file_atts: + for idx, att in enumerate(file_atts): if isinstance(att, list) and len(att) >= 2: size = len(att[1]) if isinstance(att[1], bytes) else 0 - names.append(("file", str(att[0]), size)) + safe = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) + names.append(("file", safe, size)) if LXMF.FIELD_IMAGE in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) if data: size = len(data) ext = ConversationMessage._ext_from_media_format(fmt, data) - names.append(("file", "image"+ext, size)) + safe = ConversationMessage.safe_attachment_name("image"+ext, fallback="image") + names.append(("file", safe, size)) if LXMF.FIELD_AUDIO in fields: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) if data: size = len(data) ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) - names.append(("file", "audio"+ext, size)) + safe = ConversationMessage.safe_attachment_name("audio"+ext, fallback="audio") + names.append(("file", safe, size)) self._cached_has_attachments = True self._cached_attachment_names = names @@ -674,11 +678,18 @@ class ConversationMessage: try: with open(manifest_path, "rb") as f: manifest = msgpack.unpackb(f.read(), raw=False) - for f in manifest["files"]: - f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8") - + safe_files = [] + for idx, entry in enumerate(manifest.get("files", [])): + if not isinstance(entry, dict): + continue + entry["name"] = ConversationMessage.safe_attachment_name(entry.get("name"), fallback="attachment_"+str(idx)) + stored = entry.get("stored_name") + if not isinstance(stored, str) or not re.fullmatch(r"file_\d+", stored): + continue + safe_files.append(entry) + manifest["files"] = safe_files return manifest - + except Exception as e: RNS.log(f"Error loading attachment manifest: {e}") return None @@ -714,12 +725,12 @@ class ConversationMessage: for idx, att in enumerate(file_attachments): try: if isinstance(att, list) and len(att) >= 2: - filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8") + filename = ConversationMessage.safe_attachment_name(att[0], fallback="attachment_"+str(idx)) data = att[1] if isinstance(att[1], bytes) else b"" stored_name = "file_"+str(idx) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)}) - + except Exception as e: RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR) continue @@ -728,7 +739,7 @@ class ConversationMessage: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE]) if data: ext = ConversationMessage._ext_from_media_format(fmt, data) - filename = "image" + ext + filename = ConversationMessage.safe_attachment_name("image" + ext, fallback="image") stored_name = "file_"+str(len(manifest["files"])) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) @@ -738,7 +749,7 @@ class ConversationMessage: fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_AUDIO]) if data: ext = ConversationMessage._ext_from_media_format(fmt, data, is_audio=True) - filename = "audio" + ext + filename = ConversationMessage.safe_attachment_name("audio" + ext, fallback="audio") stored_name = "file_"+str(len(manifest["files"])) with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data) @@ -747,6 +758,29 @@ class ConversationMessage: with open(os.path.join(att_dir, "manifest"), "wb") as f: f.write(msgpack.packb(manifest)) + @staticmethod + def safe_attachment_name(name, fallback="attachment"): + try: + if isinstance(name, bytes): + name = name.decode("utf-8", errors="replace") + elif not isinstance(name, str): + name = str(name) if name is not None else "" + except Exception: + name = "" + name = re.sub(r"[\x00-\x1f\x7f]", "", name) + parts = re.split(r"[/\\]", name) + name = parts[-1] if parts else "" + if ":" in name: + name = name.split(":")[-1] + name = name.lstrip(".") + if not name or name in (".", ".."): + return fallback + if len(name) > 200: + base, ext = os.path.splitext(name) + ext = ext[:16] + name = base[:200 - len(ext)] + ext + return name + @staticmethod def _unpack_media_field(field_data): """Normalize FIELD_IMAGE/FIELD_AUDIO which can be raw bytes or [format, bytes]. @@ -789,10 +823,10 @@ class ConversationMessage: @staticmethod def _ext_from_media_format(fmt, data, is_audio=False): - """Derive file extension from format identifier and data. - fmt can be a string ('webp'), an integer (LXMF audio mode), or None.""" if isinstance(fmt, str) and len(fmt) > 0: - return "." + fmt.lower().strip(".") + safe = re.sub(r"[^A-Za-z0-9]", "", fmt).lower()[:8] + if safe: + return "." + safe if isinstance(fmt, int) and is_audio: if fmt >= 16 and fmt <= 25: return ".ogg" diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 4043529..abaed97 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -372,6 +372,7 @@ class RoomWidget(urwid.WidgetWrap): editor = RoomMessageEdit(caption="", edit_text="", multiline=True) editor.delegate = self self.editor = editor + urwid.connect_signal(editor, "postchange", self._on_editor_change) editor_attr = urwid.AttrMap(editor, "msg_editor") self.link_delegate = _ChatLinkDelegate(self.display, self.hub) @@ -474,6 +475,10 @@ class RoomWidget(urwid.WidgetWrap): self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1) self.messagelist.name = "messagelist" + try: + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass if replace and hasattr(self, "frame"): self.frame.contents["body"] = (self.messagelist, None) if hasattr(self, "users_pile"): @@ -495,6 +500,7 @@ class RoomWidget(urwid.WidgetWrap): del body[0] try: self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") except Exception: pass except Exception as e: @@ -503,6 +509,17 @@ class RoomWidget(urwid.WidgetWrap): if hasattr(self, "users_pile"): self._refresh_users_pane() + def _on_editor_change(self, editor, old_text): + if self.messagelist is None: + return + try: + body = self.messagelist._listbox.body + if len(body) > 0: + self.messagelist._listbox.set_focus(len(body)-1) + self.messagelist._listbox.set_focus_valign("bottom") + except Exception: + pass + def send_message(self): text = self.editor.get_edit_text() if not text.strip(): diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 2af34f1..4adefff 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1798,32 +1798,34 @@ class ClickableAttachment(urwid.Text): self.set_text(" "+g["cross"]+" Save failed: "+str(e)) -def _copy_attachment_to_dest(filename, src_path): +def _resolve_attachment_save_path(filename): app = nomadnet.NomadNetworkApp.get_shared_instance() save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path if not os.path.isdir(save_dir): os.makedirs(save_dir) - save_path = os.path.join(save_dir, filename) + safe_name = ConversationMessage.safe_attachment_name(filename) + base_dir = os.path.realpath(save_dir) + os.sep + candidate = os.path.realpath(os.path.join(save_dir, safe_name)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) counter = 0 - base, ext = os.path.splitext(filename) - while os.path.isfile(save_path): + base, ext = os.path.splitext(safe_name) + while os.path.isfile(candidate): counter += 1 - save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext)) + if not (candidate + os.sep).startswith(base_dir): + raise OSError(13, os.strerror(13)) + return candidate + + +def _copy_attachment_to_dest(filename, src_path): + save_path = _resolve_attachment_save_path(filename) shutil.copy2(src_path, save_path) return save_path def _save_attachment_to_disk(filename, data): - app = nomadnet.NomadNetworkApp.get_shared_instance() - save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path - if not os.path.isdir(save_dir): - os.makedirs(save_dir) - save_path = os.path.join(save_dir, filename) - counter = 0 - base, ext = os.path.splitext(filename) - while os.path.isfile(save_path): - counter += 1 - save_path = os.path.join(save_dir, base+"_"+str(counter)+ext) + save_path = _resolve_attachment_save_path(filename) with open(save_path, "wb") as f: f.write(data) return save_path From c4b0bd0b5796d0699a5efa824e546e03958de394 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 16 May 2026 22:58:56 +0200 Subject: [PATCH 09/14] Uuh... We didn't have periodic lxmf announces? --- nomadnet/NomadNetworkApp.py | 15 ++++++++++++++- nomadnet/ui/textui/Guide.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index b0d99e2..b40ddba 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -268,7 +268,7 @@ class NomadNetworkApp: RNS.log("No peer settings file found, creating new...") self.peer_settings = { "display_name": "Anonymous Peer", - "announce_interval": None, + "announce_interval": 6*60*60, "last_announce": None, "node_last_announce": None, "propagation_node": None, @@ -447,6 +447,9 @@ class NomadNetworkApp: RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) self.request_lxmf_sync(limit=self.lxmf_sync_limit) + if now > self.peer_settings["last_announce"] + self.peer_settings["announce_interval"]: + self.announce_now() + time.sleep(self.job_interval) def set_display_name(self, display_name): @@ -530,6 +533,7 @@ class NomadNetworkApp: self.message_router.cancel_propagation_node_requests() def announce_now(self): + RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) self.lxmf_destination.display_name = self.peer_settings["display_name"] self.message_router.announce(self.lxmf_destination.hash) @@ -760,6 +764,11 @@ class NomadNetworkApp: value = self.config["client"].as_bool(option) self.peer_announce_at_start = value + if option == "announce_interval": + value = self.config["client"].as_int(option) + if value < 30: value = 30 + self.peer_settings["announce_interval"] = value*60 + if option == "try_propagation_on_send_fail": value = self.config["client"].as_bool(option) self.try_propagation_on_fail = value @@ -1095,6 +1104,10 @@ notify_on_new_message = yes # to let other peers reach it immediately. announce_at_start = yes +# Automatic announce interval in minutes for +# your LXMF address. 6 hours by default. +announce_interval = 360 + # By default, the client will try to deliver a # message via the LXMF propagation network, if # a direct delivery to the recipient is not diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index ce6bcc5..d67c4c4 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -717,6 +717,12 @@ Sets whether to output a notification character (bell or flash) to the terminal Determines whether your LXMF address is automatically announced when the program starts. Must be a boolean value. < +>>> +`!announce_interval = 360`! +>>>> +Determines how often, in minutes, your LXMF address is announced on the network. Defaults to 6 hours. +< + >>> `!try_propagation_on_send_fail = yes`! >>>> From 5c9a5cd4786250210341f89f1d9ae73dbb8a10eb Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:09:55 +0200 Subject: [PATCH 10/14] Added RRC room history persistence by neutral --- nomadnet/NomadNetworkApp.py | 64 ++++++++----- nomadnet/RRC.py | 160 +++++++++++++++++++++++++++++++-- nomadnet/ui/textui/Channels.py | 3 +- nomadnet/ui/textui/Guide.py | 10 +++ 4 files changed, 209 insertions(+), 28 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index b40ddba..a81c762 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -146,10 +146,12 @@ class NomadNetworkApp: self.lxmf_sync_interval = 360*60 self.lxmf_sync_limit = 8 self.compact_stream = False - + self.required_stamp_cost = None self.accept_invalid_stamps = False + self.rrc_history_per_room_cap = 500 + if not os.path.isdir(self.storagepath): os.makedirs(self.storagepath) @@ -200,11 +202,11 @@ class NomadNetworkApp: import shutil examplespath = os.path.join(os.path.dirname(__file__), "examples") shutil.copytree(examplespath, self.examplespath, ignore=shutil.ignore_patterns("__pycache__")) - + except Exception as e: RNS.log("Could not copy examples into the "+self.examplespath+" directory.", RNS.LOG_ERROR) RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR) - + RNS.log("Could not load config file, creating default configuration file...") self.createDefaultConfig() self.firstrun = True @@ -377,7 +379,7 @@ class NomadNetworkApp: if not self.disable_propagation: RNS.log("LXMF Propagation Node started on: "+RNS.prettyhexrep(self.message_router.propagation_destination.hash)) - + self.node = nomadnet.Node(self) else: self.node = None @@ -442,7 +444,7 @@ class NomadNetworkApp: RNS.log("Starting job scheduler now", RNS.LOG_DEBUG) while self.should_run_jobs: now = time.time() - + if now > self.peer_settings["last_lxmf_sync"] + self.lxmf_sync_interval: RNS.log("Initiating automatic LXMF sync", RNS.LOG_VERBOSE) self.request_lxmf_sync(limit=self.lxmf_sync_limit) @@ -576,7 +578,7 @@ class NomadNetworkApp: self.peer_settings["propagation_node"] = node_hash self.save_peer_settings() self.autoselect_propagation_node() - + def get_default_propagation_node(self): return self.message_router.get_outbound_propagation_node() @@ -608,7 +610,7 @@ class NomadNetworkApp: if self.print_messages: if self.print_all_messages: return True - + else: source_hash_text = RNS.hexrep(message.source_hash, delimit=False) @@ -651,7 +653,7 @@ class NomadNetworkApp: received = time.time() g = self.ui.glyphs - + m_rtime = datetime.fromtimestamp(message.timestamp) stime = m_rtime.strftime(self.time_format) @@ -718,7 +720,7 @@ class NomadNetworkApp: def createDefaultConfig(self): self.config = ConfigObj(__default_nomadnet_config__) self.config.filename = self.configpath - + if not os.path.isdir(self.configdir): os.makedirs(self.configdir) self.config.write() @@ -784,7 +786,7 @@ class NomadNetworkApp: self.lxmf_sync_interval = value if option == "lxmf_sync_limit": - value = self.config["client"].as_int(option) + value = self.config["client"].as_int(option) if value > 0: self.lxmf_sync_limit = value @@ -810,7 +812,7 @@ class NomadNetworkApp: self.accept_invalid_stamps = value if option == "max_accepted_size": - value = self.config["client"].as_float(option) + value = self.config["client"].as_float(option) if value > 0: self.lxmf_max_incoming_size = value @@ -900,6 +902,16 @@ class NomadNetworkApp: if value == "web": self.uimode = nomadnet.ui.UI_WEB + if "rrc" in self.config: + for option in self.config["rrc"]: + if option == "history_per_room_cap": + try: + value = self.config["rrc"].as_int(option) + except Exception: + value = None + if value is not None and value >= 0: + self.rrc_history_per_room_cap = value + if "node" in self.config: if not "enable_node" in self.config["node"]: self.enable_node = False @@ -956,10 +968,10 @@ class NomadNetworkApp: value = self.config["node"].as_int("propagation_cost") if value < 13: value = 13 self.node_propagation_cost = value - + if "pages_path" in self.config["node"]: self.pagespath = self.config["node"]["pages_path"] - + if not "page_refresh_interval" in self.config["node"]: self.page_refresh_interval = 0 else: @@ -967,11 +979,11 @@ class NomadNetworkApp: if value < 0: value = 0 self.page_refresh_interval = value - + if "files_path" in self.config["node"]: self.filespath = self.config["node"]["files_path"] - + if not "file_refresh_interval" in self.config["node"]: self.file_refresh_interval = 0 else: @@ -979,7 +991,7 @@ class NomadNetworkApp: if value < 0: value = 0 self.file_refresh_interval = value - + if "prioritise_destinations" in self.config["node"]: self.prioritised_lxmf_destinations = self.config["node"].as_list("prioritise_destinations") @@ -990,7 +1002,7 @@ class NomadNetworkApp: self.static_peers = self.config["node"].as_list("static_peers") else: self.static_peers = [] - + if not "max_peers" in self.config["node"]: self.max_peers = None else: @@ -1030,13 +1042,13 @@ class NomadNetworkApp: self.print_all_messages = True if self.config["printing"]["print_from"].lower() == "trusted": - + self.print_all_messages = False self.print_trusted_messages = True if len(self.config["printing"]["print_from"]) == (RNS.Identity.TRUNCATED_HASHLENGTH//8)*2: self.allowed_message_print_destinations.append(self.config["printing"]["print_from"]) - + if type(self.config["printing"]["print_from"]) == list: self.allowed_message_print_destinations = self.config["printing"].as_list("print_from") for allowed_entry in self.allowed_message_print_destinations: @@ -1056,7 +1068,7 @@ class NomadNetworkApp: template_file.write(__printing_template_msg__.encode("utf-8")) self.printing_template_msg = __printing_template_msg__ - + @staticmethod def get_shared_instance(): if NomadNetworkApp._shared_instance != None: @@ -1212,6 +1224,18 @@ hide_guide = no # announces. sanitize_names = yes +[rrc] + +# Maximum number of messages retained per +# room in the in-memory scrollback buffer, +# and the number of messages restored from +# on-disk history at startup. The on-disk +# log itself is appended to indefinitely; +# this cap only controls how much backlog +# is visible. Set to 0 to keep every message +# in memory (full history). +history_per_room_cap = 500 + [node] # Whether to enable node hosting diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 982dbe1..7c92db3 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -10,6 +10,17 @@ import RNS from nomadnet.vendor import cbor +HISTORY_DIR_NAME = "rrc_history" +HISTORY_FILENAME_SANITIZE_RE = re.compile(r"[^a-z0-9._-]+") + +H_KIND = "k" +H_SRC = "s" +H_NICK = "n" +H_TEXT = "t" +H_TS = "ts" +H_MENTION = "m" + + _MENTION_RE_CACHE = {} @@ -241,6 +252,8 @@ class RRCHub: self._pending_parts = set() self._silent_joins = set() + self._history_write_failed = False + def _log(self, msg, level=None): if level is None: level = RNS.LOG_INFO @@ -264,9 +277,20 @@ class RRCHub: self.unread_rooms.discard(r) self.mention_rooms.discard(r) self.members.pop(r, None) + self._delete_history(r) self.manager.save() self.manager._notify_change(self) + def clear_messages(self, room): + r = self._normalize_room(room) + with self._lock: + if r in self.messages: + self.messages[r] = [] + self.unread_rooms.discard(r) + self.mention_rooms.discard(r) + self._delete_history(r) + self.manager._notify_change(self) + def get_members(self, room): with self._lock: return list(self.members.get(room, set())) @@ -595,28 +619,124 @@ class RRCHub: self._record_message(RRCMessage("msg", 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)) + except Exception: + return None + return v if v > 0 else None + + def _entry_for(self, msg): + return { + H_KIND: msg.kind, + H_SRC: bytes(msg.src) if isinstance(msg.src, (bytes, bytearray)) else None, + H_NICK: msg.nick if isinstance(msg.nick, str) else None, + H_TEXT: msg.text if isinstance(msg.text, str) else "", + H_TS: int(msg.ts) if isinstance(msg.ts, int) else _now_ms(), + H_MENTION: bool(getattr(msg, "mention", False)), + } + + def _msg_from_entry(self, room, entry): + if not isinstance(entry, dict): + return None + m = RRCMessage( + entry.get(H_KIND) if isinstance(entry.get(H_KIND), str) else "msg", + room, + entry.get(H_SRC) if isinstance(entry.get(H_SRC), (bytes, bytearray)) else None, + entry.get(H_NICK) if isinstance(entry.get(H_NICK), str) else None, + entry.get(H_TEXT) if isinstance(entry.get(H_TEXT), str) else "", + entry.get(H_TS) if isinstance(entry.get(H_TS), int) else 0, + ) + m.mention = bool(entry.get(H_MENTION, False)) + return m + + def _persistable_room(self, room): + return isinstance(room, str) and room and room != "*" + + def _append_history(self, room, msg): + if not self._persistable_room(room): + return + try: + self.manager._ensure_history_dir(self) + path = self.manager._history_path(self, room) + with open(path, "ab") as f: + f.write(cbor.encode(self._entry_for(msg))) + self._history_write_failed = False + except Exception as e: + if not self._history_write_failed: + self._history_write_failed = True + self._log("history persistence failed, suppressing further warnings until recovery: "+str(e), RNS.LOG_ERROR) + + def _delete_history(self, room): + if not self._persistable_room(room): + return + path = self.manager._history_path(self, room) + try: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass + + def _load_history(self): + with self._lock: + rooms = list(self.messages.keys()) + for room in rooms: + if not self._persistable_room(room): + continue + path = self.manager._history_path(self, room) + if not os.path.isfile(path): + continue + window = deque(maxlen=self._per_room_cap()) + decode_error = None + try: + with open(path, "rb") as f: + while True: + try: + window.append(cbor.load(f)) + except EOFError: + break + except Exception as ex: + decode_error = ex + break + except OSError as ex: + self._log("history load failed for #"+room+": "+str(ex), RNS.LOG_ERROR) + continue + if decode_error is not None: + self._log("history file for #"+room+" is corrupt, truncating to last "+str(len(window))+" valid messages: "+str(decode_error), RNS.LOG_ERROR) + msgs = [] + for e in window: + m = self._msg_from_entry(room, e) + if m is not None: + msgs.append(m) + with self._lock: + self.messages[room] = msgs + def _record_message(self, msg, local=False): + cap = self._per_room_cap() with self._lock: buf = self.messages.setdefault(msg.room or "*", []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] if not local and msg.room: if msg.room != self.manager.active_room_for(self): self.unread_rooms.add(msg.room) if msg.mention: self.mention_rooms.add(msg.room) + self._append_history(msg.room, msg) self.manager._notify_messages(self, msg) def _record_system(self, room, text): if not room: return msg = RRCMessage("system", room, None, None, text, _now_ms()) + cap = self._per_room_cap() with self._lock: buf = self.messages.setdefault(room, []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] + self._append_history(room, msg) self.manager._notify_messages(self, msg) def _record_notice(self, msg): @@ -626,6 +746,7 @@ class RRCHub: if target_room: msg.room = target_room + cap = self._per_room_cap() with self._lock: self.notices.append(msg) if len(self.notices) > 200: @@ -633,10 +754,12 @@ class RRCHub: if target_room: buf = self.messages.setdefault(target_room, []) buf.append(msg) - if len(buf) > 500: - del buf[:len(buf)-500] + if cap is not None and len(buf) > cap: + del buf[:len(buf)-cap] if target_room != self.manager.active_room_for(self): self.unread_rooms.add(target_room) + if target_room: + self._append_history(target_room, msg) self.manager._notify_messages(self, msg) def get_messages(self, room): @@ -1108,6 +1231,27 @@ class RRCManager: def _store_path(self): return os.path.join(self.app.storagepath, "rrc_hubs") + def _history_root(self): + return os.path.join(self.app.storagepath, HISTORY_DIR_NAME) + + def _history_dir(self, hub): + hub_key = hub.hub_hash.hex() + if hub.dest_name and hub.dest_name != DEFAULT_DEST_NAME: + suffix = hashlib.sha256(hub.dest_name.encode("utf-8")).hexdigest()[:8] + hub_key = hub_key + "__" + suffix + return os.path.join(self._history_root(), hub_key) + + def _history_path(self, hub, room): + sanitized = HISTORY_FILENAME_SANITIZE_RE.sub("_", room or "")[:64] + room_hash = hashlib.sha256((room or "").encode("utf-8")).hexdigest()[:8] + filename = (sanitized + "_" + room_hash + ".log") if sanitized else (room_hash + ".log") + return os.path.join(self._history_dir(hub), filename) + + def _ensure_history_dir(self, hub): + d = self._history_dir(hub) + os.makedirs(d, exist_ok=True) + return d + def load(self): if self._loaded: return @@ -1161,6 +1305,10 @@ class RRCManager: no = e.get("nick") if isinstance(no, str) and no: hub.nick_override = no + try: + hub._load_history() + except Exception as ex: + RNS.log("Failed to load RRC history for "+hub.name+": "+str(ex), RNS.LOG_ERROR) except Exception as e: RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR) finally: diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index abaed97..74fc6e2 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -727,8 +727,7 @@ class RoomWidget(urwid.WidgetWrap): return if cmd == "clear": - with self.hub._lock: - self.hub.messages[self.room] = [] + self.hub.clear_messages(self.room) self.update_messages(replace=True) return diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index d67c4c4..92c4c98 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -771,6 +771,16 @@ The maximum accepted unpacked size for messages received directly from other pee With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives. < +>> RRC Section + +This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows: + +>>> +`!history_per_room_cap = 500`! +>>>> +Maximum number of messages retained per room in the in-memory scrollback buffer, and the number of messages restored from on-disk history at startup. The on-disk log itself is appended to indefinitely; this cap only controls how much backlog is visible. Set to 0 to keep every message in memory. +< + >> Text UI Section This section hold configuration directives related to the look and feel of the text-based user interface of the program. It is delimited by the `![textui]`! header in the configuration file. Available directives, along with their default values, are as follows: From 0f0090d69c67cd90bd996bf4ae0378363fe68b72 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:11:15 +0200 Subject: [PATCH 11/14] Updated versions --- nomadnet/_version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nomadnet/_version.py b/nomadnet/_version.py index 92192ee..6849410 100644 --- a/nomadnet/_version.py +++ b/nomadnet/_version.py @@ -1 +1 @@ -__version__ = "1.0.4" +__version__ = "1.1.0" diff --git a/setup.py b/setup.py index b38d9d7..f3cfec0 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,6 @@ setuptools.setup( entry_points= { 'console_scripts': ['nomadnet=nomadnet.nomadnet:main'] }, - install_requires=["rns>=1.2.5", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"], + install_requires=["rns>=1.2.7", "lxmf>=0.9.8", "urwid>=2.6.16", "qrcode"], python_requires=">=3.8", ) From 5f2d9207c2881df5e199752b2e72cbea78fd3c82 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:25:28 +0200 Subject: [PATCH 12/14] Arrows point left on part --- nomadnet/ui/textui/Channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 74fc6e2..a96bd0f 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -860,18 +860,19 @@ def _message_widget(app, hub, m, link_delegate=None): pass if m.kind == "system": + evt_icon = g["arrow_l"] if m.text.endswith(" left") else g["arrow_r"] spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_system", " "+g["arrow_r"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_system", evt_icon+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) if m.kind == "notice": spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_notice", " "+g["info"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_notice", g["info"]+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) if m.kind == "error": spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick) - markup = [_ts_prefix(m.ts), ("irc_error", " "+g["warning"]+" ")] + spans + markup = [_ts_prefix(m.ts), ("irc_error", g["warning"]+" ")] + spans return _wrap_text(markup, link_delegate if has_links else None) own = False From 9e8d5fa96048def667f7dc47fee253c9861a49a2 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 01:36:18 +0200 Subject: [PATCH 13/14] Allow configuring lxmf.delivery announce interval --- nomadnet/NomadNetworkApp.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index a81c762..3e1b1ab 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -86,6 +86,8 @@ class NomadNetworkApp: self.uimode = None + self.announce_interval = 6*60*60 + if configdir == None: self.configdir = NomadNetworkApp.configdir else: @@ -258,6 +260,8 @@ class NomadNetworkApp: if not "served_file_requests" in self.peer_settings: self.peer_settings["served_file_requests"] = 0 + self.peer_settings["announce_interval"] = self.announce_interval + except Exception as e: RNS.logdest = RNS.LOG_STDOUT RNS.log(f"Could not load local peer settings from {self.peersettingspath}", RNS.LOG_ERROR) @@ -270,7 +274,7 @@ class NomadNetworkApp: RNS.log("No peer settings file found, creating new...") self.peer_settings = { "display_name": "Anonymous Peer", - "announce_interval": 6*60*60, + "announce_interval": self.announce_interval, "last_announce": None, "node_last_announce": None, "propagation_node": None, @@ -769,7 +773,7 @@ class NomadNetworkApp: if option == "announce_interval": value = self.config["client"].as_int(option) if value < 30: value = 30 - self.peer_settings["announce_interval"] = value*60 + self.announce_interval = value*60 if option == "try_propagation_on_send_fail": value = self.config["client"].as_bool(option) From e946120dc9d0a69b424aff82673d69bbe3f16776 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 17 May 2026 02:02:27 +0200 Subject: [PATCH 14/14] Fixed urwid race/crash in conversations by neutral --- nomadnet/ui/textui/Conversations.py | 45 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 4adefff..57521e3 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1,4 +1,5 @@ import RNS +import collections import os import shutil import time @@ -133,7 +134,40 @@ class ConversationsDisplay(): self.shortcuts_display = self.list_shortcuts self.widget = self.columns_widget - nomadnet.Conversation.created_callback = self.update_conversation_list + + self._pending_actions = collections.deque() + self._wake_fd = None + try: + self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending) + except Exception: + pass + + nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + + def _process_pending(self, data): + while True: + try: + action = self._pending_actions.popleft() + except IndexError: + break + try: + action() + except Exception as e: + RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR) + return True + + def _wake(self, action): + self._pending_actions.append(action) + if self._wake_fd is not None: + try: + os.write(self._wake_fd, b".") + return + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None)) + except Exception: + pass def focus_change_event(self): if not self.dialog_open: @@ -1024,7 +1058,7 @@ class ConversationWidget(urwid.WidgetWrap): self.update_message_widgets() - self.conversation.register_changed_callback(self.conversation_changed) + self.conversation.register_changed_callback(self._on_conversation_changed_from_callback) #title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False) title_editor = MessageEdit(caption="", edit_text="", multiline=False) @@ -1243,6 +1277,13 @@ class ConversationWidget(urwid.WidgetWrap): else: return super(ConversationWidget, self).keypress(size, key) + def _on_conversation_changed_from_callback(self, conversation): + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "_wake"): + delegate._wake(lambda: self.conversation_changed(conversation)) + else: + self.conversation_changed(conversation) + def conversation_changed(self, conversation): if hasattr(self, "peer_info_widget"): self._update_peer_info()