From 856b9f3c05fe07d14159335297c8094fd3aea4ee Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Mon, 16 Feb 2026 00:16:02 +0100 Subject: [PATCH] feat(teamsbot): system bot accounts with encrypted credentials, per-user settings, credentials removed from UI Co-authored-by: Cursor --- .../features/teamsbot/datamodelTeamsbot.py | 45 +++- .../teamsbot/interfaceFeatureTeamsbot.py | 66 ++++++ .../features/teamsbot/routeFeatureTeamsbot.py | 223 +++++++++++++++++- 3 files changed, 327 insertions(+), 7 deletions(-) diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index 5bfaf69c..b5523b5a 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -116,7 +116,50 @@ class TeamsbotBotResponse(BaseModel): # ============================================================================ -# Configuration Model (stored in FeatureInstance.config JSONB) +# System Bot Accounts (stored in PostgreSQL, credentials encrypted) +# ============================================================================ + +class TeamsbotSystemBot(BaseModel): + """A system bot account for authenticated meeting joins. + Credentials are stored encrypted in the database, NOT in the UI-visible config. + Only mandate admins can manage system bots.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="System bot ID") + mandateId: str = Field(description="Mandate ID (FK) - bots are scoped to mandates") + name: str = Field(description="Display name (e.g. 'Nyla Larsson')") + email: str = Field(description="Microsoft account email") + encryptedPassword: str = Field(description="Encrypted Microsoft account password") + isActive: bool = Field(default=True, description="Whether this bot account is active") + creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation") + lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification") + + +# ============================================================================ +# Per-User Settings (stored in PostgreSQL, per user per instance) +# ============================================================================ + +class TeamsbotUserSettings(BaseModel): + """Per-user settings for the Teams Bot feature. + Each user has their own settings per feature instance. + These override the instance-level defaults (TeamsbotConfig).""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Settings ID") + userId: str = Field(description="User ID (FK)") + instanceId: str = Field(description="Feature instance ID (FK)") + botName: Optional[str] = Field(default=None, description="Bot display name override") + backgroundImageUrl: Optional[str] = Field(default=None, description="Background image URL override") + aiSystemPrompt: Optional[str] = Field(default=None, description="AI system prompt override") + responseMode: Optional[str] = Field(default=None, description="Response mode override: auto, manual, transcribeOnly") + responseChannel: Optional[str] = Field(default=None, description="Response channel override: voice, chat, both") + language: Optional[str] = Field(default=None, description="Language override (e.g. de-DE)") + voiceId: Optional[str] = Field(default=None, description="TTS voice ID override") + triggerIntervalSeconds: Optional[int] = Field(default=None, description="Trigger interval override") + triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override") + contextWindowSegments: Optional[int] = Field(default=None, description="Context window override") + creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation") + lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification") + + +# ============================================================================ +# Configuration Model (stored in FeatureInstance.config JSONB -- serves as DEFAULT template) # ============================================================================ class TeamsbotConfig(BaseModel): diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 2ce40e18..3a2caf47 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -18,6 +18,8 @@ from .datamodelTeamsbot import ( TeamsbotSessionStatus, TeamsbotTranscript, TeamsbotBotResponse, + TeamsbotSystemBot, + TeamsbotUserSettings, ) logger = logging.getLogger(__name__) @@ -182,6 +184,70 @@ class TeamsbotObjects: count += 1 return count + # ========================================================================= + # System Bots (mandate-scoped, admin-managed) + # ========================================================================= + + def getSystemBots(self, mandateId: str) -> List[Dict[str, Any]]: + """Get all system bot accounts for a mandate.""" + records = self.db.getRecordset(TeamsbotSystemBot, recordFilter={"mandateId": mandateId}) + # Strip encrypted passwords from returned records + for r in records: + r.pop("encryptedPassword", None) + return records + + def getSystemBot(self, botId: str) -> Optional[Dict[str, Any]]: + """Get a system bot by ID (includes encrypted password for internal use).""" + records = self.db.getRecordset(TeamsbotSystemBot, recordFilter={"id": botId}) + return records[0] if records else None + + def getActiveSystemBot(self, mandateId: str) -> Optional[Dict[str, Any]]: + """Get the first active system bot for a mandate (includes encrypted password).""" + records = self.db.getRecordset(TeamsbotSystemBot, recordFilter={"mandateId": mandateId, "isActive": True}) + return records[0] if records else None + + def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]: + """Create a new system bot account.""" + botData["creationDate"] = getIsoTimestamp() + botData["lastModified"] = getIsoTimestamp() + return self.db.recordCreate(TeamsbotSystemBot, botData) + + def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update a system bot account.""" + updates["lastModified"] = getIsoTimestamp() + return self.db.recordModify(TeamsbotSystemBot, botId, updates) + + def deleteSystemBot(self, botId: str) -> bool: + """Delete a system bot account.""" + return self.db.recordDelete(TeamsbotSystemBot, botId) + + # ========================================================================= + # User Settings (per-user per-instance) + # ========================================================================= + + def getUserSettings(self, userId: str, instanceId: str) -> Optional[Dict[str, Any]]: + """Get user-specific settings for a feature instance.""" + records = self.db.getRecordset( + TeamsbotUserSettings, + recordFilter={"userId": userId, "instanceId": instanceId}, + ) + return records[0] if records else None + + def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: + """Create user settings.""" + settingsData["creationDate"] = getIsoTimestamp() + settingsData["lastModified"] = getIsoTimestamp() + return self.db.recordCreate(TeamsbotUserSettings, settingsData) + + def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update user settings.""" + updates["lastModified"] = getIsoTimestamp() + return self.db.recordModify(TeamsbotUserSettings, settingsId, updates) + + def deleteUserSettings(self, settingsId: str) -> bool: + """Delete user settings (reset to defaults).""" + return self.db.recordDelete(TeamsbotUserSettings, settingsId) + # ========================================================================= # Stats / Aggregation # ========================================================================= diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 59b009be..70aba37f 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -31,6 +31,10 @@ from .datamodelTeamsbot import ( TeamsbotConfigUpdateRequest, TeamsbotConfig, TeamsbotJoinMode, + TeamsbotSystemBot, + TeamsbotUserSettings, + TeamsbotResponseChannel, + TeamsbotResponseMode, ) # Import service @@ -187,11 +191,19 @@ async def startSession( 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 joinMode = body.joinMode if not joinMode: - # Default: use system bot if credentials are configured, otherwise anonymous - if config.botAccountEmail and config.botAccountPassword: + # Default: check if a system bot exists for this mandate + systemBot = interface.getActiveSystemBot(mandateId) + if systemBot: + joinMode = TeamsbotJoinMode.SYSTEM_BOT + elif effectiveConfig.botAccountEmail and effectiveConfig.botAccountPassword: + # Legacy fallback: credentials in config (will be removed in future) joinMode = TeamsbotJoinMode.SYSTEM_BOT else: joinMode = TeamsbotJoinMode.ANONYMOUS @@ -200,16 +212,27 @@ async def startSession( effectiveEmail = None effectivePassword = None if joinMode == TeamsbotJoinMode.SYSTEM_BOT: - effectiveEmail = config.botAccountEmail - effectivePassword = config.botAccountPassword + # First try: system bot from database (secure, encrypted) + systemBot = interface.getActiveSystemBot(mandateId) + if systemBot: + effectiveEmail = systemBot.get("email") + encPwd = systemBot.get("encryptedPassword") + if encPwd: + from modules.shared.configuration import handleSecretText + effectivePassword = handleSecretText(encPwd, userId="system", keyName="systemBotPassword") + + # Fallback: legacy credentials from config (will be deprecated) + if not effectiveEmail: + effectiveEmail = effectiveConfig.botAccountEmail + effectivePassword = effectiveConfig.botAccountPassword elif joinMode == TeamsbotJoinMode.USER_ACCOUNT: # TODO: Resolve OAuth token from user's Microsoft connection logger.info(f"User account join mode requested but not yet implemented - falling back to anonymous") joinMode = TeamsbotJoinMode.ANONYMOUS # ANONYMOUS mode: no credentials - # Temporarily override config credentials for this session's join mode - sessionConfig = config.model_copy(update={ + # Build session config with resolved credentials and user settings + sessionConfig = effectiveConfig.model_copy(update={ "botAccountEmail": effectiveEmail, "botAccountPassword": effectivePassword, }) @@ -425,6 +448,194 @@ async def updateConfig( 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 # =========================================================================