Files
AzerothAI/scripts/spectator-server.py
T
2026-09-24 09:24:57 +12:00

268 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Read-only public realm projection. Never proxy arbitrary routes or headers."""
import json
import math
import os
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] / 'spectator'
ALLIANCE = {'Human', 'Dwarf', 'Night Elf', 'Gnome', 'Draenei'}
HORDE = {'Orc', 'Undead', 'Tauren', 'Troll', 'Blood Elf'}
def text(value, limit=200):
return str(value or '')[:limit]
def number(value, default=0):
return value if type(value) in (int, float) and math.isfinite(value) else default
def project(state):
"""Explicit field allowlist: no settings, IDs, memories, whispers or host data."""
managed = [b for b in state.get('bots', []) if b.get('autonomy', {}).get('managed')]
names = {b['name'] for b in managed}
bots = []
parties = {}
for b in managed:
if b.get('status') != 'online':
continue
world = b.get('world') or {}
race = text(b.get('race'), 40)
team = world.get('team')
faction = ('Alliance' if team == 0 else 'Horde' if team == 1 else
'Alliance' if race in ALLIANCE else 'Horde' if race in HORDE else 'Unknown')
bots.append({'name': text(b.get('name'), 40), 'class': text(b.get('role'), 40),
'race': race, 'level': number(b.get('level')), 'zone': text(b.get('zone'), 100),
'faction': faction, 'guild': text(world.get('guildName'), 80)})
members = sorted({text(n, 40) for n in world.get('groupNames', []) if n in names})
if world.get('groupId') and len(members) > 1:
parties[tuple(members)] = {'members': members, 'faction': faction}
community = state.get('community') or {}
feed = []
for e in community.get('feed', []):
if e.get('status') != 'confirmed' or e.get('kind') not in {'conversation', 'relationship', 'party', 'leave', 'recruit', 'guild'}:
continue
# Only completed autonomous bot-to-bot exchanges. Human conversations,
# whispers and synthetic opener prompts never enter this public feed.
if e['kind'] == 'conversation' and not (
str(e.get('id', '')).startswith('social-') and str(e.get('id', '')).endswith(':reply')
and len(e.get('names', [])) == 2 and all(n in names for n in e['names'])):
continue
feed.append({'at': text(e.get('at'), 40), 'kind': e['kind'],
'names': [text(n, 80) for n in e.get('names', [])[:5]],
'text': text(e.get('text'), 1200)})
guilds = [{'name': text(g.get('name'), 80), 'goal': text(g.get('goal'), 30),
'progress': number(g.get('progress')), 'target': number(g.get('target'), 20)}
for g in community.get('guilds', [])[:100]]
return {'rosterAt': number(state.get('aleRosterAt')), 'fetchedAt': int(time.time()),
'bots': sorted(bots, key=lambda b: (b['faction'], b['name'])),
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
def public_state(state):
"""Admin-shaped data for the shared read-only UI, with an explicit allowlist."""
def pick(obj, keys):
return {k: obj[k] for k in keys.split() if k in obj and isinstance(obj[k], (str, int, float, bool))}
def recipe(r):
return {**pick(r, 'id name description sociability aggression curiosity economy version'),
'tags': [text(t, 80) for t in r.get('tags', [])],
'activities': [a for a in r.get('activities', []) if a in ['quest','explore','trade','rest','socialize']]}
projected = project(state)
feed = [{**e, 'id': 'public-'+str(i), 'status': 'confirmed'} for i, e in enumerate(reversed(projected['feed']))]
bots = []
for b in state.get('bots', []):
if not b.get('autonomy', {}).get('managed'):
continue
bot = pick(b, 'id name role race level zone status tier mortal plotArmorHpFloor simulationBand homeId recipeId recipeVersion goal decisions')
bot['personality'] = recipe(b.get('personality', {}))
bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
world = b.get('world') or {}
bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince survivalSince survivalAt survivalAction isDead isGhost manaPct healthPct trainingPending trainingLevel trainingTrainer trainingStatus trainingAt trainingLearned lastTrainingAt leaderName minDurabilityPct brokenItems freeTalents foodCount drinkCount readinessAt'),
'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []}
bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
# Show shared history, never personal whispers or operator-injected memories.
bot['memories'] = [{'id': e['id'], 'text': e['text'], 'at': e['at'], 'source': 'observation', 'importance': 5}
for e in feed if b['name'] in e['names']]
if b.get('death'):
bot['death'] = pick(b['death'], 'id at zone level cause killer source verified')
if b.get('lastDecision'):
bot['lastDecision'] = {'action': b['lastDecision']['action'], 'reason': 'Following the resident’s current activity.', 'goal': bot.get('goal','')}
bots.append(bot)
settings = state.get('settings') or {}
return {'revision': state.get('revision',0), 'sequence': state.get('sequence',0), 'aleRosterAt': state.get('aleRosterAt',0),
'bots': bots, 'recipes': [recipe(r) for r in state.get('recipes',[])],
'homes': [{**pick(h,'id name zone type capacity storageSlots safety description'), 'ownerIds':h.get('ownerIds',[]), 'residentIds':h.get('residentIds',[])} for h in state.get('homes',[])],
'events': [{'id':e['id'],'seq':i,'at':e['at'],'kind':'relationship' if e['kind']=='relationship' else 'lifecycle','message':e['text'],'mode':'azerothcore'} for i,e in enumerate(feed)],
'settings': {**pick(settings,'connector provider model'),
'brains':pick(settings.get('brains',{}),'enabled residentModel heroModel backgroundUseLLM residentMemoryLimit heroMemoryLimit residentMaxTokens heroMaxTokens'),
'hardcore':pick(settings.get('hardcore',{}),'enabled requireVerifiedDeath memorialize'),
'population':pick(settings.get('population',{}),'defaultZoneTarget zoneMax serverMax foregroundMax backgroundMax')},
'director':{**pick(state.get('director',{}),'experience zone brief'),'playerName':''}, 'population':{}, 'operations':{},
'opsRetentionDays':state.get('opsRetentionDays',7),
'community':{'feed':feed,'evidence':[],'guilds':[{**g,'id':i} for i,g in enumerate(projected['guilds'])],
'partiesEnabled':state.get('community',{}).get('partiesEnabled',True),
'guildsEnabled':state.get('community',{}).get('guildsEnabled',True)}}
def local_get(path):
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787'+path, timeout=10) as response:
return response.read(8 * 1024 * 1024)
class Snapshot:
def __init__(self):
self.lock = threading.Lock()
self.data = None
self.checked = 0
self.failed = False
def get(self):
with self.lock:
if time.monotonic() - self.checked > 10:
try:
# Fixed local endpoint. Viewer input cannot affect this request.
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787/api/state', timeout=5) as response:
raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large')
state = json.loads(raw)
self.data = {**project(state), "state": public_state(state)}
self.failed = False
except Exception:
self.failed = True
self.checked = time.monotonic()
if self.data is None:
return None
return {**self.data, 'stale': self.failed or time.time() - self.data['rosterAt'] > 120}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
SNAPSHOT = Snapshot()
ASSETS = {'/': ('index.html', 'text/html; charset=utf-8'),
'/app.js': ('app.js', 'text/javascript; charset=utf-8'),
'/style.css': ('style.css', 'text/css; charset=utf-8')}
class Handler(BaseHTTPRequestHandler):
server_version = 'AzerothAI-Spectator'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(10)
def reply(self, status, body, content_type='application/json; charset=utf-8'):
self.send_response(status)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path in ('/', '/visitor', '/visitor/'):
try:
self.reply(200, local_get('/visitor'), 'text/html; charset=utf-8')
except Exception:
self.reply(503, b'{"error":"Realm temporarily unavailable"}')
elif path == '/api/state':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data['state'] if data else {'error':'Realm unavailable'}).encode())
elif path == '/api/health':
data = SNAPSHOT.get()
self.reply(200, json.dumps({'ok':bool(data),'aleConfigured':True,'aleActiveRecently':bool(data and not data['stale']), 'llmConfigured':bool(data and data['state']['settings'].get('provider')!='deterministic')}).encode())
elif path == '/api/world-monitor':
data = SNAPSHOT.get()
at = data['rosterAt'] if data else 0
from datetime import datetime, timezone
self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
elif path == '/api/realm-insights':
try:
# Fixed read-only endpoint: public aggregate facts and bot activity only.
self.reply(200, local_get('/api/realm-insights'))
except Exception:
self.reply(503,b'{"error":"Realm insights unavailable"}')
elif path == '/api/operations':
try:
health = json.loads(local_get('/api/operations'))
samples = [{k:v for k,v in row.items() if k in ('host','at','cpuPercent','memoryTotalMB','memoryAvailableMB','diskFreeGB','databaseMB','logsMB','modelAvailable','runtimeService')} for row in health.get('samples',[])]
self.reply(200, json.dumps({'samples':samples,'stuck':[{k:v for k,v in row.items() if k in ('name','zone','minutes')} for row in health.get('stuck',[])]}).encode())
except Exception:
self.reply(503,b'{"error":"Health data unavailable"}')
elif path.startswith(('/assets/', '/_vinext/', '/_next/')):
# Built public assets only. No source maps, dotfiles or traversal.
import mimetypes
root = ROOT.parent / 'dist/client'
asset = (root / path.lstrip('/')).resolve()
if '%' in path or '..' in path or not asset.is_relative_to(root.resolve()) or asset.suffix not in ('.js','.css','.woff','.woff2','.png','.svg','.ico') or not asset.is_file():
self.reply(404,b'{"error":"Not found"}')
else:
self.reply(200,asset.read_bytes(),mimetypes.guess_type(str(asset))[0] or 'application/octet-stream')
elif path in ASSETS:
filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data or {'error': 'Realm temporarily unavailable'}).encode())
elif path == '/health':
self.reply(200, b'{"service":"spectator","readOnly":true}')
elif path == '/robots.txt':
self.reply(200, b'User-agent: *\nDisallow: /\n', 'text/plain; charset=utf-8')
else:
self.reply(404, b'{"error":"Not found"}')
do_HEAD = do_GET
def reject(self):
self.close_connection = True
self.reply(405, b'{"error":"This monitor is read-only"}')
do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_TRACE = do_CONNECT = reject
def log_message(self, format, *args):
pass
class Server(ThreadingHTTPServer):
daemon_threads = True
slots = threading.BoundedSemaphore(24)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if __name__ == '__main__':
Server(('127.0.0.1', int(os.environ.get('AZEROTHAI_SPECTATOR_PORT', '8790'))), Handler).serve_forever()