gateway/modules/features/teamsbot/routeFeatureTeamsbot.py
patrick-motsch cd9b405d2e fix: increase test-auth timeout to 900s for 5 browser variants
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 13:46:23 +01:00

981 lines
37 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Teamsbot routes for the backend API.
Implements Teams Bot session management, live streaming, and configuration endpoints.
"""
import logging
import json
import re
import asyncio
from typing import Optional
from urllib.parse import urlparse, parse_qs, unquote
from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect, Query, Request
from fastapi.responses import StreamingResponse
# Import auth modules
from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces
from . import interfaceFeatureTeamsbot as interfaceDb
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
# Import models
from .datamodelTeamsbot import (
TeamsbotSession,
TeamsbotSessionStatus,
TeamsbotStartSessionRequest,
TeamsbotSessionResponse,
TeamsbotConfigUpdateRequest,
TeamsbotConfig,
TeamsbotJoinMode,
TeamsbotSystemBot,
TeamsbotUserSettings,
TeamsbotResponseChannel,
TeamsbotResponseMode,
)
# Import service
from .service import TeamsbotService
logger = logging.getLogger(__name__)
# Create router
router = APIRouter(
prefix="/api/teamsbot",
tags=["Teamsbot"],
responses={404: {"description": "Not found"}}
)
# =========================================================================
# Helpers
# =========================================================================
def _extractTeamsMeetingUrl(rawInput: str) -> str:
"""
Extract a clean Teams meeting URL from various input formats:
- Full invitation text (extracts the URL from surrounding text)
- SafeLinks wrapped URLs (decodes the inner url= parameter)
- Already clean Teams URLs (passed through)
Raises HTTPException if no valid Teams URL can be found.
"""
rawInput = rawInput.strip()
# Step 1: Extract any URL from the text (user may have pasted full invitation)
urlPattern = r'https?://[^\s<>"\')\]]+'
urls = re.findall(urlPattern, rawInput)
if not urls:
raise HTTPException(status_code=400, detail="Kein gültiger Meeting-Link gefunden. Bitte einen Teams-Link eingeben.")
# Step 2: Find the Teams URL (prefer direct teams.microsoft.com, then SafeLinks)
teamsUrl = None
safeLinksUrl = None
for url in urls:
url = url.rstrip(".,;:)") # Strip trailing punctuation
parsed = urlparse(url)
if "teams.microsoft.com" in parsed.netloc:
teamsUrl = url
break
elif "safelinks.protection.outlook.com" in parsed.netloc:
safeLinksUrl = url
# Step 3: Unwrap SafeLinks if no direct Teams URL found
if not teamsUrl and safeLinksUrl:
parsed = urlparse(safeLinksUrl)
params = parse_qs(parsed.query)
innerUrl = params.get("url", [None])[0]
if innerUrl:
teamsUrl = unquote(innerUrl)
# Step 4: If raw input itself is a Teams URL (simplest case)
if not teamsUrl and "teams.microsoft.com" in rawInput:
teamsUrl = rawInput
if not teamsUrl or "teams.microsoft.com" not in teamsUrl:
raise HTTPException(
status_code=400,
detail="Kein gültiger Teams-Meeting-Link gefunden. Der Link muss 'teams.microsoft.com' enthalten."
)
logger.info(f"Extracted meeting URL: {teamsUrl[:80]}... (from input length {len(rawInput)})")
return teamsUrl
def _getInterface(context: RequestContext, instanceId: Optional[str] = None):
"""Get teamsbot interface with instance context."""
mandateId = str(context.mandateId) if context.mandateId else None
return interfaceDb.getInterface(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId
)
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""Validate user access to the feature instance. Returns mandateId."""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(status_code=404, detail=f"Feature instance '{instanceId}' not found")
mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None)
if not mandateId:
raise HTTPException(status_code=500, detail="Feature instance has no mandateId")
return str(mandateId)
def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
"""Load TeamsbotConfig from FeatureInstance.config JSONB field."""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
return TeamsbotConfig()
configDict = instance.get("config") if isinstance(instance, dict) else getattr(instance, "config", None)
if configDict and isinstance(configDict, dict):
try:
return TeamsbotConfig(**configDict)
except Exception:
pass
return TeamsbotConfig()
# =========================================================================
# Session Endpoints
# =========================================================================
@router.post("/{instanceId}/sessions")
@limiter.limit("10/minute")
async def startSession(
request: Request,
instanceId: str,
body: TeamsbotStartSessionRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Start a new Teams Bot session -- bot joins the specified meeting."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
config = _getInstanceConfig(instanceId)
# Extract and validate meeting URL from user input (handles SafeLinks, invitation text, etc.)
cleanMeetingUrl = _extractTeamsMeetingUrl(body.meetingLink)
# Create session
sessionData = TeamsbotSession(
instanceId=instanceId,
mandateId=mandateId,
meetingLink=cleanMeetingUrl,
botName=body.botName or config.botName,
backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl,
sessionContext=body.sessionContext,
status=TeamsbotSessionStatus.PENDING,
startedByUserId=str(context.user.id),
).model_dump()
createdSession = interface.createSession(sessionData)
sessionId = createdSession.get("id")
# Use APP_API_URL from global config for the gateway base URL
# request.base_url can be http:// behind a reverse proxy even when client uses https://
from modules.shared.configuration import APP_CONFIG
appApiUrl = APP_CONFIG.get("APP_API_URL", "")
gatewayBaseUrl = appApiUrl.rstrip("/") if appApiUrl else str(request.base_url).rstrip("/")
# Get effective config (merged: instance defaults + user overrides)
userId = str(context.user.id)
effectiveConfig = _getEffectiveConfig(instanceId, userId, interface)
# Determine effective join mode and bot name.
# NOTE: Authentication is currently disabled. The bot always joins as an anonymous
# guest with the system bot's display name. See Teamsbot-Auth-Join-Learnings.md.
# Credentials are NOT sent to the browser bot.
joinMode = body.joinMode or TeamsbotJoinMode.ANONYMOUS
effectiveBotName = body.botName
# If a system bot exists, use its display name as the bot name (e.g. "Nyla Larsson")
systemBot = interface.getActiveSystemBot(mandateId)
if systemBot:
if not effectiveBotName:
effectiveBotName = systemBot.get("name") or effectiveConfig.botName
logger.info(f"System bot found: {systemBot.get('name')} ({systemBot.get('email')}), using name: {effectiveBotName}")
if not effectiveBotName:
effectiveBotName = effectiveConfig.botName
# Update session with the effective bot name (may differ from initial creation)
if effectiveBotName != (body.botName or config.botName):
interface.updateSession(sessionId, {"botName": effectiveBotName})
# Build session config — no credentials sent (auth disabled)
sessionConfig = effectiveConfig.model_copy(update={
"botAccountEmail": None,
"botAccountPassword": None,
"botName": effectiveBotName,
})
# Start the bot in background (join meeting via bridge)
service = TeamsbotService(context.user, mandateId, instanceId, sessionConfig)
asyncio.create_task(
service.joinMeeting(sessionId, cleanMeetingUrl, body.connectionId, gatewayBaseUrl)
)
logger.info(f"Teamsbot session {sessionId} created for instance {instanceId}")
return {"session": createdSession}
@router.get("/{instanceId}/sessions")
@limiter.limit("30/minute")
async def listSessions(
request: Request,
instanceId: str,
includeEnded: bool = Query(True, description="Include ended sessions"),
context: RequestContext = Depends(getRequestContext),
):
"""List all sessions for a feature instance."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
sessions = interface.getSessions(instanceId, includeEnded=includeEnded)
return {"sessions": sessions}
@router.get("/{instanceId}/sessions/{sessionId}")
@limiter.limit("30/minute")
async def getSession(
request: Request,
instanceId: str,
sessionId: str,
includeTranscripts: bool = Query(True),
includeResponses: bool = Query(True),
context: RequestContext = Depends(getRequestContext),
):
"""Get session details with optional transcripts and bot responses."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail=f"Session '{sessionId}' not found")
result = {"session": session}
if includeTranscripts:
result["transcripts"] = interface.getTranscripts(sessionId)
if includeResponses:
result["botResponses"] = interface.getBotResponses(sessionId)
result["stats"] = interface.getSessionStats(sessionId)
return result
@router.get("/{instanceId}/sessions/{sessionId}/stream")
@limiter.limit("60/minute")
async def streamSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext),
):
"""
SSE live stream for a session.
Streams transcript segments and bot responses in real-time.
Events: transcript, botResponse, statusChange, error
"""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail=f"Session '{sessionId}' not found")
async def _eventGenerator():
"""Generate SSE events from the session event queue."""
from .service import _sessionEvents
# Send initial session state
yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n"
# Stream events
eventQueue = _sessionEvents.get(sessionId)
if not eventQueue:
_sessionEvents[sessionId] = asyncio.Queue()
eventQueue = _sessionEvents[sessionId]
try:
while True:
try:
event = await asyncio.wait_for(eventQueue.get(), timeout=30.0)
yield f"data: {json.dumps(event)}\n\n"
# Stop streaming if session ended
if event.get("type") == "statusChange" and event.get("data", {}).get("status") in ["ended", "error"]:
break
except asyncio.TimeoutError:
# Send keepalive ping
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
except asyncio.CancelledError:
pass
return StreamingResponse(
_eventGenerator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
)
@router.post("/{instanceId}/sessions/{sessionId}/stop")
@limiter.limit("10/minute")
async def stopSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Stop an active session -- bot leaves the meeting."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
config = _getInstanceConfig(instanceId)
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail=f"Session '{sessionId}' not found")
currentStatus = session.get("status")
if currentStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]:
raise HTTPException(status_code=400, detail=f"Session already in terminal state: {currentStatus}")
# Stop the bot
service = TeamsbotService(context.user, mandateId, instanceId, config)
await service.leaveMeeting(sessionId)
logger.info(f"Teamsbot session {sessionId} stop requested")
return {"status": "stopping", "sessionId": sessionId}
@router.delete("/{instanceId}/sessions/{sessionId}")
@limiter.limit("10/minute")
async def deleteSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Delete a session and all related data."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail=f"Session '{sessionId}' not found")
# Don't delete active sessions
currentStatus = session.get("status")
if currentStatus in [TeamsbotSessionStatus.ACTIVE.value, TeamsbotSessionStatus.JOINING.value]:
raise HTTPException(status_code=400, detail="Cannot delete an active session. Stop it first.")
interface.deleteSession(sessionId)
logger.info(f"Teamsbot session {sessionId} deleted")
return {"deleted": True, "sessionId": sessionId}
# =========================================================================
# Configuration Endpoints
# =========================================================================
@router.get("/{instanceId}/config")
@limiter.limit("30/minute")
async def getConfig(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Get the teamsbot configuration for a feature instance."""
_validateInstanceAccess(instanceId, context)
config = _getInstanceConfig(instanceId)
return {"config": config.model_dump()}
@router.put("/{instanceId}/config")
@limiter.limit("10/minute")
async def updateConfig(
request: Request,
instanceId: str,
configUpdate: TeamsbotConfigUpdateRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Update the teamsbot configuration."""
mandateId = _validateInstanceAccess(instanceId, context)
# Load current config and merge updates
currentConfig = _getInstanceConfig(instanceId)
updateDict = configUpdate.model_dump(exclude_none=True)
mergedConfig = currentConfig.model_copy(update=updateDict)
# Save to FeatureInstance.config
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
featureInterface.updateFeatureInstance(instanceId, {"config": mergedConfig.model_dump()})
logger.info(f"Teamsbot config updated for instance {instanceId}: {list(updateDict.keys())}")
return {"config": mergedConfig.model_dump()}
# =========================================================================
# User Settings Endpoints (per-user per-instance)
# =========================================================================
def _getEffectiveConfig(instanceId: str, userId: str, interface) -> TeamsbotConfig:
"""Merge instance defaults with user-specific overrides to get effective config."""
baseConfig = _getInstanceConfig(instanceId)
userSettings = interface.getUserSettings(userId, instanceId)
if not userSettings:
return baseConfig
# Merge: user settings override instance defaults (only non-None values)
overrides = {}
for field in ["botName", "backgroundImageUrl", "aiSystemPrompt", "responseMode",
"responseChannel", "language", "voiceId",
"triggerIntervalSeconds", "triggerCooldownSeconds", "contextWindowSegments"]:
value = userSettings.get(field)
if value is not None:
overrides[field] = value
if overrides:
return baseConfig.model_copy(update=overrides)
return baseConfig
@router.get("/{instanceId}/settings")
@limiter.limit("30/minute")
async def getUserSettings(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Get per-user settings for this feature instance.
Returns user overrides merged with instance defaults."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
userSettings = interface.getUserSettings(userId, instanceId)
effectiveConfig = _getEffectiveConfig(instanceId, userId, interface)
return {
"settings": userSettings, # Raw user overrides (may be None)
"effectiveConfig": effectiveConfig.model_dump(), # Merged config
}
@router.put("/{instanceId}/settings")
@limiter.limit("10/minute")
async def updateUserSettings(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Update per-user settings for this feature instance."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
body = await request.json()
# Check if user already has settings
existing = interface.getUserSettings(userId, instanceId)
if existing:
# Update existing settings
updates = {k: v for k, v in body.items() if k not in ("id", "userId", "instanceId", "creationDate")}
interface.updateUserSettings(existing["id"], updates)
else:
# Create new settings
settingsData = TeamsbotUserSettings(
userId=userId,
instanceId=instanceId,
**{k: v for k, v in body.items() if k not in ("id", "userId", "instanceId", "creationDate", "lastModified")}
).model_dump()
interface.createUserSettings(settingsData)
# Return effective config after merge
effectiveConfig = _getEffectiveConfig(instanceId, userId, interface)
userSettings = interface.getUserSettings(userId, instanceId)
logger.info(f"User settings updated for user {userId}, instance {instanceId}")
return {
"settings": userSettings,
"effectiveConfig": effectiveConfig.model_dump(),
}
@router.delete("/{instanceId}/settings")
@limiter.limit("10/minute")
async def deleteUserSettings(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Reset per-user settings to instance defaults."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
existing = interface.getUserSettings(userId, instanceId)
if existing:
interface.deleteUserSettings(existing["id"])
effectiveConfig = _getInstanceConfig(instanceId)
logger.info(f"User settings reset for user {userId}, instance {instanceId}")
return {
"settings": None,
"effectiveConfig": effectiveConfig.model_dump(),
}
# =========================================================================
# System Bot Admin Endpoints
# =========================================================================
@router.get("/{instanceId}/system-bots")
@limiter.limit("30/minute")
async def listSystemBots(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""List all system bot accounts for this mandate. Passwords are never returned."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
bots = interface.getSystemBots(mandateId)
return {"bots": bots}
@router.post("/{instanceId}/system-bots")
@limiter.limit("5/minute")
async def createSystemBot(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Create a new system bot account. Password is encrypted before storage."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
body = await request.json()
email = body.get("email")
password = body.get("password")
name = body.get("name", email.split("@")[0] if email else "Bot")
if not email or not password:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Email and password are required")
# Encrypt the password
from modules.shared.configuration import encryptValue
encryptedPassword = encryptValue(password, userId=str(context.user.id), keyName="systemBotPassword")
botData = TeamsbotSystemBot(
mandateId=mandateId,
name=name,
email=email,
encryptedPassword=encryptedPassword,
isActive=True,
).model_dump()
created = interface.createSystemBot(botData)
# Strip password from response
created.pop("encryptedPassword", None)
logger.info(f"System bot created: {email} for mandate {mandateId}")
return {"bot": created}
@router.delete("/{instanceId}/system-bots/{botId}")
@limiter.limit("5/minute")
async def deleteSystemBot(
request: Request,
instanceId: str,
botId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Delete a system bot account."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
interface.deleteSystemBot(botId)
logger.info(f"System bot {botId} deleted")
return {"deleted": True}
# =========================================================================
# Voice Test Endpoint
# =========================================================================
@router.post("/{instanceId}/voice/test")
@limiter.limit("10/minute")
async def testVoice(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Test TTS voice with AI-generated sample text in the correct language."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
from modules.services.serviceAi.mainServiceAi import AiService
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
mandateId = _validateInstanceAccess(instanceId, context)
body = await request.json()
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
botName = body.get("botName", "AI Assistant")
try:
# Generate test text dynamically via AI in the correct language
serviceContext = type('Ctx', (), {
'user': context.user, 'mandateId': mandateId,
'featureInstanceId': instanceId, 'featureCode': 'teamsbot'
})()
aiService = AiService(serviceCenter=serviceContext)
await aiService.ensureAiObjectsInitialized()
aiRequest = AiCallRequest(
prompt=f"Generate a short, friendly introduction sentence (max 2 sentences) for a meeting bot named '{botName}'. "
f"Write ONLY in the language '{language}'. No quotes, no explanation, just the text to speak.",
context="",
options=AiCallOptions(
operationType=OperationTypeEnum.DATA_ANALYSE,
priority=PriorityEnum.SPEED,
)
)
aiResponse = await aiService.callAi(aiRequest)
testText = aiResponse.content.strip().strip('"').strip("'") if aiResponse and aiResponse.errorCount == 0 else f"Hello, I am {botName}."
logger.info(f"Voice test: generated text in {language}: '{testText[:60]}...'")
# Convert to speech
voiceInterface = getVoiceInterface(context.user, mandateId)
result = await voiceInterface.textToSpeech(
text=testText,
languageCode=language,
voiceName=voiceId,
)
if result and isinstance(result, dict):
import base64
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {
"success": True,
"audio": audioB64,
"format": "mp3",
"language": language,
"voiceId": voiceId,
"text": testText,
}
return {"success": False, "error": "TTS returned no audio"}
except Exception as e:
logger.error(f"Voice test failed: {e}")
raise HTTPException(status_code=500, detail=f"TTS-Test fehlgeschlagen: {str(e)}")
# =========================================================================
# Auth Detection Test Endpoint
# =========================================================================
@router.post("/{instanceId}/test-auth")
@limiter.limit("3/minute")
async def testAuth(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""
Run auth detection tests against a Teams meeting URL.
Tests 6 browser configuration variants to determine which ones
receive the /v2/ (authenticated) vs light-meetings (anonymous) page.
Does NOT join the meeting — only checks which page Teams serves.
"""
import aiohttp
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
effectiveConfig = _getInstanceConfig(instanceId)
body = await request.json()
meetingUrl = body.get("meetingUrl")
if not meetingUrl:
raise HTTPException(status_code=400, detail="meetingUrl is required")
# Load system bot credentials:
# 1. Use email/password from request body (direct override)
# 2. Fallback: load from DB (system bot record)
email = body.get("botEmail")
password = body.get("botPassword")
credentialDebug = {"mandateId": mandateId, "source": "none"}
if email and password:
# Direct override from request body
credentialDebug["source"] = "requestBody"
credentialDebug["botEmail"] = email
logger.info(f"[test-auth] Using credentials from request: {email}")
# Also create/update system bot in DB for future use
try:
from modules.shared.configuration import encryptValue
encryptedPassword = encryptValue(password, userId=str(context.user.id), keyName="systemBotPassword")
existingBot = interface.getActiveSystemBot(mandateId)
if not existingBot:
botData = TeamsbotSystemBot(
mandateId=mandateId,
name=email.split("@")[0].replace(".", " ").title(),
email=email,
encryptedPassword=encryptedPassword,
isActive=True,
).model_dump()
interface.createSystemBot(botData)
credentialDebug["dbCreated"] = True
logger.info(f"[test-auth] Created system bot in DB: {email} for mandate {mandateId}")
else:
credentialDebug["dbExists"] = True
except Exception as e:
logger.warning(f"[test-auth] Could not save system bot to DB: {e}")
else:
# Try loading from DB
systemBot = interface.getActiveSystemBot(mandateId)
if not systemBot:
# Fallback: search ALL active bots
allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True})
if allBots:
systemBot = allBots[0]
credentialDebug["source"] = "dbFallback"
if systemBot:
email = systemBot.get("email")
encryptedPwd = systemBot.get("encryptedPassword")
credentialDebug["source"] = credentialDebug.get("source", "db")
credentialDebug["botEmail"] = email
if encryptedPwd:
try:
from modules.shared.configuration import decryptValue
password = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
logger.info(f"[test-auth] Loaded from DB: {email}")
except Exception as e:
credentialDebug["passwordError"] = str(e)
logger.error(f"[test-auth] Password decryption failed: {e}")
else:
logger.warning(f"[test-auth] No credentials provided and no system bot in DB")
credentialDebug["source"] = "noneFound"
# Forward to browser bot service
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
if not browserBotUrl:
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
browserBotUrl = browserBotUrl.rstrip("/")
payload = {
"meetingUrl": meetingUrl,
"botAccountEmail": email,
"botAccountPassword": password,
}
try:
timeout = aiohttp.ClientTimeout(total=900)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(f"{browserBotUrl}/api/bot/test-auth", json=payload) as resp:
if resp.status == 200:
result = await resp.json()
result["credentialDebug"] = credentialDebug
return result
else:
errorText = await resp.text()
logger.error(f"Auth test failed: {resp.status} - {errorText}")
raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}")
except aiohttp.ClientError as e:
logger.error(f"Auth test connection error: {e}")
raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}")
# =========================================================================
# Browser Bot Communication Endpoints (HTTP Fallback + WebSocket)
# =========================================================================
@router.post("/{instanceId}/bot/transcript/{sessionId}")
async def postTranscript(
request: Request,
instanceId: str,
sessionId: str,
):
"""
HTTP POST fallback for transcript delivery when WebSocket is unavailable.
Used by the Browser Bot when Azure/proxy blocks WebSocket connections.
"""
body = await request.json()
transcript = body.get("transcript", {})
speaker = transcript.get("speaker", "Unknown")
text = transcript.get("text", "")
isFinal = transcript.get("isFinal", True)
if not text.strip():
return {"success": True, "message": "Empty transcript ignored"}
try:
config = _getInstanceConfig(instanceId)
# Load original user context from session
from modules.datamodels.datamodelUam import User
systemUser = User(id="system", username="system", email="system@poweron.swiss")
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
originalUser = systemUser
# Process transcript through the service pipeline
from .service import TeamsbotService
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
service = TeamsbotService(originalUser, mandateId, instanceId, config)
interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId)
voiceInterface = getVoiceInterface(originalUser, mandateId)
await service._processTranscript(
sessionId=sessionId,
speaker=speaker,
text=text,
isFinal=isFinal,
interface=interface,
voiceInterface=voiceInterface,
websocket=None, # No WebSocket in HTTP mode
)
logger.info(f"HTTP transcript received: session={sessionId}, speaker={speaker}, text={text[:50]}...")
return {"success": True}
except Exception as e:
logger.error(f"HTTP transcript error: session={sessionId}, error={e}")
return {"success": False, "error": str(e)}
@router.post("/{instanceId}/bot/status/{sessionId}")
async def postBotStatus(
request: Request,
instanceId: str,
sessionId: str,
):
"""
HTTP POST fallback for bot status updates when WebSocket is unavailable.
"""
body = await request.json()
status = body.get("status", "")
message = body.get("message")
try:
config = _getInstanceConfig(instanceId)
from modules.datamodels.datamodelUam import User
systemUser = User(id="system", username="system", email="system@poweron.swiss")
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
originalUser = systemUser
from .service import TeamsbotService
service = TeamsbotService(originalUser, mandateId, instanceId, config)
interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId)
await service._handleBotStatus(sessionId, status, message, interface)
logger.info(f"HTTP status received: session={sessionId}, status={status}")
return {"success": True}
except Exception as e:
logger.error(f"HTTP status error: session={sessionId}, error={e}")
return {"success": False, "error": str(e)}
@router.websocket("/{instanceId}/bot/ws/{sessionId}")
async def botWebsocket(
websocket: WebSocket,
instanceId: str,
sessionId: str,
):
"""
Bidirectional WebSocket for communication with the Browser Bot.
Bot sends:
- transcript: Caption text scraped from Teams meeting
- status: Bot state changes (joined, in_lobby, left, error)
Gateway sends:
- playAudio: TTS audio for the bot to play in the meeting
"""
logger.info(f"Browser Bot WebSocket INCOMING: session={sessionId}, instance={instanceId}")
await websocket.accept()
logger.info(f"Browser Bot WebSocket ACCEPTED: session={sessionId}, instance={instanceId}")
try:
config = _getInstanceConfig(instanceId)
logger.info(f"Browser Bot WebSocket config loaded: session={sessionId}")
# Load the original user who started the session (has RBAC roles in mandate)
# Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record.
from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbApp import getRootInterface
systemUser = User(id="system", username="system", email="system@poweron.swiss")
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
# Look up the original user (getRootInterface uses admin context, can load any user)
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
logger.warning(f"Could not load original user {startedByUserId}, falling back to system user")
originalUser = systemUser
service = TeamsbotService(originalUser, mandateId, instanceId, config)
logger.info(f"Browser Bot WebSocket service created: session={sessionId}, mandateId={mandateId}, user={originalUser.id}")
await service.handleBotWebSocket(websocket, sessionId)
except WebSocketDisconnect:
logger.info(f"Browser Bot WebSocket disconnected: session={sessionId}")
except Exception as e:
logger.error(f"Browser Bot WebSocket error: session={sessionId}, error={e}", exc_info=True)
finally:
logger.info(f"Browser Bot WebSocket closed: session={sessionId}")