# 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, TeamsbotUserAccount, TeamsbotResponseChannel, TeamsbotResponseMode, ) # Import service from .service import TeamsbotService from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeFeatureTeamsbot") 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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("Feature instance has no mandateId")) return str(mandateId) def _validateSessionOwnership(session: dict, context: RequestContext) -> None: """Raise 404 if the user does not own this session (sysAdmin bypasses).""" if context.hasSysAdminRole: return if session.get("startedByUserId") != str(context.user.id): raise HTTPException(status_code=404, detail=f"Session '{session.get('id')}' not found") 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, 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, bot name, and credentials joinMode = body.joinMode or TeamsbotJoinMode.ANONYMOUS logger.debug(f"startSession: requested joinMode={body.joinMode}, effective joinMode={joinMode}, userId={userId}, mandateId={mandateId}") effectiveBotName = body.botName botAccountEmail = None botAccountPassword = None # System bot access: only SysAdmin can use the system bot account. if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT: systemBot = interface.getActiveSystemBot(mandateId) if not systemBot: from .datamodelTeamsbot import TeamsbotSystemBot allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True}) if allBots: systemBot = allBots[0] logger.info(f"No mandate-specific system bot, using fallback: {systemBot.get('name')} ({systemBot.get('email')})") if systemBot: if not effectiveBotName: effectiveBotName = systemBot.get("name") if not effectiveBotName: sbEmail = systemBot.get("email", "") if sbEmail and "@" in sbEmail: emailPrefix = sbEmail.split("@")[0] effectiveBotName = " ".join(part.capitalize() for part in emailPrefix.split(".")) logger.info(f"Bot name derived from email: {effectiveBotName}") if not effectiveBotName: effectiveBotName = effectiveConfig.botName logger.info(f"System bot found: {systemBot.get('name')} ({systemBot.get('email')}), effectiveBotName={effectiveBotName}") botAccountEmail = systemBot.get("email") encryptedPwd = systemBot.get("encryptedPassword") if botAccountEmail and encryptedPwd: try: from modules.shared.configuration import decryptValue botAccountPassword = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword") logger.info(f"System bot credentials loaded and decrypted for: {botAccountEmail}") except Exception as e: logger.warning(f"Could not decrypt system bot password: {e} -- falling back to anonymous join") botAccountEmail = None botAccountPassword = None else: logger.info("No system bot found in DB -- using anonymous join") # User Account access: load saved MS credentials for the current user elif joinMode == TeamsbotJoinMode.USER_ACCOUNT: logger.debug(f"USER_ACCOUNT branch entered for userId={userId}, mandateId={mandateId}") userAccount = interface.getUserAccount(userId, mandateId) logger.debug(f"getUserAccount result: {bool(userAccount)}, keys={list(userAccount.keys()) if userAccount else 'N/A'}") if userAccount: botAccountEmail = userAccount.get("email") encryptedPwd = userAccount.get("encryptedPassword") logger.debug(f"UserAccount email={botAccountEmail}, hasEncryptedPwd={bool(encryptedPwd)}, encPwdPrefix={encryptedPwd[:20] if encryptedPwd else 'N/A'}...") if botAccountEmail and encryptedPwd: try: from modules.shared.configuration import decryptValue botAccountPassword = decryptValue(encryptedPwd, userId=userId, keyName="userAccountPassword") logger.info(f"User account credentials loaded and decrypted for: {botAccountEmail}") if not effectiveBotName: effectiveBotName = userAccount.get("displayName") if not effectiveBotName and "@" in botAccountEmail: emailPrefix = botAccountEmail.split("@")[0] effectiveBotName = " ".join(part.capitalize() for part in emailPrefix.split(".")) except Exception as e: logger.warning(f"Could not decrypt user account password: {e}") botAccountEmail = None botAccountPassword = None else: logger.warning(f"UserAccount record found but missing email or encryptedPassword") else: logger.warning(f"No saved credentials for user {userId}, mandateId={mandateId} -- falling back to anonymous join") joinMode = TeamsbotJoinMode.ANONYMOUS else: if body.joinMode == TeamsbotJoinMode.SYSTEM_BOT: logger.warning(f"Non-SysAdmin user {context.user.id} attempted to use system bot -- forced to anonymous join") joinMode = TeamsbotJoinMode.ANONYMOUS logger.info(f"User {context.user.id}: using anonymous join") 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 sessionConfig = effectiveConfig.model_copy(update={ "botName": effectiveBotName, }) # Start the bot in background — pass credentials separately (not in config) logger.debug(f"startSession FINAL: joinMode={joinMode}, hasEmail={bool(botAccountEmail)}, hasPassword={bool(botAccountPassword)}, botName={effectiveBotName}") service = TeamsbotService(context.user, mandateId, instanceId, sessionConfig) asyncio.create_task( service.joinMeeting(sessionId, cleanMeetingUrl, body.connectionId, gatewayBaseUrl, botAccountEmail, botAccountPassword) ) 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 sessions for a feature instance (filtered to own sessions unless sysAdmin).""" _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = None if context.hasSysAdminRole else str(context.user.id) sessions = interface.getSessions(instanceId, includeEnded=includeEnded, userId=userId) 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") _validateSessionOwnership(session, context) 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") _validateSessionOwnership(session, context) 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") _validateSessionOwnership(session, context) 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") _validateSessionOwnership(session, context) # Don't delete active sessions currentStatus = session.get("status") if currentStatus in [TeamsbotSessionStatus.ACTIVE.value, TeamsbotSessionStatus.JOINING.value]: raise HTTPException(status_code=400, detail=routeApiMsg("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", "aiSystemPrompt", "responseMode", "responseChannel", "transferMode", "language", "voiceId", "triggerIntervalSeconds", "triggerCooldownSeconds", "contextWindowSegments", "debugMode"]: value = userSettings.get(field) if value is not None: overrides[field] = value if overrides: return TeamsbotConfig.model_validate({**baseConfig.model_dump(), **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.""" if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) 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.""" if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) 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=routeApiMsg("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.""" if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) interface.deleteSystemBot(botId) logger.info(f"System bot {botId} deleted") return {"deleted": True} # ========================================================================= # User Account Endpoints (Mein Account) # ========================================================================= @router.get("/{instanceId}/user-account") @limiter.limit("30/minute") async def getUserAccount( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): """Get saved MS credentials status for the current user (password is never returned).""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) account = interface.getUserAccount(userId, mandateId) if not account: return {"hasSavedCredentials": False} return { "hasSavedCredentials": True, "email": account.get("email"), "displayName": account.get("displayName"), } @router.post("/{instanceId}/user-account") @limiter.limit("5/minute") async def saveUserAccount( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): """Save or update MS credentials for 'Mein Account' login.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) body = await request.json() email = body.get("email") password = body.get("password") displayName = body.get("displayName") if not email or not password: raise HTTPException(status_code=400, detail=routeApiMsg("Email and password are required")) from modules.shared.configuration import encryptValue encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword") existing = interface.getUserAccount(userId, mandateId) if existing: interface.updateUserAccount(existing["id"], { "email": email, "encryptedPassword": encryptedPassword, "displayName": displayName or existing.get("displayName"), }) logger.info(f"User account updated for user {userId}: {email}") else: interface.createUserAccount(TeamsbotUserAccount( userId=userId, mandateId=mandateId, email=email, encryptedPassword=encryptedPassword, displayName=displayName, ).model_dump()) logger.info(f"User account created for user {userId}: {email}") return {"saved": True, "email": email} @router.delete("/{instanceId}/user-account") @limiter.limit("5/minute") async def deleteUserAccount( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): """Delete saved MS credentials for the current user.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) account = interface.getUserAccount(userId, mandateId) if account: interface.deleteUserAccount(account["id"]) logger.info(f"User account deleted for user {userId}") return {"deleted": True} # ========================================================================= # MFA Code Submission (relayed to active bot session) # ========================================================================= _mfaCodeQueues: dict = {} _mfaWaitTasks: dict = {} @router.post("/{instanceId}/sessions/{sessionId}/mfa") @limiter.limit("10/minute") async def submitMfaCode( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): """Submit MFA code/confirmation from the frontend to the active bot session.""" _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") _validateSessionOwnership(session, context) body = await request.json() mfaCode = body.get("code", "") mfaAction = body.get("action", "code") logger.info(f"MFA submission for session {sessionId}: action={mfaAction}, codeLen={len(mfaCode)}") queue = _mfaCodeQueues.get(sessionId) if queue: await queue.put({"action": mfaAction, "code": mfaCode}) return {"submitted": True} else: raise HTTPException(status_code=404, detail=routeApiMsg("No active MFA challenge for this session")) # ========================================================================= # 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.serviceCenter.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. """ if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing (uses system bot credentials)")) 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=routeApiMsg("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 (single all-in-one call — may timeout with many variants) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") payload = { "meetingUrl": meetingUrl, "botAccountEmail": email, "botAccountPassword": password, } try: timeout = aiohttp.ClientTimeout(total=210) 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)}") @router.get("/{instanceId}/test-auth/variants") @limiter.limit("10/minute") async def getTestAuthVariants( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): """ Get list of available test variant IDs from the Browser Bot. Frontend calls this once, then runs each variant individually. """ if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing")) import aiohttp _validateInstanceAccess(instanceId, context) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(f"{browserBotUrl}/api/bot/test-auth/variants") as resp: if resp.status == 200: return await resp.json() else: errorText = await resp.text() raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}") except aiohttp.ClientError as e: logger.error(f"Get variants error: {e}") raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}") @router.post("/{instanceId}/test-auth/variant") @limiter.limit("10/minute") async def testAuthSingleVariant( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): """ Run a single test variant. Frontend calls this once per variant (sequentially). Each call stays within Azure's 240s timeout. """ if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing (uses system bot credentials)")) import aiohttp mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) effectiveConfig = _getInstanceConfig(instanceId) body = await request.json() variantId = body.get("variantId") meetingUrl = body.get("meetingUrl") if not variantId or not meetingUrl: raise HTTPException(status_code=400, detail=routeApiMsg("variantId and meetingUrl are required")) # Load credentials (same logic as testAuth) email = body.get("botEmail") password = body.get("botPassword") credentialDebug = {"mandateId": mandateId, "source": "none"} if email and password: credentialDebug["source"] = "requestBody" credentialDebug["botEmail"] = email else: # Fallback: load from DB try: from modules.shared.configuration import decryptValue systemBot = interface.getActiveSystemBot(mandateId) if not systemBot: systemBot = interface.getActiveSystemBot(None) if systemBot: credentialDebug["searchStrategy"] = "anyMandate" if systemBot: email = systemBot.get("email") encryptedPwd = systemBot.get("encryptedPassword") if email and encryptedPwd: password = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword") credentialDebug["source"] = "database" credentialDebug["botEmail"] = email credentialDebug["botFound"] = True except Exception as e: logger.warning(f"[test-auth-variant] Could not load system bot: {e}") browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") payload = { "variantId": variantId, "meetingUrl": meetingUrl, "botAccountEmail": email, "botAccountPassword": password, } try: timeout = aiohttp.ClientTimeout(total=180) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(f"{browserBotUrl}/api/bot/test-auth/variant", 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"[test-auth-variant] {variantId} failed: {resp.status}") raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}") except aiohttp.ClientError as e: logger.error(f"[test-auth-variant] {variantId} connection error: {e}") raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}") # ========================================================================= # Debug Screenshot Endpoints (SysAdmin only) # ========================================================================= @router.get("/{instanceId}/sessions/{sessionId}/screenshots") @limiter.limit("30/minute") async def listSessionScreenshots( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): """List debug screenshots for a session. Proxied from Browser Bot filesystem.""" if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required")) _validateInstanceAccess(instanceId, context) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) import aiohttp browserBotUrl = browserBotUrl.rstrip("/") try: timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(f"{browserBotUrl}/api/bot/screenshots/{sessionId}") as resp: if resp.status == 200: data = await resp.json() screenshots = data.get("screenshots", []) for s in screenshots: s["url"] = f"/api/teamsbot/{instanceId}/screenshots/{s['name']}" return {"screenshots": screenshots} else: errorText = await resp.text() raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}") except aiohttp.ClientError as e: logger.error(f"Screenshot list error: {e}") raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}") @router.get("/{instanceId}/screenshots/{filename}") @limiter.limit("60/minute") async def getScreenshotFile( request: Request, instanceId: str, filename: str, context: RequestContext = Depends(getRequestContext), ): """Serve a single debug screenshot image. Proxied from Browser Bot.""" if not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required")) _validateInstanceAccess(instanceId, context) if not filename.endswith(".png") or ".." in filename or "/" in filename or "\\" in filename: raise HTTPException(status_code=400, detail=routeApiMsg("Invalid filename")) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) import aiohttp from fastapi.responses import Response as FastAPIResponse browserBotUrl = browserBotUrl.rstrip("/") try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(f"{browserBotUrl}/api/bot/screenshots/file/{filename}") as resp: if resp.status == 200: imageBytes = await resp.read() return FastAPIResponse(content=imageBytes, media_type="image/png") else: raise HTTPException(status_code=resp.status, detail=routeApiMsg("Screenshot not found")) except aiohttp.ClientError as e: logger.error(f"Screenshot file 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) source = transcript.get("source", "caption") if not text.strip(): return {"success": True, "message": "Empty transcript ignored"} try: config = _getInstanceConfig(instanceId) # Load original user context from session rootInterface = getRootInterface() rootUser = rootInterface.currentUser sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: originalUser = rootUser # 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 source=source, ) 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) rootInterface = getRootInterface() rootUser = rootInterface.currentUser sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: originalUser = rootUser 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.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() rootUser = rootInterface.currentUser sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId) session = sessionInterface.getSession(sessionId) mandateId = session.get("mandateId") if session else None startedByUserId = session.get("startedByUserId") if session else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None if not originalUser: logger.warning(f"Could not load original user {startedByUserId}, falling back to root user") originalUser = rootUser # Build effective config with the session's actual bot name. # The session stores the resolved bot name (from system bot or user override). # Without this, the default config botName (e.g. "AI Assistant") is used, # which is wrong for registered system bots. sessionBotName = session.get("botName") if session else None if sessionBotName: config = config.model_copy(update={"botName": sessionBotName}) logger.info(f"Browser Bot WebSocket: Using session botName '{sessionBotName}' (not default '{_getInstanceConfig(instanceId).botName}')") # Also merge user-specific settings if available if startedByUserId: interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId) userSettings = interface.getUserSettings(startedByUserId, instanceId) if userSettings: overrides = {} for field in ["aiSystemPrompt", "responseMode", "responseChannel", "transferMode", "language", "voiceId", "triggerIntervalSeconds", "triggerCooldownSeconds", "contextWindowSegments"]: value = userSettings.get(field) if value is not None: overrides[field] = value if overrides: config = TeamsbotConfig.model_validate({**config.model_dump(), **overrides}) logger.info(f"Browser Bot WebSocket: Applied user settings overrides: {list(overrides.keys())}") service = TeamsbotService(originalUser, mandateId, instanceId, config) logger.info(f"Browser Bot WebSocket service created: session={sessionId}, mandateId={mandateId}, user={originalUser.id}, botName={config.botName}") 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}")