diff --git a/env_dev.env b/env_dev.env index 5339bbaf..da6f931f 100644 --- a/env_dev.env +++ b/env_dev.env @@ -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 diff --git a/env_int.env b/env_int.env index 5534cbdf..87cb8117 100644 --- a/env_int.env +++ b/env_int.env @@ -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 diff --git a/env_prod.env b/env_prod.env index a7b4512c..8c85fbb6 100644 --- a/env_prod.env +++ b/env_prod.env @@ -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 diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index febdce9e..8e3dc60c 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -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 diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index da5a8a62..1aff28cc 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -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": [ - TeamsbotSessionStatus.PENDING.value, - TeamsbotSessionStatus.JOINING.value, - TeamsbotSessionStatus.ACTIVE.value, - ] - } + recordFilter={"instanceId": instanceId}, ) - return records + activeStatuses = { + TeamsbotSessionStatus.PENDING.value, + TeamsbotSessionStatus.JOINING.value, + TeamsbotSessionStatus.ACTIVE.value, + } + 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) diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index e4d390d2..4c6a7877 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -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 diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index d7707e05..af3f0686 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -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 diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 25c99749..6b01ad21 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -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 []