"""nanobot 配置 Schema(基于 Pydantic)。 这份文件是“配置系统的单一结构定义”: 1. 定义配置长什么样(字段、默认值、嵌套结构) 2. 负责配置的类型校验与兼容(camelCase / snake_case) 3. 提供若干读取辅助方法(如 provider 匹配、api_key/api_base 解析) 你可以把它理解为: - `loader.py` 负责“读写配置文件” - `schema.py` 负责“配置对象的结构和规则” """ from pathlib import Path from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings class Base(BaseModel): """所有配置模型的基类。 关键点: - `alias_generator=to_camel`:自动把 `api_key` 这种字段映射到 `apiKey` - `populate_by_name=True`:读取时同时接受 snake_case 和 camelCase 结果: - Python 代码内部统一使用 snake_case,便于可读性和一致性 - 配置文件对外保持 camelCase,贴近 README 和用户习惯 """ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class WhatsAppConfig(Base): """WhatsApp 渠道配置。 说明: - nanobot 通过单独的 bridge 进程与 WhatsApp 交互 - 这里配置的是 bridge 的连接地址和访问控制 """ enabled: bool = False bridge_url: str = "ws://localhost:3001" bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers class TelegramConfig(Base): """Telegram 渠道配置。 常用字段: - token:机器人凭证(必须) - allow_from:白名单(可选,空列表表示不限制) - proxy:在网络受限场景下可配置代理 """ enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" reply_to_message: bool = False # If true, bot replies quote the original message class FeishuConfig(Base): """飞书/Lark 渠道配置(基于长连接模式)。""" enabled: bool = False app_id: str = "" # App ID from Feishu Open Platform app_secret: str = "" # App Secret from Feishu Open Platform encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids class DingTalkConfig(Base): """钉钉渠道配置(Stream 模式)。""" enabled: bool = False client_id: str = "" # AppKey client_secret: str = "" # AppSecret allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids class DiscordConfig(Base): """Discord 渠道配置。""" enabled: bool = False token: str = "" # Bot token from Discord Developer Portal allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT class MatrixConfig(Base): """Matrix (Element) 渠道配置。""" enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). sync_stop_grace_seconds: int = ( 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. ) max_media_bytes: int = ( 20 * 1024 * 1024 ) # Max attachment size accepted for Matrix media handling (inbound + outbound). allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) allow_room_mentions: bool = False class EmailConfig(Base): """Email 渠道配置(IMAP 收件 + SMTP 发件)。 设计思路: - IMAP 负责拉取新邮件 - SMTP 负责自动回复 - 行为参数控制轮询频率、正文截断、标记已读等策略 """ enabled: bool = False consent_granted: bool = False # Explicit owner permission to access mailbox data # IMAP (receive) imap_host: str = "" imap_port: int = 993 imap_username: str = "" imap_password: str = "" imap_mailbox: str = "INBOX" imap_use_ssl: bool = True # SMTP (send) smtp_host: str = "" smtp_port: int = 587 smtp_username: str = "" smtp_password: str = "" smtp_use_tls: bool = True smtp_use_ssl: bool = False from_address: str = "" # Behavior auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 subject_prefix: str = "Re: " allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses class MochatMentionConfig(Base): """Mochat 提及(mention)规则。""" require_in_groups: bool = False class MochatGroupRule(Base): """Mochat 群组级别规则(可按群单独配置是否必须 @)。""" require_mention: bool = False class MochatConfig(Base): """Mochat 渠道配置。 包含三类参数: - 连接参数:base_url / socket_url / socket_path - 重连与轮询参数:各类 *_ms 与 retry 相关字段 - 权限与会话参数:allow_from / sessions / panels / mention / groups """ enabled: bool = False base_url: str = "https://mochat.io" socket_url: str = "" socket_path: str = "/socket.io" socket_disable_msgpack: bool = False socket_reconnect_delay_ms: int = 1000 socket_max_reconnect_delay_ms: int = 10000 socket_connect_timeout_ms: int = 10000 refresh_interval_ms: int = 30000 watch_timeout_ms: int = 25000 watch_limit: int = 100 retry_delay_ms: int = 500 max_retry_attempts: int = 0 # 0 means unlimited retries claw_token: str = "" agent_user_id: str = "" sessions: list[str] = Field(default_factory=list) panels: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list) mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig) groups: dict[str, MochatGroupRule] = Field(default_factory=dict) reply_delay_mode: str = "non-mention" # off | non-mention reply_delay_ms: int = 120000 class SlackDMConfig(Base): """Slack 私聊(DM)策略配置。""" enabled: bool = True policy: str = "open" # "open" or "allowlist" allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs class SlackConfig(Base): """Slack 渠道配置。""" enabled: bool = False mode: str = "socket" # "socket" supported webhook_path: str = "/slack/events" bot_token: str = "" # xoxb-... app_token: str = "" # xapp-... user_token_read_only: bool = True reply_in_thread: bool = True react_emoji: str = "eyes" group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) class QQConfig(Base): """QQ 渠道配置(botpy SDK)。""" enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): """所有聊天渠道的总配置。 除了具体渠道参数外,还有两个全局开关: - send_progress:是否把“处理中进度”推送到渠道 - send_tool_hints:是否把“工具调用提示”推送到渠道 """ send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) mochat: MochatConfig = Field(default_factory=MochatConfig) dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) email: EmailConfig = Field(default_factory=EmailConfig) slack: SlackConfig = Field(default_factory=SlackConfig) qq: QQConfig = Field(default_factory=QQConfig) matrix: MatrixConfig = Field(default_factory=MatrixConfig) class AgentDefaults(Base): """Agent 默认行为配置。 关键参数建议理解: - model:主模型标识 - max_tokens:单次回复上限 - max_tool_iterations:一次请求里最多工具循环次数 - memory_window:每次送给模型的历史窗口大小 """ workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 memory_window: int = 100 class AgentsConfig(Base): """Agent 顶层配置(当前主要是 defaults)。""" defaults: AgentDefaults = Field(default_factory=AgentDefaults) class ProviderConfig(Base): """单个 LLM Provider 的通用配置结构。 字段说明: - api_key:访问凭证 - api_base:可选自定义网关/代理地址 - extra_headers:额外 HTTP 头(某些网关会要求) """ api_key: str = "" api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) class ProvidersConfig(Base): """所有 Provider 的配置集合。 这里的字段名必须和 `providers/registry.py` 里的 ProviderSpec.name 对齐。 这样 `_match_provider()` 才能通过 `getattr(self.providers, spec.name)` 正确取值。 """ custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) deepseek: ProviderConfig = Field(default_factory=ProviderConfig) groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # 阿里云通义千问 vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) class GatewayConfig(Base): """Gateway 服务监听配置。""" host: str = "0.0.0.0" port: int = 18790 class WebSearchConfig(Base): """Web 搜索工具配置(当前主要是 Brave Search)。""" api_key: str = "" # Brave Search API key max_results: int = 5 class WebToolsConfig(Base): """Web 工具总配置。""" search: WebSearchConfig = Field(default_factory=WebSearchConfig) class ExecToolConfig(Base): """Shell 执行工具配置。""" timeout: int = 60 class MCPServerConfig(Base): """单个 MCP 服务器配置(支持 stdio 与 HTTP 两种连接方式)。 使用方式: - stdio:配置 `command + args + env` - HTTP:配置 `url + headers` """ command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers auth_mode: str = "none" # none | oauth_backend_token auth_audience: str = "" auth_scopes: list[str] = Field(default_factory=list) tool_timeout: int = 30 # Seconds before a tool call is cancelled sensitive: bool = False # Redact secrets/args from Web views and process events class A2AConfig(Base): """A2A agent 委派配置。""" # 总开关,预留给未来需要完全禁用远程委派的场景。 enabled: bool = True # 单次远程任务的最长等待时间(秒)。 timeout_seconds: int = 30 # 非流式任务轮询间隔(秒)。 poll_interval_seconds: int = 2 # agent card 本地缓存 TTL,避免每次委派都重新拉远端元数据。 card_cache_ttl_seconds: int = 300 # group delegation 并发上限,防止一次性打爆本地或远端资源。 max_parallel_agents: int = 4 # 是否允许从 skill 元数据里暴露 agent cards。 allow_skill_cards: bool = True # 是否允许读取 workspace/agents/registry.json 中的手工登记 agent。 allow_workspace_agents: bool = True # 允许访问的远端 host 白名单;为空表示不限制。 allowed_hosts: list[str] = Field(default_factory=list) class ToolsConfig(Base): """工具层总配置。 关键安全字段: - restrict_to_workspace:开启后,工具访问将被限制在 workspace 内 """ web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) a2a: A2AConfig = Field(default_factory=A2AConfig) class AuthzConfig(Base): """外部 AuthZ/OAuth 服务配置。""" enabled: bool = False base_url: str = "http://127.0.0.1:19090" request_timeout_seconds: int = 10 outlook_mcp_url: str = "" class BackendIdentityConfig(Base): """当前 backend 在 AuthZ 服务里的身份配置。""" backend_id: str = "" client_id: str = "" client_secret: str = "" name: str = "Local Backend" public_base_url: str = "" class Config(BaseSettings): """nanobot 根配置对象。 这是业务代码中最常使用的配置入口: - `config.agents.defaults.model` - `config.channels.telegram.token` - `config.tools.restrict_to_workspace` 等都会从这里往下访问。 """ agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) authz: AuthzConfig = Field(default_factory=AuthzConfig) backend_identity: BackendIdentityConfig = Field(default_factory=BackendIdentityConfig) @property def workspace_path(self) -> Path: """返回展开后的 workspace 绝对路径对象。 `~` 会被替换成用户 home 目录,避免下游代码重复处理路径展开。 """ return Path(self.agents.defaults.workspace).expanduser() def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """根据模型名与当前配置,匹配最合适的 provider。 返回值: - ProviderConfig | None:匹配到的配置项(含 api_key/api_base) - str | None:provider 的 registry 名称(例如 openrouter/deepseek) 匹配优先级(非常重要): 1. 显式前缀匹配:`github-copilot/...` 这种明确前缀优先 2. 关键字匹配:按 PROVIDERS 顺序匹配关键词 3. 兜底匹配:选第一个“已配置 api_key 的非 OAuth provider” """ from nanobot.providers.registry import PROVIDERS # 统一做小写与连字符归一化,减少字符串匹配分歧。 model_lower = (model or self.agents.defaults.model).lower() model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") # 关键字匹配函数:同时兼容 dash/underscore 两种写法。 def _kw_matches(kw: str) -> bool: kw = kw.lower() return kw in model_lower or kw.replace("-", "_") in model_normalized # 第 1 轮:显式前缀优先 # 例如 `github-copilot/gpt-5.3-codex`,必须匹配 github_copilot, # 不能被 `codex` 关键字误匹配成 openai_codex。 for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth or p.api_key: return p, spec.name # 第 2 轮:按关键字匹配(顺序由 PROVIDERS 决定) # 顺序很关键:registry 里前面的 provider 具有更高优先级。 for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name # 第 3 轮:兜底匹配 # 规则:仅考虑“非 OAuth + 有 api_key”的 provider。 # 原因:OAuth provider 需要显式模型选择,不能静默兜底。 for spec in PROVIDERS: if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) if p and p.api_key: return p, spec.name return None, None def get_provider(self, model: str | None = None) -> ProviderConfig | None: """获取匹配到的 ProviderConfig(含 api_key/api_base/extra_headers)。""" p, _ = self._match_provider(model) return p def get_provider_name(self, model: str | None = None) -> str | None: """获取匹配到的 provider 名称(例如 deepseek/openrouter)。""" _, name = self._match_provider(model) return name def get_api_key(self, model: str | None = None) -> str | None: """获取当前模型对应的 API key(无则返回 None)。""" p = self.get_provider(model) return p.api_key if p else None def get_api_base(self, model: str | None = None) -> str | None: """获取当前模型的 api_base。 规则: 1. 若用户显式配置了 api_base,优先返回用户值 2. 否则若匹配到的是 gateway provider,则可回退到 registry 默认 base 3. 标准 provider(非 gateway)默认不在这里强制写 api_base """ from nanobot.providers.registry import find_by_name p, name = self._match_provider(model) if p and p.api_base: return p.api_base # 仅 gateway 在此处应用默认 api_base。 # 标准 provider(如 moonshot)通常在 provider 初始化时通过环境变量处理, # 避免污染全局 litellm.api_base。 if name: spec = find_by_name(name) if spec and spec.is_gateway and spec.default_api_base: return spec.default_api_base return None # BaseSettings 相关: # - env_prefix="NANOBOT_":环境变量前缀,例如 NANOBOT_AGENTS__DEFAULTS__MODEL # - env_nested_delimiter="__":双下划线用于拆分嵌套层级 model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")