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
# =========================================================================