diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f5603..ec62f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ This project follows the versioning policy in VERSIONING.md. +## 0.3.1 - 2026-05-17 + +- Added a backward-compatible direct `NOTICE` extension using envelope key + `K_DST = 8` for full destination identity hashes +- Added advisory `CAP_DIRECT_NOTICE = 2` to `WELCOME` capabilities so clients + can detect support before sending direct notices +- Direct `NOTICE` delivery now returns `ERROR` for mixed room-plus-destination + envelopes and for unknown or offline destination identities + +## 0.3.0 - 2026-05-16 + +- Added core message type `ACTION` (`T_ACTION = 22`) routing with room-content semantics +- Added advisory capability flag `CAP_ACTION` and now include `B_WELCOME_CAPS` in WELCOME payloads +- ACTION bodies are forwarded as-is and are not interpreted as slash commands by the hub +- Fixed multi-link identity handling: do not emit room `PARTED` or clear + hash-link index state when a peer still remains in the room via another active link (thanks, neutral for the patch!) + ## 0.2.2 - 2026-01-09 - **Protocol constants and welcome message limits**: Added new constants for hub diff --git a/EX1-RRCD.md b/EX1-RRCD.md index 705c364..ba0f9de 100644 --- a/EX1-RRCD.md +++ b/EX1-RRCD.md @@ -20,8 +20,8 @@ rrcd implements the core RRC protocol as specified, with the following principles: 1. **Wire format compatibility first**: All core message types (`HELLO`, - `WELCOME`, `JOIN`, `JOINED`, `PART`, `PARTED`, `MSG`, `NOTICE`, `PING`, - `PONG`, `ERROR`) are implemented per spec. + `WELCOME`, `JOIN`, `JOINED`, `PART`, `PARTED`, `MSG`, `NOTICE`, `ACTION`, + `PING`, `PONG`, `ERROR`) are implemented per spec. 2. **Envelope keys are unsigned integers**: If you send string keys in your CBOR maps, we will reject your messages. The spec says unsigned integers. We mean it. @@ -36,6 +36,66 @@ principles: **Capability Key**: `0` (`CAP_RESOURCE_ENVELOPE`) **Status**: Implemented (optional, configurable) +## Extension: ACTION Capability Signaling + +**Capability Key**: `1` (`CAP_ACTION`) +**Status**: Implemented (advisory) + +`rrcd` advertises `CAP_ACTION` in `WELCOME` capabilities to indicate that the +hub supports forwarding `ACTION` messages (type `22`) as first-class room +content. + +This capability is advisory. Clients are free to choose their own local command +syntax to generate `ACTION` envelopes, and clients are free to render incoming +`ACTION` messages however they like. + +`rrcd` does not parse slash commands from `ACTION` bodies. Slash-command +handling remains a `MSG`/`NOTICE` convention. + +## Extension: Direct NOTICE Delivery + +**Envelope Key**: `8` (`K_DST`) +**Capability Key**: `2` (`CAP_DIRECT_NOTICE`) +**Status**: Implemented (advisory) + +`rrcd` advertises `CAP_DIRECT_NOTICE` in `WELCOME` capabilities to indicate +that the hub supports client-to-client `NOTICE` delivery using an explicit +destination identity hash. + +When a client sends a `NOTICE` with `K_DST = 8`, the value must be the full +destination identity hash as bytes. The hub resolves that identity against the +currently connected sessions and forwards the `NOTICE` to exactly one link. + +Direct `NOTICE` delivery does not use room membership. `K_ROOM` must be omitted +when `K_DST` is present; if both are present, the hub rejects the message with +`ERROR` rather than guessing which delivery mode the sender intended. + +**Envelope structure**: + +```python +{ + 0: 1, # protocol version (K_V) + 1: 21, # message type T_NOTICE (K_T) + 2: <8-byte-id>, # message ID (K_ID) + 3: , # millisecond timestamp (K_TS) + 4: , # sender identity hash (K_SRC) + 6: , # notice body (K_BODY) + 8: # full destination identity hash (K_DST) +} +``` + +**Delivery semantics**: + +- The hub overwrites `K_SRC` with the authenticated sender identity for the + current link. +- The hub preserves `K_DST` on the forwarded envelope so the recipient can tell + that the `NOTICE` was direct-addressed to its identity. +- The hub may normalize or attach `K_NICK` as a display hint, just as it does + for room `MSG`/`NOTICE` forwarding. +- If the destination is not currently connected, the sender receives `ERROR`. +- Nicknames and hash prefixes are not accepted in `K_DST`; this field is full + identity bytes only. + The RRC specification has no concept of large message delivery beyond "chunk it yourself, good luck." This is fine for small messages but becomes obnoxious for: @@ -482,7 +542,7 @@ If you're implementing a client or another hub, here's what you need to know: ### Minimum Compatibility (Basic RRC Client) - Implement core message types (HELLO, WELCOME, JOIN, JOINED, PART, PARTED, MSG, - NOTICE, PING, PONG, ERROR) + NOTICE, ACTION, PING, PONG, ERROR) - Use CBOR encoding - Use unsigned integer keys in envelopes and bodies - Handle `K_NICK` (envelope key 7) for nicknames @@ -490,8 +550,12 @@ If you're implementing a client or another hub, here's what you need to know: ### Enhanced Compatibility (Recommended) - Support `T_RESOURCE_ENVELOPE` (message type 50) and Reticulum resources +- Handle `ACTION` (message type 22) as room content (rendering is client-defined) +- Handle `K_DST` (envelope key 8) on incoming `NOTICE` if you want direct-message UX +- Advertise `CAP_ACTION` in your `HELLO` capabilities if you support ACTION UX - Advertise `CAP_RESOURCE_ENVELOPE` in your `HELLO` capabilities if you support resources +- Wait for `CAP_DIRECT_NOTICE` in `WELCOME` before sending direct `NOTICE` with `K_DST` - Expect hub greeting to arrive via `NOTICE` messages after `WELCOME` - Handle chunked `NOTICE` messages (multiple messages with the same content type) diff --git a/README.md b/README.md index eae9a4d..bcafb16 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Protocol alignment notes (for implementers): - `HELLO` and `WELCOME` bodies are CBOR maps with unsigned integer keys. - Capabilities are carried in body key `2` as a CBOR map (not a bitmask). Keys inside the capabilities map are unsigned integers; values are advisory. +- Core room content types include `MSG`, `NOTICE`, and `ACTION` (type `22`). - `WELCOME` is intentionally minimal (hub name/version/caps only). Any hub greeting text is delivered after `WELCOME` via one or more `NOTICE` messages. @@ -122,6 +123,9 @@ use a hub-local convention: if a client sends a `MSG`/`NOTICE` whose body is a string beginning with `/`, and the command is recognized, the hub treats it as a command and does not forward it. +`ACTION` is treated as normal room content and is forwarded unchanged. `rrcd` +does not interpret `ACTION` bodies as slash commands. + Wire-level extensions (backwards-compatible): - **Optional envelope nickname**: the hub may include an additional envelope key @@ -141,6 +145,23 @@ Wire-level extensions (backwards-compatible): UTF-8 encodable, contain no newlines/NUL, and are at most `nick_max_chars` characters (default: 32). +- **Direct NOTICE destination**: the hub supports client-to-client `NOTICE` + delivery using an optional envelope key `K_DST = 8` (bytes), containing the + full destination identity hash. + + This extension applies only to `NOTICE`. When `K_DST` is present, the hub + delivers the message to exactly one connected client identified by that full + hash instead of broadcasting by room membership. The forwarded `NOTICE` + preserves `K_DST` so the recipient can distinguish direct delivery from + room traffic without out-of-band state. + + Direct `NOTICE` messages must not also include `K_ROOM`. Mixed room and + direct-destination semantics are rejected with `ERROR`. + + Support for this extension is advertised in `WELCOME` capabilities via + `CAP_DIRECT_NOTICE = 2`. Clients should only send `K_DST`-addressed notices + after confirming hub support. + - **Large payload transfer via RNS.Resource**: For messages that exceed the link MTU (Maximum Data Unit), `rrcd` can automatically use RNS.Resource for reliable large payload transfer instead of manual chunking. diff --git a/rrcd/__init__.py b/rrcd/__init__.py index ee7eba4..7d18268 100644 --- a/rrcd/__init__.py +++ b/rrcd/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.2.2" +__version__ = "0.3.1" diff --git a/rrcd/constants.py b/rrcd/constants.py index 89ee811..d4d12df 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -11,6 +11,7 @@ K_SRC = 4 K_ROOM = 5 K_BODY = 6 K_NICK = 7 +K_DST = 8 # Message types T_HELLO = 1 @@ -23,6 +24,7 @@ T_PARTED = 13 T_MSG = 20 T_NOTICE = 21 +T_ACTION = 22 T_PING = 30 T_PONG = 31 @@ -56,6 +58,8 @@ B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE = 4 # Capabilities map keys (values are advisory). Keep these small and numeric. CAP_RESOURCE_ENVELOPE = 0 +CAP_ACTION = 1 +CAP_DIRECT_NOTICE = 2 # RESOURCE_ENVELOPE body keys B_RES_ID = 0 diff --git a/rrcd/envelope.py b/rrcd/envelope.py index cb7944a..fad63ed 100644 --- a/rrcd/envelope.py +++ b/rrcd/envelope.py @@ -3,7 +3,18 @@ from __future__ import annotations import os import time -from .constants import K_BODY, K_ID, K_NICK, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION +from .constants import ( + K_BODY, + K_DST, + K_ID, + K_NICK, + K_ROOM, + K_SRC, + K_T, + K_TS, + K_V, + RRC_VERSION, +) from .util import normalize_nick @@ -19,6 +30,7 @@ def make_envelope( msg_type: int, *, src: bytes, + dst: bytes | None = None, room: str | None = None, body=None, nick: str | None = None, @@ -32,6 +44,8 @@ def make_envelope( K_TS: ts or now_ms(), K_SRC: src, } + if dst is not None: + env[K_DST] = bytes(dst) if room is not None: env[K_ROOM] = room if body is not None: @@ -90,3 +104,8 @@ def validate_envelope(env: dict) -> None: nick = env[K_NICK] if not isinstance(nick, str): raise TypeError("nickname must be a string") + + if K_DST in env: + dst = env[K_DST] + if not isinstance(dst, (bytes, bytearray)): + raise TypeError("destination identity must be bytes") diff --git a/rrcd/messages.py b/rrcd/messages.py index 9c68ffc..0b2b7bd 100644 --- a/rrcd/messages.py +++ b/rrcd/messages.py @@ -13,9 +13,13 @@ from .constants import ( B_LIMIT_MAX_ROOM_NAME_BYTES, B_LIMIT_MAX_ROOMS_PER_SESSION, B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE, + B_WELCOME_CAPS, B_WELCOME_HUB, B_WELCOME_LIMITS, B_WELCOME_VER, + CAP_ACTION, + CAP_DIRECT_NOTICE, + CAP_RESOURCE_ENVELOPE, T_ERROR, T_NOTICE, T_WELCOME, @@ -139,9 +143,17 @@ class MessageHelper: B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE: self.hub.config.rate_limit_msgs_per_minute, } + caps: dict[int, bool] = { + CAP_ACTION: True, + CAP_DIRECT_NOTICE: True, + } + if self.hub.config.enable_resource_transfer: + caps[CAP_RESOURCE_ENVELOPE] = True + body_w: dict[int, Any] = { B_WELCOME_HUB: self.hub.config.hub_name, B_WELCOME_VER: str(__version__), + B_WELCOME_CAPS: caps, B_WELCOME_LIMITS: limits, } diff --git a/rrcd/router.py b/rrcd/router.py index 3704bdd..f940c35 100644 --- a/rrcd/router.py +++ b/rrcd/router.py @@ -15,10 +15,12 @@ from .constants import ( B_RES_SHA256, B_RES_SIZE, K_BODY, + K_DST, K_NICK, K_ROOM, K_SRC, K_T, + T_ACTION, T_HELLO, T_JOIN, T_JOINED, @@ -151,7 +153,7 @@ class MessageRouter: self._handle_join(link, sess, peer_hash, env, outgoing) elif t == T_PART: self._handle_part(link, sess, peer_hash, env, outgoing) - elif t in (T_MSG, T_NOTICE): + elif t in (T_MSG, T_NOTICE, T_ACTION): self._handle_message(link, sess, peer_hash, env, outgoing) elif t == T_PING: self._handle_ping(link, env, outgoing) @@ -601,7 +603,19 @@ class MessageRouter: if st is not None and not st.get("registered"): self.hub.room_manager._room_state.pop(r, None) - if remaining_members and self.hub.identity is not None: + peer_still_in_room = False + if peer_hash: + for member_link in remaining_members: + other = self.hub.session_manager.sessions.get(member_link) + if other and other.get("peer") == peer_hash: + peer_still_in_room = True + break + + if ( + remaining_members + and self.hub.identity is not None + and not peer_still_in_room + ): notification_body = ( [peer_hash] if self.hub.config.include_joined_member_list else None ) @@ -640,12 +654,13 @@ class MessageRouter: env: dict, outgoing: list[tuple[RNS.Link, bytes]], ) -> None: - """Handle MSG and NOTICE messages.""" + """Handle MSG, NOTICE, and ACTION messages.""" t = env.get(K_T) room = env.get(K_ROOM) + dst = env.get(K_DST) body = env.get(K_BODY) - if isinstance(body, str): + if t in (T_MSG, T_NOTICE) and isinstance(body, str): cmdline = body.strip() if cmdline.startswith("/"): if self.log.isEnabledFor(logging.DEBUG): @@ -676,7 +691,7 @@ class MessageRouter: ) return - if t == T_MSG: + if t in (T_MSG, T_ACTION): if not isinstance(room, str) or not room: if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -707,6 +722,9 @@ class MessageRouter: ) return elif t == T_NOTICE: + if dst is not None: + self._handle_direct_notice(link, sess, peer_hash, env, outgoing) + return if not isinstance(room, str) or not room: return @@ -815,9 +833,92 @@ class MessageRouter: if t == T_MSG: self.hub.stats_manager.inc("msgs_forwarded") + elif t == T_ACTION: + self.hub.stats_manager.inc("actions_forwarded") else: self.hub.stats_manager.inc("notices_forwarded") + def _handle_direct_notice( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle client-to-client NOTICE delivery by destination identity.""" + if self.hub.identity is None: + return + + dst = env.get(K_DST) + room = env.get(K_ROOM) + + if room is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="direct notice must not include room", + ) + return + + if not isinstance(dst, (bytes, bytearray)): + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="direct notice requires destination identity", + ) + return + + target_link = self.hub.session_manager.get_link_by_hash(bytes(dst)) + if target_link is None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="destination not connected", + ) + return + + env[K_SRC] = ( + bytes(peer_hash) if isinstance(peer_hash, (bytes, bytearray)) else peer_hash + ) + + incoming_nick = env.get(K_NICK) + if incoming_nick is not None: + n = normalize_nick(incoming_nick, max_bytes=self.hub.config.max_nick_bytes) + if n is not None: + 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 + ) + env[K_NICK] = n + else: + env.pop(K_NICK, None) + else: + nick = sess.get("nick") + n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes) + if n is not None: + env[K_NICK] = n + + env[K_DST] = bytes(dst) + payload = encode(env) + self.hub.message_helper.queue_payload(outgoing, target_link, payload) + + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug( + "Forwarded direct NOTICE peer=%s nick=%r dst=%s body_type=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + bytes(dst).hex(), + type(env.get(K_BODY)).__name__, + ) + + self.hub.stats_manager.inc("notices_forwarded") + def _handle_ping( self, link: RNS.Link, diff --git a/rrcd/session.py b/rrcd/session.py index 3a9c2c7..dd9a194 100644 --- a/rrcd/session.py +++ b/rrcd/session.py @@ -119,7 +119,8 @@ class SessionManager: rooms_count = len(sess.get("rooms") or ()) if isinstance(peer, (bytes, bytearray)): - self._index_by_hash.pop(bytes(peer), None) + if self._index_by_hash.get(bytes(peer)) is link: + self._index_by_hash.pop(bytes(peer), None) if nick: self.update_nick_index(link, nick, None) @@ -134,7 +135,20 @@ class SessionManager: self.hub.room_manager.remove_member(room, link) - if remaining_members and peer_hash and self.hub.identity: + peer_still_in_room = False + if peer_hash: + for member_link in remaining_members: + other = self.sessions.get(member_link) + if other and other.get("peer") == peer_hash: + peer_still_in_room = True + break + + if ( + remaining_members + and peer_hash + and self.hub.identity + and not peer_still_in_room + ): notification_body = ( [peer_hash] if self.hub.config.include_joined_member_list else None ) diff --git a/rrcd/stats.py b/rrcd/stats.py index bcc506e..94395d6 100644 --- a/rrcd/stats.py +++ b/rrcd/stats.py @@ -43,6 +43,7 @@ class StatsManager: "parts": 0, "msgs_forwarded": 0, "notices_forwarded": 0, + "actions_forwarded": 0, "pings_in": 0, "pongs_in": 0, "pings_out": 0, @@ -128,11 +129,12 @@ class StatsManager: ) ) lines.append( - "events: joins={} parts={} msgs_fwd={} notices_fwd={} errors_sent={} rate_limited={}".format( + "events: joins={} parts={} msgs_fwd={} notices_fwd={} actions_fwd={} errors_sent={} rate_limited={}".format( c.get("joins", 0), c.get("parts", 0), c.get("msgs_forwarded", 0), c.get("notices_forwarded", 0), + c.get("actions_forwarded", 0), c.get("errors_sent", 0), c.get("rate_limited", 0), ) diff --git a/tests/test_action_router.py b/tests/test_action_router.py new file mode 100644 index 0000000..fbd75d4 --- /dev/null +++ b/tests/test_action_router.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass + +from rrcd.codec import decode +from rrcd.constants import ( + B_WELCOME_CAPS, + CAP_ACTION, + CAP_DIRECT_NOTICE, + CAP_RESOURCE_ENVELOPE, + K_BODY, + K_DST, + K_NICK, + K_SRC, + K_T, + T_ACTION, + T_NOTICE, +) +from rrcd.envelope import make_envelope +from rrcd.messages import MessageHelper +from rrcd.router import MessageRouter + + +class _FakeStats: + def __init__(self) -> None: + self.counters: dict[str, int] = {} + + def inc(self, key: str, delta: int = 1) -> None: + self.counters[key] = self.counters.get(key, 0) + delta + + +class _FakeCommandHandler: + def __init__(self) -> None: + self.called = False + + def handle_operator_command(self, *args, **kwargs) -> bool: + self.called = True + return False + + +class _FakeRoomManager: + def __init__(self, members: list[object]) -> None: + self._members = members + self._room_registry: set[str] = set() + + def get_room_members(self, room: str) -> list[object]: + return list(self._members) + + def _room_state_ensure(self, room: str) -> dict: + return {} + + def is_room_banned(self, room: str, peer_hash: bytes) -> bool: + return False + + def is_room_moderated(self, room: str) -> bool: + return False + + def is_room_voiced(self, room: str, peer_hash: bytes) -> bool: + return True + + +class _FakeSessionManager: + def __init__(self) -> None: + self.targets: dict[bytes, object] = {} + + def update_nick_index( + self, link: object, old_nick: str | None, new_nick: str | None + ) -> None: + return + + def get_link_by_hash(self, peer_hash: bytes) -> object | None: + return self.targets.get(bytes(peer_hash)) + + +class _FakeMessageHelper: + def __init__(self) -> None: + self.errors: list[tuple[object, str, str | None]] = [] + + def emit_error( + self, outgoing, link, *, src: bytes, text: str, room: str | None = None + ) -> None: + self.errors.append((link, text, room)) + + def queue_payload(self, outgoing, link, payload: bytes) -> None: + outgoing.append((link, payload)) + + +@dataclass +class _FakeIdentity: + hash: bytes + + +@dataclass +class _FakeConfig: + max_nick_bytes: int = 32 + max_msg_body_bytes: int = 350 + hub_name: str = "rrcd" + max_room_name_bytes: int = 64 + max_rooms_per_session: int = 8 + rate_limit_msgs_per_minute: int = 240 + enable_resource_transfer: bool = False + + +class _FakeLink: + MDU = 10000 + + +class _FakeHub: + def __init__(self, link: object, members: list[object] | None = None) -> None: + import logging + + self.log = logging.getLogger("test") + self.identity = _FakeIdentity(hash=b"hub") + self.config = _FakeConfig() + self.stats_manager = _FakeStats() + self.command_handler = _FakeCommandHandler() + self.room_manager = _FakeRoomManager(members if members is not None else [link]) + self.session_manager = _FakeSessionManager() + self.message_helper = _FakeMessageHelper() + self._state_lock = threading.RLock() + + def _norm_room(self, room: str) -> str: + return room.lower() + + def _fmt_hash(self, value: bytes) -> str: + return value.hex() + + def _fmt_link_id(self, link: object) -> str: + return "link" + + +def test_action_is_forwarded_without_command_interpretation() -> None: + link = _FakeLink() + hub = _FakeHub(link) + router = MessageRouter(hub) + + sess = {"rooms": {"#general"}, "nick": "alice"} + env = make_envelope(T_ACTION, src=b"peer", room="#general", body="/me waves") + outgoing: list[tuple[object, bytes]] = [] + + router._handle_message(link, sess, b"peer", env, outgoing) + + assert hub.command_handler.called is False + assert hub.message_helper.errors == [] + assert hub.stats_manager.counters.get("actions_forwarded") == 1 + assert len(outgoing) == 1 + + decoded = decode(outgoing[0][1]) + assert decoded[K_T] == T_ACTION + assert decoded[K_BODY] == "/me waves" + + +def test_welcome_advertises_action_capability() -> None: + import logging + + class _WelcomeHub: + def __init__(self) -> None: + self.log = logging.getLogger("test") + self.identity = _FakeIdentity(hash=b"hub") + self.config = _FakeConfig(enable_resource_transfer=True) + self.stats_manager = _FakeStats() + + def _fmt_hash(self, value: bytes) -> str: + return value.hex() + + def _fmt_link_id(self, link: object) -> str: + return "link" + + hub = _WelcomeHub() + helper = MessageHelper(hub) + link = _FakeLink() + outgoing: list[tuple[object, bytes]] = [] + + helper.queue_welcome(outgoing, link, peer_hash=b"peer", motd=None) + + assert len(outgoing) == 1 + decoded = decode(outgoing[0][1]) + caps = decoded[K_BODY][B_WELCOME_CAPS] + assert caps[CAP_ACTION] is True + assert caps[CAP_DIRECT_NOTICE] is True + assert caps[CAP_RESOURCE_ENVELOPE] is True + + +def test_notice_is_forwarded_to_direct_destination() -> None: + sender_link = _FakeLink() + target_link = object() + hub = _FakeHub(sender_link) + hub.session_manager.targets[b"target"] = target_link + router = MessageRouter(hub) + + sess = {"rooms": set(), "nick": "alice"} + env = make_envelope(T_NOTICE, src=b"spoofed", dst=b"target", body="hello") + outgoing: list[tuple[object, bytes]] = [] + + router._handle_message(sender_link, sess, b"peer", env, outgoing) + + assert hub.message_helper.errors == [] + assert hub.stats_manager.counters.get("notices_forwarded") == 1 + assert len(outgoing) == 1 + assert outgoing[0][0] is target_link + + decoded = decode(outgoing[0][1]) + assert decoded[K_T] == T_NOTICE + assert decoded[K_SRC] == b"peer" + assert decoded[K_DST] == b"target" + assert decoded[K_NICK] == "alice" + assert decoded[K_BODY] == "hello" + + +def test_direct_notice_rejects_room_and_destination_combination() -> None: + sender_link = _FakeLink() + hub = _FakeHub(sender_link) + hub.session_manager.targets[b"target"] = object() + router = MessageRouter(hub) + + sess = {"rooms": {"#general"}, "nick": "alice"} + env = make_envelope( + T_NOTICE, + src=b"peer", + dst=b"target", + room="#general", + body="hello", + ) + outgoing: list[tuple[object, bytes]] = [] + + router._handle_message(sender_link, sess, b"peer", env, outgoing) + + assert outgoing == [] + assert hub.message_helper.errors == [ + (sender_link, "direct notice must not include room", None) + ] + + +def test_direct_notice_rejects_unknown_destination() -> None: + sender_link = _FakeLink() + hub = _FakeHub(sender_link) + router = MessageRouter(hub) + + sess = {"rooms": set(), "nick": "alice"} + env = make_envelope(T_NOTICE, src=b"peer", dst=b"missing", body="hello") + outgoing: list[tuple[object, bytes]] = [] + + router._handle_message(sender_link, sess, b"peer", env, outgoing) + + assert outgoing == [] + assert hub.message_helper.errors == [ + (sender_link, "destination not connected", None) + ] diff --git a/tests/test_codec.py b/tests/test_codec.py index 7a664ab..04347c1 100644 --- a/tests/test_codec.py +++ b/tests/test_codec.py @@ -1,5 +1,5 @@ from rrcd.codec import decode, encode -from rrcd.constants import T_MSG +from rrcd.constants import T_ACTION, T_MSG from rrcd.envelope import make_envelope, validate_envelope @@ -9,3 +9,11 @@ def test_codec_round_trip() -> None: decoded = decode(data) assert decoded == env validate_envelope(decoded) + + +def test_codec_round_trip_action() -> None: + env = make_envelope(T_ACTION, src=b"peer", room="#general", body="waves") + data = encode(env) + decoded = decode(data) + assert decoded == env + validate_envelope(decoded) diff --git a/tests/test_envelope.py b/tests/test_envelope.py index e8b8310..ef4b98b 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -3,6 +3,7 @@ import pytest from rrcd.constants import ( B_HELLO_NICK_LEGACY, K_BODY, + K_DST, K_ID, K_NICK, K_SRC, @@ -26,6 +27,12 @@ def test_validate_accepts_optional_nick_extension() -> None: validate_envelope(env) +def test_validate_accepts_optional_destination_extension() -> None: + env = make_envelope(T_HELLO, src=b"peer", dst=b"target", body=None) + assert env[K_DST] == b"target" + validate_envelope(env) + + def test_validate_allows_ridiculous_or_empty_nick() -> None: env = make_envelope(T_HELLO, src=b"peer", body=None) env[K_NICK] = "" @@ -89,3 +96,8 @@ def test_validate_rejects_wrong_field_types() -> None: env[K_NICK] = 123 with pytest.raises(TypeError): validate_envelope(env) + + env = make_envelope(T_HELLO, src=b"peer", body=None) + env[K_DST] = "not-bytes" + with pytest.raises(TypeError): + validate_envelope(env)