diff --git a/gateway/config.py b/gateway/config.py index e45eede7c..ab51574aa 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -31,6 +31,7 @@ class Platform(Enum): SIGNAL = "signal" HOMEASSISTANT = "homeassistant" EMAIL = "email" + WEB = "web" @dataclass @@ -176,6 +177,9 @@ class GatewayConfig: # Email uses extra dict for config (address + imap_host + smtp_host) elif platform == Platform.EMAIL and config.extra.get("address"): connected.append(platform) + # Web UI uses enabled flag only + elif platform == Platform.WEB: + connected.append(platform) return connected def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: @@ -466,6 +470,18 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), ) + # Web UI + web_enabled = os.getenv("WEB_UI_ENABLED", "").lower() in ("true", "1", "yes") + if web_enabled: + if Platform.WEB not in config.platforms: + config.platforms[Platform.WEB] = PlatformConfig() + config.platforms[Platform.WEB].enabled = True + config.platforms[Platform.WEB].extra.update({ + "port": int(os.getenv("WEB_UI_PORT", "8765")), + "host": os.getenv("WEB_UI_HOST", "0.0.0.0"), + "token": os.getenv("WEB_UI_TOKEN", ""), + }) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/web.py b/gateway/platforms/web.py new file mode 100644 index 000000000..9fe7e636c --- /dev/null +++ b/gateway/platforms/web.py @@ -0,0 +1,1191 @@ +""" +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()) + + local_ip = self._get_local_ip() + print(f"[{self.name}] Web UI: http://{local_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 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_ip() -> str: + """Get the machine's LAN IP address.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "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 +
+
+
+
+
+ + + +
+
+ + + +''' diff --git a/gateway/run.py b/gateway/run.py index bee9b62a1..73bde75d4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -829,6 +829,13 @@ class GatewayRunner: return None return EmailAdapter(config) + elif platform == Platform.WEB: + from gateway.platforms.web import WebAdapter, check_web_requirements + if not check_web_requirements(): + logger.warning("Web: aiohttp not installed. Run: pip install aiohttp") + return None + return WebAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -848,6 +855,11 @@ class GatewayRunner: if source.platform == Platform.HOMEASSISTANT: return True + # Web UI users are authenticated via token at the WebSocket level. + # No additional allowlist check needed. + if source.platform == Platform.WEB: + return True + user_id = source.user_id if not user_id: return False