1394 lines
56 KiB
Python
1394 lines
56 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,
|
|
TeamsbotUserAccount,
|
|
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,
|
|
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, bot name, and credentials
|
|
joinMode = body.joinMode or TeamsbotJoinMode.ANONYMOUS
|
|
logger.debug(f"startSession: requested joinMode={body.joinMode}, effective joinMode={joinMode}, userId={userId}, mandateId={mandateId}")
|
|
effectiveBotName = body.botName
|
|
botAccountEmail = None
|
|
botAccountPassword = None
|
|
|
|
# System bot access: only SysAdmin can use the system bot account.
|
|
if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
|
systemBot = interface.getActiveSystemBot(mandateId)
|
|
if not systemBot:
|
|
from .datamodelTeamsbot import TeamsbotSystemBot
|
|
allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True})
|
|
if allBots:
|
|
systemBot = allBots[0]
|
|
logger.info(f"No mandate-specific system bot, using fallback: {systemBot.get('name')} ({systemBot.get('email')})")
|
|
|
|
if systemBot:
|
|
if not effectiveBotName:
|
|
effectiveBotName = systemBot.get("name")
|
|
if not effectiveBotName:
|
|
sbEmail = systemBot.get("email", "")
|
|
if sbEmail and "@" in sbEmail:
|
|
emailPrefix = sbEmail.split("@")[0]
|
|
effectiveBotName = " ".join(part.capitalize() for part in emailPrefix.split("."))
|
|
logger.info(f"Bot name derived from email: {effectiveBotName}")
|
|
if not effectiveBotName:
|
|
effectiveBotName = effectiveConfig.botName
|
|
logger.info(f"System bot found: {systemBot.get('name')} ({systemBot.get('email')}), effectiveBotName={effectiveBotName}")
|
|
|
|
botAccountEmail = systemBot.get("email")
|
|
encryptedPwd = systemBot.get("encryptedPassword")
|
|
if botAccountEmail and encryptedPwd:
|
|
try:
|
|
from modules.shared.configuration import decryptValue
|
|
botAccountPassword = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
|
logger.info(f"System bot credentials loaded and decrypted for: {botAccountEmail}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not decrypt system bot password: {e} -- falling back to anonymous join")
|
|
botAccountEmail = None
|
|
botAccountPassword = None
|
|
else:
|
|
logger.info("No system bot found in DB -- using anonymous join")
|
|
|
|
# User Account access: load saved MS credentials for the current user
|
|
elif joinMode == TeamsbotJoinMode.USER_ACCOUNT:
|
|
logger.debug(f"USER_ACCOUNT branch entered for userId={userId}, mandateId={mandateId}")
|
|
userAccount = interface.getUserAccount(userId, mandateId)
|
|
logger.debug(f"getUserAccount result: {bool(userAccount)}, keys={list(userAccount.keys()) if userAccount else 'N/A'}")
|
|
if userAccount:
|
|
botAccountEmail = userAccount.get("email")
|
|
encryptedPwd = userAccount.get("encryptedPassword")
|
|
logger.debug(f"UserAccount email={botAccountEmail}, hasEncryptedPwd={bool(encryptedPwd)}, encPwdPrefix={encryptedPwd[:20] if encryptedPwd else 'N/A'}...")
|
|
if botAccountEmail and encryptedPwd:
|
|
try:
|
|
from modules.shared.configuration import decryptValue
|
|
botAccountPassword = decryptValue(encryptedPwd, userId=userId, keyName="userAccountPassword")
|
|
logger.info(f"User account credentials loaded and decrypted for: {botAccountEmail}")
|
|
if not effectiveBotName:
|
|
effectiveBotName = userAccount.get("displayName")
|
|
if not effectiveBotName and "@" in botAccountEmail:
|
|
emailPrefix = botAccountEmail.split("@")[0]
|
|
effectiveBotName = " ".join(part.capitalize() for part in emailPrefix.split("."))
|
|
except Exception as e:
|
|
logger.warning(f"Could not decrypt user account password: {e}")
|
|
botAccountEmail = None
|
|
botAccountPassword = None
|
|
else:
|
|
logger.warning(f"UserAccount record found but missing email or encryptedPassword")
|
|
else:
|
|
logger.warning(f"No saved credentials for user {userId}, mandateId={mandateId} -- falling back to anonymous join")
|
|
joinMode = TeamsbotJoinMode.ANONYMOUS
|
|
|
|
else:
|
|
if body.joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
|
logger.warning(f"Non-SysAdmin user {context.user.id} attempted to use system bot -- forced to anonymous join")
|
|
joinMode = TeamsbotJoinMode.ANONYMOUS
|
|
logger.info(f"User {context.user.id}: using anonymous join")
|
|
|
|
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
|
|
sessionConfig = effectiveConfig.model_copy(update={
|
|
"botName": effectiveBotName,
|
|
})
|
|
|
|
# Start the bot in background — pass credentials separately (not in config)
|
|
logger.debug(f"startSession FINAL: joinMode={joinMode}, hasEmail={bool(botAccountEmail)}, hasPassword={bool(botAccountPassword)}, botName={effectiveBotName}")
|
|
service = TeamsbotService(context.user, mandateId, instanceId, sessionConfig)
|
|
asyncio.create_task(
|
|
service.joinMeeting(sessionId, cleanMeetingUrl, body.connectionId, gatewayBaseUrl, botAccountEmail, botAccountPassword)
|
|
)
|
|
|
|
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", "aiSystemPrompt", "responseMode",
|
|
"responseChannel", "transferMode", "language", "voiceId",
|
|
"triggerIntervalSeconds", "triggerCooldownSeconds", "contextWindowSegments",
|
|
"debugMode"]:
|
|
value = userSettings.get(field)
|
|
if value is not None:
|
|
overrides[field] = value
|
|
|
|
if overrides:
|
|
return TeamsbotConfig.model_validate({**baseConfig.model_dump(), **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."""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots")
|
|
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."""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots")
|
|
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."""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots")
|
|
_validateInstanceAccess(instanceId, context)
|
|
interface = _getInterface(context, instanceId)
|
|
|
|
interface.deleteSystemBot(botId)
|
|
logger.info(f"System bot {botId} deleted")
|
|
return {"deleted": True}
|
|
|
|
|
|
# =========================================================================
|
|
# User Account Endpoints (Mein Account)
|
|
# =========================================================================
|
|
|
|
@router.get("/{instanceId}/user-account")
|
|
@limiter.limit("30/minute")
|
|
async def getUserAccount(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Get saved MS credentials status for the current user (password is never returned)."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
interface = _getInterface(context, instanceId)
|
|
userId = str(context.user.id)
|
|
account = interface.getUserAccount(userId, mandateId)
|
|
if not account:
|
|
return {"hasSavedCredentials": False}
|
|
return {
|
|
"hasSavedCredentials": True,
|
|
"email": account.get("email"),
|
|
"displayName": account.get("displayName"),
|
|
}
|
|
|
|
|
|
@router.post("/{instanceId}/user-account")
|
|
@limiter.limit("5/minute")
|
|
async def saveUserAccount(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Save or update MS credentials for 'Mein Account' login."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
interface = _getInterface(context, instanceId)
|
|
userId = str(context.user.id)
|
|
|
|
body = await request.json()
|
|
email = body.get("email")
|
|
password = body.get("password")
|
|
displayName = body.get("displayName")
|
|
|
|
if not email or not password:
|
|
raise HTTPException(status_code=400, detail="Email and password are required")
|
|
|
|
from modules.shared.configuration import encryptValue
|
|
encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword")
|
|
|
|
existing = interface.getUserAccount(userId, mandateId)
|
|
if existing:
|
|
interface.updateUserAccount(existing["id"], {
|
|
"email": email,
|
|
"encryptedPassword": encryptedPassword,
|
|
"displayName": displayName or existing.get("displayName"),
|
|
})
|
|
logger.info(f"User account updated for user {userId}: {email}")
|
|
else:
|
|
interface.createUserAccount(TeamsbotUserAccount(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
email=email,
|
|
encryptedPassword=encryptedPassword,
|
|
displayName=displayName,
|
|
).model_dump())
|
|
logger.info(f"User account created for user {userId}: {email}")
|
|
|
|
return {"saved": True, "email": email}
|
|
|
|
|
|
@router.delete("/{instanceId}/user-account")
|
|
@limiter.limit("5/minute")
|
|
async def deleteUserAccount(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Delete saved MS credentials for the current user."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
interface = _getInterface(context, instanceId)
|
|
userId = str(context.user.id)
|
|
account = interface.getUserAccount(userId, mandateId)
|
|
if account:
|
|
interface.deleteUserAccount(account["id"])
|
|
logger.info(f"User account deleted for user {userId}")
|
|
return {"deleted": True}
|
|
|
|
|
|
# =========================================================================
|
|
# MFA Code Submission (relayed to active bot session)
|
|
# =========================================================================
|
|
|
|
_mfaCodeQueues: dict = {}
|
|
|
|
@router.post("/{instanceId}/sessions/{sessionId}/mfa")
|
|
@limiter.limit("10/minute")
|
|
async def submitMfaCode(
|
|
request: Request,
|
|
instanceId: str,
|
|
sessionId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Submit MFA code/confirmation from the frontend to the active bot session."""
|
|
_validateInstanceAccess(instanceId, context)
|
|
body = await request.json()
|
|
mfaCode = body.get("code", "")
|
|
mfaAction = body.get("action", "code")
|
|
|
|
logger.info(f"MFA submission for session {sessionId}: action={mfaAction}, codeLen={len(mfaCode)}")
|
|
|
|
queue = _mfaCodeQueues.get(sessionId)
|
|
if queue:
|
|
await queue.put({"action": mfaAction, "code": mfaCode})
|
|
return {"submitted": True}
|
|
else:
|
|
raise HTTPException(status_code=404, detail="No active MFA challenge for this session")
|
|
|
|
|
|
# =========================================================================
|
|
# 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.
|
|
"""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing (uses system bot credentials)")
|
|
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:
|
|
# 1. Use email/password from request body (direct override)
|
|
# 2. Fallback: load from DB (system bot record)
|
|
email = body.get("botEmail")
|
|
password = body.get("botPassword")
|
|
credentialDebug = {"mandateId": mandateId, "source": "none"}
|
|
|
|
if email and password:
|
|
# Direct override from request body
|
|
credentialDebug["source"] = "requestBody"
|
|
credentialDebug["botEmail"] = email
|
|
logger.info(f"[test-auth] Using credentials from request: {email}")
|
|
|
|
# Also create/update system bot in DB for future use
|
|
try:
|
|
from modules.shared.configuration import encryptValue
|
|
encryptedPassword = encryptValue(password, userId=str(context.user.id), keyName="systemBotPassword")
|
|
existingBot = interface.getActiveSystemBot(mandateId)
|
|
if not existingBot:
|
|
botData = TeamsbotSystemBot(
|
|
mandateId=mandateId,
|
|
name=email.split("@")[0].replace(".", " ").title(),
|
|
email=email,
|
|
encryptedPassword=encryptedPassword,
|
|
isActive=True,
|
|
).model_dump()
|
|
interface.createSystemBot(botData)
|
|
credentialDebug["dbCreated"] = True
|
|
logger.info(f"[test-auth] Created system bot in DB: {email} for mandate {mandateId}")
|
|
else:
|
|
credentialDebug["dbExists"] = True
|
|
except Exception as e:
|
|
logger.warning(f"[test-auth] Could not save system bot to DB: {e}")
|
|
else:
|
|
# Try loading from DB
|
|
systemBot = interface.getActiveSystemBot(mandateId)
|
|
if not systemBot:
|
|
# Fallback: search ALL active bots
|
|
allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True})
|
|
if allBots:
|
|
systemBot = allBots[0]
|
|
credentialDebug["source"] = "dbFallback"
|
|
|
|
if systemBot:
|
|
email = systemBot.get("email")
|
|
encryptedPwd = systemBot.get("encryptedPassword")
|
|
credentialDebug["source"] = credentialDebug.get("source", "db")
|
|
credentialDebug["botEmail"] = email
|
|
if encryptedPwd:
|
|
try:
|
|
from modules.shared.configuration import decryptValue
|
|
password = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
|
logger.info(f"[test-auth] Loaded from DB: {email}")
|
|
except Exception as e:
|
|
credentialDebug["passwordError"] = str(e)
|
|
logger.error(f"[test-auth] Password decryption failed: {e}")
|
|
else:
|
|
logger.warning(f"[test-auth] No credentials provided and no system bot in DB")
|
|
credentialDebug["source"] = "noneFound"
|
|
|
|
# Forward to browser bot service (single all-in-one call — may timeout with many variants)
|
|
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:
|
|
timeout = aiohttp.ClientTimeout(total=210)
|
|
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:
|
|
result = await resp.json()
|
|
result["credentialDebug"] = credentialDebug
|
|
return result
|
|
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)}")
|
|
|
|
|
|
@router.get("/{instanceId}/test-auth/variants")
|
|
@limiter.limit("10/minute")
|
|
async def getTestAuthVariants(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Get list of available test variant IDs from the Browser Bot.
|
|
Frontend calls this once, then runs each variant individually.
|
|
"""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing")
|
|
import aiohttp
|
|
|
|
_validateInstanceAccess(instanceId, context)
|
|
effectiveConfig = _getInstanceConfig(instanceId)
|
|
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
|
|
if not browserBotUrl:
|
|
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
|
|
|
|
browserBotUrl = browserBotUrl.rstrip("/")
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=30)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.get(f"{browserBotUrl}/api/bot/test-auth/variants") as resp:
|
|
if resp.status == 200:
|
|
return await resp.json()
|
|
else:
|
|
errorText = await resp.text()
|
|
raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}")
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Get variants error: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}")
|
|
|
|
|
|
@router.post("/{instanceId}/test-auth/variant")
|
|
@limiter.limit("10/minute")
|
|
async def testAuthSingleVariant(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""
|
|
Run a single test variant. Frontend calls this once per variant (sequentially).
|
|
Each call stays within Azure's 240s timeout.
|
|
"""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing (uses system bot credentials)")
|
|
import aiohttp
|
|
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
interface = _getInterface(context, instanceId)
|
|
effectiveConfig = _getInstanceConfig(instanceId)
|
|
|
|
body = await request.json()
|
|
variantId = body.get("variantId")
|
|
meetingUrl = body.get("meetingUrl")
|
|
if not variantId or not meetingUrl:
|
|
raise HTTPException(status_code=400, detail="variantId and meetingUrl are required")
|
|
|
|
# Load credentials (same logic as testAuth)
|
|
email = body.get("botEmail")
|
|
password = body.get("botPassword")
|
|
credentialDebug = {"mandateId": mandateId, "source": "none"}
|
|
|
|
if email and password:
|
|
credentialDebug["source"] = "requestBody"
|
|
credentialDebug["botEmail"] = email
|
|
else:
|
|
# Fallback: load from DB
|
|
try:
|
|
from modules.shared.configuration import decryptValue
|
|
systemBot = interface.getActiveSystemBot(mandateId)
|
|
if not systemBot:
|
|
systemBot = interface.getActiveSystemBot(None)
|
|
if systemBot:
|
|
credentialDebug["searchStrategy"] = "anyMandate"
|
|
if systemBot:
|
|
email = systemBot.get("email")
|
|
encryptedPwd = systemBot.get("encryptedPassword")
|
|
if email and encryptedPwd:
|
|
password = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
|
credentialDebug["source"] = "database"
|
|
credentialDebug["botEmail"] = email
|
|
credentialDebug["botFound"] = True
|
|
except Exception as e:
|
|
logger.warning(f"[test-auth-variant] Could not load system bot: {e}")
|
|
|
|
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
|
|
if not browserBotUrl:
|
|
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
|
|
|
|
browserBotUrl = browserBotUrl.rstrip("/")
|
|
payload = {
|
|
"variantId": variantId,
|
|
"meetingUrl": meetingUrl,
|
|
"botAccountEmail": email,
|
|
"botAccountPassword": password,
|
|
}
|
|
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=180)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.post(f"{browserBotUrl}/api/bot/test-auth/variant", json=payload) as resp:
|
|
if resp.status == 200:
|
|
result = await resp.json()
|
|
result["credentialDebug"] = credentialDebug
|
|
return result
|
|
else:
|
|
errorText = await resp.text()
|
|
logger.error(f"[test-auth-variant] {variantId} failed: {resp.status}")
|
|
raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}")
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"[test-auth-variant] {variantId} connection error: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}")
|
|
|
|
|
|
# =========================================================================
|
|
# Debug Screenshot Endpoints (SysAdmin only)
|
|
# =========================================================================
|
|
|
|
@router.get("/{instanceId}/sessions/{sessionId}/screenshots")
|
|
@limiter.limit("30/minute")
|
|
async def listSessionScreenshots(
|
|
request: Request,
|
|
instanceId: str,
|
|
sessionId: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""List debug screenshots for a session. Proxied from Browser Bot filesystem."""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required")
|
|
_validateInstanceAccess(instanceId, context)
|
|
effectiveConfig = _getInstanceConfig(instanceId)
|
|
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
|
|
if not browserBotUrl:
|
|
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
|
|
|
|
import aiohttp
|
|
browserBotUrl = browserBotUrl.rstrip("/")
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=15)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.get(f"{browserBotUrl}/api/bot/screenshots/{sessionId}") as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
screenshots = data.get("screenshots", [])
|
|
for s in screenshots:
|
|
s["url"] = f"/api/teamsbot/{instanceId}/screenshots/{s['name']}"
|
|
return {"screenshots": screenshots}
|
|
else:
|
|
errorText = await resp.text()
|
|
raise HTTPException(status_code=resp.status, detail=f"Browser Bot error: {errorText}")
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Screenshot list error: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}")
|
|
|
|
|
|
@router.get("/{instanceId}/screenshots/{filename}")
|
|
@limiter.limit("60/minute")
|
|
async def getScreenshotFile(
|
|
request: Request,
|
|
instanceId: str,
|
|
filename: str,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Serve a single debug screenshot image. Proxied from Browser Bot."""
|
|
if not context.isSysAdmin:
|
|
raise HTTPException(status_code=403, detail="SysAdmin privileges required")
|
|
_validateInstanceAccess(instanceId, context)
|
|
|
|
if not filename.endswith(".png") or ".." in filename or "/" in filename or "\\" in filename:
|
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
|
effectiveConfig = _getInstanceConfig(instanceId)
|
|
browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl()
|
|
if not browserBotUrl:
|
|
raise HTTPException(status_code=503, detail="Browser Bot URL not configured")
|
|
|
|
import aiohttp
|
|
from fastapi.responses import Response as FastAPIResponse
|
|
browserBotUrl = browserBotUrl.rstrip("/")
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=30)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.get(f"{browserBotUrl}/api/bot/screenshots/file/{filename}") as resp:
|
|
if resp.status == 200:
|
|
imageBytes = await resp.read()
|
|
return FastAPIResponse(content=imageBytes, media_type="image/png")
|
|
else:
|
|
raise HTTPException(status_code=resp.status, detail="Screenshot not found")
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"Screenshot file 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)
|
|
source = transcript.get("source", "caption")
|
|
|
|
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
|
|
source=source,
|
|
)
|
|
|
|
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
|
|
|
|
# Build effective config with the session's actual bot name.
|
|
# The session stores the resolved bot name (from system bot or user override).
|
|
# Without this, the default config botName (e.g. "AI Assistant") is used,
|
|
# which is wrong for registered system bots.
|
|
sessionBotName = session.get("botName") if session else None
|
|
if sessionBotName:
|
|
config = config.model_copy(update={"botName": sessionBotName})
|
|
logger.info(f"Browser Bot WebSocket: Using session botName '{sessionBotName}' (not default '{_getInstanceConfig(instanceId).botName}')")
|
|
|
|
# Also merge user-specific settings if available
|
|
if startedByUserId:
|
|
interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId)
|
|
userSettings = interface.getUserSettings(startedByUserId, instanceId)
|
|
if userSettings:
|
|
overrides = {}
|
|
for field in ["aiSystemPrompt", "responseMode", "responseChannel", "transferMode",
|
|
"language", "voiceId", "triggerIntervalSeconds", "triggerCooldownSeconds",
|
|
"contextWindowSegments"]:
|
|
value = userSettings.get(field)
|
|
if value is not None:
|
|
overrides[field] = value
|
|
if overrides:
|
|
config = TeamsbotConfig.model_validate({**config.model_dump(), **overrides})
|
|
logger.info(f"Browser Bot WebSocket: Applied user settings overrides: {list(overrides.keys())}")
|
|
|
|
service = TeamsbotService(originalUser, mandateId, instanceId, config)
|
|
logger.info(f"Browser Bot WebSocket service created: session={sessionId}, mandateId={mandateId}, user={originalUser.id}, botName={config.botName}")
|
|
|
|
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}")
|