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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue