teams bridge

This commit is contained in:
patrick-motsch 2026-02-13 07:27:33 +01:00
parent edecfb002c
commit b7e4efb3a3
8 changed files with 88 additions and 52 deletions

View file

@ -57,6 +57,9 @@ Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhI
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
# Teamsbot Media Bridge
TEAMSBOT_BRIDGE_URL = https://media.poweron.swiss:9440
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug

View file

@ -57,6 +57,9 @@ Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
# Teamsbot Media Bridge
TEAMSBOT_BRIDGE_URL = https://media.poweron.swiss:9440
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat

View file

@ -57,6 +57,9 @@ Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Media Bridge
TEAMSBOT_BRIDGE_URL = https://media.poweron.swiss:9440
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat

View file

@ -116,11 +116,18 @@ class TeamsbotConfig(BaseModel):
responseMode: TeamsbotResponseMode = Field(default=TeamsbotResponseMode.AUTO, description="How the bot responds")
language: str = Field(default="de-DE", description="Primary language for STT/TTS")
voiceId: Optional[str] = Field(default=None, description="Google TTS voice ID (e.g., de-DE-Standard-A)")
bridgeUrl: Optional[str] = Field(default=None, description="URL of the .NET Media Bridge service")
bridgeUrl: Optional[str] = Field(default=None, description="URL of the .NET Media Bridge service. Falls back to TEAMSBOT_BRIDGE_URL env variable if not set per-instance.")
triggerIntervalSeconds: int = Field(default=10, ge=3, le=60, description="Seconds between periodic AI analysis triggers")
triggerCooldownSeconds: int = Field(default=3, ge=1, le=30, description="Minimum seconds between AI calls")
contextWindowSegments: int = Field(default=20, ge=5, le=100, description="Number of transcript segments to include in AI context")
def _getEffectiveBridgeUrl(self) -> Optional[str]:
"""Resolve the effective bridge URL: per-instance config takes priority, then env variable."""
if self.bridgeUrl:
return self.bridgeUrl
from modules.shared.configuration import APP_CONFIG
return APP_CONFIG.get("TEAMSBOT_BRIDGE_URL")
# ============================================================================
# API Request/Response Models

View file

@ -45,7 +45,22 @@ class TeamsbotObjects:
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.db = DatabaseConnector()
self.userId = str(currentUser.id) if currentUser else "system"
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_teamsbot"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
# =========================================================================
# Sessions
@ -53,35 +68,32 @@ class TeamsbotObjects:
def getSessions(self, instanceId: str, includeEnded: bool = True) -> List[Dict[str, Any]]:
"""Get all sessions for a feature instance."""
filters = {"instanceId": instanceId}
if not includeEnded:
filters["status__ne"] = TeamsbotSessionStatus.ENDED.value
records = self.db.getRecordset(
TeamsbotSession,
filters=filters,
orderBy=[("startedAt", "DESC")]
recordFilter={"instanceId": instanceId},
)
if not includeEnded:
records = [r for r in records if r.get("status") != TeamsbotSessionStatus.ENDED.value]
# Sort by startedAt descending
records.sort(key=lambda r: r.get("startedAt") or "", reverse=True)
return records
def getActiveSessions(self, instanceId: str) -> List[Dict[str, Any]]:
"""Get only active (non-ended, non-error) sessions."""
records = self.db.getRecordset(
TeamsbotSession,
filters={
"instanceId": instanceId,
"status__in": [
recordFilter={"instanceId": instanceId},
)
activeStatuses = {
TeamsbotSessionStatus.PENDING.value,
TeamsbotSessionStatus.JOINING.value,
TeamsbotSessionStatus.ACTIVE.value,
]
}
)
return records
return [r for r in records if r.get("status") in activeStatuses]
def getSession(self, sessionId: str) -> Optional[Dict[str, Any]]:
"""Get a single session by ID."""
records = self.db.getRecordset(TeamsbotSession, filters={"id": sessionId})
records = self.db.getRecordset(TeamsbotSession, recordFilter={"id": sessionId})
return records[0] if records else None
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
@ -109,27 +121,25 @@ class TeamsbotObjects:
def getTranscripts(self, sessionId: str, limit: int = None, offset: int = None) -> List[Dict[str, Any]]:
"""Get transcript segments for a session, ordered by timestamp."""
filters = {"sessionId": sessionId}
records = self.db.getRecordset(
TeamsbotTranscript,
filters=filters,
orderBy=[("timestamp", "ASC")],
limit=limit,
offset=offset
recordFilter={"sessionId": sessionId},
)
records.sort(key=lambda r: r.get("timestamp") or "")
if offset:
records = records[offset:]
if limit:
records = records[:limit]
return records
def getRecentTranscripts(self, sessionId: str, count: int = 20) -> List[Dict[str, Any]]:
"""Get the most recent N transcript segments for context building."""
records = self.db.getRecordset(
TeamsbotTranscript,
filters={"sessionId": sessionId},
orderBy=[("timestamp", "DESC")],
limit=count
recordFilter={"sessionId": sessionId},
)
# Reverse to get chronological order
records.reverse()
return records
records.sort(key=lambda r: r.get("timestamp") or "")
return records[-count:]
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new transcript segment."""
@ -138,7 +148,7 @@ class TeamsbotObjects:
def _deleteTranscriptsBySession(self, sessionId: str) -> int:
"""Delete all transcripts for a session."""
records = self.db.getRecordset(TeamsbotTranscript, filters={"sessionId": sessionId})
records = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
count = 0
for record in records:
self.db.recordDelete(TeamsbotTranscript, record.get("id"))
@ -153,9 +163,9 @@ class TeamsbotObjects:
"""Get all bot responses for a session."""
records = self.db.getRecordset(
TeamsbotBotResponse,
filters={"sessionId": sessionId},
orderBy=[("timestamp", "ASC")]
recordFilter={"sessionId": sessionId},
)
records.sort(key=lambda r: r.get("timestamp") or "")
return records
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
@ -165,7 +175,7 @@ class TeamsbotObjects:
def _deleteResponsesBySession(self, sessionId: str) -> int:
"""Delete all bot responses for a session."""
records = self.db.getRecordset(TeamsbotBotResponse, filters={"sessionId": sessionId})
records = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
count = 0
for record in records:
self.db.recordDelete(TeamsbotBotResponse, record.get("id"))
@ -178,8 +188,8 @@ class TeamsbotObjects:
def getSessionStats(self, sessionId: str) -> Dict[str, Any]:
"""Get aggregated statistics for a session."""
transcripts = self.db.getRecordset(TeamsbotTranscript, filters={"sessionId": sessionId})
responses = self.db.getRecordset(TeamsbotBotResponse, filters={"sessionId": sessionId})
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
totalCost = sum(r.get("priceCHF", 0) for r in responses)
totalProcessingTime = sum(r.get("processingTime", 0) for r in responses)

View file

@ -98,9 +98,9 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
@router.post("/{instanceId}/sessions")
@limiter.limit("10/minute")
async def startSession(
request: Request,
instanceId: str,
request: TeamsbotStartSessionRequest,
httpRequest: Request,
body: TeamsbotStartSessionRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Start a new Teams Bot session -- bot joins the specified meeting."""
@ -112,9 +112,9 @@ async def startSession(
sessionData = TeamsbotSession(
instanceId=instanceId,
mandateId=mandateId,
meetingLink=request.meetingLink,
botName=request.botName or config.botName,
backgroundImageUrl=request.backgroundImageUrl or config.backgroundImageUrl,
meetingLink=body.meetingLink,
botName=body.botName or config.botName,
backgroundImageUrl=body.backgroundImageUrl or config.backgroundImageUrl,
status=TeamsbotSessionStatus.PENDING,
startedByUserId=str(context.user.id),
).model_dump()
@ -124,12 +124,12 @@ async def startSession(
# Derive gateway base URL from the incoming request so the bridge
# can build full callback/WS URLs targeting this specific gateway instance.
gatewayBaseUrl = str(httpRequest.base_url).rstrip("/")
gatewayBaseUrl = str(request.base_url).rstrip("/")
# Start the bot in background (join meeting via bridge)
service = TeamsbotService(context.user, mandateId, instanceId, config)
asyncio.create_task(
service.joinMeeting(sessionId, request.meetingLink, request.connectionId, gatewayBaseUrl)
service.joinMeeting(sessionId, body.meetingLink, body.connectionId, gatewayBaseUrl)
)
logger.info(f"Teamsbot session {sessionId} created for instance {instanceId}")
@ -139,9 +139,10 @@ async def startSession(
@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)
context: RequestContext = Depends(getRequestContext),
):
"""List all sessions for a feature instance."""
_validateInstanceAccess(instanceId, context)
@ -153,11 +154,12 @@ async def listSessions(
@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)
context: RequestContext = Depends(getRequestContext),
):
"""Get session details with optional transcripts and bot responses."""
_validateInstanceAccess(instanceId, context)
@ -182,9 +184,10 @@ async def getSession(
@router.get("/{instanceId}/sessions/{sessionId}/stream")
@limiter.limit("10/minute")
async def streamSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext)
context: RequestContext = Depends(getRequestContext),
):
"""
SSE live stream for a session.
@ -240,9 +243,10 @@ async def streamSession(
@router.post("/{instanceId}/sessions/{sessionId}/stop")
@limiter.limit("10/minute")
async def stopSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext)
context: RequestContext = Depends(getRequestContext),
):
"""Stop an active session -- bot leaves the meeting."""
mandateId = _validateInstanceAccess(instanceId, context)
@ -268,9 +272,10 @@ async def stopSession(
@router.delete("/{instanceId}/sessions/{sessionId}")
@limiter.limit("10/minute")
async def deleteSession(
request: Request,
instanceId: str,
sessionId: str,
context: RequestContext = Depends(getRequestContext)
context: RequestContext = Depends(getRequestContext),
):
"""Delete a session and all related data."""
_validateInstanceAccess(instanceId, context)
@ -297,8 +302,9 @@ async def deleteSession(
@router.get("/{instanceId}/config")
@limiter.limit("30/minute")
async def getConfig(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
context: RequestContext = Depends(getRequestContext),
):
"""Get the teamsbot configuration for a feature instance."""
_validateInstanceAccess(instanceId, context)
@ -309,16 +315,17 @@ async def getConfig(
@router.put("/{instanceId}/config")
@limiter.limit("10/minute")
async def updateConfig(
request: Request,
instanceId: str,
request: TeamsbotConfigUpdateRequest,
context: RequestContext = Depends(getRequestContext)
configUpdate: TeamsbotConfigUpdateRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Update the teamsbot configuration."""
mandateId = _validateInstanceAccess(instanceId, context)
# Load current config and merge updates
currentConfig = _getInstanceConfig(instanceId)
updateDict = request.model_dump(exclude_none=True)
updateDict = configUpdate.model_dump(exclude_none=True)
mergedConfig = currentConfig.model_copy(update=updateDict)
# Save to FeatureInstance.config

View file

@ -55,7 +55,7 @@ class TeamsbotService:
self.mandateId = mandateId
self.instanceId = instanceId
self.config = config
self.bridgeConnector = BridgeConnector(config.bridgeUrl)
self.bridgeConnector = BridgeConnector(config._getEffectiveBridgeUrl())
# State
self._lastAiCallTime: float = 0.0

View file

@ -108,6 +108,9 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
elif featureCode == "automation":
from modules.features.automation.mainAutomation import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "teamsbot":
from modules.features.teamsbot.mainTeamsbot import UI_OBJECTS
return UI_OBJECTS
else:
logger.warning(f"Unknown feature code: {featureCode}")
return []