diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 14ccfa18..91cd4cec 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -7,8 +7,10 @@ Implements Teams Bot session management, live streaming, and configuration endpo 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 @@ -47,6 +49,59 @@ router = APIRouter( # 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 @@ -108,11 +163,14 @@ async def startSession( 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=body.meetingLink, + meetingLink=cleanMeetingUrl, botName=body.botName or config.botName, backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl, status=TeamsbotSessionStatus.PENDING, @@ -131,7 +189,7 @@ async def startSession( # Start the bot in background (join meeting via bridge) service = TeamsbotService(context.user, mandateId, instanceId, config) asyncio.create_task( - service.joinMeeting(sessionId, body.meetingLink, body.connectionId, gatewayBaseUrl) + service.joinMeeting(sessionId, cleanMeetingUrl, body.connectionId, gatewayBaseUrl) ) logger.info(f"Teamsbot session {sessionId} created for instance {instanceId}") @@ -339,6 +397,57 @@ async def updateConfig( return {"config": mergedConfig.model_dump()} +# ========================================================================= +# 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 a sample text. Returns base64-encoded audio.""" + from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + + mandateId = _validateInstanceAccess(instanceId, context) + + body = await request.json() + text = body.get("text", "Hallo, ich bin der AI-Assistent. Wie kann ich helfen?") + language = body.get("language", "de-DE") + voiceId = body.get("voiceId") + + try: + voiceInterface = getVoiceInterface(context.user, mandateId) + result = await voiceInterface.textToSpeech( + text=text, + 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, + } + + 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)}") + + # ========================================================================= # Browser Bot Communication Endpoints # ========================================================================= diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 12504847..1241cb44 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -258,8 +258,11 @@ class TeamsbotService: statusMap = { "connecting": TeamsbotSessionStatus.JOINING.value, - "in_lobby": TeamsbotSessionStatus.JOINING.value, # Still joining, waiting in lobby + "launching": TeamsbotSessionStatus.JOINING.value, + "navigating": TeamsbotSessionStatus.JOINING.value, + "in_lobby": TeamsbotSessionStatus.JOINING.value, "joined": TeamsbotSessionStatus.ACTIVE.value, + "in_meeting": TeamsbotSessionStatus.ACTIVE.value, "left": TeamsbotSessionStatus.ENDED.value, "error": TeamsbotSessionStatus.ERROR.value, } @@ -341,12 +344,17 @@ class TeamsbotService: return if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY: + logger.debug(f"Session {sessionId}: responseMode=TRANSCRIBE_ONLY, skipping AI analysis") return - if not self._shouldTriggerAnalysis(text): + shouldTrigger = self._shouldTriggerAnalysis(text) + logger.debug(f"Session {sessionId}: shouldTriggerAnalysis={shouldTrigger}, bufferSize={len(self._contextBuffer)}, responseMode={self.config.responseMode}") + + if not shouldTrigger: return # SPEECH_TEAMS AI analysis + logger.info(f"Session {sessionId}: Triggering AI analysis (buffer: {len(self._contextBuffer)} segments)") await self._analyzeAndRespond(sessionId, interface, voiceInterface, websocket, createdTranscript) def _shouldTriggerAnalysis(self, transcriptText: str) -> bool: @@ -362,19 +370,21 @@ class TeamsbotService: # Cooldown check timeSinceLastCall = now - self._lastAiCallTime if timeSinceLastCall < self.config.triggerCooldownSeconds: + logger.debug(f"Trigger: Cooldown active ({timeSinceLastCall:.1f}s < {self.config.triggerCooldownSeconds}s)") return False # Bot name mentioned -> immediate trigger botNameLower = self.config.botName.lower() if botNameLower in transcriptText.lower(): - logger.debug(f"Trigger: Bot name '{self.config.botName}' detected in transcript") + logger.info(f"Trigger: Bot name '{self.config.botName}' detected in transcript: '{transcriptText[:60]}...'") return True # Periodic trigger if timeSinceLastCall >= self.config.triggerIntervalSeconds: - logger.debug(f"Trigger: Periodic interval ({self.config.triggerIntervalSeconds}s) elapsed") + logger.info(f"Trigger: Periodic interval ({self.config.triggerIntervalSeconds}s) elapsed ({timeSinceLastCall:.1f}s since last call)") return True + logger.debug(f"Trigger: No trigger ({timeSinceLastCall:.1f}s / {self.config.triggerIntervalSeconds}s interval)") return False async def _analyzeAndRespond( @@ -526,8 +536,8 @@ class TeamsbotService: logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}") except Exception as e: - logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {e}") - await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {str(e)}"}) + logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {type(e).__name__}: {e}", exc_info=True) + await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {type(e).__name__}: {str(e)}"}) # ========================================================================= # Meeting Summary