Merge pull request #14 from kc1awv/actions

Actions
This commit is contained in:
Steve Miller
2026-05-17 15:44:34 -04:00
committed by GitHub
13 changed files with 537 additions and 14 deletions
+17
View File
@@ -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
+67 -3
View File
@@ -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: <timestamp>, # millisecond timestamp (K_TS)
4: <sender-hash>, # sender identity hash (K_SRC)
6: <body>, # notice body (K_BODY)
8: <dest-hash> # 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)
+21
View File
@@ -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.
+1 -1
View File
@@ -1,3 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.2.2"
__version__ = "0.3.1"
+4
View File
@@ -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
+20 -1
View File
@@ -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")
+12
View File
@@ -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,
}
+106 -5
View File
@@ -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,
+16 -2
View File
@@ -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
)
+3 -1
View File
@@ -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),
)
+249
View File
@@ -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)
]
+9 -1
View File
@@ -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)
+12
View File
@@ -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)