dashboard-nanobot/backend/services/bot_service.py

535 lines
21 KiB
Python
Raw Normal View History

2026-03-31 04:31:47 +00:00
import os
2026-04-04 16:29:37 +00:00
from datetime import datetime
from pathlib import Path
2026-03-31 04:31:47 +00:00
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from sqlmodel import Session
2026-04-14 02:04:12 +00:00
from core.settings import BOTS_WORKSPACE_ROOT
2026-03-31 04:31:47 +00:00
from models.bot import BotInstance
2026-04-14 02:04:12 +00:00
from providers.bot_workspace_provider import BotWorkspaceProvider
2026-04-04 16:29:37 +00:00
from schemas.bot import ChannelConfigRequest
2026-03-31 04:31:47 +00:00
from services.bot_storage_service import (
_normalize_env_params,
_read_bot_config,
_read_bot_resources,
_read_env_store,
_safe_float,
_safe_int,
_workspace_root,
2026-04-04 16:29:37 +00:00
normalize_bot_resource_limits,
write_bot_resource_limits,
2026-03-31 04:31:47 +00:00
)
2026-04-14 02:04:12 +00:00
workspace_provider = BotWorkspaceProvider(host_data_root=BOTS_WORKSPACE_ROOT)
2026-03-31 04:31:47 +00:00
2026-04-04 16:29:37 +00:00
def normalize_bot_system_timezone(raw: Any) -> str:
2026-03-31 04:31:47 +00:00
value = str(raw or "").strip()
if not value:
2026-04-14 02:04:12 +00:00
raise ValueError("System timezone is required")
2026-03-31 04:31:47 +00:00
try:
ZoneInfo(value)
except Exception as exc:
raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc
return value
2026-04-04 16:29:37 +00:00
def resolve_bot_runtime_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
2026-03-31 04:31:47 +00:00
env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id))
2026-04-14 02:04:12 +00:00
if "TZ" not in env_params:
raise RuntimeError(f"Missing required TZ in bot env settings: {bot_id}")
env_params["TZ"] = normalize_bot_system_timezone(env_params.get("TZ"))
2026-03-31 04:31:47 +00:00
return env_params
2026-04-04 16:29:37 +00:00
def normalize_channel_extra(raw: Any) -> Dict[str, Any]:
if not isinstance(raw, dict):
return {}
return raw
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)
return rows or ["*"]
def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
if not isinstance(channels_cfg, dict):
return False, False
2026-04-14 02:04:12 +00:00
return bool(channels_cfg.get("sendProgress")), bool(channels_cfg.get("sendToolHints"))
2026-04-04 16:29:37 +00:00
def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(channel_type 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 ctype == "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": _normalize_allow_from(cfg.get("allowFrom", [])),
}
elif ctype == "dingtalk":
external_app_id = str(cfg.get("clientId") or "")
app_secret = str(cfg.get("clientSecret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
elif ctype == "telegram":
app_secret = str(cfg.get("token") or "")
extra = {
"proxy": cfg.get("proxy", ""),
"replyToMessage": bool(cfg.get("replyToMessage", False)),
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
}
elif ctype == "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 ctype == "qq":
external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
elif ctype == "weixin":
app_secret = ""
extra = {
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
}
elif ctype == "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": _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": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": external_app_id,
"app_secret": app_secret,
"internal_port": port,
"is_active": enabled,
"extra_config": extra,
"locked": ctype == "dashboard",
}
def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(row.get("channel_type") or "").strip().lower()
enabled = bool(row.get("is_active", True))
extra = 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": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "dingtalk":
return {
"enabled": enabled,
"clientId": external_app_id,
"clientSecret": app_secret,
"allowFrom": _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": _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": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "weixin":
return {
"enabled": enabled,
"token": app_secret,
}
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": _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 list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
config_data = _read_bot_config(bot.id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
send_progress, send_tool_hints = 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"} or not isinstance(cfg, dict):
continue
rows.append(channel_config_to_api(bot.id, ctype, cfg))
return rows
def normalize_initial_bot_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
seen_types: set[str] = set()
for channel in channels or []:
ctype = (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": (channel.external_app_id or "").strip() or f"{ctype}-{bot_id}",
"app_secret": (channel.app_secret or "").strip(),
"internal_port": max(1, min(int(channel.internal_port or 8080), 65535)),
"is_active": bool(channel.is_active),
"extra_config": normalize_channel_extra(channel.extra_config),
"locked": False,
}
)
return rows
2026-04-14 02:04:12 +00:00
def _read_workspace_md(bot_id: str, filename: str) -> str:
2026-03-31 04:31:47 +00:00
path = os.path.join(_workspace_root(bot_id), filename)
if not os.path.isfile(path):
2026-04-14 02:04:12 +00:00
raise RuntimeError(f"Missing required workspace file: {path}")
2026-03-31 04:31:47 +00:00
try:
2026-04-04 16:29:37 +00:00
with open(path, "r", encoding="utf-8") as file:
return file.read().strip()
2026-04-14 02:04:12 +00:00
except Exception as exc:
raise RuntimeError(f"Failed to read workspace file: {path}") from exc
2026-03-31 04:31:47 +00:00
2026-04-04 16:29:37 +00:00
def read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
2026-03-31 04:31:47 +00:00
config_data = _read_bot_config(bot.id)
2026-04-04 16:29:37 +00:00
env_params = resolve_bot_runtime_env_params(bot.id)
2026-03-31 04:31:47 +00:00
provider_name = ""
provider_cfg: Dict[str, Any] = {}
providers_cfg = config_data.get("providers")
if isinstance(providers_cfg, dict):
for p_name, p_cfg in providers_cfg.items():
provider_name = str(p_name or "").strip()
if isinstance(p_cfg, dict):
provider_cfg = p_cfg
break
2026-04-14 02:04:12 +00:00
if not provider_name or not provider_cfg:
raise RuntimeError(f"Missing provider configuration in bot config: {bot.id}")
2026-03-31 04:31:47 +00:00
agents_defaults: Dict[str, Any] = {}
agents_cfg = config_data.get("agents")
if isinstance(agents_cfg, dict):
defaults = agents_cfg.get("defaults")
if isinstance(defaults, dict):
agents_defaults = defaults
2026-04-14 02:04:12 +00:00
if not agents_defaults:
raise RuntimeError(f"Missing agents.defaults in bot config: {bot.id}")
2026-03-31 04:31:47 +00:00
channels_cfg = config_data.get("channels")
2026-04-04 16:29:37 +00:00
send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg)
2026-03-31 04:31:47 +00:00
llm_provider = provider_name or ""
llm_model = str(agents_defaults.get("model") or "")
api_key = str(provider_cfg.get("apiKey") or "").strip()
api_base = str(provider_cfg.get("apiBase") or "").strip()
2026-04-14 02:04:12 +00:00
if not llm_model:
raise RuntimeError(f"Missing model in bot config: {bot.id}")
if not api_key:
raise RuntimeError(f"Missing apiKey in bot config: {bot.id}")
if not api_base:
raise RuntimeError(f"Missing apiBase in bot config: {bot.id}")
2026-03-31 04:31:47 +00:00
api_base_lower = api_base.lower()
provider_alias = str(provider_cfg.get("dashboardProviderAlias") or "").strip().lower()
if llm_provider == "openai" and provider_alias in {"xunfei", "iflytek", "xfyun", "vllm"}:
llm_provider = "xunfei" if provider_alias in {"iflytek", "xfyun"} else provider_alias
elif llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
llm_provider = "xunfei"
2026-04-14 02:04:12 +00:00
tools_cfg = config_data.get("tools")
if tools_cfg is not None and not isinstance(tools_cfg, dict):
raise RuntimeError(f"Invalid tools configuration in bot config: {bot.id}")
mcp_servers = tools_cfg.get("mcpServers") if isinstance(tools_cfg, dict) else None
soul_md = _read_workspace_md(bot.id, "SOUL.md")
2026-03-31 04:31:47 +00:00
resources = _read_bot_resources(bot.id, config_data=config_data)
return {
"llm_provider": llm_provider,
"llm_model": llm_model,
"api_key": api_key,
"api_base": api_base,
"temperature": _safe_float(agents_defaults.get("temperature"), 0.2),
"top_p": _safe_float(agents_defaults.get("topP"), 1.0),
"max_tokens": _safe_int(agents_defaults.get("maxTokens"), 8192),
"cpu_cores": resources["cpu_cores"],
"memory_mb": resources["memory_mb"],
"storage_gb": resources["storage_gb"],
2026-04-14 02:04:12 +00:00
"system_timezone": env_params["TZ"],
2026-03-31 04:31:47 +00:00
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"soul_md": soul_md,
2026-04-14 02:04:12 +00:00
"agents_md": _read_workspace_md(bot.id, "AGENTS.md"),
"user_md": _read_workspace_md(bot.id, "USER.md"),
"tools_md": _read_workspace_md(bot.id, "TOOLS.md"),
"identity_md": _read_workspace_md(bot.id, "IDENTITY.md"),
"mcp_servers": mcp_servers if isinstance(mcp_servers, dict) else None,
2026-03-31 04:31:47 +00:00
}
2026-04-04 16:29:37 +00:00
def serialize_bot_detail(bot: BotInstance) -> Dict[str, Any]:
runtime = read_bot_runtime_snapshot(bot)
2026-03-31 06:56:31 +00:00
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
2026-03-31 04:31:47 +00:00
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"access_password": bot.access_password or "",
"has_access_password": bool(str(bot.access_password or "").strip()),
"avatar_model": "base",
"avatar_skin": "blue_suit",
"image_tag": bot.image_tag,
2026-04-14 02:04:12 +00:00
"llm_provider": runtime["llm_provider"],
"llm_model": runtime["llm_model"],
"api_key": runtime["api_key"],
2026-04-14 02:04:12 +00:00
"api_base": runtime["api_base"],
"temperature": runtime["temperature"],
"top_p": runtime["top_p"],
"max_tokens": runtime["max_tokens"],
"cpu_cores": runtime["cpu_cores"],
"memory_mb": runtime["memory_mb"],
"storage_gb": runtime["storage_gb"],
"system_timezone": runtime["system_timezone"],
"send_progress": runtime["send_progress"],
"send_tool_hints": runtime["send_tool_hints"],
"soul_md": runtime["soul_md"],
"agents_md": runtime["agents_md"],
"user_md": runtime["user_md"],
"tools_md": runtime["tools_md"],
"identity_md": runtime["identity_md"],
2026-03-31 04:31:47 +00:00
"workspace_dir": bot.workspace_dir,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
2026-03-31 06:56:31 +00:00
"created_at": created_at,
"updated_at": updated_at,
2026-03-31 04:31:47 +00:00
}
2026-04-04 16:29:37 +00:00
def serialize_bot_list_entry(bot: BotInstance) -> Dict[str, Any]:
2026-03-31 06:56:31 +00:00
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
2026-03-31 04:31:47 +00:00
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"has_access_password": bool(str(bot.access_password or "").strip()),
"image_tag": bot.image_tag,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
2026-03-31 06:56:31 +00:00
"created_at": created_at,
"updated_at": updated_at,
2026-03-31 04:31:47 +00:00
}
2026-04-04 16:29:37 +00:00
2026-04-14 02:41:09 +00:00
def _has_bot_workspace_config(bot_id: str) -> bool:
return (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "config.json").is_file()
2026-04-04 16:29:37 +00:00
def sync_bot_workspace_channels(
2026-03-31 04:31:47 +00:00
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:
2026-04-14 02:04:12 +00:00
raise RuntimeError(f"Bot not found: {bot_id}")
2026-04-04 16:29:37 +00:00
2026-04-14 02:41:09 +00:00
has_existing_config = _has_bot_workspace_config(bot_id)
if has_existing_config:
snapshot = read_bot_runtime_snapshot(bot)
bot_data: Dict[str, Any] = dict(snapshot)
else:
if not isinstance(runtime_overrides, dict):
raise RuntimeError(f"Missing required bot config for workspace sync: {bot_id}")
bot_data = {}
2026-04-04 16:29:37 +00:00
if isinstance(runtime_overrides, dict):
2026-04-14 02:04:12 +00:00
bot_data.update(runtime_overrides)
2026-04-04 16:29:37 +00:00
resources = normalize_bot_resource_limits(
bot_data.get("cpu_cores"),
bot_data.get("memory_mb"),
bot_data.get("storage_gb"),
2026-03-31 04:31:47 +00:00
)
2026-04-04 16:29:37 +00:00
bot_data.update(resources)
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"))
2026-04-14 02:41:09 +00:00
if channels_override is not None:
channels_data = channels_override
elif has_existing_config:
channels_data = list_bot_channels_from_config(bot)
else:
channels_data = []
2026-04-04 16:29:37 +00:00
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": normalize_channel_extra(row.get("extra_config")),
}
)
2026-04-14 02:04:12 +00:00
workspace_provider.write_workspace(bot_id=bot_id, bot_data=bot_data, channels=normalized_channels)
2026-04-04 16:29:37 +00:00
write_bot_resource_limits(bot_id, bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"))