dashboard-nanobot/backend/services/bot_infra_service.py

1090 lines
47 KiB
Python

import json
import os
import re
from typing import Any, Callable, Dict, List, Optional
from zoneinfo import ZoneInfo
import httpx
from fastapi import HTTPException
from sqlmodel import Session
from clients.edge.errors import log_edge_failure
from clients.edge.http import HttpEdgeClient
from core.config_manager import BotConfigManager
from models.bot import BotInstance
from providers.target import ProviderTarget
from services.node_registry_service import ManagedNode, NodeRegistryService
ReadEnvStore = Callable[[str], Dict[str, str]]
ReadBotRuntimeSnapshot = Callable[[BotInstance], Dict[str, Any]]
NormalizeMediaList = Callable[[Any, str], List[str]]
NormalizeProviderTarget = Callable[..., ProviderTarget]
ProviderTargetFromConfig = Callable[..., ProviderTarget]
ProviderTargetToDict = Callable[[ProviderTarget], Dict[str, Any]]
ResolveProviderBundleKey = Callable[[ProviderTarget], Optional[str]]
GetProvisionProvider = Callable[[Any, BotInstance], Any]
class BotInfraService:
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
def __init__(
self,
*,
app: Any,
engine: Any,
config_manager: BotConfigManager,
node_registry_service: NodeRegistryService,
logger: Any,
bots_workspace_root: str,
default_soul_md: str,
default_agents_md: str,
default_user_md: str,
default_tools_md: str,
default_identity_md: str,
default_bot_system_timezone: str,
normalize_provider_target: NormalizeProviderTarget,
provider_target_from_config: ProviderTargetFromConfig,
provider_target_to_dict: ProviderTargetToDict,
resolve_provider_bundle_key: ResolveProviderBundleKey,
get_provision_provider: GetProvisionProvider,
read_env_store: ReadEnvStore,
read_bot_runtime_snapshot: ReadBotRuntimeSnapshot,
normalize_media_list: NormalizeMediaList,
) -> None:
self._app = app
self._engine = engine
self._config_manager = config_manager
self._node_registry_service = node_registry_service
self._logger = logger
self._bots_workspace_root = bots_workspace_root
self._default_soul_md = default_soul_md
self._default_agents_md = default_agents_md
self._default_user_md = default_user_md
self._default_tools_md = default_tools_md
self._default_identity_md = default_identity_md
self._default_bot_system_timezone = default_bot_system_timezone
self._normalize_provider_target = normalize_provider_target
self._provider_target_from_config = provider_target_from_config
self._provider_target_to_dict = provider_target_to_dict
self._resolve_provider_bundle_key = resolve_provider_bundle_key
self._get_provision_provider = get_provision_provider
self._read_env_store = read_env_store
self._read_bot_runtime_snapshot = read_bot_runtime_snapshot
self._normalize_media_list = normalize_media_list
self._provider_target_overrides: Dict[str, ProviderTarget] = {}
def config_json_path(self, bot_id: str) -> str:
return os.path.join(self.bot_data_root(bot_id), "config.json")
def read_bot_config(self, bot_id: str) -> Dict[str, Any]:
if self.resolve_edge_state_context(bot_id) is not None:
data = self.read_edge_state_data(bot_id=bot_id, state_key="config", default_payload={})
return data if isinstance(data, dict) else {}
path = self.config_json_path(bot_id)
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_bot_config(self, bot_id: str, config_data: Dict[str, Any]) -> None:
normalized = dict(config_data if isinstance(config_data, dict) else {})
if self.write_edge_state_data(bot_id=bot_id, state_key="config", data=normalized):
return
path = self.config_json_path(bot_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(normalized, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def default_provider_target(self) -> ProviderTarget:
return self._normalize_provider_target(
{
"node_id": getattr(self._app.state, "provider_default_node_id", None),
"transport_kind": getattr(self._app.state, "provider_default_transport_kind", None),
"runtime_kind": getattr(self._app.state, "provider_default_runtime_kind", None),
"core_adapter": getattr(self._app.state, "provider_default_core_adapter", None),
},
fallback=ProviderTarget(),
)
def read_bot_provider_target(
self,
bot_id: str,
config_data: Optional[Dict[str, Any]] = None,
) -> ProviderTarget:
normalized_bot_id = str(bot_id or "").strip()
if normalized_bot_id and normalized_bot_id in self._provider_target_overrides:
return self._provider_target_overrides[normalized_bot_id]
if normalized_bot_id:
with Session(self._engine) as session:
bot = session.get(BotInstance, normalized_bot_id)
if bot is not None:
return self._normalize_provider_target(
{
"node_id": getattr(bot, "node_id", None),
"transport_kind": getattr(bot, "transport_kind", None),
"runtime_kind": getattr(bot, "runtime_kind", None),
"core_adapter": getattr(bot, "core_adapter", None),
},
fallback=self.default_provider_target(),
)
raw_config = config_data if isinstance(config_data, dict) else self.read_bot_config(bot_id)
return self._provider_target_from_config(raw_config, fallback=self.default_provider_target())
def resolve_bot_provider_target_for_instance(self, bot: BotInstance) -> ProviderTarget:
normalized_bot_id = str(getattr(bot, "id", "") or "").strip()
if normalized_bot_id and normalized_bot_id in self._provider_target_overrides:
return self._provider_target_overrides[normalized_bot_id]
inline_values = {
"node_id": getattr(bot, "node_id", None),
"transport_kind": getattr(bot, "transport_kind", None),
"runtime_kind": getattr(bot, "runtime_kind", None),
"core_adapter": getattr(bot, "core_adapter", None),
}
if any(str(value or "").strip() for value in inline_values.values()):
return self._normalize_provider_target(inline_values, fallback=self.default_provider_target())
return self.read_bot_provider_target(str(bot.id or ""))
def set_provider_target_override(self, bot_id: str, target: ProviderTarget) -> None:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return
self._provider_target_overrides[normalized_bot_id] = target
def clear_provider_target_override(self, bot_id: str) -> None:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return
self._provider_target_overrides.pop(normalized_bot_id, None)
def clear_provider_target_overrides(self) -> None:
self._provider_target_overrides.clear()
def apply_provider_target_to_bot(self, bot: BotInstance, target: ProviderTarget) -> None:
bot.node_id = target.node_id
bot.transport_kind = target.transport_kind
bot.runtime_kind = target.runtime_kind
bot.core_adapter = target.core_adapter
def local_managed_node(self) -> ManagedNode:
return ManagedNode(
node_id="local",
display_name="Local Node",
base_url=str(os.getenv("LOCAL_EDGE_BASE_URL", "http://127.0.0.1:8010") or "http://127.0.0.1:8010").strip(),
enabled=True,
auth_token=str(os.getenv("EDGE_AUTH_TOKEN", "") or "").strip(),
metadata={
"transport_kind": "edge",
"runtime_kind": "docker",
"core_adapter": "nanobot",
"workspace_root": str(
os.getenv("EDGE_WORKSPACE_ROOT", os.getenv("EDGE_BOTS_WORKSPACE_ROOT", "")) or ""
).strip(),
"native_command": str(os.getenv("EDGE_NATIVE_COMMAND", "") or "").strip(),
"native_workdir": str(os.getenv("EDGE_NATIVE_WORKDIR", "") or "").strip(),
"native_sandbox_mode": str(os.getenv("EDGE_NATIVE_SANDBOX_MODE", "inherit") or "inherit").strip().lower(),
},
)
def provider_target_from_node(self, node_id: Optional[str]) -> Optional[ProviderTarget]:
normalized = str(node_id or "").strip().lower()
if not normalized:
return None
node = self._node_registry_service.get_node(normalized)
if node is None:
return None
metadata = dict(node.metadata or {})
return ProviderTarget(
node_id=node.node_id,
transport_kind=str(metadata.get("transport_kind") or "edge").strip().lower() or "edge",
runtime_kind=str(metadata.get("runtime_kind") or "docker").strip().lower() or "docker",
core_adapter=str(metadata.get("core_adapter") or "nanobot").strip().lower() or "nanobot",
)
def node_display_name(self, node_id: str) -> str:
node = self._node_registry_service.get_node(node_id)
if node is not None:
return str(node.display_name or node.node_id or node_id).strip() or str(node_id or "").strip()
return str(node_id or "").strip()
def node_metadata(self, node_id: str) -> Dict[str, Any]:
node = self._node_registry_service.get_node(node_id)
if node is None:
return {}
return dict(node.metadata or {})
def serialize_provider_target_summary(self, target: ProviderTarget) -> Dict[str, Any]:
return {
**self._provider_target_to_dict(target),
"node_display_name": self.node_display_name(target.node_id),
}
def resolve_edge_client(self, target: ProviderTarget) -> HttpEdgeClient:
try:
node = self._node_registry_service.require_node(target.node_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HttpEdgeClient(
node=node,
http_client_factory=lambda: httpx.Client(timeout=15.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=15.0, trust_env=False),
)
def resolve_edge_state_context(self, bot_id: str) -> Optional[tuple[HttpEdgeClient, Optional[str], str]]:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return None
with Session(self._engine) as session:
bot = session.get(BotInstance, normalized_bot_id)
if bot is None:
return None
target = self.resolve_bot_provider_target_for_instance(bot)
if str(target.transport_kind or "").strip().lower() != "edge":
return None
client = self.resolve_edge_client(target)
metadata = self.node_metadata(target.node_id)
workspace_root = str(metadata.get("workspace_root") or "").strip() or None
return client, workspace_root, target.node_id
def read_edge_state_data(
self,
*,
bot_id: str,
state_key: str,
default_payload: Dict[str, Any],
) -> Dict[str, Any]:
context = self.resolve_edge_state_context(bot_id)
if context is None:
return dict(default_payload)
client, workspace_root, node_id = context
try:
payload = client.read_state(
bot_id=bot_id,
state_key=state_key,
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"edge-state-read:{node_id}:{bot_id}:{state_key}",
exc=exc,
message=f"Failed to read edge state for bot_id={bot_id}, state_key={state_key}",
)
return dict(default_payload)
data = payload.get("data")
if isinstance(data, dict):
return dict(data)
return dict(default_payload)
def write_edge_state_data(
self,
*,
bot_id: str,
state_key: str,
data: Dict[str, Any],
) -> bool:
context = self.resolve_edge_state_context(bot_id)
if context is None:
return False
client, workspace_root, node_id = context
try:
client.write_state(
bot_id=bot_id,
state_key=state_key,
data=dict(data or {}),
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"edge-state-write:{node_id}:{bot_id}:{state_key}",
exc=exc,
message=f"Failed to write edge state for bot_id={bot_id}, state_key={state_key}",
)
raise
return True
def resources_json_path(self, bot_id: str) -> str:
return os.path.join(self.bot_data_root(bot_id), "resources.json")
def write_bot_resources(self, bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None:
normalized = self.normalize_resource_limits(cpu_cores, memory_mb, storage_gb)
payload = {
"cpuCores": normalized["cpu_cores"],
"memoryMB": normalized["memory_mb"],
"storageGB": normalized["storage_gb"],
}
if self.write_edge_state_data(bot_id=bot_id, state_key="resources", data=payload):
return
path = self.resources_json_path(bot_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def read_legacy_resource_values(
self,
bot_id: str,
config_data: Optional[Dict[str, Any]] = None,
) -> tuple[Any, Any, Any]:
cpu_raw: Any = None
memory_raw: Any = None
storage_raw: Any = None
path = self.resources_json_path(bot_id)
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
if isinstance(data, dict):
cpu_raw = data.get("cpuCores", data.get("cpu_cores"))
memory_raw = data.get("memoryMB", data.get("memory_mb"))
storage_raw = data.get("storageGB", data.get("storage_gb"))
except Exception:
pass
if cpu_raw is None or memory_raw is None or storage_raw is None:
cfg = config_data if isinstance(config_data, dict) else self.read_bot_config(bot_id)
runtime_cfg = cfg.get("runtime")
if isinstance(runtime_cfg, dict):
resources_raw = runtime_cfg.get("resources")
if isinstance(resources_raw, dict):
if cpu_raw is None:
cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores"))
if memory_raw is None:
memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb"))
if storage_raw is None:
storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb"))
return cpu_raw, memory_raw, storage_raw
def read_bot_resources(self, bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
edge_context = self.resolve_edge_state_context(bot_id)
cpu_raw: Any = None
memory_raw: Any = None
storage_raw: Any = None
if edge_context is not None:
data = self.read_edge_state_data(
bot_id=bot_id,
state_key="resources",
default_payload={},
)
cpu_raw = data.get("cpuCores", data.get("cpu_cores"))
memory_raw = data.get("memoryMB", data.get("memory_mb"))
storage_raw = data.get("storageGB", data.get("storage_gb"))
if cpu_raw is None or memory_raw is None or storage_raw is None:
legacy_cpu, legacy_memory, legacy_storage = self.read_legacy_resource_values(
bot_id,
config_data=config_data,
)
if cpu_raw is None:
cpu_raw = legacy_cpu
if memory_raw is None:
memory_raw = legacy_memory
if storage_raw is None:
storage_raw = legacy_storage
return self.normalize_resource_limits(cpu_raw, memory_raw, storage_raw)
cpu_raw, memory_raw, storage_raw = self.read_legacy_resource_values(bot_id, config_data=config_data)
return self.normalize_resource_limits(cpu_raw, memory_raw, storage_raw)
def migrate_bot_resources_store(self, bot_id: str) -> None:
if self.resolve_edge_state_context(bot_id) is not None:
return
config_data = self.read_bot_config(bot_id)
runtime_cfg = config_data.get("runtime")
resources_raw: Dict[str, Any] = {}
if isinstance(runtime_cfg, dict):
legacy_raw = runtime_cfg.get("resources")
if isinstance(legacy_raw, dict):
resources_raw = legacy_raw
path = self.resources_json_path(bot_id)
if not os.path.isfile(path):
self.write_bot_resources(
bot_id,
resources_raw.get("cpuCores", resources_raw.get("cpu_cores")),
resources_raw.get("memoryMB", resources_raw.get("memory_mb")),
resources_raw.get("storageGB", resources_raw.get("storage_gb")),
)
if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg:
runtime_cfg.pop("resources", None)
if not runtime_cfg:
config_data.pop("runtime", None)
self.write_bot_config(bot_id, config_data)
@staticmethod
def normalize_channel_extra(raw: Any) -> Dict[str, Any]:
if not isinstance(raw, dict):
return {}
return raw
@staticmethod
def normalize_allow_from(raw: Any) -> List[str]:
rows: List[str] = []
if isinstance(raw, list):
for item in raw:
text = str(item or "").strip()
if text and text not in rows:
rows.append(text)
if not rows:
return ["*"]
return rows
def read_global_delivery_flags(self, channels_cfg: Any) -> tuple[bool, bool]:
if not isinstance(channels_cfg, dict):
return False, False
send_progress = channels_cfg.get("sendProgress")
send_tool_hints = channels_cfg.get("sendToolHints")
dashboard_cfg = channels_cfg.get("dashboard")
if isinstance(dashboard_cfg, dict):
if send_progress is None and "sendProgress" in dashboard_cfg:
send_progress = dashboard_cfg.get("sendProgress")
if send_tool_hints is None and "sendToolHints" in dashboard_cfg:
send_tool_hints = dashboard_cfg.get("sendToolHints")
return bool(send_progress), bool(send_tool_hints)
def channel_cfg_to_api_dict(self, bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
normalized_type = str(ctype or "").strip().lower()
enabled = bool(cfg.get("enabled", True))
port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535))
extra: Dict[str, Any] = {}
external_app_id = ""
app_secret = ""
if normalized_type == "feishu":
external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("appSecret") or "")
extra = {
"encryptKey": cfg.get("encryptKey", ""),
"verificationToken": cfg.get("verificationToken", ""),
"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])),
}
elif normalized_type == "dingtalk":
external_app_id = str(cfg.get("clientId") or "")
app_secret = str(cfg.get("clientSecret") or "")
extra = {"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", []))}
elif normalized_type == "telegram":
app_secret = str(cfg.get("token") or "")
extra = {
"proxy": cfg.get("proxy", ""),
"replyToMessage": bool(cfg.get("replyToMessage", False)),
"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])),
}
elif normalized_type == "slack":
external_app_id = str(cfg.get("botToken") or "")
app_secret = str(cfg.get("appToken") or "")
extra = {
"mode": cfg.get("mode", "socket"),
"replyInThread": bool(cfg.get("replyInThread", True)),
"groupPolicy": cfg.get("groupPolicy", "mention"),
"groupAllowFrom": cfg.get("groupAllowFrom", []),
"reactEmoji": cfg.get("reactEmoji", "eyes"),
}
elif normalized_type == "qq":
external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", []))}
elif normalized_type == "email":
extra = {
"consentGranted": bool(cfg.get("consentGranted", False)),
"imapHost": str(cfg.get("imapHost") or ""),
"imapPort": int(cfg.get("imapPort") or 993),
"imapUsername": str(cfg.get("imapUsername") or ""),
"imapPassword": str(cfg.get("imapPassword") or ""),
"imapMailbox": str(cfg.get("imapMailbox") or "INBOX"),
"imapUseSsl": bool(cfg.get("imapUseSsl", True)),
"smtpHost": str(cfg.get("smtpHost") or ""),
"smtpPort": int(cfg.get("smtpPort") or 587),
"smtpUsername": str(cfg.get("smtpUsername") or ""),
"smtpPassword": str(cfg.get("smtpPassword") or ""),
"smtpUseTls": bool(cfg.get("smtpUseTls", True)),
"smtpUseSsl": bool(cfg.get("smtpUseSsl", False)),
"fromAddress": str(cfg.get("fromAddress") or ""),
"autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)),
"pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30),
"markSeen": bool(cfg.get("markSeen", True)),
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])),
}
else:
external_app_id = str(
cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or ""
)
app_secret = str(
cfg.get("appSecret")
or cfg.get("clientSecret")
or cfg.get("secret")
or cfg.get("token")
or cfg.get("appToken")
or ""
)
extra = {
key: value
for key, value in cfg.items()
if key
not in {
"enabled",
"port",
"appId",
"clientId",
"botToken",
"externalAppId",
"appSecret",
"clientSecret",
"secret",
"token",
"appToken",
}
}
return {
"id": normalized_type,
"bot_id": bot_id,
"channel_type": normalized_type,
"external_app_id": external_app_id,
"app_secret": app_secret,
"internal_port": port,
"is_active": enabled,
"extra_config": extra,
"locked": normalized_type == "dashboard",
}
def channel_api_to_cfg(self, row: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(row.get("channel_type") or "").strip().lower()
enabled = bool(row.get("is_active", True))
extra = self.normalize_channel_extra(row.get("extra_config"))
external_app_id = str(row.get("external_app_id") or "")
app_secret = str(row.get("app_secret") or "")
port = max(1, min(int(row.get("internal_port") or 8080), 65535))
if ctype == "feishu":
return {
"enabled": enabled,
"appId": external_app_id,
"appSecret": app_secret,
"encryptKey": extra.get("encryptKey", ""),
"verificationToken": extra.get("verificationToken", ""),
"allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "dingtalk":
return {
"enabled": enabled,
"clientId": external_app_id,
"clientSecret": app_secret,
"allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "telegram":
return {
"enabled": enabled,
"token": app_secret,
"proxy": extra.get("proxy", ""),
"replyToMessage": bool(extra.get("replyToMessage", False)),
"allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "slack":
return {
"enabled": enabled,
"mode": extra.get("mode", "socket"),
"botToken": external_app_id,
"appToken": app_secret,
"replyInThread": bool(extra.get("replyInThread", True)),
"groupPolicy": extra.get("groupPolicy", "mention"),
"groupAllowFrom": extra.get("groupAllowFrom", []),
"reactEmoji": extra.get("reactEmoji", "eyes"),
}
if ctype == "qq":
return {
"enabled": enabled,
"appId": external_app_id,
"secret": app_secret,
"allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "email":
return {
"enabled": enabled,
"consentGranted": bool(extra.get("consentGranted", False)),
"imapHost": str(extra.get("imapHost") or ""),
"imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)),
"imapUsername": str(extra.get("imapUsername") or ""),
"imapPassword": str(extra.get("imapPassword") or ""),
"imapMailbox": str(extra.get("imapMailbox") or "INBOX"),
"imapUseSsl": bool(extra.get("imapUseSsl", True)),
"smtpHost": str(extra.get("smtpHost") or ""),
"smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)),
"smtpUsername": str(extra.get("smtpUsername") or ""),
"smtpPassword": str(extra.get("smtpPassword") or ""),
"smtpUseTls": bool(extra.get("smtpUseTls", True)),
"smtpUseSsl": bool(extra.get("smtpUseSsl", False)),
"fromAddress": str(extra.get("fromAddress") or ""),
"autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)),
"pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)),
"markSeen": bool(extra.get("markSeen", True)),
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
"allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])),
}
merged = dict(extra)
merged.update(
{
"enabled": enabled,
"appId": external_app_id,
"appSecret": app_secret,
"port": port,
}
)
return merged
def get_bot_channels_from_config(self, bot: BotInstance) -> List[Dict[str, Any]]:
config_data = self.read_bot_config(bot.id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
send_progress, send_tool_hints = self.read_global_delivery_flags(channels_cfg)
rows: List[Dict[str, Any]] = [
{
"id": "dashboard",
"bot_id": bot.id,
"channel_type": "dashboard",
"external_app_id": f"dashboard-{bot.id}",
"app_secret": "",
"internal_port": 9000,
"is_active": True,
"extra_config": {
"sendProgress": send_progress,
"sendToolHints": send_tool_hints,
},
"locked": True,
}
]
for ctype, cfg in channels_cfg.items():
if ctype in {"sendProgress", "sendToolHints", "dashboard"}:
continue
if not isinstance(cfg, dict):
continue
rows.append(self.channel_cfg_to_api_dict(bot.id, ctype, cfg))
return rows
def normalize_initial_channels(self, bot_id: str, channels: Optional[List[Any]]) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
seen_types: set[str] = set()
for channel in channels or []:
ctype = str(getattr(channel, "channel_type", "") or "").strip().lower()
if not ctype or ctype == "dashboard" or ctype in seen_types:
continue
seen_types.add(ctype)
rows.append(
{
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": str(getattr(channel, "external_app_id", "") or "").strip() or f"{ctype}-{bot_id}",
"app_secret": str(getattr(channel, "app_secret", "") or "").strip(),
"internal_port": max(1, min(int(getattr(channel, "internal_port", 8080) or 8080), 65535)),
"is_active": bool(getattr(channel, "is_active", True)),
"extra_config": self.normalize_channel_extra(getattr(channel, "extra_config", None)),
"locked": False,
}
)
return rows
def parse_message_media(self, bot_id: str, media_raw: Optional[str]) -> List[str]:
if not media_raw:
return []
try:
parsed = json.loads(media_raw)
return self._normalize_media_list(parsed, bot_id)
except Exception:
return []
def normalize_env_params(self, raw: Any) -> Dict[str, str]:
if not isinstance(raw, dict):
return {}
rows: Dict[str, str] = {}
for key, value in raw.items():
normalized_key = str(key or "").strip().upper()
if not normalized_key or not self._ENV_KEY_RE.fullmatch(normalized_key):
continue
rows[normalized_key] = str(value or "").strip()
return rows
def get_default_system_timezone(self) -> str:
value = str(self._default_bot_system_timezone or "").strip() or "Asia/Shanghai"
try:
ZoneInfo(value)
return value
except Exception:
return "Asia/Shanghai"
def normalize_system_timezone(self, raw: Any) -> str:
value = str(raw or "").strip()
if not value:
return self.get_default_system_timezone()
try:
ZoneInfo(value)
except Exception as exc:
raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc
return value
def resolve_bot_env_params(self, bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
env_params = self.normalize_env_params(raw if isinstance(raw, dict) else self._read_env_store(bot_id))
try:
env_params["TZ"] = self.normalize_system_timezone(env_params.get("TZ"))
except ValueError:
env_params["TZ"] = self.get_default_system_timezone()
return env_params
def parse_env_params(self, raw: Any) -> Dict[str, str]:
return self.normalize_env_params(raw)
@staticmethod
def safe_float(raw: Any, default: float) -> float:
try:
return float(raw)
except Exception:
return default
@staticmethod
def safe_int(raw: Any, default: int) -> int:
try:
return int(raw)
except Exception:
return default
def normalize_resource_limits(self, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]:
cpu = self.safe_float(cpu_cores, 1.0)
memory = self.safe_int(memory_mb, 1024)
storage = self.safe_int(storage_gb, 10)
if cpu < 0:
cpu = 1.0
if memory < 0:
memory = 1024
if storage < 0:
storage = 10
normalized_cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu))
normalized_memory = 0 if memory == 0 else min(65536, max(256, memory))
normalized_storage = 0 if storage == 0 else min(1024, max(1, storage))
return {
"cpu_cores": normalized_cpu,
"memory_mb": normalized_memory,
"storage_gb": normalized_storage,
}
def sync_workspace_channels(
self,
session: Session,
bot_id: str,
channels_override: Optional[List[Dict[str, Any]]] = None,
global_delivery_override: Optional[Dict[str, Any]] = None,
runtime_overrides: Optional[Dict[str, Any]] = None,
) -> None:
bot = session.get(BotInstance, bot_id)
if not bot:
return
snapshot = self._read_bot_runtime_snapshot(bot)
default_target = self.default_provider_target()
bot_data: Dict[str, Any] = {
"name": bot.name,
"node_id": snapshot.get("node_id") or default_target.node_id,
"transport_kind": snapshot.get("transport_kind") or default_target.transport_kind,
"runtime_kind": snapshot.get("runtime_kind") or default_target.runtime_kind,
"core_adapter": snapshot.get("core_adapter") or default_target.core_adapter,
"system_prompt": snapshot.get("system_prompt") or self._default_soul_md,
"soul_md": snapshot.get("soul_md") or self._default_soul_md,
"agents_md": snapshot.get("agents_md") or self._default_agents_md,
"user_md": snapshot.get("user_md") or self._default_user_md,
"tools_md": snapshot.get("tools_md") or self._default_tools_md,
"identity_md": snapshot.get("identity_md") or self._default_identity_md,
"llm_provider": snapshot.get("llm_provider") or "dashscope",
"llm_model": snapshot.get("llm_model") or "",
"api_key": snapshot.get("api_key") or "",
"api_base": snapshot.get("api_base") or "",
"temperature": self.safe_float(snapshot.get("temperature"), 0.2),
"top_p": self.safe_float(snapshot.get("top_p"), 1.0),
"max_tokens": self.safe_int(snapshot.get("max_tokens"), 8192),
"cpu_cores": self.safe_float(snapshot.get("cpu_cores"), 1.0),
"memory_mb": self.safe_int(snapshot.get("memory_mb"), 1024),
"storage_gb": self.safe_int(snapshot.get("storage_gb"), 10),
"send_progress": bool(snapshot.get("send_progress")),
"send_tool_hints": bool(snapshot.get("send_tool_hints")),
}
if isinstance(runtime_overrides, dict):
for key, value in runtime_overrides.items():
if key in {"api_key", "llm_provider", "llm_model"}:
text = str(value or "").strip()
if not text:
continue
bot_data[key] = text
continue
if key == "api_base":
bot_data[key] = str(value or "").strip()
continue
bot_data[key] = value
resources = self.normalize_resource_limits(
bot_data.get("cpu_cores"),
bot_data.get("memory_mb"),
bot_data.get("storage_gb"),
)
bot_data["cpu_cores"] = resources["cpu_cores"]
bot_data["memory_mb"] = resources["memory_mb"]
bot_data["storage_gb"] = resources["storage_gb"]
send_progress = bool(bot_data.get("send_progress", False))
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
if isinstance(global_delivery_override, dict):
if "sendProgress" in global_delivery_override:
send_progress = bool(global_delivery_override.get("sendProgress"))
if "sendToolHints" in global_delivery_override:
send_tool_hints = bool(global_delivery_override.get("sendToolHints"))
channels_data = channels_override if channels_override is not None else self.get_bot_channels_from_config(bot)
bot_data["send_progress"] = send_progress
bot_data["send_tool_hints"] = send_tool_hints
normalized_channels: List[Dict[str, Any]] = []
for row in channels_data:
ctype = str(row.get("channel_type") or "").strip().lower()
if not ctype or ctype == "dashboard":
continue
normalized_channels.append(
{
"channel_type": ctype,
"external_app_id": str(row.get("external_app_id") or ""),
"app_secret": str(row.get("app_secret") or ""),
"internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)),
"is_active": bool(row.get("is_active", True)),
"extra_config": self.normalize_channel_extra(row.get("extra_config")),
}
)
self._config_manager.update_workspace(
bot_id=bot_id,
bot_data=bot_data,
channels=normalized_channels,
)
self.write_bot_resources(
bot_id,
bot_data.get("cpu_cores"),
bot_data.get("memory_mb"),
bot_data.get("storage_gb"),
)
def set_bot_provider_target(self, bot_id: str, target: ProviderTarget) -> None:
self.set_provider_target_override(bot_id, target)
def sync_bot_workspace_via_provider(
self,
session: Session,
bot: BotInstance,
*,
target_override: Optional[ProviderTarget] = None,
channels_override: Optional[List[Dict[str, Any]]] = None,
global_delivery_override: Optional[Dict[str, Any]] = None,
runtime_overrides: Optional[Dict[str, Any]] = None,
) -> None:
bot_id = str(bot.id or "")
previous_override = self._provider_target_overrides.get(bot_id)
wrote_target = False
try:
if target_override is not None:
self.set_bot_provider_target(bot_id, target_override)
wrote_target = True
self._get_provision_provider(self._app.state, bot).sync_bot_workspace(
session=session,
bot_id=bot_id,
channels_override=channels_override,
global_delivery_override=global_delivery_override,
runtime_overrides=runtime_overrides,
)
except Exception:
if wrote_target:
if previous_override is not None:
self.set_provider_target_override(bot_id, previous_override)
else:
self.clear_provider_target_override(bot_id)
raise
def workspace_root(self, bot_id: str) -> str:
return os.path.abspath(os.path.join(self._bots_workspace_root, bot_id, ".nanobot", "workspace"))
def bot_data_root(self, bot_id: str) -> str:
return os.path.abspath(os.path.join(self._bots_workspace_root, bot_id, ".nanobot"))
def cron_store_path(self, bot_id: str) -> str:
return os.path.join(self.bot_data_root(bot_id), "cron", "jobs.json")
def env_store_path(self, bot_id: str) -> str:
return os.path.join(self.bot_data_root(bot_id), "env.json")
def sessions_root(self, bot_id: str) -> str:
return os.path.join(self.workspace_root(bot_id), "sessions")
def clear_bot_sessions(self, bot_id: str) -> int:
edge_context = self.resolve_edge_state_context(bot_id)
if edge_context is not None:
client, workspace_root, node_id = edge_context
try:
payload = client.list_tree(
bot_id=bot_id,
path="sessions",
recursive=True,
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"sessions-clear-list:{node_id}:{bot_id}",
exc=exc,
message=f"Failed to list edge session files for bot_id={bot_id}",
)
return 0
deleted = 0
for entry in list(payload.get("entries") or []):
if not isinstance(entry, dict):
continue
if str(entry.get("type") or "").strip().lower() != "file":
continue
rel_path = str(entry.get("path") or "").strip().replace("\\", "/")
if not rel_path.lower().startswith("sessions/") or not rel_path.lower().endswith(".jsonl"):
continue
try:
result = client.delete_workspace_path(
bot_id=bot_id,
path=rel_path,
workspace_root=workspace_root,
)
if bool(result.get("deleted")):
deleted += 1
except Exception as exc:
log_edge_failure(
self._logger,
key=f"sessions-clear-delete:{node_id}:{bot_id}:{rel_path}",
exc=exc,
message=f"Failed to delete edge session file for bot_id={bot_id}, path={rel_path}",
)
return deleted
root = self.sessions_root(bot_id)
if not os.path.isdir(root):
return 0
deleted = 0
for name in os.listdir(root):
path = os.path.join(root, name)
if not os.path.isfile(path):
continue
if not name.lower().endswith(".jsonl"):
continue
try:
os.remove(path)
deleted += 1
except Exception:
continue
return deleted
def clear_bot_dashboard_direct_session(self, bot_id: str) -> Dict[str, Any]:
edge_context = self.resolve_edge_state_context(bot_id)
if edge_context is not None:
client, workspace_root, node_id = edge_context
path = "sessions/dashboard_direct.jsonl"
existed = False
try:
payload = client.list_tree(
bot_id=bot_id,
path="sessions",
recursive=False,
workspace_root=workspace_root,
)
existed = any(
isinstance(entry, dict)
and str(entry.get("type") or "").strip().lower() == "file"
and str(entry.get("path") or "").strip().replace("\\", "/") == path
for entry in list(payload.get("entries") or [])
)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"dashboard-session-check:{node_id}:{bot_id}",
exc=exc,
message=f"Failed to inspect edge dashboard session file for bot_id={bot_id}",
)
try:
client.write_text_file(
bot_id=bot_id,
path=path,
content="",
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"dashboard-session-clear:{node_id}:{bot_id}",
exc=exc,
message=f"Failed to truncate edge dashboard session file for bot_id={bot_id}",
)
raise
return {"path": path, "existed": existed}
root = self.sessions_root(bot_id)
os.makedirs(root, exist_ok=True)
path = os.path.join(root, "dashboard_direct.jsonl")
existed = os.path.exists(path)
with open(path, "w", encoding="utf-8"):
pass
return {"path": path, "existed": existed}
def resolve_workspace_path(self, bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]:
root = self.workspace_root(bot_id)
rel = str(rel_path or "").strip().replace("\\", "/")
target = os.path.abspath(os.path.join(root, rel))
if os.path.commonpath([root, target]) != root:
raise HTTPException(status_code=400, detail="invalid workspace path")
return root, target
@staticmethod
def calc_dir_size_bytes(path: str) -> int:
total = 0
if not os.path.exists(path):
return 0
for root, _, files in os.walk(path):
for filename in files:
try:
file_path = os.path.join(root, filename)
if os.path.islink(file_path):
continue
total += os.path.getsize(file_path)
except Exception:
continue
return max(0, total)
@staticmethod
def is_image_attachment_path(path: str) -> bool:
lower = str(path or "").strip().lower()
return lower.endswith(".png") or lower.endswith(".jpg") or lower.endswith(".jpeg") or lower.endswith(".webp")
@staticmethod
def is_video_attachment_path(path: str) -> bool:
lower = str(path or "").strip().lower()
return (
lower.endswith(".mp4")
or lower.endswith(".mov")
or lower.endswith(".m4v")
or lower.endswith(".webm")
or lower.endswith(".mkv")
or lower.endswith(".avi")
)
def is_visual_attachment_path(self, path: str) -> bool:
return self.is_image_attachment_path(path) or self.is_video_attachment_path(path)
def ensure_provider_target_supported(self, target: ProviderTarget) -> None:
key = self._resolve_provider_bundle_key(target)
if key is None:
raise HTTPException(status_code=400, detail=f"Execution target is not supported yet: {target.key}")