dashboard-nanobot/backend/services/bot_config_service.py

325 lines
12 KiB
Python

from datetime import datetime
from typing import Any, Dict
from fastapi import HTTPException
from sqlmodel import Session
from core.docker_instance import docker_manager
from core.utils import _calc_dir_size_bytes
from models.bot import BotInstance
from schemas.bot import (
BotEnvParamsUpdateRequest,
BotMcpConfigUpdateRequest,
ChannelConfigRequest,
ChannelConfigUpdateRequest,
)
from services.bot_channel_service import (
_channel_api_to_cfg,
_get_bot_channels_from_config,
_normalize_channel_extra,
_read_global_delivery_flags,
)
from services.bot_service import _sync_workspace_channels
from services.bot_mcp_service import (
_merge_mcp_servers_preserving_extras,
_normalize_mcp_servers,
)
from services.bot_storage_service import (
_normalize_env_params,
_read_bot_config,
_read_bot_resources,
_read_env_store,
_workspace_root,
_write_bot_config,
_write_env_store,
)
from services.cache_service import _invalidate_bot_detail_cache
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def get_bot_resources_snapshot(session: Session, *, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
configured = _read_bot_resources(bot_id)
runtime = docker_manager.get_bot_resource_snapshot(bot_id)
workspace_root = _workspace_root(bot_id)
workspace_bytes = _calc_dir_size_bytes(workspace_root)
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024
workspace_percent = 0.0
if configured_storage_bytes > 0:
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0
limits = runtime.get("limits") or {}
cpu_limited = (limits.get("cpu_cores") or 0) > 0
memory_limited = (limits.get("memory_bytes") or 0) > 0
storage_limited = bool(limits.get("storage_bytes")) or bool(limits.get("storage_opt_raw"))
return {
"bot_id": bot_id,
"docker_status": runtime.get("docker_status") or bot.docker_status,
"configured": configured,
"runtime": runtime,
"workspace": {
"path": workspace_root,
"usage_bytes": workspace_bytes,
"configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None,
"usage_percent": max(0.0, workspace_percent),
},
"enforcement": {
"cpu_limited": cpu_limited,
"memory_limited": memory_limited,
"storage_limited": storage_limited,
},
"note": (
"Resource value 0 means unlimited. CPU/Memory limits come from Docker HostConfig and are enforced by cgroup. "
"Storage limit depends on Docker storage driver support."
),
"collected_at": datetime.utcnow().isoformat() + "Z",
}
def list_bot_channels_config(session: Session, *, bot_id: str):
bot = _get_bot_or_404(session, bot_id)
return _get_bot_channels_from_config(bot)
def get_bot_tools_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
return {
"bot_id": bot_id,
"tools_config": {},
"managed_by_dashboard": False,
"hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.",
}
def reject_bot_tools_config_update(
session: Session,
*,
bot_id: str,
payload: Any,
) -> None:
_get_bot_or_404(session, bot_id)
raise HTTPException(
status_code=400,
detail="Tools config is no longer managed by dashboard. Please set required env vars manually.",
)
def get_bot_mcp_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id)
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
if not isinstance(tools_cfg, dict):
tools_cfg = {}
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
return {
"bot_id": bot_id,
"mcp_servers": mcp_servers,
"locked_servers": [],
"restart_required": True,
}
def update_bot_mcp_config_state(
session: Session,
*,
bot_id: str,
payload: BotMcpConfigUpdateRequest,
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id)
if not isinstance(config_data, dict):
config_data = {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {})
current_mcp_servers = tools_cfg.get("mcpServers")
merged_mcp_servers = _merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers)
tools_cfg["mcpServers"] = merged_mcp_servers
config_data["tools"] = tools_cfg
sanitized_after_save = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
_write_bot_config(bot_id, config_data)
_invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"mcp_servers": sanitized_after_save,
"locked_servers": [],
"restart_required": True,
}
def get_bot_env_params_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
return {
"bot_id": bot_id,
"env_params": _read_env_store(bot_id),
}
def update_bot_env_params_state(
session: Session,
*,
bot_id: str,
payload: BotEnvParamsUpdateRequest,
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
normalized = _normalize_env_params(payload.env_params)
_write_env_store(bot_id, normalized)
_invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"env_params": normalized,
"restart_required": True,
}
def create_bot_channel_config(
session: Session,
*,
bot_id: str,
payload: ChannelConfigRequest,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
ctype = (payload.channel_type or "").strip().lower()
if not ctype:
raise HTTPException(status_code=400, detail="channel_type is required")
if ctype == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually")
current_rows = _get_bot_channels_from_config(bot)
if any(str(row.get("channel_type") or "").lower() == ctype for row in current_rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {ctype}")
new_row = {
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": (payload.external_app_id or "").strip() or f"{ctype}-{bot_id}",
"app_secret": (payload.app_secret or "").strip(),
"internal_port": max(1, min(int(payload.internal_port or 8080), 65535)),
"is_active": bool(payload.is_active),
"extra_config": _normalize_channel_extra(payload.extra_config),
"locked": False,
}
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg[ctype] = _channel_api_to_cfg(new_row)
_write_bot_config(bot_id, config_data)
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return new_row
def update_bot_channel_config(
session: Session,
*,
bot_id: str,
channel_id: str,
payload: ChannelConfigUpdateRequest,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = _get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
update_data = payload.model_dump(exclude_unset=True)
existing_type = str(row.get("channel_type") or "").strip().lower()
new_type = existing_type
if "channel_type" in update_data and update_data["channel_type"] is not None:
new_type = str(update_data["channel_type"]).strip().lower()
if not new_type:
raise HTTPException(status_code=400, detail="channel_type cannot be empty")
if existing_type == "dashboard" and new_type != "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed")
if new_type != existing_type and any(str(r.get("channel_type") or "").lower() == new_type for r in rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {new_type}")
if "external_app_id" in update_data and update_data["external_app_id"] is not None:
row["external_app_id"] = str(update_data["external_app_id"]).strip()
if "app_secret" in update_data and update_data["app_secret"] is not None:
row["app_secret"] = str(update_data["app_secret"]).strip()
if "internal_port" in update_data and update_data["internal_port"] is not None:
row["internal_port"] = max(1, min(int(update_data["internal_port"]), 65535))
if "is_active" in update_data and update_data["is_active"] is not None:
next_active = bool(update_data["is_active"])
if existing_type == "dashboard" and not next_active:
raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
row["is_active"] = next_active
if "extra_config" in update_data:
row["extra_config"] = _normalize_channel_extra(update_data.get("extra_config"))
row["channel_type"] = new_type
row["id"] = new_type
row["locked"] = new_type == "dashboard"
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
current_send_progress, current_send_tool_hints = _read_global_delivery_flags(channels_cfg)
if new_type == "dashboard":
extra = _normalize_channel_extra(row.get("extra_config"))
channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
else:
channels_cfg["sendProgress"] = current_send_progress
channels_cfg["sendToolHints"] = current_send_tool_hints
channels_cfg.pop("dashboard", None)
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
channels_cfg.pop(existing_type, None)
if new_type != "dashboard":
channels_cfg[new_type] = _channel_api_to_cfg(row)
_write_bot_config(bot_id, config_data)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return row
def delete_bot_channel_config(
session: Session,
*,
bot_id: str,
channel_id: str,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = _get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").lower() == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
_write_bot_config(bot_id, config_data)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return {"status": "deleted"}