feat(teamsbot): system bot accounts with encrypted credentials, per-user settings, credentials removed from UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
patrick-motsch 2026-02-16 00:16:02 +01:00
parent ef813a9304
commit 856b9f3c05
3 changed files with 327 additions and 7 deletions

View file

@ -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):

View file

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

View file

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