fix JOINED/PARTED messages, enhance code quality with type checks and new dependencies, update changelog for version 0.2.1

This commit is contained in:
kc1awv
2026-01-09 09:54:07 -05:00
parent 6a404a22e5
commit 9628955dce
10 changed files with 108 additions and 44 deletions

View File

@@ -10,6 +10,12 @@ This project follows the versioning policy in VERSIONING.md.
- Joining/parting users continue to receive the full member list (when `include_joined_member_list` is enabled)
- See EX1-RRCD.md for detailed protocol documentation
Minor fixes:
- fixed JOINED/PARTED notification logic to ensure correct member list updates
- improved type checking and annotations in several modules
- added black, ruff, and mypy to development dependencies for code quality enforcement
## 0.2.0 - 2026-01-07
- **Major internal refactoring**: Improved code organization and maintainability

View File

@@ -35,7 +35,9 @@ Issues = "https://github.com/kc1awv/rrcd/issues"
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"black>=24.0.0",
"ruff>=0.6.0",
"mypy>=1.0.0",
]
[project.scripts]
@@ -54,3 +56,13 @@ line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "RNS.*"
ignore_missing_imports = true

View File

@@ -386,8 +386,11 @@ def main(argv: list[str] | None = None) -> None:
# Use ConfigManager to load config file
if config_path:
from .config import ConfigManager
# Create temporary manager for loading
temp_hub = type('obj', (object,), {'config': cfg, 'log': None, '_state_lock': None})()
temp_hub = type(
"obj", (object,), {"config": cfg, "log": None, "_state_lock": None}
)()
temp_mgr = ConfigManager(temp_hub) # type: ignore
data = temp_mgr.load_toml(config_path)
cfg = temp_mgr.apply_config_data(cfg, data)

View File

@@ -123,8 +123,8 @@ class CommandHandler:
)
return True
st = self.hub.room_manager._room_state_get(r)
if st and st.get("private"):
room_state = self.hub.room_manager._room_state_get(r)
if room_state is not None and room_state.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"
@@ -882,7 +882,11 @@ class CommandHandler:
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
if isinstance(ph, (bytes, bytearray)) and bytes(ph) == target_hash:
if (
isinstance(ph, (bytes, bytearray))
and bytes(ph) == target_hash
and s is not None
):
s.get("rooms", set()).discard(r)
self.hub.room_manager.get_room_members(r).discard(other)
if self.hub.identity is not None:
@@ -1022,8 +1026,8 @@ class CommandHandler:
return True
target_hash = bytes(ph)
key = st.get("key")
is_keyed = isinstance(key, str) and bool(key)
key_val = st.get("key")
is_keyed = isinstance(key_val, str) and bool(key_val)
is_invite_only = bool(st.get("invite_only", False))
if is_keyed:

View File

@@ -37,7 +37,7 @@ class MessageHelper:
"""Check if payload fits within link MDU without creating/packing packets."""
try:
if hasattr(link, "MDU") and link.MDU is not None:
return len(payload) <= link.MDU
return bool(len(payload) <= link.MDU)
pkt = RNS.Packet(link, payload)
pkt.pack()
return True

View File

@@ -492,16 +492,17 @@ class MessageRouter:
self.hub.room_manager.touch_room(r)
# Notify existing room members about the new joiner
# Send JOINED message with single identity hash to existing members
existing_members = [
member_link
for member_link in self.hub.room_manager.get_room_members(r)
if member_link != link
]
if existing_members:
if existing_members and self.hub.identity is not None:
notification_body = (
[peer_hash] if self.hub.config.include_joined_member_list else None
)
member_notification = make_envelope(
T_JOINED, src=self.hub.identity.hash, room=r, body=[peer_hash]
T_JOINED, src=self.hub.identity.hash, room=r, body=notification_body
)
member_notification_payload = encode(member_notification)
for member_link in existing_members:
@@ -509,7 +510,6 @@ class MessageRouter:
outgoing, member_link, member_notification_payload
)
# Send JOINED message with full member list to the joining user
joined_body = None
if self.hub.config.include_joined_member_list:
members: list[bytes] = []
@@ -520,10 +520,11 @@ class MessageRouter:
members.append(bytes(ph))
joined_body = members
joined = make_envelope(
T_JOINED, src=self.hub.identity.hash, room=r, body=joined_body
)
self.hub.message_helper.queue_env(outgoing, link, joined)
if self.hub.identity is not None:
joined = make_envelope(
T_JOINED, src=self.hub.identity.hash, room=r, body=joined_body
)
self.hub.message_helper.queue_env(outgoing, link, joined)
try:
inv = st.get("invited")
@@ -581,14 +582,13 @@ class MessageRouter:
return
sess["rooms"].discard(r)
# Get remaining members before removing the parting user
remaining_members = [
member_link
for member_link in self.hub.room_manager.get_room_members(r)
if member_link != link
]
if self.hub.room_manager.get_room_members(r):
self.hub.room_manager.remove_member(r, link)
if not self.hub.room_manager.get_room_members(r):
@@ -601,11 +601,12 @@ class MessageRouter:
if st is not None and not st.get("registered"):
self.hub.room_manager._room_state.pop(r, None)
# Notify remaining members about the user parting
# Send PARTED message with single identity hash to remaining members
if remaining_members:
if remaining_members and self.hub.identity is not None:
notification_body = (
[peer_hash] if self.hub.config.include_joined_member_list else None
)
member_notification = make_envelope(
T_PARTED, src=self.hub.identity.hash, room=r, body=[peer_hash]
T_PARTED, src=self.hub.identity.hash, room=r, body=notification_body
)
member_notification_payload = encode(member_notification)
for member_link in remaining_members:
@@ -613,7 +614,6 @@ class MessageRouter:
outgoing, member_link, member_notification_payload
)
# Send PARTED message with current member list to the parting user
parted_body = None
if self.hub.config.include_joined_member_list:
members: list[bytes] = []
@@ -697,7 +697,7 @@ class MessageRouter:
return
try:
r = self.hub._norm_room(room)
r = self.hub._norm_room(str(room)) if room else ""
except Exception as e:
if self.hub.identity is not None:
self.hub.message_helper.emit_error(

View File

@@ -60,8 +60,8 @@ class HubService:
self.identity = self._load_identity(self.config.identity_path)
self.trust_manager.load_from_config(
self.config.trusted_identities,
self.config.banned_identities,
list(self.config.trusted_identities),
list(self.config.banned_identities),
)
self._load_registered_rooms_from_registry()
@@ -363,7 +363,7 @@ class HubService:
"""Resolve token to identity hash. Returns hash if successful, None otherwise.
For ambiguous matches, use _resolve_identity_hash_with_matches instead.
"""
target_link = self._find_target_link(token, room=room)
target_link = self.command_handler._find_target_link(token, room=room)
if target_link is not None:
s = self.session_manager.sessions.get(target_link)
ph = s.get("peer") if s else None
@@ -381,7 +381,7 @@ class HubService:
Returns (hash, matches) tuple. Hash is None if ambiguous or not found.
Use matches list to provide helpful error messages.
"""
matches = self._find_target_links(token, room=room)
matches = self.command_handler._find_target_links(token, room=room)
if len(matches) == 1:
s = self.session_manager.sessions.get(matches[0])

View File

@@ -104,6 +104,10 @@ class SessionManager:
(peer_hash, nick, rooms_count) for logging
Must be called with state lock held.
"""
from .codec import encode
from .constants import T_PARTED
from .envelope import make_envelope
sess = self.sessions.pop(link, None)
self._rate.pop(link, None)
@@ -120,9 +124,43 @@ class SessionManager:
if nick:
self.update_nick_index(link, nick, None)
# Notify remaining members and remove from rooms
peer_hash = bytes(peer) if isinstance(peer, (bytes, bytearray)) else None
for room in list(sess["rooms"]):
# Get remaining members before removing the disconnecting user
remaining_members = [
member_link
for member_link in self.hub.room_manager.get_room_members(room)
if member_link != link
]
# Remove the disconnecting user from the room
self.hub.room_manager.remove_member(room, link)
# Send PARTED notification to remaining members
if remaining_members and peer_hash and self.hub.identity:
notification_body = (
[peer_hash] if self.hub.config.include_joined_member_list else None
)
member_notification = make_envelope(
T_PARTED,
src=self.hub.identity.hash,
room=room,
body=notification_body,
)
member_notification_payload = encode(member_notification)
for member_link in remaining_members:
try:
import RNS
RNS.Packet(member_link, member_notification_payload).send()
self.hub.stats_manager.inc(
"bytes_out", len(member_notification_payload)
)
except Exception:
# Link may already be closed or in bad state, ignore send failures
pass
return peer, nick, rooms_count
def update_nick_index(

View File

@@ -12,7 +12,7 @@ if TYPE_CHECKING:
class StatsManager:
"""
Manages hub statistics collection and reporting.
Tracks counters for:
- Bytes in/out
- Packets processed
@@ -28,10 +28,10 @@ class StatsManager:
def __init__(self, hub: HubService) -> None:
self.hub = hub
self.log = hub.log
self.started_wall_time: float | None = None
self.started_monotonic: float | None = None
self._counters: dict[str, int] = {
"bytes_in": 0,
"bytes_out": 0,
@@ -71,7 +71,7 @@ class StatsManager:
def format_stats(self) -> str:
"""Format current statistics as a human-readable string."""
from . import __version__
now_mono = time.monotonic()
started_mono = self.started_monotonic
uptime_s = (now_mono - started_mono) if started_mono is not None else 0.0

View File

@@ -1,4 +1,5 @@
"""Tests for resource transfer functionality."""
import hashlib
import os
@@ -24,7 +25,7 @@ def test_resource_envelope_serialization():
rid = os.urandom(8)
payload = b"This is a test payload that is larger than typical MDU"
sha256 = hashlib.sha256(payload).digest()
body = {
B_RES_ID: rid,
B_RES_KIND: RES_KIND_NOTICE,
@@ -32,21 +33,21 @@ def test_resource_envelope_serialization():
B_RES_SHA256: sha256,
B_RES_ENCODING: "utf-8",
}
envelope = make_envelope(
T_RESOURCE_ENVELOPE,
src=src,
room="test",
body=body,
)
# Serialize and deserialize
encoded = encode(envelope)
decoded = decode(encoded)
assert decoded[K_T] == T_RESOURCE_ENVELOPE
assert decoded[K_SRC] == src
decoded_body = decoded[K_BODY]
assert decoded_body[B_RES_ID] == rid
assert decoded_body[B_RES_KIND] == RES_KIND_NOTICE
@@ -59,22 +60,22 @@ def test_resource_envelope_minimal():
"""Test resource envelope with minimal required fields."""
src = os.urandom(16)
rid = os.urandom(8)
body = {
B_RES_ID: rid,
B_RES_KIND: "blob",
B_RES_SIZE: 1024,
}
envelope = make_envelope(
T_RESOURCE_ENVELOPE,
src=src,
body=body,
)
encoded = encode(envelope)
decoded = decode(encoded)
decoded_body = decoded[K_BODY]
assert B_RES_SHA256 not in decoded_body
assert B_RES_ENCODING not in decoded_body
@@ -85,12 +86,12 @@ def test_sha256_verification():
"""Test SHA256 hash computation for payload verification."""
payload = b"Test payload for SHA256 verification"
expected = hashlib.sha256(payload).digest()
# Verify we can compute and compare hashes correctly
computed = hashlib.sha256(payload).digest()
assert computed == expected
assert len(computed) == 32
# Verify mismatch detection
wrong_payload = b"Different payload"
wrong_hash = hashlib.sha256(wrong_payload).digest()