931 lines
34 KiB
Python
931 lines
34 KiB
Python
# 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,
|
||
TeamsbotResponseChannel,
|
||
TeamsbotResponseMode,
|
||
)
|
||
|
||
# Import service
|
||
from .service import TeamsbotService
|
||
|
||
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="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
|
||
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="Feature instance has no mandateId")
|
||
|
||
return str(mandateId)
|
||
|
||
|
||
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,
|
||
backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl,
|
||
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 and bot name.
|
||
# NOTE: Authentication is currently disabled. The bot always joins as an anonymous
|
||
# guest with the system bot's display name. See Teamsbot-Auth-Join-Learnings.md.
|
||
# Credentials are NOT sent to the browser bot.
|
||
joinMode = body.joinMode or TeamsbotJoinMode.ANONYMOUS
|
||
effectiveBotName = body.botName
|
||
|
||
# If a system bot exists, use its display name as the bot name (e.g. "Nyla Larsson")
|
||
systemBot = interface.getActiveSystemBot(mandateId)
|
||
if systemBot:
|
||
if not effectiveBotName:
|
||
effectiveBotName = systemBot.get("name") or effectiveConfig.botName
|
||
logger.info(f"System bot found: {systemBot.get('name')} ({systemBot.get('email')}), using name: {effectiveBotName}")
|
||
|
||
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 — no credentials sent (auth disabled)
|
||
sessionConfig = effectiveConfig.model_copy(update={
|
||
"botAccountEmail": None,
|
||
"botAccountPassword": None,
|
||
"botName": effectiveBotName,
|
||
})
|
||
|
||
# Start the bot in background (join meeting via bridge)
|
||
service = TeamsbotService(context.user, mandateId, instanceId, sessionConfig)
|
||
asyncio.create_task(
|
||
service.joinMeeting(sessionId, cleanMeetingUrl, body.connectionId, gatewayBaseUrl)
|
||
)
|
||
|
||
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 all sessions for a feature instance."""
|
||
_validateInstanceAccess(instanceId, context)
|
||
interface = _getInterface(context, instanceId)
|
||
sessions = interface.getSessions(instanceId, includeEnded=includeEnded)
|
||
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")
|
||
|
||
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")
|
||
|
||
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")
|
||
|
||
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")
|
||
|
||
# Don't delete active sessions
|
||
currentStatus = session.get("status")
|
||
if currentStatus in [TeamsbotSessionStatus.ACTIVE.value, TeamsbotSessionStatus.JOINING.value]:
|
||
raise HTTPException(status_code=400, detail="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", "backgroundImageUrl", "aiSystemPrompt", "responseMode",
|
||
"responseChannel", "language", "voiceId",
|
||
"triggerIntervalSeconds", "triggerCooldownSeconds", "contextWindowSegments"]:
|
||
value = userSettings.get(field)
|
||
if value is not None:
|
||
overrides[field] = value
|
||
|
||
if overrides:
|
||
return baseConfig.model_copy(update=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."""
|
||
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."""
|
||
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="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."""
|
||
_validateInstanceAccess(instanceId, context)
|
||
interface = _getInterface(context, instanceId)
|
||
|
||
interface.deleteSystemBot(botId)
|
||
logger.info(f"System bot {botId} deleted")
|
||
return {"deleted": True}
|
||
|
||
|
||
# =========================================================================
|
||
# 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.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.
|
||
"""
|
||
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="meetingUrl is required")
|
||
|
||
# Load system bot credentials for the auth test variant
|
||
email = None
|
||
password = None
|
||
systemBot = interface.getActiveSystemBot(mandateId)
|
||
if systemBot:
|
||
email = systemBot.get("email")
|
||
encryptedPwd = systemBot.get("encryptedPassword")
|
||
if encryptedPwd:
|
||
from modules.shared.configuration import decryptValue
|
||
password = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
||
|
||
# Forward to browser bot service
|
||
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
|
||
if not browserBotUrl:
|
||
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
|
||
|
||
browserBotUrl = browserBotUrl.rstrip("/")
|
||
payload = {
|
||
"meetingUrl": meetingUrl,
|
||
"botAccountEmail": email,
|
||
"botAccountPassword": password,
|
||
}
|
||
|
||
try:
|
||
# Generous timeout: 5 variants × ~45s each = ~225s max
|
||
timeout = aiohttp.ClientTimeout(total=300)
|
||
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:
|
||
return await resp.json()
|
||
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)}")
|
||
|
||
|
||
# =========================================================================
|
||
# 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)
|
||
|
||
if not text.strip():
|
||
return {"success": True, "message": "Empty transcript ignored"}
|
||
|
||
try:
|
||
config = _getInstanceConfig(instanceId)
|
||
|
||
# Load original user context from session
|
||
from modules.datamodels.datamodelUam import User
|
||
|
||
systemUser = User(id="system", username="system", email="system@poweron.swiss")
|
||
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
|
||
session = sessionInterface.getSession(sessionId)
|
||
mandateId = session.get("mandateId") if session else None
|
||
startedByUserId = session.get("startedByUserId") if session else None
|
||
|
||
rootInterface = getRootInterface()
|
||
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
|
||
if not originalUser:
|
||
originalUser = systemUser
|
||
|
||
# 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
|
||
)
|
||
|
||
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)
|
||
|
||
from modules.datamodels.datamodelUam import User
|
||
|
||
systemUser = User(id="system", username="system", email="system@poweron.swiss")
|
||
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
|
||
session = sessionInterface.getSession(sessionId)
|
||
mandateId = session.get("mandateId") if session else None
|
||
startedByUserId = session.get("startedByUserId") if session else None
|
||
|
||
rootInterface = getRootInterface()
|
||
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
|
||
if not originalUser:
|
||
originalUser = systemUser
|
||
|
||
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.datamodels.datamodelUam import User
|
||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||
|
||
systemUser = User(id="system", username="system", email="system@poweron.swiss")
|
||
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
|
||
session = sessionInterface.getSession(sessionId)
|
||
mandateId = session.get("mandateId") if session else None
|
||
startedByUserId = session.get("startedByUserId") if session else None
|
||
|
||
# Look up the original user (getRootInterface uses admin context, can load any user)
|
||
rootInterface = getRootInterface()
|
||
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
|
||
|
||
if not originalUser:
|
||
logger.warning(f"Could not load original user {startedByUserId}, falling back to system user")
|
||
originalUser = systemUser
|
||
|
||
service = TeamsbotService(originalUser, mandateId, instanceId, config)
|
||
logger.info(f"Browser Bot WebSocket service created: session={sessionId}, mandateId={mandateId}, user={originalUser.id}")
|
||
|
||
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}")
|