""" Web platform adapter. Provides a browser-based chat interface via HTTP + WebSocket. Serves a single-page chat UI with markdown rendering, code highlighting, voice messages, and mobile responsive design. No external dependencies beyond aiohttp (already in messaging extra). """ import asyncio import base64 import json import logging import os import secrets import shutil import socket import time import uuid from pathlib import Path from typing import Dict, List, Optional, Any logger = logging.getLogger(__name__) try: from aiohttp import web AIOHTTP_AVAILABLE = True except ImportError: AIOHTTP_AVAILABLE = False web = None import sys from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) def check_web_requirements() -> bool: """Check if aiohttp is available.""" return AIOHTTP_AVAILABLE class WebAdapter(BasePlatformAdapter): """ Web-based chat adapter. Runs a local HTTP server serving a chat UI. Clients connect via WebSocket for real-time bidirectional messaging. """ def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WEB) self._app: Optional[web.Application] = None self._runner: Optional[web.AppRunner] = None self._site: Optional[web.TCPSite] = None # Config self._host: str = config.extra.get("host", "0.0.0.0") self._port: int = config.extra.get("port", 8765) self._token: str = config.extra.get("token", "") or secrets.token_hex(16) # Connected WebSocket clients: session_id -> ws self._clients: Dict[str, web.WebSocketResponse] = {} # Media directory for uploaded/generated files self._media_dir = Path.home() / ".hermes" / "web_media" # Cleanup task handle self._cleanup_task: Optional[asyncio.Task] = None async def connect(self) -> bool: """Start the HTTP server and begin accepting connections.""" if not AIOHTTP_AVAILABLE: return False self._media_dir.mkdir(parents=True, exist_ok=True) self._app = web.Application(client_max_size=50 * 1024 * 1024) # 50MB upload limit self._app.router.add_get("/", self._handle_index) self._app.router.add_get("/ws", self._handle_websocket) self._app.router.add_post("/upload", self._handle_upload) self._app.router.add_static("/media", str(self._media_dir), show_index=False) self._runner = web.AppRunner(self._app) await self._runner.setup() try: self._site = web.TCPSite(self._runner, self._host, self._port) await self._site.start() except OSError as e: logger.error("Failed to start web server on %s:%s — %s", self._host, self._port, e) await self._runner.cleanup() return False self._running = True self._cleanup_task = asyncio.ensure_future(self._media_cleanup_loop()) all_ips = self._get_local_ips() primary_ip = self._get_local_ip() print(f"[{self.name}] Web UI: http://{primary_ip}:{self._port}") for ip in all_ips: if ip != primary_ip: print(f"[{self.name}] also: http://{ip}:{self._port}") print(f"[{self.name}] Access token: {self._token}") return True async def disconnect(self) -> None: """Stop the server and close all connections.""" if self._cleanup_task: self._cleanup_task.cancel() self._cleanup_task = None for ws in list(self._clients.values()): try: await ws.close() except Exception: pass self._clients.clear() if self._site: await self._site.stop() if self._runner: await self._runner.cleanup() self._running = False self._app = None self._runner = None self._site = None print(f"[{self.name}] Disconnected") async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a text message to all connected clients.""" msg_id = str(uuid.uuid4())[:8] payload = { "type": "message", "id": msg_id, "content": content, "timestamp": time.time(), } await self._broadcast(payload) return SendResult(success=True, message_id=msg_id) async def edit_message( self, chat_id: str, message_id: str, content: str ) -> SendResult: """Edit a previously sent message (used for streaming updates).""" payload = { "type": "edit", "id": message_id, "content": content, "timestamp": time.time(), } await self._broadcast(payload) return SendResult(success=True, message_id=message_id) async def send_typing(self, chat_id: str, metadata=None) -> None: """Send typing indicator to all clients.""" await self._broadcast({"type": "typing"}) async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: """Send an image to all connected clients.""" msg_id = str(uuid.uuid4())[:8] payload = { "type": "image", "id": msg_id, "url": image_url, "caption": caption or "", "timestamp": time.time(), } await self._broadcast(payload) return SendResult(success=True, message_id=msg_id) async def send_voice( self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, ) -> SendResult: """Send a voice message by copying audio to media dir and broadcasting URL.""" filename = f"voice_{uuid.uuid4().hex[:8]}{Path(audio_path).suffix}" dest = self._media_dir / filename try: shutil.copy2(audio_path, dest) except Exception as e: return SendResult(success=False, error=f"Failed to copy audio: {e}") msg_id = str(uuid.uuid4())[:8] payload = { "type": "voice", "id": msg_id, "url": f"/media/{filename}", "caption": caption or "", "timestamp": time.time(), } await self._broadcast(payload) return SendResult(success=True, message_id=msg_id) async def play_tts( self, chat_id: str, audio_path: str, **kwargs, ) -> SendResult: """Play TTS audio invisibly — no bubble in chat, just audio playback.""" filename = f"tts_{uuid.uuid4().hex[:8]}{Path(audio_path).suffix}" dest = self._media_dir / filename try: shutil.copy2(audio_path, dest) except Exception as e: return SendResult(success=False, error=f"Failed to copy audio: {e}") payload = { "type": "play_audio", "url": f"/media/{filename}", } await self._broadcast(payload) return SendResult(success=True) async def send_image_file( self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: """Send a local image file by copying to media dir.""" filename = f"img_{uuid.uuid4().hex[:8]}{Path(image_path).suffix}" dest = self._media_dir / filename try: shutil.copy2(image_path, dest) except Exception as e: return SendResult(success=False, error=f"Failed to copy image: {e}") return await self.send_image(chat_id, f"/media/{filename}", caption, reply_to) async def send_document( self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, ) -> SendResult: """Send a document file by copying to media dir.""" orig_name = file_name or Path(file_path).name safe_name = f"{uuid.uuid4().hex[:8]}_{orig_name}" dest = self._media_dir / safe_name try: shutil.copy2(file_path, dest) except Exception as e: return SendResult(success=False, error=f"Failed to copy file: {e}") msg_id = str(uuid.uuid4())[:8] payload = { "type": "document", "id": msg_id, "url": f"/media/{safe_name}", "filename": orig_name, "caption": caption or "", "timestamp": time.time(), } await self._broadcast(payload) return SendResult(success=True, message_id=msg_id) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return basic chat info for the web session.""" return {"name": "Web Chat", "type": "dm"} # ---- HTTP Handlers ---- async def _handle_index(self, request: web.Request) -> web.Response: """Serve the chat UI HTML page.""" html = _build_chat_html() return web.Response(text=html, content_type="text/html") async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: """Handle WebSocket connections for real-time chat.""" ws = web.WebSocketResponse(max_msg_size=50 * 1024 * 1024) await ws.prepare(request) session_id = uuid.uuid4().hex[:12] authenticated = False try: async for msg in ws: if msg.type == web.WSMsgType.TEXT: try: data = json.loads(msg.data) except json.JSONDecodeError: continue msg_type = data.get("type", "") # Auth handshake if msg_type == "auth": if data.get("token") == self._token: authenticated = True self._clients[session_id] = ws await ws.send_str(json.dumps({ "type": "auth_ok", "session_id": session_id, })) else: await ws.send_str(json.dumps({ "type": "auth_fail", "error": "Invalid token", })) continue if not authenticated: await ws.send_str(json.dumps({"type": "auth_required"})) continue # Chat message if msg_type == "message": text = data.get("text", "").strip() if text: await self._process_user_message(session_id, text) # Voice message (base64 audio) elif msg_type == "voice": await self._process_voice_message(session_id, data) elif msg.type in (web.WSMsgType.ERROR, web.WSMsgType.CLOSE): break except Exception as e: logger.debug("WebSocket session %s error: %s", session_id, e) finally: self._clients.pop(session_id, None) return ws async def _handle_upload(self, request: web.Request) -> web.Response: """Handle file uploads (images, voice recordings).""" token = request.headers.get("Authorization", "").replace("Bearer ", "") if token != self._token: return web.json_response({"error": "Unauthorized"}, status=401) reader = await request.multipart() field = await reader.next() if not field: return web.json_response({"error": "No file"}, status=400) orig_name = field.filename or "file" filename = f"upload_{uuid.uuid4().hex[:8]}_{orig_name}" dest = self._media_dir / filename with open(dest, "wb") as f: while True: chunk = await field.read_chunk() if not chunk: break f.write(chunk) return web.json_response({"url": f"/media/{filename}", "filename": filename}) # ---- Message Processing ---- async def _process_user_message(self, session_id: str, text: str) -> None: """Build MessageEvent from user text and feed to handler.""" msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT source = self.build_source( chat_id="web", chat_name="Web Chat", chat_type="dm", user_id=session_id, user_name="Web User", ) event = MessageEvent( text=text, message_type=msg_type, source=source, message_id=uuid.uuid4().hex[:8], ) if self._message_handler: await self.handle_message(event) async def _process_voice_message(self, session_id: str, data: dict) -> None: """Decode base64 voice audio, transcribe via STT, and process as message.""" import tempfile audio_b64 = data.get("audio", "") if not audio_b64: return audio_bytes = base64.b64decode(audio_b64) fmt = data.get("format", "webm") tmp_path = os.path.join( tempfile.gettempdir(), f"web_voice_{uuid.uuid4().hex[:8]}.{fmt}", ) with open(tmp_path, "wb") as f: f.write(audio_bytes) try: from tools.transcription_tools import transcribe_audio result = await asyncio.to_thread(transcribe_audio, tmp_path) if not result.get("success"): await self._send_to_session(session_id, { "type": "error", "error": f"Transcription failed: {result.get('error', 'Unknown')}", }) return transcript = result.get("transcript", "").strip() if not transcript: return # Show transcript to user await self._send_to_session(session_id, { "type": "transcript", "text": transcript, }) # Process as voice message source = self.build_source( chat_id="web", chat_name="Web Chat", chat_type="dm", user_id=session_id, user_name="Web User", ) event = MessageEvent( text=transcript, message_type=MessageType.VOICE, source=source, message_id=uuid.uuid4().hex[:8], media_urls=[tmp_path], media_types=[f"audio/{fmt}"], ) if self._message_handler: await self.handle_message(event) except Exception as e: logger.warning("Voice processing failed: %s", e, exc_info=True) finally: try: os.unlink(tmp_path) except OSError: pass # ---- Internal Utilities ---- async def _broadcast(self, payload: dict) -> None: """Send JSON payload to all connected WebSocket clients.""" data = json.dumps(payload) dead: List[str] = [] for sid, ws in self._clients.items(): try: await ws.send_str(data) except Exception: dead.append(sid) for sid in dead: self._clients.pop(sid, None) async def _send_to_session(self, session_id: str, payload: dict) -> None: """Send a message to a specific client session.""" ws = self._clients.get(session_id) if ws: try: await ws.send_str(json.dumps(payload)) except Exception: self._clients.pop(session_id, None) async def _media_cleanup_loop(self) -> None: """Periodically delete old media files (older than 24h).""" try: while self._running: await asyncio.sleep(3600) cutoff = time.time() - 86400 removed = 0 for f in self._media_dir.iterdir(): if f.is_file() and f.stat().st_mtime < cutoff: try: f.unlink() removed += 1 except OSError: pass if removed: logger.debug("Web media cleanup: removed %d old file(s)", removed) except asyncio.CancelledError: pass @staticmethod def _get_local_ips() -> List[str]: """Get all non-loopback IPv4 addresses on this machine.""" ips = [] try: import netifaces for iface in netifaces.interfaces(): addrs = netifaces.ifaddresses(iface).get(netifaces.AF_INET, []) for addr in addrs: ip = addr.get("addr", "") if ip and not ip.startswith("127."): ips.append(ip) except ImportError: # Fallback: parse ifconfig output import subprocess try: out = subprocess.check_output(["ifconfig"], text=True, timeout=5) for line in out.splitlines(): line = line.strip() if line.startswith("inet ") and "127.0.0.1" not in line: parts = line.split() if len(parts) >= 2: ips.append(parts[1]) except Exception: pass if not ips: # Last resort: UDP trick (may return VPN IP) try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ips.append(s.getsockname()[0]) s.close() except Exception: ips.append("127.0.0.1") return ips @staticmethod def _get_local_ip() -> str: """Get the most likely LAN IP address.""" ips = WebAdapter._get_local_ips() # Prefer 192.168.x.x or 10.x.x.x over VPN ranges like 172.16.x.x for ip in ips: if ip.startswith("192.168.") or ip.startswith("10."): return ip return ips[0] if ips else "127.0.0.1" # --------------------------------------------------------------------------- # Chat UI HTML # --------------------------------------------------------------------------- def _build_chat_html() -> str: """Build the complete single-page chat UI as an HTML string.""" return ''' Hermes

Hermes

Enter access token to connect

Invalid token. Try again.
Hermes
Connected
'''