mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-06-08 14:11:53 -07:00
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.2.2"
|
||||
__version__ = "0.3.1"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user