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] 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)