From de2cc3a0e30a55bf99b87244b7fd9d6b79eb2c15 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Apr 2026 00:08:55 +0200 Subject: [PATCH] Add WS message filtering, idle grace period, webhook env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browser WS clients can now send {"type": "subscribe", "message_types": [...]} to only receive specific message types. Default is all (no change for browsers). - Discord bot subscribes to only "rare" and "chat" — eliminates 82GB+ of unnecessary telemetry/vitals/inventory traffic. - Idle detection now has a 5-minute grace period before firing Discord alerts, preventing false positives on brief idle states. - Added DISCORD_ACLOG_WEBHOOK env var to docker-compose.yml for death/idle alerts. Co-Authored-By: Claude Opus 4.6 (1M context) --- discord-rare-monitor/discord_rare_monitor.py | 10 ++- docker-compose.yml | 1 + main.py | 71 ++++++++++++++------ 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/discord-rare-monitor/discord_rare_monitor.py b/discord-rare-monitor/discord_rare_monitor.py index 7063691c..6d9ae66c 100644 --- a/discord-rare-monitor/discord_rare_monitor.py +++ b/discord-rare-monitor/discord_rare_monitor.py @@ -293,7 +293,15 @@ class DiscordRareMonitor: # Send connection established message await self.post_status_to_aclog("🔗 WebSocket connection established") - + + # Subscribe only to message types we care about (rare + chat) + # This dramatically reduces network traffic vs receiving the full firehose + await websocket.send(json.dumps({ + "type": "subscribe", + "message_types": ["rare", "chat"] + })) + logger.info("📋 Subscribed to message types: rare, chat") + # Simple message processing with comprehensive error handling try: message_count = 0 diff --git a/docker-compose.yml b/docker-compose.yml index 49ac3a15..fa4000b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "DEBUG" INVENTORY_SERVICE_URL: "http://inventory-service:8000" + DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK:-}" restart: unless-stopped logging: driver: "json-file" diff --git a/main.py b/main.py index bf2051dc..2acef8b8 100644 --- a/main.py +++ b/main.py @@ -2476,8 +2476,11 @@ async def list_equipment_sets_proxy(): # -------------------- WebSocket endpoints ----------------------- ## WebSocket connection tracking -# Set of browser WebSocket clients subscribed to live updates -browser_conns: set[WebSocket] = set() +# Browser WebSocket clients subscribed to live updates. +# Maps ws → None (all messages) or a set of message types to receive. +# Clients can send {"type": "subscribe", "message_types": ["rare", "chat"]} +# to filter. Without subscribing, all message types are forwarded (browser default). +browser_conns: Dict[WebSocket, set[str] | None] = {} # Mapping of plugin clients by character_name to their WebSocket for command forwarding plugin_conns: Dict[str, WebSocket] = {} @@ -2496,6 +2499,8 @@ live_combat_stats: Dict[str, dict] = {} # --- Idle detection + Discord alerts ---------- DISCORD_ACLOG_WEBHOOK = os.getenv("DISCORD_ACLOG_WEBHOOK", "") _idle_alerted: set[str] = set() # chars we've already alerted for this idle period +_idle_since: Dict[str, float] = {} # char → timestamp when first detected idle +_IDLE_GRACE_SECONDS = 300 # 5 minutes before alerting _death_alerted: Dict[str, float] = {} # char → last death alert timestamp @@ -2531,16 +2536,24 @@ async def _idle_detection_loop(): vt_state in ("combat", "hunt") and kph == 0 ) - if is_idle and name not in _idle_alerted: - _idle_alerted.add(name) - state_text = p.get("vt_state") or "idle" - await _send_discord_aclog( - f"⚠️ **{name}** appears idle (state: {state_text}, KPH: {kph})" - ) - logger.info(f"IDLE_ALERT: {name} state={state_text} kph={kph}") - elif not is_idle and name in _idle_alerted: - # Character recovered — clear alert + now = time.time() + if is_idle: + if name not in _idle_since: + # First time seeing idle — start grace timer + _idle_since[name] = now + elif name not in _idle_alerted and (now - _idle_since[name]) >= _IDLE_GRACE_SECONDS: + # Grace period elapsed — fire alert + _idle_alerted.add(name) + idle_mins = int((now - _idle_since[name]) / 60) + state_text = p.get("vt_state") or "idle" + await _send_discord_aclog( + f"⚠️ **{name}** appears idle for {idle_mins}min (state: {state_text}, KPH: {kph})" + ) + logger.info(f"IDLE_ALERT: {name} state={state_text} kph={kph} idle_mins={idle_mins}") + elif not is_idle: + # Character recovered — clear alert and grace timer _idle_alerted.discard(name) + _idle_since.pop(name, None) except Exception as e: logger.debug(f"Idle detection error: {e}") @@ -2718,14 +2731,23 @@ async def _send_to_browser(ws: WebSocket, data: dict) -> WebSocket | None: async def _do_broadcast(data: dict): - """Send data to all browser clients concurrently. Runs as a background task.""" - clients = list(browser_conns) - if not clients: + """Send data to all browser clients concurrently. Runs as a background task. + + Respects per-client message type filters: clients that sent a ``subscribe`` + message only receive the types they asked for. + """ + msg_type = data.get("type") + # Build list of clients that should receive this message + targets = [] + for ws, allowed_types in list(browser_conns.items()): + if allowed_types is None or msg_type in allowed_types: + targets.append(ws) + if not targets: return - results = await asyncio.gather(*(_send_to_browser(ws, data) for ws in clients)) + results = await asyncio.gather(*(_send_to_browser(ws, data) for ws in targets)) for ws in results: if ws is not None: - browser_conns.discard(ws) + browser_conns.pop(ws, None) async def _broadcast_to_browser_clients(snapshot: dict): @@ -3626,7 +3648,7 @@ async def cleanup_stale_connections(): stale_browsers.append(ws) for ws in stale_browsers: - browser_conns.discard(ws) + browser_conns.pop(ws, None) if stale_browsers: logger.info(f"Cleaned up {len(stale_browsers)} stale browser connections") @@ -3657,7 +3679,7 @@ async def ws_live_updates(websocket: WebSocket): global _browser_connections # Add new browser client to the set await websocket.accept() - browser_conns.add(websocket) + browser_conns[websocket] = None # None = receive all message types logger.info(f"Browser WebSocket connected: {websocket.client}") # Track browser connection @@ -3673,6 +3695,17 @@ async def ws_live_updates(websocket: WebSocket): except WebSocketDisconnect: logger.info(f"Browser WebSocket disconnected: {websocket.client}") break + # Handle subscribe requests — clients can filter which message types they receive + if data.get("type") == "subscribe": + types = data.get("message_types") + if isinstance(types, list) and types: + browser_conns[websocket] = set(types) + logger.info(f"Browser {websocket.client} subscribed to: {types}") + else: + browser_conns[websocket] = None # reset to all + logger.info(f"Browser {websocket.client} reset to all message types") + continue + # Handle dungeon map requests from browser if data.get("type") == "request_dungeon_map": landblock = data.get("landblock") @@ -3726,7 +3759,7 @@ async def ws_live_updates(websocket: WebSocket): # Track browser disconnection _browser_connections = max(0, _browser_connections - 1) - browser_conns.discard(websocket) + browser_conns.pop(websocket, None) logger.debug( f"Removed browser WebSocket from connection pool: {websocket.client}" )