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.
- runspec_room-0.3.0/.gitignore +6 -0
- runspec_room-0.3.0/CHANGELOG.md +76 -0
- runspec_room-0.3.0/PKG-INFO +120 -0
- runspec_room-0.3.0/README.md +96 -0
- runspec_room-0.3.0/console_room/__init__.py +28 -0
- runspec_room-0.3.0/console_room/hub.py +130 -0
- runspec_room-0.3.0/console_room/models.py +116 -0
- runspec_room-0.3.0/console_room/ndjson_server.py +130 -0
- runspec_room-0.3.0/console_room/server.py +168 -0
- runspec_room-0.3.0/console_room/store.py +81 -0
- runspec_room-0.3.0/console_room/web/__init__.py +7 -0
- runspec_room-0.3.0/console_room/web/app.py +197 -0
- runspec_room-0.3.0/console_room/web/auth.py +119 -0
- runspec_room-0.3.0/console_room/web/connection.py +77 -0
- runspec_room-0.3.0/console_room/web/frontend/app.js +142 -0
- runspec_room-0.3.0/console_room/web/frontend/index.html +42 -0
- runspec_room-0.3.0/console_room/web/frontend/style.css +97 -0
- runspec_room-0.3.0/pyproject.toml +56 -0
- runspec_room-0.3.0/tests/__init__.py +0 -0
- runspec_room-0.3.0/tests/test_auth.py +135 -0
- runspec_room-0.3.0/tests/test_auth_app.py +128 -0
- runspec_room-0.3.0/tests/test_browser_connection.py +153 -0
- runspec_room-0.3.0/tests/test_hub.py +181 -0
- runspec_room-0.3.0/tests/test_models.py +71 -0
- runspec_room-0.3.0/tests/test_ndjson_server.py +185 -0
- runspec_room-0.3.0/tests/test_store.py +61 -0
- runspec_room-0.3.0/tests/test_web_app.py +91 -0
|
@@ -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)
|