diff --git a/rrcd/commands.py b/rrcd/commands.py index 13747ad..6b689ea 100644 --- a/rrcd/commands.py +++ b/rrcd/commands.py @@ -30,7 +30,7 @@ class CommandHandler: outgoing: list[tuple[RNS.Link, bytes]] | None = None, ) -> bool: """Handle an operator command. - + Returns True if it was a recognized command (handled). Unknown commands return False so the message can be forwarded as normal chat. """ @@ -55,11 +55,9 @@ class CommandHandler: room=None, ) return True - # Hub-level command - send responses without room field self.hub._reload_config_and_rooms(link, None, outgoing) return True - # Global/server-operator commands if cmd == "stats": if not self.hub.trust_manager.is_server_op(peer_hash): if self.hub.identity is not None: @@ -71,20 +69,19 @@ class CommandHandler: room=None, ) return True - # Send response without room field for hub-level command - self.hub.message_helper.emit_notice(outgoing, link, None, self.hub.stats_manager.format_stats()) + self.hub.message_helper.emit_notice( + outgoing, link, None, self.hub.stats_manager.format_stats() + ) return True if cmd == "list": - # List all registered, non-private rooms with their topics with self.hub._state_lock: registered_rooms = [] for room_name, st in self.hub.room_manager._room_state.items(): if st.get("registered") and not st.get("private"): topic = st.get("topic") registered_rooms.append((room_name, topic)) - - # Also check room registry for rooms not currently in room_state + for room_name, reg in self.hub.room_manager._room_registry.items(): if room_name not in self.hub.room_manager._room_state: if not reg.get("private"): @@ -92,20 +89,20 @@ class CommandHandler: registered_rooms.append((room_name, topic)) if not registered_rooms: - self.hub.message_helper.emit_notice(outgoing, link, None, "No public rooms registered") + self.hub.message_helper.emit_notice( + outgoing, link, None, "No public rooms registered" + ) return True - # Sort rooms alphabetically registered_rooms.sort(key=lambda x: x[0]) - - # Format room list with topics + lines = ["Registered public rooms:"] for room_name, topic in registered_rooms: if topic: lines.append(f" {room_name} - {topic}") else: lines.append(f" {room_name}") - + self.hub.message_helper.emit_notice(outgoing, link, None, "\n".join(lines)) return True @@ -114,23 +111,30 @@ class CommandHandler: if len(parts) >= 2: target_room = parts[1] if not isinstance(target_room, str) or not target_room: - self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /who [room]") + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /who [room]" + ) return True try: r = self.hub._norm_room(target_room) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True - # Check if room is private - only server operators can see private rooms st = self.hub.room_manager._room_state_get(r) if st and st.get("private"): if not self.hub.trust_manager.is_server_op(peer_hash): - self.hub.message_helper.emit_notice(outgoing, link, None, f"room {r} is private") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"room {r} is private" + ) return True members = [] - for other in sorted(self.hub.room_manager.get_room_members(r), key=lambda x: id(x)): + for other in sorted( + self.hub.room_manager.get_room_members(r), key=lambda x: id(x) + ): s = self.hub.session_manager.sessions.get(other) if not s: continue @@ -141,7 +145,6 @@ class CommandHandler: members.append(f"{nick} ({ident[:12]})") else: members.append(ident) - # Send response without room field for hub-level query self.hub.message_helper.emit_notice( outgoing, link, @@ -161,7 +164,9 @@ class CommandHandler: try: r = self.hub._norm_room(target_room) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, room, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"bad room: {e}" + ) return True if not self.hub.room_manager.is_room_op(r, peer_hash): @@ -177,23 +182,27 @@ class CommandHandler: target_link = self._find_target_link(target, room=r) if target_link is None: - # Check if ambiguous or just not found all_matches = self._find_target_links(target, room=r) self.hub.message_helper.emit_notice( - outgoing, link, room, self._format_ambiguous_targets(target, all_matches) + outgoing, + link, + room, + self._format_ambiguous_targets(target, all_matches), ) return True tsess = self.hub.session_manager.sessions.get(target_link) if not tsess or r not in tsess.get("rooms", set()): - self.hub.message_helper.emit_notice(outgoing, link, room, "target not in room") + self.hub.message_helper.emit_notice( + outgoing, link, room, "target not in room" + ) return True tsess["rooms"].discard(r) if self.hub.room_manager.get_room_members(r): self.hub.room_manager.rooms[r].discard(target_link) if not self.hub.room_manager.rooms[r]: - pass # room cleanup handled by room_manager + pass if self.hub.identity is not None: self._emit_error( @@ -203,7 +212,9 @@ class CommandHandler: text=f"kicked from {r}", room=r, ) - self.hub.message_helper.emit_notice(outgoing, link, room, f"kicked {target} from {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"kicked {target} from {r}" + ) return True if cmd == "kline": @@ -218,7 +229,6 @@ class CommandHandler: ) return True - # Hub-level command - all responses without room field if len(parts) < 2: self.hub.message_helper.emit_notice( outgoing, @@ -263,65 +273,86 @@ class CommandHandler: ph = tsess.get("peer") if tsess else None if isinstance(ph, (bytes, bytearray)): self.hub.trust_manager.add_ban(bytes(ph)) - self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) try: target_link.teardown() except Exception: pass - self.hub.message_helper.emit_notice(outgoing, link, None, f"kline added for {target}") - return True - - # Not found as active link - check if ambiguous or try as raw hash - all_matches = self._find_target_links(target, room=None) - if all_matches: - # Ambiguous self.hub.message_helper.emit_notice( - outgoing, link, None, self._format_ambiguous_targets(target, all_matches) + outgoing, link, None, f"kline added for {target}" + ) + return True + + all_matches = self._find_target_links(target, room=None) + if all_matches: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + self._format_ambiguous_targets(target, all_matches), ) return True - # Try as raw hash try: h = self.hub._parse_identity_hash(target) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad identity hash: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad identity hash: {e}" + ) return True self.hub.trust_manager.add_ban(h) - self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) - self.hub.message_helper.emit_notice(outgoing, link, None, f"kline added for {h.hex()}") + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) + self.hub.message_helper.emit_notice( + outgoing, link, None, f"kline added for {h.hex()}" + ) return True - # op == "del" try: h = self.hub._parse_identity_hash(target) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad identity hash: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad identity hash: {e}" + ) return True if self.hub.trust_manager.is_banned(h): self.hub.trust_manager.remove_ban(h) - self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) - self.hub.message_helper.emit_notice(outgoing, link, None, f"kline removed for {h.hex()}") + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) + self.hub.message_helper.emit_notice( + outgoing, link, None, f"kline removed for {h.hex()}" + ) else: - self.hub.message_helper.emit_notice(outgoing, link, None, f"not klined: {h.hex()}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"not klined: {h.hex()}" + ) return True - # Room-scoped moderation and maintenance if cmd == "register": if len(parts) < 2: - self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /register ") + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /register " + ) return True try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True - # Registration rules: requester must be in the room and must be the founder. - # (No server-op override by design.) if ( not room or self.hub._norm_room(room) != r - or r not in self.hub.session_manager.sessions.get(link, {}).get("rooms", set()) + or r + not in self.hub.session_manager.sessions.get(link, {}).get( + "rooms", set() + ) ): self.hub.message_helper.emit_notice( outgoing, link, room, "must be present in the room to register it" @@ -330,8 +361,9 @@ class CommandHandler: st = self.hub.room_manager._room_state_ensure(r) - # Clean up expired invites (best-effort). - if self.hub.room_manager.prune_expired_invites(r) and bool(st.get("registered")): + if self.hub.room_manager.prune_expired_invites(r) and bool( + st.get("registered") + ): self.hub.room_manager.persist_room_state(link, r) founder = st.get("founder") if not ( @@ -353,51 +385,64 @@ class CommandHandler: ) return True st["registered"] = True - # Default modes for registered rooms: +nrt st["no_outside_msgs"] = True st["topic_ops_only"] = True if isinstance(founder, (bytes, bytearray)): st.setdefault("ops", set()).add(bytes(founder)) self.hub.room_manager.touch_room(r) - # Ensure registry mirrors registered rooms. self.hub.room_manager._room_registry[r] = { - "founder": bytes(founder) - if isinstance(founder, (bytes, bytearray)) - else None, + "founder": ( + bytes(founder) if isinstance(founder, (bytes, bytearray)) else None + ), "registered": True, "topic": st.get("topic"), "moderated": bool(st.get("moderated", False)), - "ops": set(st.get("ops", set())) - if isinstance(st.get("ops"), set) - else set(), - "voiced": set(st.get("voiced", set())) - if isinstance(st.get("voiced"), set) - else set(), - "bans": set(st.get("bans", set())) - if isinstance(st.get("bans"), set) - else set(), + "ops": ( + set(st.get("ops", set())) + if isinstance(st.get("ops"), set) + else set() + ), + "voiced": ( + set(st.get("voiced", set())) + if isinstance(st.get("voiced"), set) + else set() + ), + "bans": ( + set(st.get("bans", set())) + if isinstance(st.get("bans"), set) + else set() + ), "last_used_ts": st.get("last_used_ts"), } self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"registered room {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"registered room {r}" + ) return True if cmd == "unregister": if len(parts) < 2: - self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /unregister ") + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /unregister " + ) return True try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True if ( not room or self.hub._norm_room(room) != r - or r not in self.hub.session_manager.sessions.get(link, {}).get("rooms", set()) + or r + not in self.hub.session_manager.sessions.get(link, {}).get( + "rooms", set() + ) ): self.hub.message_helper.emit_notice( outgoing, link, room, "must be present in the room to unregister it" @@ -420,26 +465,35 @@ class CommandHandler: return True if not st.get("registered"): - self.hub.message_helper.emit_notice(outgoing, link, room, f"room {r} is not registered") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"room {r} is not registered" + ) return True st["registered"] = False self.hub.room_manager._room_registry.pop(r, None) - self.hub._delete_room_from_registry(link, r) - # Drop state if empty. - if not self.hub.room_manager.get_room_members(r) or not self.hub.room_manager.get_room_members(r): + self.hub.room_manager.delete_room_from_registry(link, r) + if not self.hub.room_manager.get_room_members( + r + ) or not self.hub.room_manager.get_room_members(r): self.hub.room_manager._room_state.pop(r, None) - self.hub.message_helper.emit_notice(outgoing, link, room, f"unregistered room {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"unregistered room {r}" + ) return True if cmd == "topic": if len(parts) < 2: - self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /topic [topic]") + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /topic [topic]" + ) return True try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True st = self.hub.room_manager._room_state_ensure(r) if len(parts) == 2: @@ -469,7 +523,6 @@ class CommandHandler: st["topic"] = topic if topic else None self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - # Broadcast topic change to current members. for other in list(self.hub.room_manager.get_room_members(r)): self.hub.message_helper.emit_notice( outgoing, @@ -488,7 +541,9 @@ class CommandHandler: try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True if not self.hub.room_manager.is_room_op(r, peer_hash): if self.hub.identity is not None: @@ -500,14 +555,19 @@ class CommandHandler: room=r, ) return True - - target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[2], room=r) + + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[2], room=r + ) if target_hash is None: self.hub.message_helper.emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[2], all_matches) + outgoing, + link, + room, + self._format_ambiguous_targets(parts[2], all_matches), ) return True - + st = self.hub.room_manager._room_state_ensure(r) founder = st.get("founder") founder_b = ( @@ -523,16 +583,22 @@ class CommandHandler: ops.add(target_hash) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"op granted in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"op granted in {r}" + ) return True else: if founder_b is not None and target_hash == founder_b: - self.hub.message_helper.emit_notice(outgoing, link, room, "cannot deop founder") + self.hub.message_helper.emit_notice( + outgoing, link, room, "cannot deop founder" + ) return True ops.discard(target_hash) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"op removed in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"op removed in {r}" + ) return True voiced = st.setdefault("voiced", set()) @@ -543,13 +609,17 @@ class CommandHandler: voiced.add(target_hash) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"voice granted in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"voice granted in {r}" + ) return True else: voiced.discard(target_hash) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"voice removed in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"voice removed in {r}" + ) return True if cmd == "mode": @@ -564,7 +634,9 @@ class CommandHandler: try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True if not self.hub.room_manager.is_room_op(r, peer_hash): if self.hub.identity is not None: @@ -623,7 +695,9 @@ class CommandHandler: return True key = " ".join(parts[3:]).strip() if not key: - self.hub.message_helper.emit_notice(outgoing, link, room, "key must not be empty") + self.hub.message_helper.emit_notice( + outgoing, link, room, "key must not be empty" + ) return True st["key"] = key else: @@ -649,10 +723,15 @@ class CommandHandler: ) return True - target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=r) + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=r + ) if target_hash is None: self.hub.message_helper.emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), ) return True @@ -729,7 +808,9 @@ class CommandHandler: try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True op = parts[2].strip().lower() @@ -737,7 +818,9 @@ class CommandHandler: st = self.hub.room_manager._room_state_ensure(r) bans = st.get("bans") if not isinstance(bans, set) or not bans: - self.hub.message_helper.emit_notice(outgoing, link, room, f"no bans in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"no bans in {r}" + ) return True items = sorted( bytes(x).hex() for x in bans if isinstance(x, (bytes, bytearray)) @@ -773,10 +856,15 @@ class CommandHandler: ) return True - target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=r) + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=r + ) if target_hash is None: self.hub.message_helper.emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), ) return True @@ -791,7 +879,6 @@ class CommandHandler: self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - # If currently present in room, remove them. for other in list(self.hub.room_manager.get_room_members(r)): s = self.hub.session_manager.sessions.get(other) ph = s.get("peer") if s else None @@ -806,15 +893,22 @@ class CommandHandler: text=f"banned from {r}", room=r, ) - if self.hub.room_manager.get_room_members(r) and not self.hub.room_manager.rooms[r]: - pass # room cleanup handled by room_manager - self.hub.message_helper.emit_notice(outgoing, link, room, f"ban added in {r}") + if ( + self.hub.room_manager.get_room_members(r) + and not self.hub.room_manager.rooms[r] + ): + pass + self.hub.message_helper.emit_notice( + outgoing, link, room, f"ban added in {r}" + ) return True bans.discard(target_hash) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"ban removed in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"ban removed in {r}" + ) return True if cmd == "invite": @@ -830,7 +924,9 @@ class CommandHandler: try: r = self.hub._norm_room(parts[1]) except Exception as e: - self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) return True if not self.hub.room_manager.is_room_op(r, peer_hash): @@ -852,7 +948,6 @@ class CommandHandler: invited = {} st["invited"] = invited - # Drop expired entries before operating. pruned = self.hub.room_manager.prune_expired_invites(r) if op == "list": @@ -902,7 +997,6 @@ class CommandHandler: token = parts[3] target_link = self._find_target_link(token, room=None) if target_link is None: - # Check if ambiguous or just not found all_matches = self._find_target_links(token, room=None) if self.hub.identity is not None: self._emit_error( @@ -928,7 +1022,6 @@ class CommandHandler: return True target_hash = bytes(ph) - # Always send the invite as a NOTICE so the user can choose to join. key = st.get("key") is_keyed = isinstance(key, str) and bool(key) is_invite_only = bool(st.get("invite_only", False)) @@ -945,7 +1038,6 @@ class CommandHandler: outgoing, target_link, r, f"You have been invited to join {r}." ) - # Persist an expiring invite only when it has semantics: +k bypass and/or +i allow. if is_keyed or is_invite_only: ttl = ( float(self.hub.config.room_invite_timeout_s) @@ -970,10 +1062,15 @@ class CommandHandler: ) return True - target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=None) + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=None + ) if target_hash is None: self.hub.message_helper.emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), ) return True @@ -981,12 +1078,13 @@ class CommandHandler: invited.pop(target_hash, None) self.hub.room_manager.touch_room(r) self.hub.room_manager.persist_room_state(link, r) - self.hub.message_helper.emit_notice(outgoing, link, room, f"invite removed in {r}") + self.hub.message_helper.emit_notice( + outgoing, link, room, f"invite removed in {r}" + ) return True return False - # Helper methods def _find_target_link(self, token: str, room: str | None = None) -> RNS.Link | None: """Find a link by nick or identity hash prefix. Uses indexes for O(1) lookups. Returns the link if exactly one match, None otherwise. @@ -1004,7 +1102,6 @@ class CommandHandler: if not t: return [] - # If it's hex-like, treat as an identity hash prefix. hex_candidate = t[2:] if t.startswith("0x") else t if ( all(c in "0123456789abcdef" for c in hex_candidate) @@ -1014,29 +1111,30 @@ class CommandHandler: prefix = bytes.fromhex(hex_candidate) except Exception: prefix = None - + if prefix is not None: with self.hub._state_lock: - # Search hash index for matching prefixes matches: list[RNS.Link] = [] - for peer_hash, candidate_link in self.hub.session_manager._index_by_hash.items(): + for ( + peer_hash, + candidate_link, + ) in self.hub.session_manager._index_by_hash.items(): if peer_hash.startswith(prefix): - # Check room membership if specified if room is not None: - sess = self.hub.session_manager.sessions.get(candidate_link) + sess = self.hub.session_manager.sessions.get( + candidate_link + ) if sess and room not in sess.get("rooms", set()): continue matches.append(candidate_link) - + return matches - # Otherwise treat as nickname - use nick index for O(1) lookup with self.hub._state_lock: candidate_links = self.hub.session_manager._index_by_nick.get(t, set()) if not candidate_links: return [] - - # Filter by room membership if specified + if room is not None: matches = [] for candidate_link in candidate_links: @@ -1045,16 +1143,14 @@ class CommandHandler: matches.append(candidate_link) else: matches = list(candidate_links) - + return matches - def _format_ambiguous_targets( - self, token: str, matches: list[RNS.Link] - ) -> str: + def _format_ambiguous_targets(self, token: str, matches: list[RNS.Link]) -> str: """Format a helpful message when target lookup is ambiguous.""" if not matches: return f"target '{token}' not found" - + with self.hub._state_lock: items = [] for match_link in matches: @@ -1066,10 +1162,10 @@ class CommandHandler: hash_str = self.hub._fmt_hash(peer, prefix=16) if peer else "?" nick_str = f"nick={nick!r}" if nick else "(no nick)" items.append(f"{hash_str} {nick_str}") - + if len(items) == 0: return f"target '{token}' not found" - + return ( f"ambiguous: '{token}' matches {len(items)} identities:\n" + "\n".join(f" - {item}" for item in items) @@ -1087,9 +1183,9 @@ class CommandHandler: return env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text) if outgoing is None: - self.hub._send(link, env) + self.hub.message_helper.send(link, env) else: - self.hub._queue_env(outgoing, link, env) + self.hub.message_helper.queue_env(outgoing, link, env) def _emit_error( self, @@ -1103,6 +1199,6 @@ class CommandHandler: self.hub.stats_manager.inc("errors_sent") env = make_envelope(T_ERROR, src=src, room=room, body=text) if outgoing is None: - self.hub._send(link, env) + self.hub.message_helper.send(link, env) else: - self.hub._queue_env(outgoing, link, env) + self.hub.message_helper.queue_env(outgoing, link, env) diff --git a/rrcd/config.py b/rrcd/config.py index 6a5f73c..2c62a11 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import threading from dataclasses import asdict, dataclass, replace from typing import TYPE_CHECKING, Any @@ -32,7 +31,7 @@ class HubRuntimeConfig: rate_limit_msgs_per_minute: int = 240 ping_interval_s: float = 0.0 ping_timeout_s: float = 0.0 - max_resource_bytes: int = 256 * 1024 # 256 KiB default + max_resource_bytes: int = 256 * 1024 max_pending_resource_expectations: int = 8 resource_expectation_ttl_s: float = 30.0 enable_resource_transfer: bool = True @@ -47,7 +46,7 @@ class HubRuntimeConfig: class ConfigManager: """ Manages hub configuration loading, reloading, and persistence. - + Handles: - Loading TOML configuration files - Applying configuration updates @@ -69,9 +68,7 @@ class ConfigManager: data = tomllib.load(f) return data if isinstance(data, dict) else {} - def apply_config_data( - self, base: HubRuntimeConfig, data: dict - ) -> HubRuntimeConfig: + def apply_config_data(self, base: HubRuntimeConfig, data: dict) -> HubRuntimeConfig: """Apply configuration data from TOML to a runtime config instance.""" hub = data.get("hub") if isinstance(data, dict) else None if isinstance(hub, dict): @@ -95,7 +92,6 @@ class ConfigManager: data = {**data, **mapped} allowed = set(asdict(base).keys()) - # This identifies where to reload from; do not let the file override it. allowed.discard("config_path") updates = {k: v for k, v in data.items() if k in allowed} @@ -155,7 +151,7 @@ class ConfigManager: def get_config_path_for_writes(self) -> str | None: """Get the resolved config file path for write operations.""" from .util import expand_path - + p = self.hub.config.config_path if not p: return None diff --git a/rrcd/envelope.py b/rrcd/envelope.py index 366d420..cb7944a 100644 --- a/rrcd/envelope.py +++ b/rrcd/envelope.py @@ -85,11 +85,8 @@ def validate_envelope(env: dict) -> None: room = env[K_ROOM] if not isinstance(room, str): raise TypeError("room name must be a string") - # Per RRC spec, room field may be empty (e.g., for hub commands) if K_NICK in env: nick = env[K_NICK] if not isinstance(nick, str): raise TypeError("nickname must be a string") - # Per spec, nicknames are advisory and may be empty or "ridiculous". - # Type-check only; implementations may sanitize/ignore for display. diff --git a/rrcd/logging_config.py b/rrcd/logging_config.py index a5e9180..f953aed 100644 --- a/rrcd/logging_config.py +++ b/rrcd/logging_config.py @@ -99,7 +99,6 @@ def configure_logging( root.setLevel(level) - # Library loggers logging.getLogger("RNS").setLevel(rns_level) logging.captureWarnings(True) diff --git a/rrcd/messages.py b/rrcd/messages.py index 9b16124..dc992be 100644 --- a/rrcd/messages.py +++ b/rrcd/messages.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any import RNS from .codec import encode -from .constants import T_ERROR, T_NOTICE, T_WELCOME, B_WELCOME_HUB, B_WELCOME_VER +from .constants import B_WELCOME_HUB, B_WELCOME_VER, T_ERROR, T_NOTICE, T_WELCOME from .envelope import make_envelope if TYPE_CHECKING: @@ -17,7 +17,7 @@ if TYPE_CHECKING: class MessageHelper: """ Helper methods for sending and queueing messages. - + Handles: - Message queueing (outgoing lists) - Notice chunking for large messages @@ -33,10 +33,8 @@ class MessageHelper: def packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool: """Check if payload fits within link MDU without creating/packing packets.""" try: - # Query link MDU directly if available (more efficient than packing) - if hasattr(link, 'MDU') and link.MDU is not None: + if hasattr(link, "MDU") and link.MDU is not None: return len(payload) <= link.MDU - # Fall back to packet creation if MDU not available pkt = RNS.Packet(link, payload) pkt.pack() return True @@ -71,15 +69,12 @@ class MessageHelper: if not text: return - # Prefer splitting on lines for readability. If a single line is too - # large, further split it by characters using a pack preflight. lines = text.splitlines() or [text] for line in lines: remaining = line if not remaining: continue - # Start with a generous chunk size; shrink on demand. max_chars = min(len(remaining), 512) while remaining: take = min(len(remaining), max_chars) @@ -98,7 +93,6 @@ class MessageHelper: continue if max_chars <= 1: - # Nothing we can do; avoid an infinite loop. self.log.warning( "NOTICE chunk would not fit MTU; dropping remainder (%s chars)", len(remaining), @@ -120,13 +114,11 @@ class MessageHelper: return from . import __version__ - - g = str(motd) if motd else "" + body_w: dict[int, Any] = { B_WELCOME_HUB: self.hub.config.hub_name, B_WELCOME_VER: str(__version__), } - # Capabilities are optional; keep WELCOME minimal unless needed. welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w) welcome_payload = encode(welcome) @@ -163,13 +155,15 @@ class MessageHelper: - Chunked messages otherwise """ from .constants import RES_KIND_MOTD, RES_KIND_NOTICE - - # Determine resource kind if not specified + resource_kind = kind if resource_kind is None: - resource_kind = RES_KIND_MOTD if msg_type == T_NOTICE and room is None else RES_KIND_NOTICE + resource_kind = ( + RES_KIND_MOTD + if msg_type == T_NOTICE and room is None + else RES_KIND_NOTICE + ) - # Try resource transfer if enabled, outgoing is None, and message is large enough if ( self.hub.config.enable_resource_transfer and outgoing is None @@ -200,8 +194,7 @@ class MessageHelper: "Resource send failed, falling back to chunks link_id=%s", self.hub._fmt_link_id(link), ) - - # Fall back to chunking for NOTICE + if msg_type == T_NOTICE: self.log.debug( "Falling back to chunking link_id=%s outgoing_is_none=%s", @@ -224,7 +217,6 @@ class MessageHelper: else: self.queue_notice_chunks(outgoing, link, room=room, text=text) else: - # For other message types, just drop or log error self.log.error( "Message too large and not NOTICE link_id=%s type=%s", self.hub._fmt_link_id(link), @@ -284,7 +276,6 @@ class MessageHelper: try: RNS.Packet(link, payload).send() except OSError as e: - # Common failure mode on low-MTU links: packet too large. self.log.warning( "Send failed link_id=%s bytes=%s err=%s", self.hub._fmt_link_id(link), diff --git a/rrcd/resources.py b/rrcd/resources.py index 38f4879..62c19e0 100644 --- a/rrcd/resources.py +++ b/rrcd/resources.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: @dataclass class _ResourceExpectation: """Tracks an expected incoming Resource transfer.""" + id: bytes kind: str size: int @@ -48,11 +49,10 @@ class ResourceManager: def __init__(self, hub: HubService) -> None: self.hub = hub self.log = hub.log - - # Resource state - self._resource_expectations: dict[RNS.Link, dict[bytes, _ResourceExpectation]] = {} + self._resource_expectations: dict[ + RNS.Link, dict[bytes, _ResourceExpectation] + ] = {} self._active_resources: dict[RNS.Link, set[RNS.Resource]] = {} - # Tracks which expectation RID was matched to an advertised Resource. self._resource_bindings: dict[RNS.Resource, bytes] = {} def on_link_established(self, link: RNS.Link) -> None: @@ -74,7 +74,7 @@ class ResourceManager: """Set up resource callbacks for a link if resource transfer is enabled.""" if not self.hub.config.enable_resource_transfer: return - + try: link.set_resource_strategy(RNS.Link.ACCEPT_APP) link.set_resource_callback(self._resource_advertised) @@ -90,15 +90,13 @@ class ResourceManager: e, ) - # Resource expectation management - def cleanup_expired_expectations(self, link: RNS.Link) -> None: """Remove expired resource expectations for a link.""" now = time.time() exp_dict = self._resource_expectations.get(link) if not exp_dict: return - + expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] for rid in expired: exp_dict.pop(rid, None) @@ -115,8 +113,10 @@ class ResourceManager: for link, exp_dict in list(self._resource_expectations.items()): if not exp_dict: continue - - expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] + + expired = [ + rid for rid, exp in exp_dict.items() if exp.expires_at <= now + ] for rid in expired: exp_dict.pop(rid, None) self.log.debug( @@ -138,16 +138,16 @@ class ResourceManager: ) -> bool: """Add a resource expectation. Returns False if limit exceeded.""" self.cleanup_expired_expectations(link) - + exp_dict = self._resource_expectations.setdefault(link, {}) - + if len(exp_dict) >= self.hub.config.max_pending_resource_expectations: self.log.warning( "Max pending expectations exceeded link_id=%s", self.hub._fmt_link_id(link), ) return False - + now = time.time() exp = _ResourceExpectation( id=rid, @@ -160,7 +160,7 @@ class ResourceManager: room=room, ) exp_dict[rid] = exp - + self.log.debug( "Added resource expectation link_id=%s rid=%s kind=%s size=%s", self.hub._fmt_link_id(link), @@ -175,16 +175,15 @@ class ResourceManager: ) -> _ResourceExpectation | None: """Find a matching resource expectation by size (fallback matching).""" self.cleanup_expired_expectations(link) - + exp_dict = self._resource_expectations.get(link) if not exp_dict: return None - - # Match by size (first match wins) + for exp in exp_dict.values(): if exp.size == size: return exp - + return None def get_resource_expectation_by_rid( @@ -217,7 +216,6 @@ class ResourceManager: if not exp_dict: return None - # Avoid linear scan if nothing matches by size. for exp in exp_dict.values(): if exp.size != size: continue @@ -235,18 +233,15 @@ class ResourceManager: return None return exp_dict.pop(rid, None) - # Resource transfer callbacks - def _resource_advertised(self, resource: RNS.Resource) -> bool: """ Callback when a Resource is advertised by remote peer. Returns True to accept, False to reject. - + Minimize lock scope to prevent potential deadlocks with RNS internal locks. """ link = resource.link - - # Check config outside lock (immutable during runtime) + if not self.hub.config.enable_resource_transfer: self.log.debug( "Rejecting resource (disabled) link_id=%s", @@ -254,8 +249,7 @@ class ResourceManager: ) self.hub.stats_manager.inc("resources_rejected") return False - - # Check size limit (immutable config) + size = resource.total_size if hasattr(resource, "total_size") else resource.size if size > self.hub.config.max_resource_bytes: self.log.warning( @@ -266,8 +260,7 @@ class ResourceManager: ) self.hub.stats_manager.inc("resources_rejected") return False - - # Check session exists and find expectation with minimal lock scope + with self.hub._state_lock: sess = self.hub.session_manager.sessions.get(link) if not sess: @@ -277,11 +270,9 @@ class ResourceManager: ) self.hub.stats_manager.inc("resources_rejected") return False - - # Find matching expectation + exp = self.find_resource_expectation(link, size) - - # Check expectation outside lock + if not exp: self.log.warning( "Rejecting resource (no matching expectation) link_id=%s size=%s", @@ -290,29 +281,25 @@ class ResourceManager: ) self.hub.stats_manager.inc("resources_rejected") return False - - # Accept and register with minimal lock scope + self.log.info( "Accepting resource link_id=%s size=%s kind=%s", self.hub._fmt_link_id(link), size, exp.kind, ) - + with self.hub._state_lock: self._active_resources.setdefault(link, set()).add(resource) - # Remember which expectation RID this resource was matched to so the - # conclusion handler can verify and pop the correct entry. self._resource_bindings[resource] = exp.id - + return True def _resource_concluded(self, resource: RNS.Resource) -> None: """Callback when a Resource transfer completes.""" link = resource.link - + with self.hub._state_lock: - # Remove from active set and retrieve any bound expectation RID. active_set = self._active_resources.get(link) if active_set: active_set.discard(resource) @@ -326,9 +313,12 @@ class ResourceManager: ) return - # Get payload outside the lock. try: - payload = resource.data.read() if hasattr(resource.data, "read") else resource.data + payload = ( + resource.data.read() + if hasattr(resource.data, "read") + else resource.data + ) if isinstance(payload, bytearray): payload = bytes(payload) except Exception as e: @@ -342,8 +332,9 @@ class ResourceManager: size = len(payload) actual_hash = hashlib.sha256(payload).digest() - # Find expectation using bound RID first, then RID lookup, then size/sha fallback. - exp = self.match_resource_expectation(link, rid=bound_rid, size=size, sha256=actual_hash) + exp = self.match_resource_expectation( + link, rid=bound_rid, size=size, sha256=actual_hash + ) if not exp: self.log.warning( "Received resource without expectation link_id=%s size=%s", @@ -352,7 +343,6 @@ class ResourceManager: ) return - # Verify SHA256 if provided; keep expectation if mismatch so sender can retry. if exp.sha256 and actual_hash != exp.sha256: self.log.error( "Resource SHA256 mismatch link_id=%s expected=%s actual=%s", @@ -362,20 +352,18 @@ class ResourceManager: ) return - # Pop expectation only after validation succeeds. self.pop_resource_expectation(link, exp.id) self.hub.stats_manager.inc("resources_received") self.hub.stats_manager.inc("resource_bytes_received", size) - + self.log.info( "Resource received link_id=%s size=%s kind=%s", self.hub._fmt_link_id(link), size, exp.kind, ) - - # Dispatch by kind + try: self._dispatch_received_resource(link, exp, payload) except Exception as e: @@ -391,7 +379,6 @@ class ResourceManager: ) -> None: """Dispatch a received resource payload to appropriate handler.""" if exp.kind == RES_KIND_NOTICE: - # Decode as text and deliver as notice encoding = exp.encoding or "utf-8" try: text = payload.decode(encoding) @@ -403,21 +390,20 @@ class ResourceManager: e, ) return - + self.log.info( "Received large NOTICE via resource link_id=%s room=%r chars=%s", self.hub._fmt_link_id(link), exp.room, len(text), ) - - # Forward NOTICE to room members if room is specified + if exp.room and self.hub.identity is not None: with self.hub._state_lock: sess = self.hub.session_manager.sessions.get(link) peer_hash = sess.get("peer") if sess else None room_members = self.hub.room_manager.get_room_members(exp.room) - + if peer_hash and room_members: notice_env = make_envelope( T_NOTICE, @@ -426,8 +412,7 @@ class ResourceManager: body=text, ) notice_payload = encode(notice_env) - - # Forward to all room members except sender + forwarded = 0 for other in room_members: if other != link: @@ -440,7 +425,7 @@ class ResourceManager: self.hub._fmt_link_id(other), e, ) - + if forwarded > 0: self.hub.stats_manager.inc("notices_forwarded") self.log.debug( @@ -448,9 +433,8 @@ class ResourceManager: forwarded, exp.room, ) - + elif exp.kind == RES_KIND_MOTD: - # Similar to NOTICE encoding = exp.encoding or "utf-8" try: text = payload.decode(encoding) @@ -461,15 +445,14 @@ class ResourceManager: e, ) return - + self.log.info( "Received MOTD via resource link_id=%s chars=%s", self.hub._fmt_link_id(link), len(text), ) - + elif exp.kind == RES_KIND_BLOB: - # Generic binary data self.log.info( "Received BLOB via resource link_id=%s bytes=%s", self.hub._fmt_link_id(link), @@ -494,14 +477,14 @@ class ResourceManager: """ Send large payload via Resource. Returns True if successfully initiated, False otherwise. - + Note: This sends the resource envelope immediately, then creates and advertises the resource. Should only be called when immediate sending is desired (not when batching messages). """ if not self.hub.config.enable_resource_transfer: return False - + size = len(payload) if size > self.hub.config.max_resource_bytes: self.log.error( @@ -510,17 +493,13 @@ class ResourceManager: self.hub.config.max_resource_bytes, ) return False - - # Generate resource ID + rid = os.urandom(8) - - # Compute SHA256 sha256 = hashlib.sha256(payload).digest() - - # Send envelope first + if self.hub.identity is None: return False - + envelope_body = { B_RES_ID: rid, B_RES_KIND: kind, @@ -529,19 +508,19 @@ class ResourceManager: } if encoding: envelope_body[B_RES_ENCODING] = encoding - + envelope = make_envelope( T_RESOURCE_ENVELOPE, src=self.hub.identity.hash, room=room, body=envelope_body, ) - + try: envelope_payload = encode(envelope) RNS.Packet(link, envelope_payload).send() self.hub.stats_manager.inc("bytes_out", len(envelope_payload)) - + self.log.debug( "Sent resource envelope link_id=%s rid=%s kind=%s size=%s", self.hub._fmt_link_id(link), @@ -556,19 +535,16 @@ class ResourceManager: e, ) return False - - # Create and advertise resource + try: - resource = RNS.Resource( - payload, link, advertise=True, auto_compress=False - ) - + resource = RNS.Resource(payload, link, advertise=True, auto_compress=False) + with self.hub._state_lock: self._active_resources.setdefault(link, set()).add(resource) - + self.hub.stats_manager.inc("resources_sent") self.hub.stats_manager.inc("resource_bytes_sent", size) - + self.log.info( "Sent resource link_id=%s rid=%s kind=%s size=%s", self.hub._fmt_link_id(link), @@ -577,7 +553,7 @@ class ResourceManager: size, ) return True - + except Exception as e: self.log.error( "Failed to create resource link_id=%s: %s", diff --git a/rrcd/rooms.py b/rrcd/rooms.py index 8654fc2..7ea212b 100644 --- a/rrcd/rooms.py +++ b/rrcd/rooms.py @@ -28,13 +28,7 @@ class RoomManager: def __init__(self, hub: HubService) -> None: self.hub = hub self.log = logging.getLogger("rrcd.rooms") - - # Room memberships: room name -> set of links in that room self.rooms: dict[str, set[RNS.Link]] = {} - - # Room state (hub-local conventions; no new on-wire message types). - # _room_state holds active in-memory state (and registered state for empty rooms). - # _room_registry holds registered rooms loaded from config. self._room_state: dict[str, dict[str, Any]] = {} self._room_registry: dict[str, dict[str, Any]] = {} @@ -50,7 +44,9 @@ class RoomManager: """Get set of links currently in a room.""" return self.rooms.get(room, set()) - def add_member(self, room: str, link: RNS.Link, *, founder: bytes | None = None) -> None: + def add_member( + self, room: str, link: RNS.Link, *, founder: bytes | None = None + ) -> None: """Add a link to a room, creating the room if needed.""" if room not in self.rooms: self.rooms[room] = set() @@ -65,7 +61,6 @@ class RoomManager: if not self.rooms[room]: self.rooms.pop(room, None) st = self._room_state_get(room) - # Clean up unregistered empty rooms if st is not None and not st.get("registered"): self._room_state.pop(room, None) @@ -94,8 +89,6 @@ class RoomManager: "top_rooms": top_rooms, } - # Room state management - def _room_state_get(self, room: str) -> dict[str, Any] | None: """Get room state dict if it exists.""" return self._room_state.get(room) @@ -111,7 +104,6 @@ class RoomManager: st.setdefault("ops", set()).add(founder) return st - # Load from registry if registered if room in self._room_registry: base = self._room_registry[room] invited = base.get("invited") @@ -142,7 +134,6 @@ class RoomManager: self._room_state[room] = st return st - # Create new unregistered room st = { "founder": founder, "registered": False, @@ -174,8 +165,6 @@ class RoomManager: except Exception: pass - # Room modes and permissions - def get_room_modes(self, room: str) -> dict[str, Any]: """Get dict of room mode flags.""" st = self._room_state_ensure(room) @@ -201,7 +190,6 @@ class RoomManager: """Get IRC-style mode string for a room.""" m = self.get_room_modes(room) flags: list[str] = [] - # Keep roughly IRC-ish order. if m.get("invite_only"): flags.append("i") if m.get("has_key"): @@ -265,8 +253,6 @@ class RoomManager: bans = st.get("bans") return isinstance(bans, set) and peer_hash in bans - # Invite management - def is_invited(self, room: str, peer_hash: bytes) -> bool: """Check if peer has a valid (non-expired) invite.""" st = self._room_state_ensure(room) @@ -302,8 +288,6 @@ class RoomManager: removed_any = True return removed_any - # Room registry persistence - def load_registry_from_path( self, path: str, *, invite_timeout_s: float ) -> tuple[dict[str, dict[str, Any]], str | None]: @@ -360,7 +344,9 @@ class RoomManager: for op in operators: if isinstance(op, str): try: - ops.add(bytes.fromhex(op.strip().lower().removeprefix("0x"))) + ops.add( + bytes.fromhex(op.strip().lower().removeprefix("0x")) + ) except Exception: continue @@ -370,7 +356,9 @@ class RoomManager: for v in voiced_list: if isinstance(v, str): try: - voiced.add(bytes.fromhex(v.strip().lower().removeprefix("0x"))) + voiced.add( + bytes.fromhex(v.strip().lower().removeprefix("0x")) + ) except Exception: continue @@ -380,7 +368,9 @@ class RoomManager: for b in bans_list: if isinstance(b, str): try: - bans.add(bytes.fromhex(b.strip().lower().removeprefix("0x"))) + bans.add( + bytes.fromhex(b.strip().lower().removeprefix("0x")) + ) except Exception: continue @@ -390,7 +380,9 @@ class RoomManager: for h, exp in invited_dict.items(): if isinstance(h, str): try: - h_bytes = bytes.fromhex(h.strip().lower().removeprefix("0x")) + h_bytes = bytes.fromhex( + h.strip().lower().removeprefix("0x") + ) exp_f = float(exp) if exp_f > now: invited[h_bytes] = exp_f @@ -446,7 +438,7 @@ class RoomManager: def get_registry_path_for_writes(self) -> str | None: """Get path to room registry file for write operations.""" from .util import expand_path - + p = self.hub.config.room_registry_path if not p: return None @@ -567,7 +559,9 @@ class RoomManager: except Exception: pass except Exception as e: - self.hub.message_helper.notice_to(link, room, f"room config persist failed: {e}") + self.hub.message_helper.notice_to( + link, room, f"room config persist failed: {e}" + ) def delete_room_from_registry(self, link: RNS.Link, room: str) -> None: """Remove a room from the registry TOML file.""" @@ -607,21 +601,22 @@ class RoomManager: except Exception: pass except Exception as e: - self.hub.message_helper.notice_to(link, room, f"room unregister persist failed: {e}") + self.hub.message_helper.notice_to( + link, room, f"room unregister persist failed: {e}" + ) def prune_unused_registered_rooms( self, prune_after_s: float, started_wall_time: float ) -> list[str]: """ Prune registered rooms that haven't been used recently. - + Returns list of pruned room names. """ now = float(time.time()) rooms_to_prune: list[str] = [] for room, reg in list(self._room_registry.items()): - # Skip active rooms if room in self.rooms and self.rooms.get(room): continue @@ -631,13 +626,11 @@ class RoomManager: except Exception: last_used = None if last_used is None: - # Never-used rooms are eligible after prune_after from process start last_used = started_wall_time if (now - float(last_used)) < prune_after_s: continue - # Prune in-memory self._room_registry.pop(room, None) self._room_state.pop(room, None) rooms_to_prune.append(room) @@ -647,7 +640,7 @@ class RoomManager: def merge_registry_into_state(self, registry: dict[str, dict[str, Any]]) -> None: """ Merge registry into live room state. - + Updates in-memory state for active rooms with registry data. """ for r, st in list(self._room_state.items()): @@ -656,7 +649,6 @@ class RoomManager: reg = registry.get(r) if reg is None: - # If a room was unregistered on disk, reflect that if st.get("registered"): st["registered"] = False continue diff --git a/rrcd/router.py b/rrcd/router.py index 0d279f6..27ea735 100644 --- a/rrcd/router.py +++ b/rrcd/router.py @@ -5,11 +5,6 @@ from typing import TYPE_CHECKING, Any import RNS - -class OutgoingList(list): - """Custom list that allows attaching callback attributes.""" - pass - from .codec import decode, encode from .constants import ( B_HELLO_CAPS, @@ -24,7 +19,6 @@ from .constants import ( K_ROOM, K_SRC, K_T, - RES_KIND_MOTD, T_HELLO, T_JOIN, T_JOINED, @@ -39,6 +33,13 @@ from .constants import ( from .envelope import make_envelope, validate_envelope from .util import normalize_nick + +class OutgoingList(list): + """Custom list that allows attaching callback attributes.""" + + pass + + if TYPE_CHECKING: from .service import HubService @@ -46,7 +47,7 @@ if TYPE_CHECKING: class MessageRouter: """ Handles message routing and dispatching for the RRC hub. - + This class is responsible for: - Decoding and validating incoming packets - Dispatching messages by type (HELLO, JOIN, PART, MSG, NOTICE, PING, etc.) @@ -67,7 +68,7 @@ class MessageRouter: ) -> None: """ Main entry point for routing an incoming packet. - + This method should be called with the state lock held. """ sess = self.hub.session_manager.sessions.get(link) @@ -81,13 +82,11 @@ class MessageRouter: if peer_hash is None: ri = link.get_remote_identity() if ri is None: - # Per spec: the Link is the handshake. Ignore all traffic until it - # is identified. return peer_hash = ri.hash sess["peer"] = peer_hash - if not self.hub._refill_and_take(link, 1.0): + if not self.hub.session_manager.refill_and_take(link, 1.0): self.hub.stats_manager.inc("rate_limited") if self.log.isEnabledFor(logging.DEBUG): self.log.debug( @@ -140,7 +139,6 @@ class MessageRouter: body_len, ) - # Dispatch by message type if t == T_PONG: self._handle_pong(link, sess) elif t == T_RESOURCE_ENVELOPE: @@ -202,7 +200,6 @@ class MessageRouter: sha256 = body.get(B_RES_SHA256) encoding = body.get(B_RES_ENCODING) - # Validate required fields if not isinstance(rid, (bytes, bytearray)): if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -236,7 +233,6 @@ class MessageRouter: ) return - # Check size limit if size > self.hub.config.max_resource_bytes: if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -248,7 +244,6 @@ class MessageRouter: ) return - # Validate optional fields if sha256 is not None and not isinstance(sha256, (bytes, bytearray)): if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -263,7 +258,6 @@ class MessageRouter: if encoding is not None and not isinstance(encoding, str): encoding = None - # Add expectation if not self.hub.resource_manager.add_resource_expectation( link, rid=bytes(rid), @@ -314,7 +308,6 @@ class MessageRouter: if isinstance(body, dict): sess["peer_caps"] = self._extract_caps(body) - # Back-compat: if a legacy client put nick in HELLO body, accept it. if new_nick is None: legacy_nick = body.get(B_HELLO_NICK_LEGACY) n2 = normalize_nick( @@ -331,7 +324,6 @@ class MessageRouter: self.hub._fmt_link_id(link), ) - # Send welcome message and MOTD self.hub.session_manager.send_welcome( link, outgoing, @@ -355,7 +347,6 @@ class MessageRouter: if self.hub.identity is None: return - # Reset session state and process as new HELLO old_nick = sess.get("nick") old_rooms = set(sess.get("rooms", set())) sess["welcomed"] = False @@ -363,13 +354,11 @@ class MessageRouter: sess["nick"] = None sess["peer_caps"] = {} - # Remove this link from all room membership sets and prune empties. for r in old_rooms: self.hub.room_manager.remove_member(r, link) new_nick = None - # Process the HELLO message if isinstance(nick, str): n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) if n is not None: @@ -394,7 +383,6 @@ class MessageRouter: self.hub._fmt_link_id(link), ) - # Send welcome message and MOTD self.hub.session_manager.send_welcome( link, outgoing, @@ -442,13 +430,11 @@ class MessageRouter: ) return - # If room is registered, load its state now. if r in self.hub.room_manager._room_registry: self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r) - # +i invite-only if bool(st.get("invite_only", False)): is_invited = self.hub.room_manager.is_invited(r, peer_hash) if not self.hub.room_manager.is_room_op(r, peer_hash) and not is_invited: @@ -462,7 +448,6 @@ class MessageRouter: ) return - # +k key/password (JOIN body must be the key string) key = st.get("key") if isinstance(key, str) and key: is_invited = self.hub.room_manager.is_invited(r, peer_hash) @@ -479,7 +464,6 @@ class MessageRouter: ) return - # Room bans are room-local and apply to JOIN. if self.hub.room_manager.is_room_banned(r, peer_hash): if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -491,9 +475,8 @@ class MessageRouter: ) return - # If the room doesn't exist yet (in-memory), the first joiner is the founder. if not self.hub.room_manager.get_room_members(r): - pass # room created by add_member + pass self.hub.room_manager._room_state_ensure(r, founder=peer_hash) sess["rooms"].add(r) @@ -524,7 +507,6 @@ class MessageRouter: ) self.hub.message_helper.queue_env(outgoing, link, joined) - # Consume invite on successful join. try: inv = st.get("invited") if isinstance(inv, dict) and peer_hash in inv: @@ -593,7 +575,6 @@ class MessageRouter: if st is not None and not st.get("registered"): self.hub.room_manager._room_state.pop(r, None) - # Per spec: acknowledge PART with PARTED. parted_body = None if self.hub.config.include_joined_member_list: members: list[bytes] = [] @@ -631,12 +612,9 @@ class MessageRouter: room = env.get(K_ROOM) body = env.get(K_BODY) - # Check for slash commands first, as they may not require a room. - # Per RRC spec, the room field is optional and may be empty. if isinstance(body, str): cmdline = body.strip() if cmdline.startswith("/"): - # It's a slash command - attempt to handle it if self.log.isEnabledFor(logging.DEBUG): self.log.debug( "Slash command peer=%s link_id=%s cmd=%r room=%r", @@ -655,7 +633,6 @@ class MessageRouter: len(outgoing), ) return - # Unrecognized slash command - send error if self.hub.identity is not None: self.hub.message_helper.emit_error( outgoing, @@ -666,8 +643,6 @@ class MessageRouter: ) return - # NOTICE messages are informational/non-conversational and don't require a room. - # MSG messages require a room for delivery. if t == T_MSG: if not isinstance(room, str) or not room: if self.hub.identity is not None: @@ -679,7 +654,6 @@ class MessageRouter: ) return elif t == T_NOTICE: - # NOTICE without a room is allowed - just don't forward it anywhere if not isinstance(room, str) or not room: return @@ -693,8 +667,6 @@ class MessageRouter: return if r not in sess["rooms"]: - # +n (no outside messages): when enabled, require membership. - # When disabled (-n), allow sending to existing/registered rooms. st = None if r in self.hub.room_manager._room_registry: st = self.hub.room_manager._room_state_ensure(r) @@ -723,7 +695,6 @@ class MessageRouter: ) return - # Per-room moderation: bans and moderated mode. if self.hub.room_manager.is_room_banned(r, peer_hash): if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -734,7 +705,9 @@ class MessageRouter: room=r, ) return - if self.hub.room_manager.is_room_moderated(r) and not self.hub.room_manager.is_room_voiced(r, peer_hash): + if self.hub.room_manager.is_room_moderated( + r + ) and not self.hub.room_manager.is_room_voiced(r, peer_hash): if self.hub.identity is not None: self.hub.message_helper.emit_error( outgoing, @@ -753,25 +726,20 @@ class MessageRouter: ) env[K_ROOM] = r - # Preserve the nickname from the incoming envelope if present. - # Fall back to session nickname (from HELLO) if client didn't provide one. - # This allows clients to update their nickname mid-session. incoming_nick = env.get(K_NICK) if incoming_nick is not None: - # Client provided a nickname in this message - validate and preserve it n = normalize_nick(incoming_nick, max_chars=self.hub.config.nick_max_chars) if n is not None: - # Update session nick and index if it changed old_session_nick = sess.get("nick") if old_session_nick != n: sess["nick"] = n - self.hub.session_manager.update_nick_index(link, old_session_nick, n) + self.hub.session_manager.update_nick_index( + link, old_session_nick, n + ) env[K_NICK] = n else: - # Invalid nickname provided - remove it env.pop(K_NICK, None) else: - # No nickname in message - use session nickname from HELLO if available nick = sess.get("nick") n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) if n is not None: diff --git a/rrcd/service.py b/rrcd/service.py index 5be44a5..9d92835 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -5,24 +5,15 @@ import os import signal import threading import time -from dataclasses import asdict, replace from typing import Any import RNS -from . import __version__ from .codec import encode from .commands import CommandHandler from .config import ConfigManager, HubRuntimeConfig from .constants import ( - B_WELCOME_HUB, - B_WELCOME_VER, - RES_KIND_MOTD, - RES_KIND_NOTICE, - T_ERROR, - T_NOTICE, T_PING, - T_WELCOME, ) from .envelope import make_envelope from .logging_config import configure_logging @@ -40,95 +31,24 @@ class HubService: def __init__(self, config: HubRuntimeConfig) -> None: self.config = config self.log = logging.getLogger("rrcd.hub") - - # Shared mutable state (sessions/rooms/room registry/etc) is accessed from - # Reticulum callbacks and background worker threads. Guard it with a - # single re-entrant lock. self._state_lock = threading.RLock() - self._shutdown = threading.Event() - - # Message router for handling protocol messages self.router = MessageRouter(self) - - # Session manager for connection lifecycle self.session_manager = SessionManager(self) - - # Command handler for operator commands self.command_handler = CommandHandler(self) - - # Resource manager for file/data transfers self.resource_manager = ResourceManager(self) - - # Room manager for room memberships and permissions self.room_manager = RoomManager(self) - - # Stats manager for metrics and reporting self.stats_manager = StatsManager(self) - - # Trust manager for trusted/banned identities self.trust_manager = TrustManager(self) - - # Config manager for configuration loading and reloading self.config_manager = ConfigManager(self) - - # Message helper for sending and queueing messages self.message_helper = MessageHelper(self) - self.identity: RNS.Identity | None = None self.destination: RNS.Destination | None = None - self._prune_thread: threading.Thread | None = None - self._ping_thread: threading.Thread | None = None self._announce_thread: threading.Thread | None = None self._resource_cleanup_thread: threading.Thread | None = None - - - def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str: - if isinstance(h, (bytes, bytearray)): - s = bytes(h).hex() - return s if prefix <= 0 else s[: min(prefix, len(s))] - return "-" - - def _fmt_link_id(self, link: RNS.Link) -> str: - lid = getattr(link, "link_id", None) - if isinstance(lid, (bytes, bytearray)): - return bytes(lid).hex() - h = getattr(link, "hash", None) - if isinstance(h, (bytes, bytearray)): - return bytes(h).hex() - return "-" - - def _update_nick_index(self, link: RNS.Link, old_nick: str | None, new_nick: str | None) -> None: - """Update nick index when a nick changes. Delegates to SessionManager.""" - self.session_manager.update_nick_index(link, old_nick, new_nick) - - # Resource transfer methods - delegates to message_helper for smart sending - - def _send_text_smart( - self, - link: RNS.Link, - *, - msg_type: int, - text: str, - room: str | None = None, - encoding: str = "utf-8", - outgoing: list[tuple[RNS.Link, bytes]] | None = None, - kind: str | None = None, - ) -> None: - """Delegate to message_helper for smart text sending.""" - self.message_helper.send_text_smart( - link, - msg_type=msg_type, - text=text, - room=room, - kind=kind, - outgoing=outgoing, - encoding=encoding, - ) - def start(self) -> None: self.log.info("Starting Reticulum") if self.stats_manager.started_wall_time is None: @@ -199,10 +119,11 @@ class HubService: ) self._prune_thread.start() - # Start resource cleanup thread if resource transfer is enabled if self.config.enable_resource_transfer: self._resource_cleanup_thread = threading.Thread( - target=self._resource_cleanup_loop, name="rrcd-resource-cleanup", daemon=True + target=self._resource_cleanup_loop, + name="rrcd-resource-cleanup", + daemon=True, ) self._resource_cleanup_thread.start() @@ -276,7 +197,6 @@ class HubService: return b def _ensure_worker_threads(self) -> None: - # Announce loop if self._announce_thread is None or not self._announce_thread.is_alive(): if ( self.config.announce_period_s @@ -289,7 +209,6 @@ class HubService: ) self._announce_thread.start() - # Ping loop if self._ping_thread is None or not self._ping_thread.is_alive(): if self.config.ping_interval_s and float(self.config.ping_interval_s) > 0: self._ping_thread = threading.Thread( @@ -297,7 +216,6 @@ class HubService: ) self._ping_thread.start() - # Prune loop if self._prune_thread is None or not self._prune_thread.is_alive(): if ( self.config.room_registry_prune_interval_s @@ -312,6 +230,21 @@ class HubService: ) self._prune_thread.start() + def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str: + if isinstance(h, (bytes, bytearray)): + s = bytes(h).hex() + return s if prefix <= 0 else s[: min(prefix, len(s))] + return "-" + + def _fmt_link_id(self, link: RNS.Link) -> str: + lid = getattr(link, "link_id", None) + if isinstance(lid, (bytes, bytearray)): + return bytes(lid).hex() + h = getattr(link, "hash", None) + if isinstance(h, (bytes, bytearray)): + return bytes(h).hex() + return "-" + def _reload_config_and_rooms( self, link: RNS.Link, @@ -320,7 +253,7 @@ class HubService: ) -> None: cfg_path = self.config_manager.get_config_path_for_writes() if not cfg_path or not os.path.exists(cfg_path): - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, "reload failed: config_path not set or missing" ) return @@ -331,17 +264,15 @@ class HubService: old_banned = set(self.trust_manager._banned) old_registry = dict(self.room_manager._room_registry) - # Stage config parse try: data = self.config_manager.load_toml(cfg_path) new_cfg = self.config_manager.apply_config_data(old_cfg, data) except Exception as e: - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, f"reload failed: config parse error: {e}" ) return - # Stage identity lists try: new_trusted = { self._parse_identity_hash(h) @@ -354,12 +285,11 @@ class HubService: if str(h).strip() } except Exception as e: - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, f"reload failed: identity list parse error: {e}" ) return - # Stage room registry parse (strict) reg_path = ( expand_path(str(new_cfg.room_registry_path)) if new_cfg.room_registry_path @@ -370,30 +300,29 @@ class HubService: invite_timeout_s=new_cfg.room_invite_timeout_s, ) if reg_err is not None: - self._emit_notice(outgoing, link, room, f"reload failed: {reg_err}") + self.message_helper.emit_notice( + outgoing, link, room, f"reload failed: {reg_err}" + ) return with self._state_lock: - # Apply (all-or-nothing) self.config = new_cfg self.trust_manager._trusted = new_trusted self.trust_manager._banned = new_banned self.room_manager._room_registry = new_registry - - # Merge registry into live per-room state (for active rooms). - # This makes /reload take effect immediately for existing members. self.room_manager.merge_registry_into_state(new_registry) self._ensure_worker_threads() - # Apply logging changes immediately. try: configure_logging(self.config) except Exception: self.log.exception("Failed to reconfigure logging") cfg_changes = self.config_manager.diff_config_summary(old_cfg, new_cfg) - room_changes = self.room_manager.diff_registry_summary(old_registry, new_registry) + room_changes = self.room_manager.diff_registry_summary( + old_registry, new_registry + ) lines: list[str] = [] lines.append( @@ -415,7 +344,7 @@ class HubService: lines.append("rooms_changes:") lines.extend(f"- {x}" for x in room_changes) - self._emit_notice(outgoing, link, room, "\n".join(lines)) + self.message_helper.emit_notice(outgoing, link, room, "\n".join(lines)) def _load_registered_rooms_from_registry(self) -> None: reg_path = self.room_manager.get_registry_path_for_writes() @@ -453,18 +382,15 @@ class HubService: Use matches list to provide helpful error messages. """ matches = self._find_target_links(token, room=room) - + if len(matches) == 1: - # Exactly one match - get hash from session s = self.session_manager.sessions.get(matches[0]) ph = s.get("peer") if s else None if isinstance(ph, (bytes, bytearray)): return (bytes(ph), matches) elif len(matches) > 1: - # Ambiguous - return None hash but provide matches for error message return (None, matches) - - # No matches from nick/hash-prefix lookup - try raw hash parse + try: h = self._parse_identity_hash(token) return (h, []) @@ -474,7 +400,6 @@ class HubService: def _resource_cleanup_loop(self) -> None: """Periodically cleanup expired resource expectations.""" while not self._shutdown.is_set(): - # Run cleanup every 30 seconds time.sleep(30.0) if self._shutdown.is_set(): break @@ -524,8 +449,7 @@ class HubService: identified_link, ident ) ) - - # Set up resource callbacks + self.resource_manager.configure_link_callbacks(link) self.log.info("Link established link_id=%s", self._fmt_link_id(link)) @@ -536,7 +460,9 @@ class HubService: banned = False peer_hash = None with self._state_lock: - banned, peer_hash = self.session_manager.on_remote_identified(link, identity) + banned, peer_hash = self.session_manager.on_remote_identified( + link, identity + ) if banned: self.log.warning( @@ -546,7 +472,9 @@ class HubService: ) if self.identity is not None: try: - self._error(link, src=self.identity.hash, text="banned") + self.message_helper.error( + link, src=self.identity.hash, text="banned" + ) except Exception: pass try: @@ -560,7 +488,6 @@ class HubService: rooms_count = 0 with self._state_lock: - # Clean up resource and session state self.resource_manager.on_link_closed(link) peer, nick, rooms_count = self.session_manager.on_link_closed(link) @@ -572,38 +499,6 @@ class HubService: self._fmt_link_id(link), ) - def _send(self, link: RNS.Link, env: dict) -> None: - """Delegate to message_helper for sending.""" - self.message_helper.send(link, env) - - def _error( - self, link: RNS.Link, src: bytes, text: str, room: str | None = None - ) -> None: - """Delegate to message_helper for error sending.""" - self.message_helper.error(link, src, text, room) - - def _emit_error( - self, - outgoing: list[tuple[RNS.Link, bytes]] | None, - link: RNS.Link, - *, - src: bytes, - text: str, - room: str | None = None, - ) -> None: - """Delegate to message_helper for error emission.""" - self.message_helper.emit_error(outgoing, link, src=src, text=text, room=room) - - def _emit_notice( - self, - outgoing: list[tuple[RNS.Link, bytes]] | None, - link: RNS.Link, - room: str | None, - text: str, - ) -> None: - """Delegate to message_helper for notice emission.""" - self.message_helper.emit_notice(outgoing, link, room, text) - def _norm_room(self, room: str) -> str: r = room.strip().lower() if not r: @@ -612,17 +507,10 @@ class HubService: raise ValueError("room name too long") return r - def _refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool: - """Token bucket rate limiting. Delegates to SessionManager.""" - return self.session_manager.refill_and_take(link, cost) - def _on_packet(self, link: RNS.Link, data: bytes) -> None: - # Packet callbacks can occur concurrently with other link callbacks and - # background worker threads. Keep state mutations under the shared lock, - # but avoid holding the lock while sending packets via RNS. outgoing: list[tuple[RNS.Link, bytes]] = OutgoingList() with self._state_lock: - self._on_packet_locked(link, data, outgoing) + self.router.route_packet(link, data, outgoing) if self.log.isEnabledFor(logging.DEBUG) and outgoing: self.log.debug( @@ -649,28 +537,14 @@ class HubService: len(payload), exc_info=True, ) - - # Execute any post-send callbacks (e.g., for MOTD after WELCOME) - if hasattr(outgoing, '_post_send_callbacks'): + + if hasattr(outgoing, "_post_send_callbacks"): for callback in outgoing._post_send_callbacks: # type: ignore try: callback() except Exception: self.log.exception("Post-send callback failed") - def _on_packet_locked( - self, - link: RNS.Link, - data: bytes, - outgoing: list[tuple[RNS.Link, bytes]], - ) -> None: - """ - Handle incoming packet with state lock held. - - Delegates to MessageRouter for message routing and dispatching. - """ - self.router.route_packet(link, data, outgoing) - def _ping_loop(self) -> None: while not self._shutdown.is_set(): interval = float(self.config.ping_interval_s) @@ -715,6 +589,6 @@ class HubService: ping = make_envelope(T_PING, src=self.identity.hash, body=now) try: self.stats_manager.inc("pings_out") - self._send(link, ping) + self.message_helper.send(link, ping) except Exception: pass diff --git a/rrcd/session.py b/rrcd/session.py index f2b95cd..c2f1302 100644 --- a/rrcd/session.py +++ b/rrcd/session.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: @dataclass class _RateState: """Token bucket state for rate limiting.""" + tokens: float last_refill: float @@ -21,7 +22,7 @@ class _RateState: class SessionManager: """ Manages session lifecycle for RRC hub connections. - + This class is responsible for: - Session creation and initialization - Session state management (nicknames, rooms, capabilities) @@ -34,21 +35,15 @@ class SessionManager: def __init__(self, hub: HubService) -> None: self.hub = hub self.log = logging.getLogger("rrcd.session") - - # Session state storage (keyed by RNS.Link) self.sessions: dict[RNS.Link, dict[str, Any]] = {} - - # Rate limiting state self._rate: dict[RNS.Link, _RateState] = {} - - # Secondary indexes for efficient lookups self._index_by_hash: dict[bytes, RNS.Link] = {} # identity hash -> link self._index_by_nick: dict[str, set[RNS.Link]] = {} # normalized nick -> links def on_link_established(self, link: RNS.Link) -> None: """ Handle new link establishment. - + Creates session state and sets up callbacks. Must be called with state lock held. """ @@ -73,7 +68,7 @@ class SessionManager: ) -> tuple[bool, bytes | None]: """ Handle remote identity being established. - + Returns: (is_banned, peer_hash) tuple Must be called with state lock held. @@ -85,20 +80,18 @@ class SessionManager: if identity is not None: peer_hash = identity.hash sess["peer"] = peer_hash - - # Update hash index + self._index_by_hash[bytes(peer_hash)] = link - # Check if banned banned = self.hub.trust_manager.is_banned(bytes(peer_hash)) - + if not banned: self.log.info( "Remote identified peer=%s link_id=%s", self.hub._fmt_hash(peer_hash), self.hub._fmt_link_id(link), ) - + return banned, peer_hash return False, None @@ -106,7 +99,7 @@ class SessionManager: def on_link_closed(self, link: RNS.Link) -> tuple[bytes | None, str | None, int]: """ Handle link closure and cleanup. - + Returns: (peer_hash, nick, rooms_count) for logging Must be called with state lock held. @@ -121,14 +114,12 @@ class SessionManager: nick = sess.get("nick") rooms_count = len(sess.get("rooms") or ()) - # Clean up indexes if isinstance(peer, (bytes, bytearray)): self._index_by_hash.pop(bytes(peer), None) if nick: self.update_nick_index(link, nick, None) - # Clean up room memberships for room in list(sess["rooms"]): self.hub.room_manager.remove_member(room, link) @@ -139,10 +130,9 @@ class SessionManager: ) -> None: """ Update nickname index when a nick changes. - + Must be called with state lock held. """ - # Remove old nick mapping if old_nick: old_key = old_nick.strip().lower() if old_key in self._index_by_nick: @@ -150,7 +140,6 @@ class SessionManager: if not self._index_by_nick[old_key]: self._index_by_nick.pop(old_key, None) - # Add new nick mapping if new_nick: new_key = new_nick.strip().lower() self._index_by_nick.setdefault(new_key, set()).add(link) @@ -158,10 +147,10 @@ class SessionManager: def refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool: """ Token bucket rate limiting. - + Refills tokens based on elapsed time and attempts to take `cost` tokens. Returns True if tokens were available and taken, False if rate limited. - + Must be called with state lock held. """ state = self._rate.get(link) @@ -197,7 +186,7 @@ class SessionManager: def clear_all(self) -> list[RNS.Link]: """ Clear all sessions and return list of links for teardown. - + Must be called with state lock held. """ links = list(self.sessions.keys()) @@ -212,7 +201,7 @@ class SessionManager: total = len(self.sessions) welcomed = sum(1 for s in self.sessions.values() if s.get("welcomed")) identified = sum(1 for s in self.sessions.values() if s.get("peer") is not None) - + return { "total": total, "welcomed": welcomed, @@ -232,39 +221,35 @@ class SessionManager: ) -> None: """ Send WELCOME message to a client and optionally MOTD. - + This handles: - Setting session as welcomed - Updating nick index if needed - Queueing WELCOME message - Setting up MOTD callback for post-send delivery - + Must be called with state lock held. """ from .constants import RES_KIND_MOTD, T_NOTICE - + sess = self.sessions.get(link) if sess is None: return - - # Update nick index if nick changed + if old_nick != new_nick: self.update_nick_index(link, old_nick, new_nick) - - # Mark session as welcomed + sess["welcomed"] = True - - # Queue WELCOME message + self.hub.message_helper.queue_welcome( outgoing, link, peer_hash=peer_hash, motd=self.hub.config.greeting, ) - - # Send MOTD after WELCOME (outside of outgoing queue to enable resource transfer) - # The outgoing queue will be sent first, then this callback will send the MOTD + if self.hub.config.greeting: + def send_motd(): self.hub.message_helper.send_text_smart( link, @@ -273,7 +258,7 @@ class SessionManager: room=None, kind=RES_KIND_MOTD, ) - # Store callback to be executed after outgoing packets are sent - if not hasattr(outgoing, '_post_send_callbacks'): + + if not hasattr(outgoing, "_post_send_callbacks"): outgoing._post_send_callbacks = [] # type: ignore outgoing._post_send_callbacks.append(send_motd) # type: ignore diff --git a/rrcd/stats.py b/rrcd/stats.py index 7733914..0c01743 100644 --- a/rrcd/stats.py +++ b/rrcd/stats.py @@ -2,7 +2,6 @@ from __future__ import annotations -import threading import time from typing import TYPE_CHECKING @@ -33,8 +32,6 @@ class StatsManager: self.started_wall_time: float | None = None self.started_monotonic: float | None = None - # Lifetime counters for uptime statistics (monotonically increasing after startup). - # Python int has arbitrary precision, so overflow is not a concern. self._counters: dict[str, int] = { "bytes_in": 0, "bytes_out": 0, diff --git a/rrcd/trust.py b/rrcd/trust.py index c5f37ca..f409124 100644 --- a/rrcd/trust.py +++ b/rrcd/trust.py @@ -7,13 +7,14 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: import RNS + from .service import HubService class TrustManager: """ Manages trusted and banned identities for the hub. - + Handles: - Trusted identity lists (server operators) - Banned identity lists @@ -24,11 +25,13 @@ class TrustManager: def __init__(self, hub: HubService) -> None: self.hub = hub self.log = hub.log - + self._trusted: set[bytes] = set() self._banned: set[bytes] = set() - def load_from_config(self, trusted_list: list[str] | None, banned_list: list[str] | None) -> None: + def load_from_config( + self, trusted_list: list[str] | None, banned_list: list[str] | None + ) -> None: """Load trusted and banned identities from config lists.""" self._trusted = { self.hub._parse_identity_hash(h) @@ -77,7 +80,9 @@ class TrustManager: "banned_count": len(self._banned), } - def update_from_config(self, trusted_list: list[str] | None, banned_list: list[str] | None) -> tuple[set[bytes], set[bytes]]: + def update_from_config( + self, trusted_list: list[str] | None, banned_list: list[str] | None + ) -> tuple[set[bytes], set[bytes]]: """ Update trusted and banned lists from config. Returns the old (trusted, banned) sets for comparison. diff --git a/rrcd/util.py b/rrcd/util.py index 6e42069..6afbe29 100644 --- a/rrcd/util.py +++ b/rrcd/util.py @@ -25,8 +25,6 @@ def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | if limit > 0 and len(s) > limit: return None - # Keep this conservative: avoid embedded newlines or NUL, which frequently - # cause UI/log formatting issues. if "\n" in s or "\r" in s or "\x00" in s: return None