import os from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo from sqlmodel import Session from core.config_manager import BotConfigManager from core.settings import BOTS_WORKSPACE_ROOT, DEFAULT_BOT_SYSTEM_TIMEZONE from models.bot import BotInstance from schemas.bot import ChannelConfigRequest from services.bot_storage_service import ( _normalize_env_params, _read_bot_config, _read_bot_resources, _read_env_store, _safe_float, _safe_int, _workspace_root, normalize_bot_resource_limits, write_bot_resource_limits, ) from services.template_service import get_agent_md_templates config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT) def get_default_bot_system_timezone() -> str: value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai" try: ZoneInfo(value) return value except Exception: return "Asia/Shanghai" def normalize_bot_system_timezone(raw: Any) -> str: value = str(raw or "").strip() if not value: return get_default_bot_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_runtime_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]: env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id)) try: env_params["TZ"] = normalize_bot_system_timezone(env_params.get("TZ")) except ValueError: env_params["TZ"] = get_default_bot_system_timezone() return env_params 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 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_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 def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: path = os.path.join(_workspace_root(bot_id), filename) if not os.path.isfile(path): return default_value try: with open(path, "r", encoding="utf-8") as file: return file.read().strip() except Exception: return default_value def read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: config_data = _read_bot_config(bot.id) env_params = resolve_bot_runtime_env_params(bot.id) template_defaults = get_agent_md_templates() 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 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 channels_cfg = config_data.get("channels") send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg) 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() 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" soul_md = _read_workspace_md(bot.id, "SOUL.md", template_defaults.get("soul_md", "")) 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"], "system_timezone": env_params.get("TZ") or get_default_bot_system_timezone(), "send_progress": send_progress, "send_tool_hints": send_tool_hints, "soul_md": soul_md, "agents_md": _read_workspace_md(bot.id, "AGENTS.md", template_defaults.get("agents_md", "")), "user_md": _read_workspace_md(bot.id, "USER.md", template_defaults.get("user_md", "")), "tools_md": _read_workspace_md(bot.id, "TOOLS.md", template_defaults.get("tools_md", "")), "identity_md": _read_workspace_md(bot.id, "IDENTITY.md", template_defaults.get("identity_md", "")), "system_prompt": soul_md, } def serialize_bot_detail(bot: BotInstance) -> Dict[str, Any]: runtime = read_bot_runtime_snapshot(bot) 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 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, "llm_provider": runtime.get("llm_provider") or "", "llm_model": runtime.get("llm_model") or "", "system_prompt": runtime.get("system_prompt") or "", "api_base": runtime.get("api_base") or "", "temperature": _safe_float(runtime.get("temperature"), 0.2), "top_p": _safe_float(runtime.get("top_p"), 1.0), "max_tokens": _safe_int(runtime.get("max_tokens"), 8192), "cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0), "memory_mb": _safe_int(runtime.get("memory_mb"), 1024), "storage_gb": _safe_int(runtime.get("storage_gb"), 10), "system_timezone": str(runtime.get("system_timezone") or get_default_bot_system_timezone()), "send_progress": bool(runtime.get("send_progress")), "send_tool_hints": bool(runtime.get("send_tool_hints")), "soul_md": runtime.get("soul_md") or "", "agents_md": runtime.get("agents_md") or "", "user_md": runtime.get("user_md") or "", "tools_md": runtime.get("tools_md") or "", "identity_md": runtime.get("identity_md") or "", "workspace_dir": bot.workspace_dir, "docker_status": bot.docker_status, "current_state": bot.current_state, "last_action": bot.last_action, "created_at": created_at, "updated_at": updated_at, } def serialize_bot_list_entry(bot: BotInstance) -> Dict[str, Any]: 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 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, "created_at": created_at, "updated_at": updated_at, } def sync_bot_workspace_channels( 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 = read_bot_runtime_snapshot(bot) template_defaults = get_agent_md_templates() bot_data: Dict[str, Any] = { "name": bot.name, "system_prompt": snapshot.get("system_prompt") or template_defaults.get("soul_md", ""), "soul_md": snapshot.get("soul_md") or template_defaults.get("soul_md", ""), "agents_md": snapshot.get("agents_md") or template_defaults.get("agents_md", ""), "user_md": snapshot.get("user_md") or template_defaults.get("user_md", ""), "tools_md": snapshot.get("tools_md") or template_defaults.get("tools_md", ""), "identity_md": snapshot.get("identity_md") or template_defaults.get("identity_md", ""), "llm_provider": snapshot.get("llm_provider") or "", "llm_model": snapshot.get("llm_model") or "", "api_key": snapshot.get("api_key") or "", "api_base": snapshot.get("api_base") or "", "temperature": snapshot.get("temperature"), "top_p": snapshot.get("top_p"), "max_tokens": snapshot.get("max_tokens"), "cpu_cores": snapshot.get("cpu_cores"), "memory_mb": snapshot.get("memory_mb"), "storage_gb": snapshot.get("storage_gb"), "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 = normalize_bot_resource_limits( bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"), ) 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")) channels_data = channels_override if channels_override is not None else list_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": normalize_channel_extra(row.get("extra_config")), } ) config_manager.update_workspace(bot_id=bot_id, bot_data=bot_data, channels=normalized_channels) write_bot_resource_limits(bot_id, bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"))