import os import re import shutil from typing import Any, Dict, List, Optional from fastapi import HTTPException from sqlmodel import Session, select from core.cache import cache from core.docker_instance import docker_manager from core.settings import BOTS_WORKSPACE_ROOT from models.bot import BotInstance, NanobotImage from schemas.bot import BotCreateRequest, BotUpdateRequest from services.bot_service import ( normalize_initial_bot_channels, normalize_bot_system_timezone, resolve_bot_runtime_env_params, serialize_bot_detail, serialize_bot_list_entry, sync_bot_workspace_channels, ) from services.bot_storage_service import ( normalize_bot_env_params, normalize_bot_resource_limits, write_bot_env_params, write_bot_resource_limits, ) from services.cache_service import _cache_key_bot_detail, _cache_key_bots_list, _invalidate_bot_detail_cache from services.platform_activity_service import record_activity_event BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") MANAGED_WORKSPACE_FILENAMES = ("AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md") def _managed_bot_file_paths(bot_id: str) -> Dict[str, str]: bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot") workspace_root = os.path.join(bot_root, "workspace") paths = { "config": os.path.join(bot_root, "config.json"), "env": os.path.join(bot_root, "env.json"), "resources": os.path.join(bot_root, "resources.json"), } for filename in MANAGED_WORKSPACE_FILENAMES: paths[f"workspace:{filename}"] = os.path.join(workspace_root, filename) return paths def _snapshot_managed_bot_files(bot_id: str) -> Dict[str, Optional[bytes]]: snapshot: Dict[str, Optional[bytes]] = {} for key, path in _managed_bot_file_paths(bot_id).items(): if os.path.isfile(path): with open(path, "rb") as file: snapshot[key] = file.read() else: snapshot[key] = None return snapshot def _restore_managed_bot_files(bot_id: str, snapshot: Dict[str, Optional[bytes]]) -> None: for key, path in _managed_bot_file_paths(bot_id).items(): payload = snapshot.get(key) if payload is None: if os.path.exists(path): os.remove(path) continue os.makedirs(os.path.dirname(path), exist_ok=True) tmp_path = f"{path}.tmp" with open(tmp_path, "wb") as file: file.write(payload) os.replace(tmp_path, path) def _cleanup_bot_workspace_root(bot_id: str) -> None: bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id) if os.path.isdir(bot_root): shutil.rmtree(bot_root, ignore_errors=True) def _require_runtime_text(raw: Any, *, field: str) -> str: value = str(raw if raw is not None else "").strip() if not value: raise HTTPException(status_code=400, detail=f"{field} is required") return value def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[str, Any]: normalized_bot_id = str(payload.id or "").strip() if not normalized_bot_id: raise HTTPException(status_code=400, detail="Bot ID is required") if not BOT_ID_PATTERN.fullmatch(normalized_bot_id): raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores") if session.get(BotInstance, normalized_bot_id): raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}") image_row = session.get(NanobotImage, payload.image_tag) if not image_row: raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}") if image_row.status != "READY": raise HTTPException(status_code=400, detail=f"Image status is not READY: {payload.image_tag} ({image_row.status})") if not docker_manager.has_image(payload.image_tag): raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}") normalized_env_params = normalize_bot_env_params(payload.env_params) try: normalized_env_params["TZ"] = normalize_bot_system_timezone(payload.system_timezone) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc llm_provider = _require_runtime_text(payload.llm_provider, field="llm_provider") llm_model = _require_runtime_text(payload.llm_model, field="llm_model") api_key = _require_runtime_text(payload.api_key, field="api_key") api_base = _require_runtime_text(payload.api_base, field="api_base") bot = BotInstance( id=normalized_bot_id, name=payload.name, enabled=bool(payload.enabled) if payload.enabled is not None else True, access_password=str(payload.access_password or ""), image_tag=payload.image_tag, workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id), ) resource_limits = normalize_bot_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) try: session.add(bot) session.flush() write_bot_env_params(normalized_bot_id, normalized_env_params) write_bot_resource_limits( normalized_bot_id, resource_limits["cpu_cores"], resource_limits["memory_mb"], resource_limits["storage_gb"], ) sync_bot_workspace_channels( session, normalized_bot_id, channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels), global_delivery_override={ "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, }, runtime_overrides={ "llm_provider": llm_provider, "llm_model": llm_model, "api_key": api_key, "api_base": api_base, "temperature": payload.temperature, "top_p": payload.top_p, "max_tokens": payload.max_tokens, "cpu_cores": resource_limits["cpu_cores"], "memory_mb": resource_limits["memory_mb"], "storage_gb": resource_limits["storage_gb"], "soul_md": payload.soul_md, "agents_md": payload.agents_md, "user_md": payload.user_md, "tools_md": payload.tools_md, "identity_md": payload.identity_md, "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, }, ) record_activity_event( session, normalized_bot_id, "bot_created", channel="system", detail=f"Bot {normalized_bot_id} created", metadata={"image_tag": payload.image_tag}, ) session.commit() session.refresh(bot) except Exception: session.rollback() _cleanup_bot_workspace_root(normalized_bot_id) raise _invalidate_bot_detail_cache(normalized_bot_id) return serialize_bot_detail(bot) def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]: cached = cache.get_json(_cache_key_bots_list()) if isinstance(cached, list): return cached bots = session.exec( select(BotInstance).order_by(BotInstance.created_at.desc(), BotInstance.id.asc()) ).all() dirty = False for bot in bots: actual_status = docker_manager.get_bot_status(bot.id) if bot.docker_status != actual_status: bot.docker_status = actual_status if actual_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: bot.current_state = "IDLE" session.add(bot) dirty = True if dirty: session.commit() for bot in bots: session.refresh(bot) rows = [serialize_bot_list_entry(bot) for bot in bots] cache.set_json(_cache_key_bots_list(), rows, ttl=30) return rows def get_bot_detail_cached(session: Session, *, bot_id: str) -> Dict[str, Any]: cached = cache.get_json(_cache_key_bot_detail(bot_id)) if isinstance(cached, dict): return cached bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") row = serialize_bot_detail(bot) cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30) return row def authenticate_bot_page_access(session: Session, *, bot_id: str, password: str) -> Dict[str, Any]: bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") configured = str(bot.access_password or "").strip() if not configured: return {"ok": True, "enabled": False, "bot_id": bot_id} candidate = str(password or "").strip() if not candidate: raise HTTPException(status_code=401, detail="Bot access password required") if candidate != configured: raise HTTPException(status_code=401, detail="Invalid bot access password") return {"ok": True, "enabled": True, "bot_id": bot_id} def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateRequest) -> Dict[str, Any]: bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") managed_file_snapshot = _snapshot_managed_bot_files(bot_id) update_data = payload.model_dump(exclude_unset=True) if "image_tag" in update_data and update_data["image_tag"]: image_tag = str(update_data["image_tag"]).strip() image_row = session.get(NanobotImage, image_tag) if not image_row: raise HTTPException(status_code=400, detail=f"Image not registered in DB: {image_tag}") if image_row.status != "READY": raise HTTPException(status_code=400, detail=f"Image status is not READY: {image_tag} ({image_row.status})") if not docker_manager.has_image(image_tag): raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}") env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None normalized_system_timezone: Optional[str] = None if system_timezone is not None: try: normalized_system_timezone = normalize_bot_system_timezone(system_timezone) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc runtime_fields = { "llm_provider", "llm_model", "api_key", "api_base", "temperature", "top_p", "max_tokens", "cpu_cores", "memory_mb", "storage_gb", "soul_md", "agents_md", "user_md", "tools_md", "identity_md", "send_progress", "send_tool_hints", } runtime_overrides: Dict[str, Any] = {} update_data.pop("tools_config", None) if isinstance(update_data, dict) else None for field in runtime_fields: if field in update_data: runtime_overrides[field] = update_data.pop(field) for text_field in ("llm_provider", "llm_model", "api_key", "api_base"): if text_field in runtime_overrides: runtime_overrides[text_field] = _require_runtime_text( runtime_overrides.get(text_field), field=text_field, ) if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()): runtime_overrides.update( normalize_bot_resource_limits( runtime_overrides.get("cpu_cores"), runtime_overrides.get("memory_mb"), runtime_overrides.get("storage_gb"), ) ) for key, value in update_data.items(): if key in {"name", "image_tag", "access_password", "enabled"}: setattr(bot, key, value) try: session.add(bot) session.flush() if env_params is not None or normalized_system_timezone is not None: next_env_params = resolve_bot_runtime_env_params(bot_id) if env_params is not None: next_env_params = normalize_bot_env_params(env_params) if normalized_system_timezone is not None: next_env_params["TZ"] = normalized_system_timezone write_bot_env_params(bot_id, next_env_params) global_delivery_override: Optional[Dict[str, Any]] = None if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides: global_delivery_override = {} if "send_progress" in runtime_overrides: global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress")) if "send_tool_hints" in runtime_overrides: global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints")) sync_bot_workspace_channels( session, bot_id, runtime_overrides=runtime_overrides if runtime_overrides else None, global_delivery_override=global_delivery_override, ) session.commit() session.refresh(bot) except Exception: session.rollback() _restore_managed_bot_files(bot_id, managed_file_snapshot) refreshed_bot = session.get(BotInstance, bot_id) if refreshed_bot: session.refresh(refreshed_bot) bot = refreshed_bot raise _invalidate_bot_detail_cache(bot_id) return serialize_bot_detail(bot)