""" Aetheel WebChat Adapter ======================== Browser-based chat interface using aiohttp HTTP + WebSocket. Features: - Serves a self-contained chat UI at GET / - WebSocket endpoint at /ws for real-time messaging - Per-connection session isolation with unique conversation IDs - Runs sync ai_handler in thread pool executor - Supports multiple concurrent WebSocket connections Setup: 1. Enable webchat in config.json: {"webchat": {"enabled": true}} 2. Start with: python main.py --webchat 3. Open http://127.0.0.1:8080 in your browser Usage: from adapters.webchat_adapter import WebChatAdapter adapter = WebChatAdapter(host="127.0.0.1", port=8080) adapter.on_message(my_handler) adapter.start() """ import asyncio import logging import os import threading import uuid import aiohttp from aiohttp import web from adapters.base import BaseAdapter, IncomingMessage logger = logging.getLogger("aetheel.adapters.webchat") class WebChatAdapter(BaseAdapter): """ WebChat adapter serving an HTTP/WebSocket interface for browser-based chat. Each WebSocket connection gets a unique session ID and conversation ID, ensuring full session isolation between concurrent users. """ def __init__(self, host: str = "127.0.0.1", port: int = 8080): super().__init__() self._host = host self._port = port self._app = web.Application() self._sessions: dict[str, str] = {} # ws_id -> conversation_id self._runner: web.AppRunner | None = None self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None self._setup_routes() # ------------------------------------------------------------------- # Route setup # ------------------------------------------------------------------- def _setup_routes(self) -> None: """Register HTTP and WebSocket routes.""" self._app.router.add_get("/", self._serve_html) self._app.router.add_get("/logo.jpeg", self._serve_logo) self._app.router.add_get("/ws", self._handle_websocket) # ------------------------------------------------------------------- # HTTP handler # ------------------------------------------------------------------- async def _serve_html(self, request: web.Request) -> web.Response: """Serve the chat UI HTML file.""" static_dir = os.path.join(os.path.dirname(__file__), "..", "static") html_path = os.path.join(static_dir, "chat.html") if os.path.isfile(html_path): return web.FileResponse(html_path) return web.Response(text="Chat UI not found", status=404) async def _serve_logo(self, request: web.Request) -> web.Response: """Serve the logo image.""" static_dir = os.path.join(os.path.dirname(__file__), "..", "static") logo_path = os.path.join(static_dir, "logo.jpeg") if os.path.isfile(logo_path): return web.FileResponse(logo_path) return web.Response(status=404) # ------------------------------------------------------------------- # WebSocket handler # ------------------------------------------------------------------- async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: """Handle a WebSocket connection with per-session isolation.""" ws = web.WebSocketResponse() await ws.prepare(request) session_id = uuid.uuid4().hex self._sessions[session_id] = f"webchat-{session_id}" logger.info(f"WebChat session connected: {session_id}") try: async for ws_msg in ws: if ws_msg.type == aiohttp.WSMsgType.TEXT: incoming = IncomingMessage( text=ws_msg.data, user_id=session_id, user_name="WebChat User", channel_id=session_id, channel_name="webchat", conversation_id=f"webchat-{session_id}", source="webchat", is_dm=True, raw_event={"session_id": session_id}, ) loop = asyncio.get_event_loop() response = await loop.run_in_executor( None, self._run_handler, incoming ) if response: await ws.send_str(response) elif ws_msg.type in ( aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE, ): break finally: self._sessions.pop(session_id, None) logger.info(f"WebChat session disconnected: {session_id}") return ws def _run_handler(self, msg: IncomingMessage) -> str | None: """Run registered message handlers synchronously (called from executor).""" for handler in self._message_handlers: try: response = handler(msg) if response: return response except Exception as e: logger.error(f"WebChat handler error: {e}", exc_info=True) return "⚠️ Something went wrong processing your message." return None # ------------------------------------------------------------------- # BaseAdapter implementation # ------------------------------------------------------------------- @property def source_name(self) -> str: return "webchat" def start(self) -> None: """Start the WebChat server (blocking).""" asyncio.run(self._run_server()) def start_async(self) -> None: """Start the WebChat server in a background thread (non-blocking).""" self._thread = threading.Thread( target=self._run_async, daemon=True, name="webchat" ) self._thread.start() def _run_async(self) -> None: """Run the server in a dedicated thread with its own event loop.""" self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._run_server()) async def _run_server(self) -> None: """Set up and run the aiohttp server.""" self._runner = web.AppRunner(self._app) await self._runner.setup() site = web.TCPSite(self._runner, self._host, self._port) await site.start() logger.info(f"WebChat server running at http://{self._host}:{self._port}") try: while True: await asyncio.sleep(3600) except asyncio.CancelledError: pass def stop(self) -> None: """Stop the WebChat server gracefully.""" if self._runner: if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe( self._runner.cleanup(), self._loop ) logger.info("WebChat server stopped") def send_message( self, channel_id: str, text: str, thread_id: str | None = None, ) -> None: """ Send a message to a channel. For WebChat, responses are sent directly over the WebSocket in the handler, so this method only logs a debug message. """ logger.debug( f"WebChat send_message called for {channel_id} " "(responses sent via WebSocket)" )