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:
parent
ef813a9304
commit
856b9f3c05
3 changed files with 327 additions and 7 deletions
|
|
@ -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):
|
class TeamsbotConfig(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotSessionStatus,
|
TeamsbotSessionStatus,
|
||||||
TeamsbotTranscript,
|
TeamsbotTranscript,
|
||||||
TeamsbotBotResponse,
|
TeamsbotBotResponse,
|
||||||
|
TeamsbotSystemBot,
|
||||||
|
TeamsbotUserSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -182,6 +184,70 @@ class TeamsbotObjects:
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
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
|
# Stats / Aggregation
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotConfigUpdateRequest,
|
TeamsbotConfigUpdateRequest,
|
||||||
TeamsbotConfig,
|
TeamsbotConfig,
|
||||||
TeamsbotJoinMode,
|
TeamsbotJoinMode,
|
||||||
|
TeamsbotSystemBot,
|
||||||
|
TeamsbotUserSettings,
|
||||||
|
TeamsbotResponseChannel,
|
||||||
|
TeamsbotResponseMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import service
|
# Import service
|
||||||
|
|
@ -187,11 +191,19 @@ async def startSession(
|
||||||
appApiUrl = APP_CONFIG.get("APP_API_URL", "")
|
appApiUrl = APP_CONFIG.get("APP_API_URL", "")
|
||||||
gatewayBaseUrl = appApiUrl.rstrip("/") if appApiUrl else str(request.base_url).rstrip("/")
|
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
|
# Determine effective join mode
|
||||||
joinMode = body.joinMode
|
joinMode = body.joinMode
|
||||||
if not joinMode:
|
if not joinMode:
|
||||||
# Default: use system bot if credentials are configured, otherwise anonymous
|
# Default: check if a system bot exists for this mandate
|
||||||
if config.botAccountEmail and config.botAccountPassword:
|
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
|
joinMode = TeamsbotJoinMode.SYSTEM_BOT
|
||||||
else:
|
else:
|
||||||
joinMode = TeamsbotJoinMode.ANONYMOUS
|
joinMode = TeamsbotJoinMode.ANONYMOUS
|
||||||
|
|
@ -200,16 +212,27 @@ async def startSession(
|
||||||
effectiveEmail = None
|
effectiveEmail = None
|
||||||
effectivePassword = None
|
effectivePassword = None
|
||||||
if joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
if joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
||||||
effectiveEmail = config.botAccountEmail
|
# First try: system bot from database (secure, encrypted)
|
||||||
effectivePassword = config.botAccountPassword
|
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:
|
elif joinMode == TeamsbotJoinMode.USER_ACCOUNT:
|
||||||
# TODO: Resolve OAuth token from user's Microsoft connection
|
# 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")
|
logger.info(f"User account join mode requested but not yet implemented - falling back to anonymous")
|
||||||
joinMode = TeamsbotJoinMode.ANONYMOUS
|
joinMode = TeamsbotJoinMode.ANONYMOUS
|
||||||
# ANONYMOUS mode: no credentials
|
# ANONYMOUS mode: no credentials
|
||||||
|
|
||||||
# Temporarily override config credentials for this session's join mode
|
# Build session config with resolved credentials and user settings
|
||||||
sessionConfig = config.model_copy(update={
|
sessionConfig = effectiveConfig.model_copy(update={
|
||||||
"botAccountEmail": effectiveEmail,
|
"botAccountEmail": effectiveEmail,
|
||||||
"botAccountPassword": effectivePassword,
|
"botAccountPassword": effectivePassword,
|
||||||
})
|
})
|
||||||
|
|
@ -425,6 +448,194 @@ async def updateConfig(
|
||||||
return {"config": mergedConfig.model_dump()}
|
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
|
# Voice Test Endpoint
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue