feat: room broadcast — say command delivers to all occupants in room

- say <message> 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)
This commit is contained in:
Alexander Whitestone
2026-04-13 00:34:19 -04:00
parent df1978b4a9
commit b8a31e07f2
2 changed files with 125 additions and 1 deletions

View File

@@ -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":