add ACTION message type and capability signaling

This commit is contained in:
kc1awv
2026-05-16 22:10:43 +00:00
parent 1599f21f90
commit 01376066b1
10 changed files with 218 additions and 10 deletions
+6
View File
@@ -2,6 +2,12 @@
This project follows the versioning policy in VERSIONING.md.
## 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
## 0.2.2 - 2026-01-09
- **Protocol constants and welcome message limits**: Added new constants for hub
+21 -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,22 @@ 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.
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 +498,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,6 +506,8 @@ 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)
- Advertise `CAP_ACTION` in your `HELLO` capabilities if you support ACTION UX
- Advertise `CAP_RESOURCE_ENVELOPE` in your `HELLO` capabilities if you support
resources
- Expect hub greeting to arrive via `NOTICE` messages after `WELCOME`
+4
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
+1 -1
View File
@@ -1,3 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.2.2"
__version__ = "0.3.0"
+2
View File
@@ -23,6 +23,7 @@ T_PARTED = 13
T_MSG = 20
T_NOTICE = 21
T_ACTION = 22
T_PING = 30
T_PONG = 31
@@ -56,6 +57,7 @@ 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
# RESOURCE_ENVELOPE body keys
B_RES_ID = 0
+10
View File
@@ -13,9 +13,12 @@ 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_RESOURCE_ENVELOPE,
T_ERROR,
T_NOTICE,
T_WELCOME,
@@ -139,9 +142,16 @@ class MessageHelper:
B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE: self.hub.config.rate_limit_msgs_per_minute,
}
caps: dict[int, bool] = {
CAP_ACTION: 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,
}
+7 -4
View File
@@ -22,6 +22,7 @@ from .constants import (
T_HELLO,
T_JOIN,
T_JOINED,
T_ACTION,
T_MSG,
T_NOTICE,
T_PART,
@@ -151,7 +152,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)
@@ -640,12 +641,12 @@ 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)
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 +677,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(
@@ -815,6 +816,8 @@ 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")
+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),
)
+155
View File
@@ -0,0 +1,155 @@
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_RESOURCE_ENVELOPE, K_BODY, K_T, T_ACTION
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 update_nick_index(self, link: object, old_nick: str | None, new_nick: str | None) -> None:
return
class _FakeMessageHelper:
def emit_error(self, outgoing, link, *, src: bytes, text: str, room: str | None = None) -> None:
raise AssertionError(f"unexpected error emitted: {text}")
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) -> 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([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.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_RESOURCE_ENVELOPE] is True
+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)