# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Browser Bot Connector - Communication with the Node.js Browser Bot Service. Handles HTTP and WebSocket communication for meeting join/leave and transcript/audio streaming. This replaces the old .NET Media Bridge connector with a simpler browser-based approach. """ import logging import aiohttp import asyncio from typing import Optional, Dict, Any, Callable, Awaitable logger = logging.getLogger(__name__) # Default timeout for bot HTTP calls (60s to allow for Microsoft auth flow) _BOT_TIMEOUT = aiohttp.ClientTimeout(total=60) class BrowserBotConnector: """Connector to the Node.js Browser Bot service.""" def __init__(self, botUrl: Optional[str] = None): self.botUrl = (botUrl or "").rstrip("/") def _isConfigured(self) -> bool: """Check if the bot URL is configured.""" return bool(self.botUrl) async def joinMeeting( self, sessionId: str, meetingUrl: str, botName: str, instanceId: str, gatewayWsUrl: str, language: str = "de-DE", botAccountEmail: Optional[str] = None, botAccountPassword: Optional[str] = None, transferMode: str = "auto", ) -> Dict[str, Any]: """ Send join command to the Browser Bot service. The bot will: 1. Launch a browser (headful for auth, headless for anonymous) 2. If botAccountEmail/Password provided: authenticate with Microsoft first 3. Navigate to Teams web app and join the meeting 4. Enable captions/audio capture based on transferMode 5. Connect back to Gateway via WebSocket using gatewayWsUrl Args: gatewayWsUrl: Full WebSocket URL for the bot to connect back to language: BCP-47 language code for captions spoken language botAccountEmail: Microsoft account email for authenticated join (None = anonymous) botAccountPassword: Microsoft account password transferMode: How to capture meeting content: caption, audio, or auto Returns: Dict with 'success' bool and optional 'error' string. """ if not self._isConfigured(): logger.warning("Browser Bot URL not configured. Simulating join for development.") return { "success": True, "message": "Development mode: Browser Bot not connected" } payload = { "sessionId": sessionId, "meetingUrl": meetingUrl, "botName": botName, "instanceId": instanceId, "gatewayWsUrl": gatewayWsUrl, "language": language, "transferMode": transferMode, } # Add authenticated join credentials if configured if botAccountEmail and botAccountPassword: payload["botAccountEmail"] = botAccountEmail payload["botAccountPassword"] = botAccountPassword logger.info(f"Bot will join authenticated as {botAccountEmail}") try: async with aiohttp.ClientSession(timeout=_BOT_TIMEOUT) as session: async with session.post(f"{self.botUrl}/api/bot", json=payload) as resp: if resp.status == 200: data = await resp.json() return { "success": data.get("success", True), } else: errorText = await resp.text() logger.error(f"Browser Bot join failed: {resp.status} - {errorText}") return {"success": False, "error": f"Bot returned {resp.status}: {errorText}"} except aiohttp.ClientError as e: logger.error(f"Browser Bot connection error ({type(e).__name__}): {e} | URL: {self.botUrl}/api/bot") return {"success": False, "error": f"Bot connection failed ({type(e).__name__}): {str(e)}"} except Exception as e: logger.error(f"Browser Bot join error ({type(e).__name__}): {e!r} | URL: {self.botUrl}/api/bot") return {"success": False, "error": f"{type(e).__name__}: {str(e) or repr(e)}"} async def leaveMeeting(self, sessionId: str) -> Dict[str, Any]: """Send leave command to the Browser Bot service.""" if not self._isConfigured(): logger.warning("Browser Bot URL not configured. Simulating leave for development.") return {"success": True, "message": "Development mode: Browser Bot not connected"} try: async with aiohttp.ClientSession(timeout=_BOT_TIMEOUT) as session: async with session.post(f"{self.botUrl}/api/bot/{sessionId}/leave") as resp: if resp.status == 200: return {"success": True} else: errorText = await resp.text() logger.error(f"Browser Bot leave failed: {resp.status} - {errorText}") return {"success": False, "error": f"Bot returned {resp.status}: {errorText}"} except Exception as e: logger.error(f"Browser Bot leave error: {e}") return {"success": False, "error": str(e)} async def getStatus(self, sessionId: Optional[str] = None) -> Dict[str, Any]: """Get bot health and optionally session status.""" if not self._isConfigured(): return {"healthy": False, "message": "Browser Bot URL not configured"} try: async with aiohttp.ClientSession(timeout=_BOT_TIMEOUT) as session: if sessionId: url = f"{self.botUrl}/api/bot/{sessionId}/status" else: url = f"{self.botUrl}/health" async with session.get(url) as resp: if resp.status == 200: data = await resp.json() return {"healthy": True, **data} else: return {"healthy": False, "error": f"Bot returned {resp.status}"} except Exception as e: return {"healthy": False, "error": str(e)} async def sendAudio( self, sessionId: str, audioData: bytes, audioFormat: str = "mp3", ) -> bool: """ Send TTS audio to the bot for playback in the meeting. This is called via the WebSocket connection, not HTTP. Note: This method is here for reference but actual audio is sent via the WebSocket in the route handler. """ # Audio is sent via WebSocket, not HTTP # This method is a placeholder for documentation logger.debug(f"sendAudio called for session {sessionId} - should use WebSocket") return True