From 89166a8f70cbaa13112af483ecd9378141658a07 Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Sun, 15 Feb 2026 03:26:23 +0100
Subject: [PATCH] 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
---
.../features/teamsbot/routeFeatureTeamsbot.py | 113 +++++++++++++++++-
modules/features/teamsbot/service.py | 22 +++-
2 files changed, 127 insertions(+), 8 deletions(-)
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