runspec-room 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ *.db
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
@@ -0,0 +1,76 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-room` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
+ semantic versioning.
6
+
7
+ ## [0.3.0]
8
+
9
+ **S3: OIDC login + sessions + TLS.** The browser door is now authenticated.
10
+
11
+ ### Added
12
+ - `console_room.web.auth` — OIDC config (`auth_from_env`, env-driven, secrets
13
+ never on the CLI) and pure identity helpers (`identity_from_userinfo`,
14
+ `session_user`, `AuthConfig.redirect_uri` / `.secure_cookies`).
15
+ - OIDC login on the browser door (authlib): `/login` → your IdP, `/auth/callback`
16
+ → a signed-cookie session, `/logout`. The app shell (`/`) and the WebSocket are
17
+ gated on the session identity, so the browser no longer declares who it is —
18
+ and a spoofed `?user=` is ignored. A `/me` endpoint reports the logged-in user;
19
+ the frontend locks the name field and shows a sign-out link in auth mode.
20
+ - TLS: `--tls-cert` / `--tls-key` (or `CONSOLE_ROOM_TLS_*`) make uvicorn serve
21
+ https/wss directly. uvicorn runs proxy-header-aware (`X-Forwarded-Proto`), so it
22
+ also works behind a TLS-terminating reverse proxy; session cookies are marked
23
+ `Secure` when the public URL is https.
24
+
25
+ ### Changed
26
+ - Adds runtime dependencies `authlib` + `itsdangerous`. **Dev mode unchanged:**
27
+ with no `CONSOLE_ROOM_OIDC_*` config the browser door runs unauthenticated
28
+ (query-param identity), logging a warning — handy for local use, not for public
29
+ exposure. `--ndjson-only` still needs none of the web deps.
30
+ - Static assets moved to `/static/`; the app shell is served from a gated `/`
31
+ route (was a root static mount).
32
+
33
+ ## [0.2.0]
34
+
35
+ **S2: the browser front door.**
36
+
37
+ ### Added
38
+ - `console_room.web.connection.BrowserConnection` — the browser counterpart of the
39
+ NDJSON console connection, satisfying the hub's `Participant` protocol with
40
+ `wants_backscroll = True` (people see recent history; consoles never do). Speaks
41
+ to a tiny `WebSocketLike` seam, so it's unit-tested with a fake socket.
42
+ - `console_room.web.app.create_app` — the Starlette app: a `/ws` WebSocket door, a
43
+ `/healthz` check, and the static frontend served at `/`. When given an
44
+ `ndjson_addr`, its lifespan also runs the console NDJSON door on the same loop,
45
+ so one process serves both front doors.
46
+ - A vanilla, no-build static frontend (`web/frontend/`): transcript + presence +
47
+ composer, with auto-reconnect. Browser identity comes from a `?user=&room=` join
48
+ form (a dev stand-in, replaced by the OIDC session in S3).
49
+ - `server.py` now runs the **web server** by default (uvicorn + the app);
50
+ `--ndjson-only` keeps the stdlib console-door path for a minimal deploy.
51
+
52
+ ### Changed
53
+ - Adds runtime dependencies `starlette` + `uvicorn[standard]` (the package is now a
54
+ web server). The `--ndjson-only` path imports neither — they're lazy — so a
55
+ console-only deploy can still run without them.
56
+
57
+ ## [0.1.0]
58
+
59
+ Initial release — **S1: the console front door**.
60
+
61
+ ### Added
62
+ - `console_room.hub.Hub` — the transport-agnostic room broker: join/leave with
63
+ live presence, message stamping (server-assigned `id`/`ts`/`sender`), persist,
64
+ and broadcast (sender included, so all views converge). Browser-only backscroll
65
+ asymmetry baked in via the participant's `wants_backscroll`.
66
+ - `console_room.store.TranscriptStore` — durable SQLite transcript (`append` /
67
+ `recent`), stdlib-only, idempotent on message id.
68
+ - `console_room.ndjson_server` — the NDJSON listener on localhost (the console
69
+ door): a `hello` handshake binds identity to the connection; `message` /
70
+ `presence` frames drive the hub; blank/malformed lines are skipped.
71
+ - `console_room.models` — the wire schema: `Message`, frame builders, and
72
+ `hello` parsing/validation.
73
+ - `console-room` CLI entry point (`--host` / `--port` / `--db`, env-overridable).
74
+
75
+ Stdlib-only at this stage (no runtime dependencies). The browser door (S2) and
76
+ OIDC + TLS (S3) follow. See `docs/design/console-room-server.md`.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-room
3
+ Version: 0.3.0
4
+ Summary: The runspec console chat-room server — a dumb message pipe with presence: console SSH-tunnel door + OIDC-authenticated browser web door
5
+ Author: runspec
6
+ License-Expression: MIT
7
+ Keywords: chat,console,room,runspec,ssh
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Communications :: Chat
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: authlib>=1.3
15
+ Requires-Dist: itsdangerous>=2.1
16
+ Requires-Dist: starlette>=0.37
17
+ Requires-Dist: uvicorn[standard]>=0.27
18
+ Provides-Extra: dev
19
+ Requires-Dist: httpx>=0.27; extra == 'dev'
20
+ Requires-Dist: mypy; extra == 'dev'
21
+ Requires-Dist: pytest>=7; extra == 'dev'
22
+ Requires-Dist: ruff; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # runspec-room
26
+
27
+ The server behind the runspec-console **chat room** — a *dumb message pipe with
28
+ presence*. It holds, per room, the connected participants, their live presence,
29
+ and a durable transcript, and it does three things: accept connections, stamp +
30
+ persist each inbound message, and broadcast every message / presence change to
31
+ everyone else in the room. **All the intelligence stays in the consoles** — the
32
+ server never runs anything.
33
+
34
+ See `docs/design/console-room-server.md` for the full design and decisions.
35
+
36
+ ## Status — complete server (S1–S3)
37
+
38
+ Two front doors, one process:
39
+
40
+ - **Console door** — an NDJSON listener on **localhost**, which each
41
+ runspec-console reaches by opening a `direct-tcpip` channel over its existing
42
+ pooled SSH connection. A console's right to be in the room *is* its SSH access —
43
+ no new exposed port, no second credential.
44
+ - **Browser door** — a WebSocket endpoint (`/ws`) plus a small vanilla static
45
+ frontend (transcript / composer / presence) with recent-history backscroll for
46
+ people. Authenticated via **OIDC** (no passwords stored); identity comes from
47
+ the signed-cookie session. TLS is self-terminated or left to a reverse proxy.
48
+
49
+ ## Run it
50
+
51
+ ```bash
52
+ pip install -e .
53
+
54
+ # Full server (browser + console doors):
55
+ console-room --port 8080 --ndjson-port 8765 --db ./console_room.db
56
+
57
+ # Console door only (no web deps needed):
58
+ console-room --ndjson-only --ndjson-port 8765 --db ./console_room.db
59
+ ```
60
+
61
+ The console door stays on localhost — consoles tunnel in. Open the browser door
62
+ at the web port.
63
+
64
+ ### Enabling login (OIDC) + TLS
65
+
66
+ Without OIDC config the browser door runs in **dev mode** — unauthenticated,
67
+ identity from a `?user=` join form (a warning is logged; don't expose it). Set the
68
+ OIDC env vars to require single sign-on:
69
+
70
+ ```bash
71
+ export CONSOLE_ROOM_OIDC_ISSUER="https://login.microsoftonline.com/<tenant>/v2.0"
72
+ # or CONSOLE_ROOM_OIDC_METADATA_URL=<discovery doc URL>
73
+ export CONSOLE_ROOM_OIDC_CLIENT_ID="…"
74
+ export CONSOLE_ROOM_OIDC_CLIENT_SECRET="…"
75
+ export CONSOLE_ROOM_SESSION_SECRET="$(openssl rand -hex 32)"
76
+ export CONSOLE_ROOM_PUBLIC_URL="https://room.example.com" # for the redirect_uri
77
+ # register $CONSOLE_ROOM_PUBLIC_URL/auth/callback as the redirect URI at your IdP
78
+
79
+ console-room --host 0.0.0.0 --port 443 \
80
+ --tls-cert /etc/ssl/room.crt --tls-key /etc/ssl/room.key
81
+ ```
82
+
83
+ uvicorn is proxy-header-aware (`X-Forwarded-Proto`), so you can drop `--tls-*` and
84
+ let nginx/Caddy terminate TLS instead.
85
+
86
+ ## Wire protocol (NDJSON, one JSON object per line)
87
+
88
+ Console → server:
89
+
90
+ ```json
91
+ {"type":"hello", "room":"ops", "user":"alice@web01"}
92
+ {"type":"message", "room":"ops", "text":"rolling out"}
93
+ {"type":"presence","room":"ops", "status":"available"}
94
+ ```
95
+
96
+ The **first** frame must be `hello` — that's how a console declares its identity
97
+ (the server can't see the OS user over the tunnel). The name is bound to the
98
+ connection and stamped onto every later message; per-frame `user` fields are
99
+ ignored, so a frame can't spoof a different sender.
100
+
101
+ Server → client:
102
+
103
+ ```json
104
+ {"type":"message", "room":"ops", "sender":"alice@web01", "text":"rolling out", "id":"…", "ts":0.0}
105
+ {"type":"presence","room":"ops", "user":"alice@web01", "status":"available"}
106
+ ```
107
+
108
+ The server echoes a sender's own message back (with the assigned `id`/`ts`); a
109
+ console drops its own echo so it never re-reacts to its own messages.
110
+
111
+ Browsers speak the **same `message` / `presence` schema** over the WebSocket — but
112
+ their identity comes from the **connection** (the `?user=&room=` query in dev, the
113
+ OIDC session in S3), so they send no `hello` frame.
114
+
115
+ ## Develop
116
+
117
+ ```bash
118
+ pip install -e ".[dev]"
119
+ pytest
120
+ ```
@@ -0,0 +1,96 @@
1
+ # runspec-room
2
+
3
+ The server behind the runspec-console **chat room** — a *dumb message pipe with
4
+ presence*. It holds, per room, the connected participants, their live presence,
5
+ and a durable transcript, and it does three things: accept connections, stamp +
6
+ persist each inbound message, and broadcast every message / presence change to
7
+ everyone else in the room. **All the intelligence stays in the consoles** — the
8
+ server never runs anything.
9
+
10
+ See `docs/design/console-room-server.md` for the full design and decisions.
11
+
12
+ ## Status — complete server (S1–S3)
13
+
14
+ Two front doors, one process:
15
+
16
+ - **Console door** — an NDJSON listener on **localhost**, which each
17
+ runspec-console reaches by opening a `direct-tcpip` channel over its existing
18
+ pooled SSH connection. A console's right to be in the room *is* its SSH access —
19
+ no new exposed port, no second credential.
20
+ - **Browser door** — a WebSocket endpoint (`/ws`) plus a small vanilla static
21
+ frontend (transcript / composer / presence) with recent-history backscroll for
22
+ people. Authenticated via **OIDC** (no passwords stored); identity comes from
23
+ the signed-cookie session. TLS is self-terminated or left to a reverse proxy.
24
+
25
+ ## Run it
26
+
27
+ ```bash
28
+ pip install -e .
29
+
30
+ # Full server (browser + console doors):
31
+ console-room --port 8080 --ndjson-port 8765 --db ./console_room.db
32
+
33
+ # Console door only (no web deps needed):
34
+ console-room --ndjson-only --ndjson-port 8765 --db ./console_room.db
35
+ ```
36
+
37
+ The console door stays on localhost — consoles tunnel in. Open the browser door
38
+ at the web port.
39
+
40
+ ### Enabling login (OIDC) + TLS
41
+
42
+ Without OIDC config the browser door runs in **dev mode** — unauthenticated,
43
+ identity from a `?user=` join form (a warning is logged; don't expose it). Set the
44
+ OIDC env vars to require single sign-on:
45
+
46
+ ```bash
47
+ export CONSOLE_ROOM_OIDC_ISSUER="https://login.microsoftonline.com/<tenant>/v2.0"
48
+ # or CONSOLE_ROOM_OIDC_METADATA_URL=<discovery doc URL>
49
+ export CONSOLE_ROOM_OIDC_CLIENT_ID="…"
50
+ export CONSOLE_ROOM_OIDC_CLIENT_SECRET="…"
51
+ export CONSOLE_ROOM_SESSION_SECRET="$(openssl rand -hex 32)"
52
+ export CONSOLE_ROOM_PUBLIC_URL="https://room.example.com" # for the redirect_uri
53
+ # register $CONSOLE_ROOM_PUBLIC_URL/auth/callback as the redirect URI at your IdP
54
+
55
+ console-room --host 0.0.0.0 --port 443 \
56
+ --tls-cert /etc/ssl/room.crt --tls-key /etc/ssl/room.key
57
+ ```
58
+
59
+ uvicorn is proxy-header-aware (`X-Forwarded-Proto`), so you can drop `--tls-*` and
60
+ let nginx/Caddy terminate TLS instead.
61
+
62
+ ## Wire protocol (NDJSON, one JSON object per line)
63
+
64
+ Console → server:
65
+
66
+ ```json
67
+ {"type":"hello", "room":"ops", "user":"alice@web01"}
68
+ {"type":"message", "room":"ops", "text":"rolling out"}
69
+ {"type":"presence","room":"ops", "status":"available"}
70
+ ```
71
+
72
+ The **first** frame must be `hello` — that's how a console declares its identity
73
+ (the server can't see the OS user over the tunnel). The name is bound to the
74
+ connection and stamped onto every later message; per-frame `user` fields are
75
+ ignored, so a frame can't spoof a different sender.
76
+
77
+ Server → client:
78
+
79
+ ```json
80
+ {"type":"message", "room":"ops", "sender":"alice@web01", "text":"rolling out", "id":"…", "ts":0.0}
81
+ {"type":"presence","room":"ops", "user":"alice@web01", "status":"available"}
82
+ ```
83
+
84
+ The server echoes a sender's own message back (with the assigned `id`/`ts`); a
85
+ console drops its own echo so it never re-reacts to its own messages.
86
+
87
+ Browsers speak the **same `message` / `presence` schema** over the WebSocket — but
88
+ their identity comes from the **connection** (the `?user=&room=` query in dev, the
89
+ OIDC session in S3), so they send no `hello` frame.
90
+
91
+ ## Develop
92
+
93
+ ```bash
94
+ pip install -e ".[dev]"
95
+ pytest
96
+ ```
@@ -0,0 +1,28 @@
1
+ """
2
+ console_room — the runspec chat-room server.
3
+
4
+ A small "dumb message pipe with presence" the consoles connect to: it holds, per
5
+ room, the connected participants, their live presence, and a durable transcript,
6
+ and broadcasts every message / presence change. All intelligence stays in the
7
+ consoles. See docs/design/console-room-server.md.
8
+
9
+ S1 (this release) serves the console NDJSON-localhost door; the browser door and
10
+ OIDC/TLS land in later slices.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .hub import Hub, Participant
16
+ from .models import Message, presence_frame
17
+ from .store import TranscriptStore
18
+
19
+ __version__ = "0.3.0"
20
+
21
+ __all__ = [
22
+ "Hub",
23
+ "Participant",
24
+ "Message",
25
+ "TranscriptStore",
26
+ "presence_frame",
27
+ "__version__",
28
+ ]
@@ -0,0 +1,130 @@
1
+ """
2
+ hub.py — the in-process room broker (transport-agnostic).
3
+
4
+ The hub owns rooms → participants and does the three jobs of the server: it lets
5
+ participants join/leave, broadcasts every message and presence change, and
6
+ persists messages to the transcript. It knows nothing about sockets: a
7
+ *participant* is anything with ``.user`` / ``.room`` / ``.wants_backscroll`` and an
8
+ async ``send(frame)``, so the NDJSON console connection (S1) and the WS browser
9
+ connection (S2) plug into the same hub. See docs/design/console-room-server.md §4.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+ from .models import Message, presence_frame
19
+
20
+ logger = logging.getLogger("console_room.hub")
21
+
22
+
23
+ @runtime_checkable
24
+ class Participant(Protocol):
25
+ """A connected member of a room — the hub's view of any connection.
26
+
27
+ ``wants_backscroll`` is the load-bearing asymmetry: browsers want the recent
28
+ transcript on join, consoles must never get it (forward-only)."""
29
+
30
+ user: str
31
+ room: str
32
+ wants_backscroll: bool
33
+
34
+ async def send(self, frame: dict[str, Any]) -> None: ...
35
+
36
+
37
+ @runtime_checkable
38
+ class Store(Protocol):
39
+ """The slice of the transcript store the hub needs (synchronous; the hub
40
+ calls it via ``asyncio.to_thread``)."""
41
+
42
+ def append(self, msg: Message) -> None: ...
43
+ def recent(self, room: str, limit: int) -> list[Message]: ...
44
+
45
+
46
+ async def _safe_send(p: Participant, frame: dict[str, Any]) -> None:
47
+ """Send to one participant, swallowing failures so a dead/slow connection
48
+ can't break a broadcast to the rest of the room."""
49
+ try:
50
+ await p.send(frame)
51
+ except Exception:
52
+ logger.warning("room: send to %r failed; dropping from this round", p.user)
53
+
54
+
55
+ class Hub:
56
+ """Owns the live rooms and routes messages/presence between participants."""
57
+
58
+ def __init__(self, store: Store, *, backscroll_limit: int = 50) -> None:
59
+ self._store = store
60
+ self._backscroll = backscroll_limit
61
+ self._rooms: dict[str, set[Participant]] = {}
62
+ self._lock = asyncio.Lock() # guards _rooms membership
63
+
64
+ async def join(self, p: Participant) -> None:
65
+ """Add ``p`` to its room: tell it who's already here, replay backscroll if
66
+ it wants it (browsers only), then announce it to the others."""
67
+ async with self._lock:
68
+ members = self._rooms.setdefault(p.room, set())
69
+ existing = list(members)
70
+ members.add(p)
71
+ # Catch the newcomer up on who is already present.
72
+ for other in existing:
73
+ await _safe_send(p, presence_frame(p.room, other.user, "available"))
74
+ # Backscroll: browsers see recent history; consoles never do.
75
+ if p.wants_backscroll:
76
+ for msg in await self._recent(p.room):
77
+ await _safe_send(p, msg.to_frame())
78
+ # Announce the newcomer to everyone already in the room.
79
+ await self._broadcast(
80
+ p.room, presence_frame(p.room, p.user, "available"), exclude=p
81
+ )
82
+
83
+ async def leave(self, p: Participant) -> None:
84
+ """Remove ``p`` and tell the room it's gone (best-effort; idempotent)."""
85
+ async with self._lock:
86
+ members = self._rooms.get(p.room)
87
+ present = bool(members and p in members)
88
+ if members and p in members:
89
+ members.remove(p)
90
+ if not members:
91
+ self._rooms.pop(p.room, None)
92
+ if present:
93
+ await self._broadcast(
94
+ p.room, presence_frame(p.room, p.user, "away"), exclude=p
95
+ )
96
+
97
+ async def on_message(self, p: Participant, text: str) -> None:
98
+ """Stamp, persist, and broadcast a message — echoed to the sender too, so
99
+ every client (including the author's other views) converges on the same
100
+ server-assigned id/ts."""
101
+ msg = Message.new(p.room, p.user, text)
102
+ await asyncio.to_thread(self._store.append, msg)
103
+ await self._broadcast(p.room, msg.to_frame())
104
+
105
+ async def on_presence(self, p: Participant, status: str) -> None:
106
+ """Relay a participant's presence change (e.g. a console's rota flip) to
107
+ the rest of the room."""
108
+ await self._broadcast(p.room, presence_frame(p.room, p.user, status), exclude=p)
109
+
110
+ async def _recent(self, room: str) -> list[Message]:
111
+ return await asyncio.to_thread(self._store.recent, room, self._backscroll)
112
+
113
+ async def _broadcast(
114
+ self,
115
+ room: str,
116
+ frame: dict[str, Any],
117
+ *,
118
+ exclude: Participant | None = None,
119
+ ) -> None:
120
+ async with self._lock:
121
+ members = list(self._rooms.get(room, ()))
122
+ for m in members:
123
+ if m is exclude:
124
+ continue
125
+ await _safe_send(m, frame)
126
+
127
+ def room_size(self, room: str) -> int:
128
+ """Number of participants currently in ``room`` (for tests / a health
129
+ endpoint). Read-only snapshot."""
130
+ return len(self._rooms.get(room, ()))
@@ -0,0 +1,116 @@
1
+ """
2
+ models.py — the room wire schema: a Message and the frame builders / validators.
3
+
4
+ The server is the authority on a message's ``id`` / ``ts`` / ``sender`` — clients
5
+ send only ``room`` + ``text`` and the server stamps the rest (see
6
+ docs/design/console-room-server.md §3). These helpers are pure and have no I/O, so
7
+ the framing rules are unit-tested on their own.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ # Cap a message body so a pathological post can't bloat the transcript / a frame,
18
+ # mirroring the console source's _TEXT_CAP.
19
+ TEXT_CAP = 20_000
20
+
21
+ # Frame type tags.
22
+ HELLO = "hello"
23
+ MESSAGE = "message"
24
+ PRESENCE = "presence"
25
+
26
+ _VALID_STATUS = ("available", "away")
27
+
28
+
29
+ def new_id() -> str:
30
+ """A fresh message id (server-assigned)."""
31
+ return uuid.uuid4().hex
32
+
33
+
34
+ def now_ts() -> float:
35
+ """The current wall-clock timestamp (server-assigned)."""
36
+ return time.time()
37
+
38
+
39
+ def _str(val: Any) -> str:
40
+ return "" if val is None else str(val)
41
+
42
+
43
+ def _cap(text: str) -> str:
44
+ return text if len(text) <= TEXT_CAP else text[:TEXT_CAP] + "…"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Message:
49
+ """One sent message — the unit the transcript stores and broadcasts."""
50
+
51
+ room: str
52
+ sender: str
53
+ text: str
54
+ id: str
55
+ ts: float
56
+
57
+ @classmethod
58
+ def new(cls, room: str, sender: str, text: str) -> Message:
59
+ """Build a message, stamping a fresh ``id`` + ``ts`` (the server's job)."""
60
+ return cls(
61
+ room=_str(room),
62
+ sender=_str(sender),
63
+ text=_cap(_str(text)),
64
+ id=new_id(),
65
+ ts=now_ts(),
66
+ )
67
+
68
+ def to_frame(self) -> dict[str, Any]:
69
+ """The server → client ``message`` frame for this message."""
70
+ return {
71
+ "type": MESSAGE,
72
+ "room": self.room,
73
+ "sender": self.sender,
74
+ "text": self.text,
75
+ "id": self.id,
76
+ "ts": self.ts,
77
+ }
78
+
79
+
80
+ def presence_frame(room: str, user: str, status: str) -> dict[str, Any]:
81
+ """A server → client ``presence`` frame. ``status`` is normalised to one of
82
+ ``available`` / ``away`` (anything not ``available`` becomes ``away``)."""
83
+ return {
84
+ "type": PRESENCE,
85
+ "room": _str(room),
86
+ "user": _str(user),
87
+ "status": normalize_status(status),
88
+ }
89
+
90
+
91
+ def normalize_status(status: Any) -> str:
92
+ """Coerce a status to ``available`` / ``away`` (default ``away``)."""
93
+ return "available" if _str(status) == "available" else "away"
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Hello:
98
+ """A parsed ``hello`` frame — a console declaring its identity on connect."""
99
+
100
+ user: str
101
+ room: str
102
+
103
+
104
+ def parse_hello(obj: Any) -> Hello | None:
105
+ """Parse a ``hello`` frame, or ``None`` if it isn't a valid one.
106
+
107
+ A blank ``room`` is rejected (a console must say which room it's joining); a
108
+ blank ``user`` falls back to ``"unknown"`` so a mis-configured console is still
109
+ visibly present rather than silently dropped."""
110
+ if not isinstance(obj, dict) or _str(obj.get("type")) != HELLO:
111
+ return None
112
+ room = _str(obj.get("room")).strip()
113
+ if not room:
114
+ return None
115
+ user = _str(obj.get("user")).strip() or "unknown"
116
+ return Hello(user=user, room=room)