feat(teamsbot): URL extraction, status mapping, voice test, improved logging

- URL extraction: _extractTeamsMeetingUrl() handles SafeLinks, invitation text, and clean URLs
- Status mapping: added in_meeting, launching, navigating to bot status map
- Voice test endpoint: POST /{instanceId}/voice/test for TTS preview
- Improved error logging in browserBotConnector (shows exception type + repr)
- Improved AI trigger logging in service (shows buffer size, cooldown state, trigger reasons)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
patrick-motsch 2026-02-15 03:26:23 +01:00
parent 7f63dba4aa
commit 89166a8f70
2 changed files with 127 additions and 8 deletions

View file

@ -7,8 +7,10 @@ Implements Teams Bot session management, live streaming, and configuration endpo
import logging import logging
import json import json
import re
import asyncio import asyncio
from typing import Optional from typing import Optional
from urllib.parse import urlparse, parse_qs, unquote
from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect, Query, Request from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -47,6 +49,59 @@ router = APIRouter(
# Helpers # 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): def _getInterface(context: RequestContext, instanceId: Optional[str] = None):
"""Get teamsbot interface with instance context.""" """Get teamsbot interface with instance context."""
mandateId = str(context.mandateId) if context.mandateId else None mandateId = str(context.mandateId) if context.mandateId else None
@ -108,11 +163,14 @@ async def startSession(
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
config = _getInstanceConfig(instanceId) config = _getInstanceConfig(instanceId)
# Extract and validate meeting URL from user input (handles SafeLinks, invitation text, etc.)
cleanMeetingUrl = _extractTeamsMeetingUrl(body.meetingLink)
# Create session # Create session
sessionData = TeamsbotSession( sessionData = TeamsbotSession(
instanceId=instanceId, instanceId=instanceId,
mandateId=mandateId, mandateId=mandateId,
meetingLink=body.meetingLink, meetingLink=cleanMeetingUrl,
botName=body.botName or config.botName, botName=body.botName or config.botName,
backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl, backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl,
status=TeamsbotSessionStatus.PENDING, status=TeamsbotSessionStatus.PENDING,
@ -131,7 +189,7 @@ async def startSession(
# Start the bot in background (join meeting via bridge) # Start the bot in background (join meeting via bridge)
service = TeamsbotService(context.user, mandateId, instanceId, config) service = TeamsbotService(context.user, mandateId, instanceId, config)
asyncio.create_task( 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}") logger.info(f"Teamsbot session {sessionId} created for instance {instanceId}")
@ -339,6 +397,57 @@ async def updateConfig(
return {"config": mergedConfig.model_dump()} 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 # Browser Bot Communication Endpoints
# ========================================================================= # =========================================================================

View file

@ -258,8 +258,11 @@ class TeamsbotService:
statusMap = { statusMap = {
"connecting": TeamsbotSessionStatus.JOINING.value, "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, "joined": TeamsbotSessionStatus.ACTIVE.value,
"in_meeting": TeamsbotSessionStatus.ACTIVE.value,
"left": TeamsbotSessionStatus.ENDED.value, "left": TeamsbotSessionStatus.ENDED.value,
"error": TeamsbotSessionStatus.ERROR.value, "error": TeamsbotSessionStatus.ERROR.value,
} }
@ -341,12 +344,17 @@ class TeamsbotService:
return return
if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY: if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY:
logger.debug(f"Session {sessionId}: responseMode=TRANSCRIBE_ONLY, skipping AI analysis")
return 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 return
# SPEECH_TEAMS AI analysis # 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) await self._analyzeAndRespond(sessionId, interface, voiceInterface, websocket, createdTranscript)
def _shouldTriggerAnalysis(self, transcriptText: str) -> bool: def _shouldTriggerAnalysis(self, transcriptText: str) -> bool:
@ -362,19 +370,21 @@ class TeamsbotService:
# Cooldown check # Cooldown check
timeSinceLastCall = now - self._lastAiCallTime timeSinceLastCall = now - self._lastAiCallTime
if timeSinceLastCall < self.config.triggerCooldownSeconds: if timeSinceLastCall < self.config.triggerCooldownSeconds:
logger.debug(f"Trigger: Cooldown active ({timeSinceLastCall:.1f}s < {self.config.triggerCooldownSeconds}s)")
return False return False
# Bot name mentioned -> immediate trigger # Bot name mentioned -> immediate trigger
botNameLower = self.config.botName.lower() botNameLower = self.config.botName.lower()
if botNameLower in transcriptText.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 return True
# Periodic trigger # Periodic trigger
if timeSinceLastCall >= self.config.triggerIntervalSeconds: 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 return True
logger.debug(f"Trigger: No trigger ({timeSinceLastCall:.1f}s / {self.config.triggerIntervalSeconds}s interval)")
return False return False
async def _analyzeAndRespond( async def _analyzeAndRespond(
@ -526,8 +536,8 @@ class TeamsbotService:
logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}") logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}")
except Exception as e: except Exception as e:
logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {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: {str(e)}"}) await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {type(e).__name__}: {str(e)}"})
# ========================================================================= # =========================================================================
# Meeting Summary # Meeting Summary