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:
parent
7f63dba4aa
commit
89166a8f70
2 changed files with 127 additions and 8 deletions
|
|
@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue