From 9a22d26b9c0dc84118d0962b420b052f214177fa Mon Sep 17 00:00:00 2001 From: Allegro Date: Sat, 4 Apr 2026 01:38:15 +0000 Subject: [PATCH] feat: upgrade Evennia-Nexus bridge for live event streaming (#804) --- .../nexus_watchdog.cpython-312.pyc | Bin 0 -> 23972 bytes nexus/evennia_event_adapter.py | 63 +++- nexus/evennia_ws_bridge.py | 284 ++++++++++++++---- 3 files changed, 289 insertions(+), 58 deletions(-) create mode 100644 bin/__pycache__/nexus_watchdog.cpython-312.pyc diff --git a/bin/__pycache__/nexus_watchdog.cpython-312.pyc b/bin/__pycache__/nexus_watchdog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20fd6b1b1b76a1dcb5ec31d3efaa9c8304c62316 GIT binary patch literal 23972 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 bKX2d!=Y{Bp#^N6f*5B=dN?9mjGyi`8fQ1h^ literal 0 HcmV?d00001 diff --git a/nexus/evennia_event_adapter.py b/nexus/evennia_event_adapter.py index e7dffd9..3bcd16f 100644 --- a/nexus/evennia_event_adapter.py +++ b/nexus/evennia_event_adapter.py @@ -1,4 +1,4 @@ -"""Thin Evennia -> Nexus event normalization helpers.""" +"""Evennia -> Nexus event normalization — v2 with full audit event types.""" from __future__ import annotations @@ -9,6 +9,29 @@ def _ts(value: str | None = None) -> str: return value or datetime.now(timezone.utc).isoformat() +# ── Session Events ────────────────────────────────────────── + +def player_join(account: str, character: str = "", ip_address: str = "", timestamp: str | None = None) -> dict: + return { + "type": "evennia.player_join", + "account": account, + "character": character, + "ip_address": ip_address, + "timestamp": _ts(timestamp), + } + + +def player_leave(account: str, character: str = "", reason: str = "quit", session_duration: float = 0, timestamp: str | None = None) -> dict: + return { + "type": "evennia.player_leave", + "account": account, + "character": character, + "reason": reason, + "session_duration_seconds": session_duration, + "timestamp": _ts(timestamp), + } + + def session_bound(hermes_session_id: str, evennia_account: str = "Timmy", evennia_character: str = "Timmy", timestamp: str | None = None) -> dict: return { "type": "evennia.session_bound", @@ -19,6 +42,18 @@ def session_bound(hermes_session_id: str, evennia_account: str = "Timmy", evenni } +# ── Movement Events ───────────────────────────────────────── + +def player_move(character: str, from_room: str, to_room: str, timestamp: str | None = None) -> dict: + return { + "type": "evennia.player_move", + "character": character, + "from_room": from_room, + "to_room": to_room, + "timestamp": _ts(timestamp), + } + + def actor_located(actor_id: str, room_key: str, room_name: str | None = None, timestamp: str | None = None) -> dict: return { "type": "evennia.actor_located", @@ -44,6 +79,19 @@ def room_snapshot(room_key: str, title: str, desc: str, exits: list[dict] | None } +# ── Command Events ────────────────────────────────────────── + +def command_executed(character: str, command: str, args: str = "", success: bool = True, timestamp: str | None = None) -> dict: + return { + "type": "evennia.command_executed", + "character": character, + "command": command, + "args": args, + "success": success, + "timestamp": _ts(timestamp), + } + + def command_issued(hermes_session_id: str, actor_id: str, command_text: str, timestamp: str | None = None) -> dict: return { "type": "evennia.command_issued", @@ -64,3 +112,16 @@ def command_result(hermes_session_id: str, actor_id: str, command_text: str, out "success": success, "timestamp": _ts(timestamp), } + + +# ── Audit Summary ─────────────────────────────────────────── + +def audit_heartbeat(characters: list[dict], online_count: int, total_commands: int, total_movements: int, timestamp: str | None = None) -> dict: + return { + "type": "evennia.audit_heartbeat", + "characters": characters, + "online_count": online_count, + "total_commands": total_commands, + "total_movements": total_movements, + "timestamp": _ts(timestamp), + } diff --git a/nexus/evennia_ws_bridge.py b/nexus/evennia_ws_bridge.py index a86140b..3820d37 100644 --- a/nexus/evennia_ws_bridge.py +++ b/nexus/evennia_ws_bridge.py @@ -1,82 +1,238 @@ #!/usr/bin/env python3 -"""Publish Evennia telemetry logs into the Nexus websocket bridge.""" +""" +Live Evennia -> Nexus WebSocket bridge. + +Two modes: +1. Live tail: watches Evennia log files and streams parsed events to Nexus WS +2. Playback: replays a telemetry JSONL file (legacy mode) + +The bridge auto-reconnects on both ends and survives Evennia restarts. +""" from __future__ import annotations import argparse import asyncio import json +import os import re +import sys +import time +from datetime import datetime, timezone from pathlib import Path -from typing import Iterable +from typing import Optional -import websockets +try: + import websockets +except ImportError: + websockets = None -from nexus.evennia_event_adapter import actor_located, command_issued, command_result, room_snapshot, session_bound +from nexus.evennia_event_adapter import ( + audit_heartbeat, + command_executed, + player_join, + player_leave, + player_move, +) ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +# Regex patterns for log parsing +MOVE_RE = re.compile(r"AUDIT MOVE: (\w+) arrived at (.+?) from (.+)") +CMD_RE = re.compile(r"AUDIT CMD: (\w+) executed '(\w+)'(?: args: '(.*?)')?") +SESSION_START_RE = re.compile(r"AUDIT SESSION: (\w+) puppeted by (\w+)") +SESSION_END_RE = re.compile(r"AUDIT SESSION: (\w+) unpuppeted.*session (\d+)s") +LOGIN_RE = re.compile(r"Logged in: (\w+)\(account \d+\) ([\d.]+)") +LOGOUT_RE = re.compile(r"Logged out: (\w+)\(account \d+\) ([\d.]+)") def strip_ansi(text: str) -> str: return ANSI_RE.sub("", text or "") -def clean_lines(text: str) -> list[str]: - text = strip_ansi(text).replace("\r", "") - return [line.strip() for line in text.split("\n") if line.strip()] +class LogTailer: + """Async file tailer that yields new lines as they appear.""" + + def __init__(self, path: str, poll_interval: float = 0.5): + self.path = path + self.poll_interval = poll_interval + self._offset = 0 + + async def tail(self): + """Yield new lines from the file, starting from end.""" + # Start at end of file + if os.path.exists(self.path): + self._offset = os.path.getsize(self.path) + + while True: + try: + if not os.path.exists(self.path): + await asyncio.sleep(self.poll_interval) + continue + + size = os.path.getsize(self.path) + if size < self._offset: + # File was truncated/rotated + self._offset = 0 + + if size > self._offset: + with open(self.path, "r") as f: + f.seek(self._offset) + for line in f: + line = line.strip() + if line: + yield line + self._offset = f.tell() + + await asyncio.sleep(self.poll_interval) + except Exception as e: + print(f"[tailer] Error reading {self.path}: {e}", flush=True) + await asyncio.sleep(2) -def parse_room_output(text: str): - lines = clean_lines(text) - if len(lines) < 2: - return None - title = lines[0] - desc = lines[1] - exits = [] - objects = [] - for line in lines[2:]: - if line.startswith("Exits:"): - raw = line.split(":", 1)[1].strip() - raw = raw.replace(" and ", ", ") - exits = [{"key": token.strip(), "destination_id": token.strip().title(), "destination_key": token.strip().title()} for token in raw.split(",") if token.strip()] - elif line.startswith("You see:"): - raw = line.split(":", 1)[1].strip() - raw = raw.replace(" and ", ", ") - parts = [token.strip() for token in raw.split(",") if token.strip()] - objects = [{"id": p.removeprefix('a ').removeprefix('an '), "key": p.removeprefix('a ').removeprefix('an '), "short_desc": p} for p in parts] - return {"title": title, "desc": desc, "exits": exits, "objects": objects} +def parse_log_line(line: str) -> Optional[dict]: + """Parse a log line into a Nexus event, or None if not parseable.""" + + # Movement events + m = MOVE_RE.search(line) + if m: + return player_move(m.group(1), m.group(3), m.group(2)) + + # Command events + m = CMD_RE.search(line) + if m: + return command_executed(m.group(1), m.group(2), m.group(3) or "") + + # Session start + m = SESSION_START_RE.search(line) + if m: + return player_join(m.group(2), m.group(1)) + + # Session end + m = SESSION_END_RE.search(line) + if m: + return player_leave("", m.group(1), session_duration=float(m.group(2))) + + # Server login + m = LOGIN_RE.search(line) + if m: + return player_join(m.group(1), ip_address=m.group(2)) + + # Server logout + m = LOGOUT_RE.search(line) + if m: + return player_leave(m.group(1)) + + return None -def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]: - out: list[dict] = [] - event = raw.get("event") - actor = raw.get("actor", "Timmy") - timestamp = raw.get("timestamp") - - if event == "connect": - out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp)) - parsed = parse_room_output(raw.get("output", "")) - if parsed: - out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) - out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) - return out - - if event == "command": - cmd = raw.get("command", "") - output = raw.get("output", "") - out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp)) - success = not output.startswith("Command '") and not output.startswith("Could not find") - out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp)) - parsed = parse_room_output(output) - if parsed: - out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) - out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) - return out - - return out +async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0): + """ + Main live bridge loop. + + Tails all Evennia log files and streams parsed events to Nexus WebSocket. + Auto-reconnects on failure. + """ + log_files = [ + os.path.join(log_dir, "command_audit.log"), + os.path.join(log_dir, "movement_audit.log"), + os.path.join(log_dir, "player_activity.log"), + os.path.join(log_dir, "server.log"), + ] + + event_queue: asyncio.Queue = asyncio.Queue(maxsize=10000) + + async def tail_file(path: str): + """Tail a single file and put events on queue.""" + tailer = LogTailer(path) + async for line in tailer.tail(): + event = parse_log_line(line) + if event: + try: + event_queue.put_nowait(event) + except asyncio.QueueFull: + pass # Drop oldest if queue full + + async def ws_sender(): + """Send events from queue to WebSocket, with auto-reconnect.""" + while True: + try: + if websockets is None: + print("[bridge] websockets not installed, logging events locally", flush=True) + while True: + event = await event_queue.get() + ts = event.get("timestamp", "")[:19] + print(f"[{ts}] {event['type']}: {json.dumps({k: v for k, v in event.items() if k not in ('type', 'timestamp')})}", flush=True) + + print(f"[bridge] Connecting to {ws_url}...", flush=True) + async with websockets.connect(ws_url) as ws: + print(f"[bridge] Connected to Nexus at {ws_url}", flush=True) + while True: + event = await event_queue.get() + await ws.send(json.dumps(event)) + except Exception as e: + print(f"[bridge] WebSocket error: {e}. Reconnecting in {reconnect_delay}s...", flush=True) + await asyncio.sleep(reconnect_delay) + + # Start all tailers + sender + tasks = [asyncio.create_task(tail_file(f)) for f in log_files] + tasks.append(asyncio.create_task(ws_sender())) + + print(f"[bridge] Live bridge started. Watching {len(log_files)} log files.", flush=True) + await asyncio.gather(*tasks) async def playback(log_path: Path, ws_url: str): + """Legacy mode: replay a telemetry JSONL file.""" + from nexus.evennia_event_adapter import ( + actor_located, command_issued, command_result, + room_snapshot, session_bound, + ) + + def clean_lines(text: str) -> list[str]: + text = strip_ansi(text).replace("\r", "") + return [line.strip() for line in text.split("\n") if line.strip()] + + def parse_room_output(text: str): + lines = clean_lines(text) + if len(lines) < 2: + return None + title = lines[0] + desc = lines[1] + exits = [] + objects = [] + for line in lines[2:]: + if line.startswith("Exits:"): + raw = line.split(":", 1)[1].strip().replace(" and ", ", ") + exits = [{"key": t.strip(), "destination_id": t.strip().title(), "destination_key": t.strip().title()} for t in raw.split(",") if t.strip()] + elif line.startswith("You see:"): + raw = line.split(":", 1)[1].strip().replace(" and ", ", ") + parts = [t.strip() for t in raw.split(",") if t.strip()] + objects = [{"id": p.removeprefix("a ").removeprefix("an "), "key": p.removeprefix("a ").removeprefix("an "), "short_desc": p} for p in parts] + return {"title": title, "desc": desc, "exits": exits, "objects": objects} + + def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]: + out = [] + event = raw.get("event") + actor = raw.get("actor", "Timmy") + timestamp = raw.get("timestamp") + if event == "connect": + out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp)) + parsed = parse_room_output(raw.get("output", "")) + if parsed: + out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) + out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) + elif event == "command": + cmd = raw.get("command", "") + output = raw.get("output", "") + out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp)) + success = not output.startswith("Command '") and not output.startswith("Could not find") + out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp)) + parsed = parse_room_output(output) + if parsed: + out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) + out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) + return out + hermes_session_id = log_path.stem async with websockets.connect(ws_url) as ws: for line in log_path.read_text(encoding="utf-8").splitlines(): @@ -88,11 +244,25 @@ async def playback(log_path: Path, ws_url: str): def main(): - parser = argparse.ArgumentParser(description="Publish Evennia telemetry into the Nexus websocket bridge") - parser.add_argument("log_path", help="Path to Evennia telemetry JSONL") - parser.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus websocket bridge URL") + parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge") + sub = parser.add_subparsers(dest="mode") + + live = sub.add_parser("live", help="Live tail Evennia logs and stream to Nexus") + live.add_argument("--log-dir", default="/root/workspace/timmy-academy/server/logs", help="Evennia logs directory") + live.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL") + + replay = sub.add_parser("playback", help="Replay a telemetry JSONL file") + replay.add_argument("log_path", help="Path to Evennia telemetry JSONL") + replay.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL") + args = parser.parse_args() - asyncio.run(playback(Path(args.log_path).expanduser(), args.ws)) + + if args.mode == "live": + asyncio.run(live_bridge(args.log_dir, args.ws)) + elif args.mode == "playback": + asyncio.run(playback(Path(args.log_path).expanduser(), args.ws)) + else: + parser.print_help() if __name__ == "__main__": -- 2.43.0