feat: Add Global Presence Aggregator system

- GLOBAL_PRESENCE_AGGREGATOR_SPEC.md documentation
- matrix-presence-aggregator service (Python/FastAPI)
  - Matrix sync loop for presence/typing
  - NATS publishing for room presence
- city-service: presence_gateway for WS broadcast
- Frontend: real-time online count in room list
  - useGlobalPresence hook
  - Live typing indicators
  - Active room highlighting
This commit is contained in:
Apple
2025-11-26 14:22:34 -08:00
parent a3e632b9e7
commit 78849cc108
13 changed files with 1486 additions and 25 deletions

View File

@@ -16,6 +16,11 @@ import routes_city
import ws_city
import repo_city
from common.redis_client import get_redis, close_redis
from presence_gateway import (
websocket_global_presence,
start_presence_gateway,
stop_presence_gateway
)
# Logging
logging.basicConfig(level=logging.INFO)
@@ -311,10 +316,16 @@ async def websocket_room_endpoint(websocket: WebSocket, room_id: str):
@app.websocket("/ws/city/presence")
async def websocket_presence_endpoint(websocket: WebSocket):
"""WebSocket для Presence System"""
"""WebSocket для Presence System (user heartbeats)"""
await ws_city.websocket_city_presence(websocket)
@app.websocket("/ws/city/global-presence")
async def websocket_global_presence_endpoint(websocket: WebSocket):
"""WebSocket для Global Room Presence (aggregated from Matrix)"""
await websocket_global_presence(websocket)
@app.on_event("startup")
async def startup_event():
"""Запустити background tasks для WebSocket оновлень"""
@@ -334,6 +345,13 @@ async def startup_event():
asyncio.create_task(agents_presence_generator())
asyncio.create_task(ws_city.presence_cleanup_task())
# Start global presence gateway (NATS subscriber)
try:
await start_presence_gateway()
logger.info("✅ Global presence gateway started")
except Exception as e:
logger.warning(f"⚠️ Global presence gateway failed to start: {e}")
logger.info("✅ WebSocket background tasks started")
@@ -341,6 +359,7 @@ async def startup_event():
async def shutdown_event():
"""Cleanup при зупинці"""
logger.info("🛑 City Service shutting down...")
await stop_presence_gateway()
await repo_city.close_pool()
await close_redis()

View File

@@ -0,0 +1,173 @@
"""
Global Presence Gateway for City Service
Subscribes to NATS presence events from matrix-presence-aggregator
and broadcasts to WebSocket clients.
"""
import asyncio
import json
import logging
from typing import Dict, Set, Optional
import os
from fastapi import WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
# NATS URL
NATS_URL = os.getenv("NATS_URL", "nats://localhost:4222")
class GlobalPresenceManager:
"""Manages WebSocket connections for global room presence"""
def __init__(self):
self.connections: Set[WebSocket] = set()
self.room_presence: Dict[str, dict] = {} # slug -> {online_count, typing_count}
self.nc = None # NATS connection
self.is_running = False
async def connect(self, websocket: WebSocket):
"""Add a new WebSocket client"""
await websocket.accept()
self.connections.add(websocket)
# Send initial snapshot
await self._send_snapshot(websocket)
logger.info(f"Global presence client connected. Total: {len(self.connections)}")
def disconnect(self, websocket: WebSocket):
"""Remove a WebSocket client"""
self.connections.discard(websocket)
logger.info(f"Global presence client disconnected. Total: {len(self.connections)}")
async def _send_snapshot(self, websocket: WebSocket):
"""Send current presence snapshot to a client"""
rooms = [
{
"room_slug": slug,
"online_count": data.get("online_count", 0),
"typing_count": data.get("typing_count", 0)
}
for slug, data in self.room_presence.items()
]
await websocket.send_json({
"type": "snapshot",
"rooms": rooms
})
async def broadcast(self, message: dict):
"""Broadcast a message to all connected clients"""
if not self.connections:
return
disconnected = set()
for websocket in self.connections:
try:
await websocket.send_json(message)
except Exception as e:
logger.error(f"Failed to send to websocket: {e}")
disconnected.add(websocket)
# Remove disconnected clients
for ws in disconnected:
self.connections.discard(ws)
def update_room_presence(self, slug: str, online_count: int, typing_count: int):
"""Update cached presence for a room"""
self.room_presence[slug] = {
"online_count": online_count,
"typing_count": typing_count
}
async def start_nats_subscriber(self):
"""Start NATS subscription for presence events"""
try:
import nats
self.nc = await nats.connect(NATS_URL)
self.is_running = True
logger.info(f"Connected to NATS at {NATS_URL} for presence events")
# Subscribe to room presence events
await self.nc.subscribe("city.presence.room.*", cb=self._on_room_presence)
logger.info("Subscribed to city.presence.room.*")
except ImportError:
logger.warning("nats-py not installed, NATS presence disabled")
except Exception as e:
logger.error(f"Failed to connect to NATS: {e}")
async def _on_room_presence(self, msg):
"""Handle room presence event from NATS"""
try:
data = json.loads(msg.data.decode())
slug = data.get("room_slug")
online_count = data.get("online_count", 0)
typing_count = data.get("typing_count", 0)
if slug:
# Update cache
self.update_room_presence(slug, online_count, typing_count)
# Broadcast to WebSocket clients
await self.broadcast({
"type": "room.presence",
"room_slug": slug,
"online_count": online_count,
"typing_count": typing_count
})
logger.debug(f"Room presence update: {slug} -> {online_count} online, {typing_count} typing")
except Exception as e:
logger.error(f"Error processing NATS presence event: {e}")
async def stop(self):
"""Stop NATS subscription"""
self.is_running = False
if self.nc:
await self.nc.drain()
logger.info("NATS connection closed")
# Global instance
global_presence_manager = GlobalPresenceManager()
async def websocket_global_presence(websocket: WebSocket):
"""
WebSocket endpoint for global room presence
/ws/city/global-presence
Sends:
- Initial snapshot of all room presence
- Real-time updates when presence changes
"""
await global_presence_manager.connect(websocket)
try:
while True:
# Keep connection alive, handle pings
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
global_presence_manager.disconnect(websocket)
async def start_presence_gateway():
"""Start the global presence gateway (call on startup)"""
await global_presence_manager.start_nats_subscriber()
async def stop_presence_gateway():
"""Stop the global presence gateway (call on shutdown)"""
await global_presence_manager.stop()