From b8a31e07f231928456d310e6fdba993a5f652e2d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 13 Apr 2026 00:34:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20room=20broadcast=20=E2=80=94=20say=20co?= =?UTF-8?q?mmand=20delivers=20to=20all=20occupants=20in=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - say now queues room_broadcast events on other sessions - New GET /bridge/room_events/{user_id} endpoint (drain-on-read) - WS connections receive real-time room broadcasts - 5 new tests: broadcast, no-echo, room isolation, drain, 404 - Total tests: 27 (all passing) --- nexus/multi_user_bridge.py | 39 ++++++++++++++- tests/test_multi_user_bridge.py | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/nexus/multi_user_bridge.py b/nexus/multi_user_bridge.py index a33510aa..a8826d0a 100644 --- a/nexus/multi_user_bridge.py +++ b/nexus/multi_user_bridge.py @@ -110,6 +110,7 @@ class UserSession: room: str = "The Tower" message_history: list[dict] = field(default_factory=list) ws_connections: list = field(default_factory=list) + room_events: list[dict] = field(default_factory=list) crisis_state: CrisisState = field(default_factory=CrisisState) created_at: float = field(default_factory=time.time) last_active: float = field(default_factory=time.time) @@ -227,6 +228,7 @@ class MultiUserBridge: self._app.router.add_post("/bridge/chat", self.handle_chat) self._app.router.add_get("/bridge/sessions", self.handle_sessions) self._app.router.add_get("/bridge/health", self.handle_health) + self._app.router.add_get("/bridge/room_events/{user_id}", self.handle_room_events) self._app.router.add_get("/bridge/ws/{user_id}", self.handle_ws) return self._app @@ -244,6 +246,20 @@ class MultiUserBridge: "total": self.sessions.active_count, }) + async def handle_room_events(self, request: web.Request) -> web.Response: + """GET /bridge/room_events/{user_id} — Drain pending room events for a user.""" + user_id = request.match_info["user_id"] + session = self.sessions.get(user_id) + if not session: + return web.json_response({"error": "session not found"}, status=404) + events = list(session.room_events) + session.room_events.clear() + return web.json_response({ + "user_id": user_id, + "events": events, + "count": len(events), + }) + async def handle_chat(self, request: web.Request) -> web.Response: """ POST /bridge/chat @@ -294,6 +310,13 @@ class MultiUserBridge: } await self._broadcast_to_user(session, ws_event) + # Deliver room events to other users' WS connections (non-destructive) + for other_session in self.sessions._sessions.values(): + if other_session.user_id != user_id and other_session.room_events: + for event in other_session.room_events: + if event.get("from_user") == user_id: + await self._broadcast_to_user(other_session, event) + return web.json_response({ "response": full_response, "user_id": user_id, @@ -386,7 +409,21 @@ class MultiUserBridge: if msg_lower.startswith("say "): speech = message[4:] - # Broadcast to room occupants (non-WS for now) + # Broadcast to other occupants in same room + occupants = self.sessions.get_room_occupants(session.room) + others = [o for o in occupants if o != session.user_id] + if others: + broadcast = { + "type": "room_broadcast", + "from_user": session.user_id, + "from_username": session.username, + "room": session.room, + "message": f'{session.username} says: "{speech}"', + } + for other_id in others: + other_session = self.sessions.get(other_id) + if other_session: + other_session.room_events.append(broadcast) return f'You say: "{speech}"' if msg_lower == "who": diff --git a/tests/test_multi_user_bridge.py b/tests/test_multi_user_bridge.py index 7e7e7e72..420e0992 100644 --- a/tests/test_multi_user_bridge.py +++ b/tests/test_multi_user_bridge.py @@ -277,3 +277,90 @@ class TestHTTPEndpoints: assert "cats" in d1["response"].lower() or d1["user_id"] == "alice" assert "dogs" in d2["response"].lower() or d2["user_id"] == "bob" assert d1["user_id"] != d2["user_id"] + + +# ── Room Broadcast Tests ───────────────────────────────────── + +class TestRoomBroadcast: + + @pytest.mark.asyncio + async def test_say_broadcasts_to_room_occupants(self, client): + c, _ = client + # Position both users in the same room + await c.post("/bridge/chat", json={ + "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" + }) + # Alice says something + await c.post("/bridge/chat", json={ + "user_id": "alice", "username": "Alice", "message": "say Hello everyone!", "room": "Tower" + }) + # Bob should have a pending room event + resp = await c.get("/bridge/room_events/bob") + data = await resp.json() + assert data["count"] >= 1 + assert any("Alice" in e.get("message", "") for e in data["events"]) + + @pytest.mark.asyncio + async def test_say_does_not_echo_to_speaker(self, client): + c, _ = client + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "bob", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": 'say Hello!', "room": "Tower" + }) + # Alice should NOT have room events from herself + resp = await c.get("/bridge/room_events/alice") + data = await resp.json() + alice_events = [e for e in data["events"] if e.get("from_user") == "alice"] + assert len(alice_events) == 0 + + @pytest.mark.asyncio + async def test_say_no_broadcast_to_different_room(self, client): + c, _ = client + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "bob", "message": "hi", "room": "Chapel" + }) + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": 'say Hello!', "room": "Tower" + }) + # Bob is in Chapel, shouldn't get Tower broadcasts + resp = await c.get("/bridge/room_events/bob") + data = await resp.json() + assert data["count"] == 0 + + @pytest.mark.asyncio + async def test_room_events_drain_after_read(self, client): + c, _ = client + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "bob", "message": "hi", "room": "Tower" + }) + await c.post("/bridge/chat", json={ + "user_id": "alice", "message": 'say First!', "room": "Tower" + }) + # First read drains + resp = await c.get("/bridge/room_events/bob") + data = await resp.json() + assert data["count"] >= 1 + # Second read is empty + resp2 = await c.get("/bridge/room_events/bob") + data2 = await resp2.json() + assert data2["count"] == 0 + + @pytest.mark.asyncio + async def test_room_events_404_for_unknown_user(self, client): + c, _ = client + resp = await c.get("/bridge/room_events/nonexistent") + assert resp.status == 404