Merge pull request #1 from kc1awv/handle_nick

Add nickname support to envelope protocol and update version to 0.1.1
This commit is contained in:
Steve Miller
2025-12-30 09:44:28 -05:00
committed by GitHub
9 changed files with 126 additions and 6 deletions

View File

@@ -13,3 +13,7 @@ Initial public release.
- Persistent config + room registry in TOML (`rrcd.toml`, `rooms.toml`)
- Reduced lock contention by flushing outbound packets outside the shared state lock
- Added small packaging metadata and README polish
## 0.1.1 - 2025-12-30
- Protocol extension: hub may attach an optional nickname (`K_NICK = 7`) to forwarded `MSG`/`NOTICE` envelopes based on the nickname provided in `HELLO`.

View File

@@ -74,6 +74,21 @@ 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.
Wire-level extensions (backwards-compatible):
- **Optional envelope nickname**: the hub may include an additional envelope key
`K_NICK = 7` (string) when forwarding `MSG`/`NOTICE`. This is an optional
hint associated with `K_SRC` so clients can display a human-friendly
nickname instead of only the sender identity hash.
The hub learns this value from the client's `HELLO` body key
`B_HELLO_NICK = 0` and treats it as the authoritative nickname for that
link. Clients should treat `K_NICK` as optional and fall back to `K_SRC`
when it is missing.
Nickname policy (current implementation): trimmed Unicode string, UTF-8
encodable on the wire, maximum 32 characters.
Configure trusted operators and banned identities in the TOML config:
- `trusted_identities`: list of Reticulum Identity hashes (hex) allowed to run

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rrcd"
version = "0.1.0"
version = "0.1.1"
description = "Reticulum Relay Chat daemon (hub service)"
readme = "README.md"
license = { file = "LICENSE" }

View File

@@ -1,3 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
__version__ = "0.1.1"

View File

@@ -10,6 +10,8 @@ K_TS = 3
K_SRC = 4
K_ROOM = 5
K_BODY = 6
K_NICK = 7
NICK_MAX_CHARS = 32
# Message types
T_HELLO = 1

View File

@@ -3,7 +3,19 @@ from __future__ import annotations
import os
import time
from .constants import K_BODY, K_ID, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION
from .constants import (
K_BODY,
K_ID,
K_NICK,
K_ROOM,
K_SRC,
K_T,
K_TS,
K_V,
NICK_MAX_CHARS,
RRC_VERSION,
)
from .util import normalize_nick
def now_ms() -> int:
@@ -20,6 +32,7 @@ def make_envelope(
src: bytes,
room: str | None = None,
body=None,
nick: str | None = None,
mid: bytes | None = None,
ts: int | None = None,
) -> dict:
@@ -34,6 +47,10 @@ def make_envelope(
env[K_ROOM] = room
if body is not None:
env[K_BODY] = body
if nick is not None:
n = normalize_nick(nick)
if n is not None:
env[K_NICK] = n
return env
@@ -81,3 +98,22 @@ def validate_envelope(env: dict) -> None:
raise TypeError("room name must be a string")
if room == "":
raise ValueError("room name must not be empty")
if K_NICK in env:
nick = env[K_NICK]
if not isinstance(nick, str):
raise TypeError("nickname must be a string")
if nick.strip() == "":
raise ValueError("nickname must not be empty")
# Require normalized form on the wire.
if nick != nick.strip():
raise ValueError("nickname must not have leading/trailing whitespace")
if len(nick) > int(NICK_MAX_CHARS):
raise ValueError("nickname too long")
if "\n" in nick or "\r" in nick or "\x00" in nick:
raise ValueError("nickname contains control characters")
try:
nick.encode("utf-8", "strict")
except UnicodeError as e:
raise ValueError(f"nickname is not valid UTF-8: {e}") from e

View File

@@ -18,6 +18,7 @@ from .constants import (
B_WELCOME_GREETING,
B_WELCOME_HUB,
K_BODY,
K_NICK,
K_ROOM,
K_SRC,
K_T,
@@ -33,7 +34,7 @@ from .constants import (
T_WELCOME,
)
from .envelope import make_envelope, validate_envelope
from .util import expand_path
from .util import expand_path, normalize_nick
@dataclass
@@ -2150,8 +2151,9 @@ class HubService:
if isinstance(body, dict):
nick = body.get(B_HELLO_NICK)
if isinstance(nick, str) and nick.strip():
sess["nick"] = nick.strip()
n = normalize_nick(nick)
if n is not None:
sess["nick"] = n
if self.identity is not None:
sess["welcomed"] = True
@@ -2347,6 +2349,15 @@ class HubService:
env[K_SRC] = bytes(peer_hash) if isinstance(peer_hash, (bytes, bytearray)) else peer_hash
env[K_ROOM] = r
# Backwards-compatible extension: hub can attach the nickname learned
# from HELLO so clients can render a human-friendly name.
nick = sess.get("nick")
if isinstance(nick, str) and nick.strip():
env[K_NICK] = nick.strip()
else:
# Prevent client-supplied spoofed nicknames.
env.pop(K_NICK, None)
payload = encode(env)
for other in list(self.rooms.get(r, set())):
if other is link:

View File

@@ -2,6 +2,32 @@ from __future__ import annotations
import os
from .constants import NICK_MAX_CHARS
def expand_path(p: str) -> str:
return os.path.expanduser(os.path.expandvars(p))
def normalize_nick(value) -> str | None:
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return None
if len(s) > int(NICK_MAX_CHARS):
return None
# Keep this conservative: avoid embedded newlines or NUL, which frequently
# cause UI/log formatting issues.
if "\n" in s or "\r" in s or "\x00" in s:
return None
try:
s.encode("utf-8", "strict")
except UnicodeError:
return None
return s

View File

@@ -4,6 +4,7 @@ from rrcd.constants import (
B_HELLO_NICK,
K_BODY,
K_ID,
K_NICK,
K_SRC,
K_T,
K_TS,
@@ -19,6 +20,26 @@ def test_validate_accepts_make_envelope() -> None:
validate_envelope(env)
def test_validate_accepts_optional_nick_extension() -> None:
env = make_envelope(T_HELLO, src=b"peer", body=None, nick="alice")
assert env[K_NICK] == "alice"
validate_envelope(env)
def test_validate_rejects_nick_too_long() -> None:
env = make_envelope(T_HELLO, src=b"peer", body=None)
env[K_NICK] = "a" * 33
with pytest.raises(ValueError):
validate_envelope(env)
def test_validate_rejects_nick_with_whitespace() -> None:
env = make_envelope(T_HELLO, src=b"peer", body=None)
env[K_NICK] = " alice "
with pytest.raises(ValueError):
validate_envelope(env)
def test_validate_rejects_missing_required_key() -> None:
env = make_envelope(T_HELLO, src=b"peer", body=None)
env.pop(K_TS)
@@ -67,3 +88,8 @@ def test_validate_rejects_wrong_field_types() -> None:
env[K_TS] = "not-int"
with pytest.raises(TypeError):
validate_envelope(env)
env = make_envelope(T_HELLO, src=b"peer", body=None)
env[K_NICK] = 123
with pytest.raises(TypeError):
validate_envelope(env)