164 lines
6.7 KiB
Python
164 lines
6.7 KiB
Python
# 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
|