gateway/modules/features/teamsbot/browserBotConnector.py
patrick-motsch ad5c9d10cd feat(teamsbot): dedicated bot account support with authenticated join
- New config fields: botAccountEmail, botAccountPassword for dedicated MSFT account
- BrowserBotConnector passes credentials + backgroundImageUrl to bot service
- Service passes config credentials to connector in joinMeeting
- Enables: full language settings, virtual background, no lobby wait
- Fallback: anonymous join when no bot account configured

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 11:56:04 +01:00

167 lines
6.8 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
_BOT_TIMEOUT = aiohttp.ClientTimeout(total=30)
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,
backgroundImageUrl: Optional[str] = None,
) -> Dict[str, Any]:
"""
Send join command to the Browser Bot service.
The bot will:
1. Launch a headless browser
2. If botAccountEmail/Password provided: authenticate with Microsoft first
3. Navigate to Teams web app and join the meeting
4. Enable captions and start scraping
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
backgroundImageUrl: URL to background image for virtual background
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,
}
# 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}")
# Add background image if configured
if backgroundImageUrl:
payload["backgroundImageUrl"] = backgroundImageUrl
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