Add WS message filtering, idle grace period, webhook env var

- 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-15 00:08:55 +02:00
parent 3885b408c9
commit de2cc3a0e3
3 changed files with 62 additions and 20 deletions

View file

@ -294,6 +294,14 @@ class DiscordRareMonitor:
# Send connection established message # Send connection established message
await self.post_status_to_aclog("🔗 WebSocket connection established") 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 # Simple message processing with comprehensive error handling
try: try:
message_count = 0 message_count = 0

View file

@ -29,6 +29,7 @@ services:
SECRET_KEY: "${SECRET_KEY}" SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "DEBUG" LOG_LEVEL: "DEBUG"
INVENTORY_SERVICE_URL: "http://inventory-service:8000" INVENTORY_SERVICE_URL: "http://inventory-service:8000"
DISCORD_ACLOG_WEBHOOK: "${DISCORD_ACLOG_WEBHOOK:-}"
restart: unless-stopped restart: unless-stopped
logging: logging:
driver: "json-file" driver: "json-file"

71
main.py
View file

@ -2476,8 +2476,11 @@ async def list_equipment_sets_proxy():
# -------------------- WebSocket endpoints ----------------------- # -------------------- WebSocket endpoints -----------------------
## WebSocket connection tracking ## WebSocket connection tracking
# Set of browser WebSocket clients subscribed to live updates # Browser WebSocket clients subscribed to live updates.
browser_conns: set[WebSocket] = set() # 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 # Mapping of plugin clients by character_name to their WebSocket for command forwarding
plugin_conns: Dict[str, WebSocket] = {} plugin_conns: Dict[str, WebSocket] = {}
@ -2496,6 +2499,8 @@ live_combat_stats: Dict[str, dict] = {}
# --- Idle detection + Discord alerts ---------- # --- Idle detection + Discord alerts ----------
DISCORD_ACLOG_WEBHOOK = os.getenv("DISCORD_ACLOG_WEBHOOK", "") DISCORD_ACLOG_WEBHOOK = os.getenv("DISCORD_ACLOG_WEBHOOK", "")
_idle_alerted: set[str] = set() # chars we've already alerted for this idle period _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 _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 vt_state in ("combat", "hunt") and kph == 0
) )
if is_idle and name not in _idle_alerted: now = time.time()
_idle_alerted.add(name) if is_idle:
state_text = p.get("vt_state") or "idle" if name not in _idle_since:
await _send_discord_aclog( # First time seeing idle — start grace timer
f"⚠️ **{name}** appears idle (state: {state_text}, KPH: {kph})" _idle_since[name] = now
) elif name not in _idle_alerted and (now - _idle_since[name]) >= _IDLE_GRACE_SECONDS:
logger.info(f"IDLE_ALERT: {name} state={state_text} kph={kph}") # Grace period elapsed — fire alert
elif not is_idle and name in _idle_alerted: _idle_alerted.add(name)
# Character recovered — clear alert 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_alerted.discard(name)
_idle_since.pop(name, None)
except Exception as e: except Exception as e:
logger.debug(f"Idle detection error: {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): async def _do_broadcast(data: dict):
"""Send data to all browser clients concurrently. Runs as a background task.""" """Send data to all browser clients concurrently. Runs as a background task.
clients = list(browser_conns)
if not clients: 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 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: for ws in results:
if ws is not None: if ws is not None:
browser_conns.discard(ws) browser_conns.pop(ws, None)
async def _broadcast_to_browser_clients(snapshot: dict): async def _broadcast_to_browser_clients(snapshot: dict):
@ -3626,7 +3648,7 @@ async def cleanup_stale_connections():
stale_browsers.append(ws) stale_browsers.append(ws)
for ws in stale_browsers: for ws in stale_browsers:
browser_conns.discard(ws) browser_conns.pop(ws, None)
if stale_browsers: if stale_browsers:
logger.info(f"Cleaned up {len(stale_browsers)} stale browser connections") 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 global _browser_connections
# Add new browser client to the set # Add new browser client to the set
await websocket.accept() await websocket.accept()
browser_conns.add(websocket) browser_conns[websocket] = None # None = receive all message types
logger.info(f"Browser WebSocket connected: {websocket.client}") logger.info(f"Browser WebSocket connected: {websocket.client}")
# Track browser connection # Track browser connection
@ -3673,6 +3695,17 @@ async def ws_live_updates(websocket: WebSocket):
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"Browser WebSocket disconnected: {websocket.client}") logger.info(f"Browser WebSocket disconnected: {websocket.client}")
break 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 # Handle dungeon map requests from browser
if data.get("type") == "request_dungeon_map": if data.get("type") == "request_dungeon_map":
landblock = data.get("landblock") landblock = data.get("landblock")
@ -3726,7 +3759,7 @@ async def ws_live_updates(websocket: WebSocket):
# Track browser disconnection # Track browser disconnection
_browser_connections = max(0, _browser_connections - 1) _browser_connections = max(0, _browser_connections - 1)
browser_conns.discard(websocket) browser_conns.pop(websocket, None)
logger.debug( logger.debug(
f"Removed browser WebSocket from connection pool: {websocket.client}" f"Removed browser WebSocket from connection pool: {websocket.client}"
) )