diff --git a/README.MD b/README.MD index 137bb98..7cbba28 100644 --- a/README.MD +++ b/README.MD @@ -126,28 +126,49 @@ sudo apt-get install -y fonts-noto-color-emoji fonts-noto-cjk fonts-wqy-microhei ### 1. 配置文件 -配置文件位于 `config.yaml`,包含以下主要配置项: +推荐先复制 `config.example.yaml` 为 `config.yaml`,再通过环境变量注入敏感信息: + +```bash +# Linux / Mac +cp config.example.yaml config.yaml +export ABOT_DB_PASSWORD="你的数据库密码" +export ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY="你的 Dify Key" + +# Windows PowerShell +Copy-Item config.example.yaml config.yaml +$env:ABOT_DB_PASSWORD="你的数据库密码" +$env:ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY="你的 Dify Key" +``` + +`config.yaml` 现已支持 `${ENV_NAME}` / `${ENV_NAME:默认值}` 两种写法: + +- `${ABOT_DB_PASSWORD}`:必须由环境变量提供,否则启动时报错 +- `${ABOT_DB_HOST:127.0.0.1}`:若环境变量缺失,则回退默认值 + +启动时系统会自动执行配置完整性检查,并在日志中输出脱敏后的配置快照。包含以下主要配置项: #### 数据库配置 ```yaml db_config: - pool_name: "wechat_boot_pool" - pool_size: 10 - host: "your-db-host" - user: "your-db-user" - password: "your-db-password" - database: "message_archive" - charset: "utf8mb4" + pool_name: "${ABOT_DB_POOL_NAME:wechat_boot_pool}" + pool_size: "${ABOT_DB_POOL_SIZE:10}" + host: "${ABOT_DB_HOST:127.0.0.1}" + port: "${ABOT_DB_PORT:3306}" + user: "${ABOT_DB_USER:root}" + password: "${ABOT_DB_PASSWORD}" + database: "${ABOT_DB_NAME:message_archive}" + charset: "${ABOT_DB_CHARSET:utf8mb4}" ``` #### Redis配置 ```yaml redis_config: - host: "your-redis-host" - port: 6379 - db: 0 + host: "${ABOT_REDIS_HOST:127.0.0.1}" + port: "${ABOT_REDIS_PORT:6379}" + password: "${ABOT_REDIS_PASSWORD:}" + db: "${ABOT_REDIS_DB:0}" decode_responses: true ``` #### ipad 客户端配置 @@ -272,9 +293,9 @@ abot/ ### 开发规范 - 遵循PEP 8编码规范 -- 添加适当的注释 -- 编写单元测试 -- 更新文档 +- 添加适当的中文注释 +- 优先补齐文档与人工验证步骤 +- 敏感配置优先使用环境变量注入 ## ⚠️ 注意事项 @@ -342,4 +363,4 @@ python -m pip install --upgrade pip ## 🙏 致谢 -感谢所有为本项目做出贡献的开发者。 \ No newline at end of file +感谢所有为本项目做出贡献的开发者。 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..3b60420 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,141 @@ +environment: "${ABOT_ENVIRONMENT:development}" +plugin_dir: "${ABOT_PLUGIN_DIR:plugins}" + +db_config: + pool_name: "${ABOT_DB_POOL_NAME:wechat_boot_pool}" + pool_size: "${ABOT_DB_POOL_SIZE:10}" + host: "${ABOT_DB_HOST:127.0.0.1}" + # 新配置统一使用 port;prot 仅作为历史兼容字段保留。 + port: "${ABOT_DB_PORT:3306}" + prot: "${ABOT_DB_PORT:3306}" + user: "${ABOT_DB_USER:root}" + password: "${ABOT_DB_PASSWORD}" + database: "${ABOT_DB_NAME:message_archive}" + charset: "${ABOT_DB_CHARSET:utf8mb4}" + use_unicode: true + get_warnings: true + pool_reset_session: true + +redis_config: + host: "${ABOT_REDIS_HOST:127.0.0.1}" + port: "${ABOT_REDIS_PORT:6379}" + password: "${ABOT_REDIS_PASSWORD:}" + db: "${ABOT_REDIS_DB:0}" + decode_responses: true + +# 邮件发送配置 +email_config: + smtp_server: "${ABOT_EMAIL_SMTP_SERVER:smtp.163.com}" + smtp_port: "${ABOT_EMAIL_SMTP_PORT:465}" + sender_email: "${ABOT_EMAIL_SENDER:}" + sender_password: "${ABOT_EMAIL_PASSWORD:}" + alert_recipient: "${ABOT_EMAIL_ALERT_RECIPIENT:}" + +glances: + host: "${ABOT_GLANCES_HOST:127.0.0.1}" + port: "${ABOT_GLANCES_PORT:61208}" + +wx_config: + # 微信管理账号,用于接收部分管理员指令。 + admin: [ "${ABOT_WX_ADMIN:admin}" ] + +llm: + default_backend: "${ABOT_LLM_DEFAULT_BACKEND:dify_workflow_chat}" + backends: + dify_workflow_chat: + provider: "dify" + mode: "workflow" + api_key: "${ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "workflows/run" + response_mode: "blocking" + request_timeout: 120 + max_retries: 1 + retry_delay_seconds: 1.0 + dify_workflow_member_context: + provider: "dify" + mode: "workflow" + api_key: "${ABOT_LLM_DIFY_MEMBER_CONTEXT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "workflows/run" + workflow_output_key: "text" + response_mode: "streaming" + request_timeout: 240 + dify_workflow_message_summary: + provider: "dify" + mode: "workflow" + api_key: "${ABOT_LLM_DIFY_MESSAGE_SUMMARY_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "workflows/run" + workflow_output_key: "text" + response_mode: "streaming" + request_timeout: 180 + dify_workflow_douyu_daily_report: + provider: "dify" + mode: "workflow" + api_key: "${ABOT_LLM_DIFY_DOUYU_REPORT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "workflows/run" + workflow_output_key: "text" + response_mode: "blocking" + request_timeout: 240 + dify_chat_global_news: + provider: "dify" + mode: "chat" + api_key: "${ABOT_LLM_DIFY_GLOBAL_NEWS_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "chat-messages" + response_mode: "blocking" + request_timeout: 60 + openai_compatible_game_task: + provider: "openai_compatible" + api_url: "${ABOT_LLM_GAME_TASK_API_URL:https://api.example.com/v1/chat/completions}" + api_key: "${ABOT_LLM_GAME_TASK_API_KEY:}" + model: "${ABOT_LLM_GAME_TASK_MODEL:doubao-1-5-lite-32k-250115}" + stream: false + temperature: 0.2 + max_tokens: 1000 + timeout_seconds: 60 + openai_compatible_ai_auto_response: + provider: "openai_compatible" + api_base_url: "${ABOT_LLM_AUTO_REPLY_API_BASE_URL:https://api.example.com/v1}" + endpoint: "chat/completions" + api_key: "${ABOT_LLM_AUTO_REPLY_API_KEY:}" + model: "${ABOT_LLM_AUTO_REPLY_MODEL:gpt-5.4}" + stream: true + temperature: 0.35 + max_tokens: 120 + timeout_seconds: 45 + max_retries: 3 + retry_delay_seconds: 1.0 + dify_workflow_ai_auto_response: + provider: "dify" + mode: "workflow" + api_key: "${ABOT_LLM_DIFY_AUTO_REPLY_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" + endpoint: "workflows/run" + workflow_output_key: "result_json" + response_mode: "blocking" + request_timeout: 15 + max_retries: 1 + retry_delay_seconds: 1.0 + openai_compatible_ai_gen_image: + provider: "openai_compatible" + api_base_url: "${ABOT_LLM_IMAGE_API_BASE_URL:https://api.example.com/v1}" + endpoint: "chat/completions" + api_key: "${ABOT_LLM_IMAGE_API_KEY:}" + model: "${ABOT_LLM_IMAGE_MODEL:gpt-image-1}" + stream: false + timeout_seconds: 300 + max_retries: 2 + retry_delay_seconds: 1.0 + scenes: + "chat.main": "dify_workflow_chat" + "member.profile": "dify_workflow_member_context" + "summary.daily": "dify_workflow_message_summary" + "douyu.daily_report": "dify_workflow_douyu_daily_report" + "news.global": "dify_chat_global_news" + "game.task": "openai_compatible_game_task" + "auto_reply.group": "dify_workflow_ai_auto_response" + "member_roast": "openai_compatible_ai_auto_response" + "image.generate": "openai_compatible_ai_gen_image" diff --git a/config.yaml b/config.yaml index 8a457c3..3b60420 100644 --- a/config.yaml +++ b/config.yaml @@ -1,64 +1,62 @@ +environment: "${ABOT_ENVIRONMENT:development}" +plugin_dir: "${ABOT_PLUGIN_DIR:plugins}" + db_config: - pool_name: "wechat_boot_pool" - pool_size: 10 - host: "192.168.2.41" - prot: "3306" - user: "root" - password: "lw123456" - database: "message_archive" - charset: "utf8mb4" + pool_name: "${ABOT_DB_POOL_NAME:wechat_boot_pool}" + pool_size: "${ABOT_DB_POOL_SIZE:10}" + host: "${ABOT_DB_HOST:127.0.0.1}" + # 新配置统一使用 port;prot 仅作为历史兼容字段保留。 + port: "${ABOT_DB_PORT:3306}" + prot: "${ABOT_DB_PORT:3306}" + user: "${ABOT_DB_USER:root}" + password: "${ABOT_DB_PASSWORD}" + database: "${ABOT_DB_NAME:message_archive}" + charset: "${ABOT_DB_CHARSET:utf8mb4}" use_unicode: true get_warnings: true pool_reset_session: true redis_config: - host: "192.168.2.40" - port: 6379 - password: "" - db: 0 + host: "${ABOT_REDIS_HOST:127.0.0.1}" + port: "${ABOT_REDIS_PORT:6379}" + password: "${ABOT_REDIS_PASSWORD:}" + db: "${ABOT_REDIS_DB:0}" decode_responses: true - # 邮件发送配置 email_config: - smtp_server: "smtp.163.com" - smtp_port: 465 - sender_email: "bovine_liu@163.com" - sender_password: "CCWpEQzSdxQUqhDE" - alert_recipient: "bovine_liu@163.com" # 警报邮件接收者 + smtp_server: "${ABOT_EMAIL_SMTP_SERVER:smtp.163.com}" + smtp_port: "${ABOT_EMAIL_SMTP_PORT:465}" + sender_email: "${ABOT_EMAIL_SENDER:}" + sender_password: "${ABOT_EMAIL_PASSWORD:}" + alert_recipient: "${ABOT_EMAIL_ALERT_RECIPIENT:}" glances: - host: "192.168.2.170" - port: 61208 - + host: "${ABOT_GLANCES_HOST:127.0.0.1}" + port: "${ABOT_GLANCES_PORT:61208}" wx_config: - #微信管理账号,用于接收部分管理员指令 - #菜单调整和系统更新 - admin: [ "Jyunere" ] + # 微信管理账号,用于接收部分管理员指令。 + admin: [ "${ABOT_WX_ADMIN:admin}" ] llm: - default_backend: "dify_workflow_chat" + default_backend: "${ABOT_LLM_DEFAULT_BACKEND:dify_workflow_chat}" backends: dify_workflow_chat: provider: "dify" mode: "workflow" - api_key: "app-u5EnYq3ill19bm6pWJwGkY4D" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "workflows/run" response_mode: "blocking" - # 聊天工作流偶尔会超过 40 秒: - # 1. 原先 40 秒超时会导致客户端提前放弃; - # 2. 本地统一客户端默认又会自动重试,容易在 Dify 后台看到同一问题连续触发 3 次; - # 3. 这里把超时提高到 120 秒,并将重试次数收敛为 1,避免重复触发整条工作流。 request_timeout: 120 max_retries: 1 retry_delay_seconds: 1.0 dify_workflow_member_context: provider: "dify" mode: "workflow" - api_key: "app-b2cj03DipGCIAmgBfcx7SKsT" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_MEMBER_CONTEXT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "workflows/run" workflow_output_key: "text" response_mode: "streaming" @@ -66,8 +64,8 @@ llm: dify_workflow_message_summary: provider: "dify" mode: "workflow" - api_key: "app-shCA6bo5l2VDmnvhg2BtuJbk" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_MESSAGE_SUMMARY_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "workflows/run" workflow_output_key: "text" response_mode: "streaming" @@ -75,38 +73,35 @@ llm: dify_workflow_douyu_daily_report: provider: "dify" mode: "workflow" - # 斗鱼日报专用工作流:请替换为你在 Dify 上创建的“斗鱼日报”应用 Key。 - api_key: "app-S1oyi2udgIn197Vu0oOGUgAl" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_DOUYU_REPORT_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "workflows/run" - # 工作流最终输出字段建议固定为 text,便于统一客户端直接读取结果文本。 workflow_output_key: "text" response_mode: "blocking" - # 斗鱼日报 payload 较大,适当提高超时时间,避免高峰时段超时回退。 request_timeout: 240 dify_chat_global_news: provider: "dify" mode: "chat" - api_key: "app-rhhKkbvHd2IAQoGX7xTzXZJj" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_GLOBAL_NEWS_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "chat-messages" response_mode: "blocking" request_timeout: 60 openai_compatible_game_task: provider: "openai_compatible" - api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions" - api_key: "b8586595-eb81-483d-8e91-a35cc789729e" - model: "doubao-1-5-lite-32k-250115" + api_url: "${ABOT_LLM_GAME_TASK_API_URL:https://api.example.com/v1/chat/completions}" + api_key: "${ABOT_LLM_GAME_TASK_API_KEY:}" + model: "${ABOT_LLM_GAME_TASK_MODEL:doubao-1-5-lite-32k-250115}" stream: false temperature: 0.2 max_tokens: 1000 timeout_seconds: 60 openai_compatible_ai_auto_response: provider: "openai_compatible" - api_base_url: "http://192.168.2.240:3000/v1" + api_base_url: "${ABOT_LLM_AUTO_REPLY_API_BASE_URL:https://api.example.com/v1}" endpoint: "chat/completions" - api_key: "sk-hC6WMLAsTdItpywyrYdxT6pQ4E7NARGbUKuPWRH0zMheen9e" - model: "gpt-5.4" + api_key: "${ABOT_LLM_AUTO_REPLY_API_KEY:}" + model: "${ABOT_LLM_AUTO_REPLY_MODEL:gpt-5.4}" stream: true temperature: 0.35 max_tokens: 120 @@ -116,35 +111,24 @@ llm: dify_workflow_ai_auto_response: provider: "dify" mode: "workflow" - api_key: "app-ukHWWGoleANS5aZVmx28UAQ4" - api_base_url: "http://192.168.2.240/v1" + api_key: "${ABOT_LLM_DIFY_AUTO_REPLY_API_KEY:}" + api_base_url: "${ABOT_LLM_DIFY_API_BASE_URL:http://127.0.0.1:8080/v1}" endpoint: "workflows/run" workflow_output_key: "result_json" response_mode: "blocking" - # 群聊自动回复强调时效性: - # 1. Dify 请求不能等太久,否则容易出现“过了场子再补回”的违和感; - # 2. 这里把单次请求超时收紧,并关闭重试,让过期消息尽快放弃。 request_timeout: 15 max_retries: 1 retry_delay_seconds: 1.0 openai_compatible_ai_gen_image: provider: "openai_compatible" - # AI 绘图专用网关: - # 1. 这里使用用户提供的 OpenAI 兼容服务地址; - # 2. 插件会在此 base_url 基础上请求 images/generations; - # 3. endpoint 保留为图片接口默认值,便于后续统一调整。 - api_base_url: "https://freeapi.dgbmc.top/v1" + api_base_url: "${ABOT_LLM_IMAGE_API_BASE_URL:https://api.example.com/v1}" endpoint: "chat/completions" - api_key: "sk-2XccrBRsX8OmxqCEsZjdDRczhHNaAG7Mn88mNVL7Y0w0tx72" - # 图片模型默认使用 gpt-image-1; - # 若网关只支持其他模型,可后续直接在这里替换。 - model: "gpt-image-2" + api_key: "${ABOT_LLM_IMAGE_API_KEY:}" + model: "${ABOT_LLM_IMAGE_MODEL:gpt-image-1}" stream: false timeout_seconds: 300 max_retries: 2 retry_delay_seconds: 1.0 - # 场景路由层:插件建议优先使用 scene,而不是直接绑定 backend。 - # 这样当模型或供应商切换时,只需要改这里,不需要逐个改插件配置。 scenes: "chat.main": "dify_workflow_chat" "member.profile": "dify_workflow_member_context" diff --git a/configuration.py b/configuration.py index f4ad382..8c8cf0e 100644 --- a/configuration.py +++ b/configuration.py @@ -1,35 +1,388 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import logging.config +import copy import os +import re import yaml class Config(object): - def __init__(self) -> None: + """全局配置加载器。 + + 设计目标: + 1. 继续兼容项目原有的 `config.yaml` 结构,避免一次性重构过大; + 2. 支持 `${ENV_NAME}` / `${ENV_NAME:default}` 形式的环境变量注入; + 3. 在启动阶段尽早发现缺项、弱配置和明文敏感信息,降低误配置风险; + 4. 为后续后台脱敏展示、配置巡检等能力预留统一入口。 + """ + + # 环境变量占位符格式: + # 1. `${ABOT_DB_PASSWORD}` 表示必须从环境变量读取; + # 2. `${ABOT_DB_HOST:127.0.0.1}` 表示环境变量缺失时回退默认值; + # 3. 这里允许字母、数字和下划线,足够覆盖常见部署变量命名。 + ENV_PATTERN = re.compile(r"\$\{([A-Za-z0-9_]+)(?::([^}]*))?\}") + + # 敏感字段关键字: + # 1. 用于识别需要脱敏的配置项; + # 2. 同时用于扫描原始 YAML 中是否仍有明文敏感值; + # 3. 采用“关键字包含”而不是完全等值,兼容 `sender_password` / `api_key` 等不同命名。 + SENSITIVE_KEYWORDS = { + "password", + "passwd", + "secret", + "token", + "api_key", + "apikey", + "access_key", + "private_key", + } + + def __init__(self, config_path: str = None) -> None: + self.project_dir = os.path.dirname(os.path.abspath(__file__)) + self.config_path = config_path or os.path.join(self.project_dir, "config.yaml") + self.raw_config = {} + self.resolved_config = {} + self.unresolved_placeholders = [] + self.validation_report = {"errors": [], "warnings": []} self.reload() def _load_config(self) -> dict: - pwd = os.path.dirname(os.path.abspath(__file__)) - with open(f"{pwd}/config.yaml", "rb") as fp: - yconfig = yaml.safe_load(fp) + """从磁盘读取 YAML 配置。""" + with open(self.config_path, "r", encoding="utf-8") as fp: + yconfig = yaml.safe_load(fp) or {} return yconfig + def _resolve_env_placeholders_in_string(self, raw_value: str, path: str) -> str: + """解析字符串中的环境变量占位符。""" + + def _replace(match: re.Match) -> str: + env_name = str(match.group(1) or "").strip() + default_value = match.group(2) + env_value = os.environ.get(env_name) + + # 优先使用环境变量的真实值; + # 如果环境变量不存在,但模板给了默认值,则回退默认值; + # 如果两者都没有,则记录为“启动期缺失”,由 validate() 输出致命错误。 + if env_value not in (None, ""): + return str(env_value) + if default_value is not None: + return str(default_value) + + self.unresolved_placeholders.append({ + "path": path, + "env_name": env_name, + }) + return "" + + return self.ENV_PATTERN.sub(_replace, raw_value) + + def _resolve_config_tree(self, node, path: str = "root"): + """递归解析整棵配置树中的占位符。""" + if isinstance(node, dict): + return { + key: self._resolve_config_tree(value, f"{path}.{key}") + for key, value in node.items() + } + if isinstance(node, list): + return [ + self._resolve_config_tree(value, f"{path}[{index}]") + for index, value in enumerate(node) + ] + if isinstance(node, str): + return self._resolve_env_placeholders_in_string(node, path) + return node + + @staticmethod + def _safe_int(value, default: int): + """把 YAML / 环境变量中的数字字符串安全转成整数。""" + try: + if value in (None, ""): + return default + return int(value) + except (TypeError, ValueError): + return default + + def _normalize_config(self, yconfig: dict) -> dict: + """对解析后的配置做一次结构与类型归一化。""" + normalized = copy.deepcopy(yconfig or {}) + + # 数据库配置归一化: + # 1. 历史配置长期使用 `prot` 拼写; + # 2. `db.connection` 代码层已经统一读取 `port`; + # 3. 因此这里同时回填 `port/prot`,确保新老配置都可运行。 + db_config = dict(normalized.get("db_config", {}) or {}) + db_port = db_config.get("port", db_config.get("prot", 3306)) + db_config["port"] = self._safe_int(db_port, 3306) + db_config["prot"] = db_config["port"] + db_config["pool_size"] = self._safe_int(db_config.get("pool_size", 10), 10) + normalized["db_config"] = db_config + + # Redis / 邮件 / Glances 配置中不少值来自环境变量,解析后先统一转型, + # 这样后续业务代码就不需要到处防守“字符串数字”的情况。 + redis_config = dict(normalized.get("redis_config", {}) or {}) + redis_config["port"] = self._safe_int(redis_config.get("port", 6379), 6379) + redis_config["db"] = self._safe_int(redis_config.get("db", 0), 0) + redis_config["max_connections"] = self._safe_int(redis_config.get("max_connections", 30), 30) + normalized["redis_config"] = redis_config + + email_config = dict(normalized.get("email_config", {}) or {}) + email_config["smtp_port"] = self._safe_int(email_config.get("smtp_port", 465), 465) + normalized["email_config"] = email_config + + glances_config = dict(normalized.get("glances", {}) or {}) + glances_config["port"] = self._safe_int(glances_config.get("port", 61208), 61208) + normalized["glances"] = glances_config + + return normalized + + @classmethod + def _contains_placeholder(cls, value: str) -> bool: + """判断原始字符串是否仍包含环境变量模板。""" + return bool(cls.ENV_PATTERN.search(str(value or ""))) + + @classmethod + def _is_sensitive_key(cls, key: str) -> bool: + lowered_key = str(key or "").strip().lower() + return any(keyword in lowered_key for keyword in cls.SENSITIVE_KEYWORDS) + + def _append_issue(self, bucket: list, code: str, path: str, message: str) -> None: + """统一追加配置问题,便于后续日志输出与后台展示。""" + bucket.append({ + "code": code, + "path": path, + "message": message, + }) + + def _validate_required_sections(self, report: dict) -> None: + """检查核心运行依赖是否完整。""" + db_config = self.mariadb or {} + redis_config = self.redis or {} + llm_config = self.llm or {} + llm_backends = dict(llm_config.get("backends", {}) or {}) + default_backend = str(llm_config.get("default_backend", "") or "").strip() + + required_db_fields = { + "host": "数据库 host", + "user": "数据库 user", + "password": "数据库 password", + "database": "数据库 database", + } + for field_name, display_name in required_db_fields.items(): + if not str(db_config.get(field_name, "") or "").strip(): + self._append_issue( + report["errors"], + "missing_db_field", + f"db_config.{field_name}", + f"{display_name} 未配置,机器人无法正常连接 MySQL。", + ) + + if not db_config.get("port"): + self._append_issue( + report["errors"], + "missing_db_port", + "db_config.port", + "数据库 port 未配置,机器人无法正常连接 MySQL。", + ) + + if not str(redis_config.get("host", "") or "").strip(): + self._append_issue( + report["errors"], + "missing_redis_host", + "redis_config.host", + "Redis host 未配置,机器人无法正常连接 Redis。", + ) + + if not redis_config.get("port"): + self._append_issue( + report["errors"], + "missing_redis_port", + "redis_config.port", + "Redis port 未配置,机器人无法正常连接 Redis。", + ) + + if not llm_backends: + self._append_issue( + report["warnings"], + "missing_llm_backends", + "llm.backends", + "当前未配置任何 LLM backend,依赖 AI 的插件将不可用。", + ) + return + + if not default_backend: + self._append_issue( + report["warnings"], + "missing_default_llm_backend", + "llm.default_backend", + "未配置 llm.default_backend,建议指定默认 AI 路由。", + ) + elif default_backend not in llm_backends: + self._append_issue( + report["errors"], + "invalid_default_llm_backend", + "llm.default_backend", + f"默认 backend `{default_backend}` 不存在于 llm.backends 中。", + ) + + def _validate_email_config(self, report: dict) -> None: + """检查邮件告警配置是否处于“半配置”状态。""" + email_config = self.email or {} + sender_email = str(email_config.get("sender_email", "") or "").strip() + sender_password = str(email_config.get("sender_password", "") or "").strip() + alert_recipient = str(email_config.get("alert_recipient", "") or "").strip() + + if sender_email and not sender_password: + self._append_issue( + report["warnings"], + "missing_email_password", + "email_config.sender_password", + "已配置 sender_email,但缺少 sender_password,邮件告警发送会失败。", + ) + + if alert_recipient and (not sender_email or not sender_password): + self._append_issue( + report["warnings"], + "email_alert_incomplete", + "email_config.alert_recipient", + "已配置告警接收人,但发件邮箱配置不完整,告警链路不可用。", + ) + + def _validate_llm_config(self, report: dict) -> None: + """检查 LLM 配置的完整性与路由一致性。""" + llm_config = self.llm or {} + backends = dict(llm_config.get("backends", {}) or {}) + scenes = dict(llm_config.get("scenes", {}) or {}) + + for backend_name, backend_config in backends.items(): + backend_config = backend_config or {} + provider = str(backend_config.get("provider", "") or "").strip() + if not provider: + self._append_issue( + report["warnings"], + "missing_llm_provider", + f"llm.backends.{backend_name}.provider", + f"LLM backend `{backend_name}` 未配置 provider。", + ) + + # 对接第三方 AI 服务时,api_key 通常是最容易漏配的关键项; + # 这里把空值直接标成 warning,既不会误伤“暂未启用的 backend”,又能在启动期给出提醒。 + api_key = str(backend_config.get("api_key", "") or "").strip() + if not api_key: + self._append_issue( + report["warnings"], + "missing_llm_api_key", + f"llm.backends.{backend_name}.api_key", + f"LLM backend `{backend_name}` 未配置 api_key,相关 AI 能力将不可用。", + ) + + for scene_name, backend_name in scenes.items(): + backend_name = str(backend_name or "").strip() + if backend_name and backend_name not in backends: + self._append_issue( + report["warnings"], + "invalid_llm_scene_backend", + f"llm.scenes.{scene_name}", + f"场景 `{scene_name}` 指向了不存在的 backend `{backend_name}`。", + ) + + def _validate_unresolved_placeholders(self, report: dict) -> None: + """把缺失环境变量转换为启动期可读错误。""" + for unresolved_item in self.unresolved_placeholders: + self._append_issue( + report["errors"], + "missing_environment_variable", + unresolved_item.get("path", "root"), + f"环境变量 `{unresolved_item.get('env_name', '')}` 未提供,且未设置默认值。", + ) + + def _validate_plaintext_secrets(self, report: dict) -> None: + """扫描原始 YAML 中是否仍保留明文敏感配置。""" + + def _walk(node, path: str = "root") -> None: + if isinstance(node, dict): + for key, value in node.items(): + next_path = f"{path}.{key}" + if isinstance(value, str) and self._is_sensitive_key(key): + stripped_value = value.strip() + if stripped_value and not self._contains_placeholder(stripped_value): + self._append_issue( + report["warnings"], + "plaintext_sensitive_value", + next_path, + "该敏感配置仍以明文形式写在 YAML 中,建议改为环境变量注入。", + ) + _walk(value, next_path) + return + + if isinstance(node, list): + for index, value in enumerate(node): + _walk(value, f"{path}[{index}]") + + _walk(self.raw_config) + + def validate(self) -> dict: + """返回当前配置的校验报告。""" + report = {"errors": [], "warnings": []} + self._validate_unresolved_placeholders(report) + self._validate_required_sections(report) + self._validate_email_config(report) + self._validate_llm_config(report) + self._validate_plaintext_secrets(report) + return report + + @staticmethod + def _mask_secret_value(value: str) -> str: + """对敏感值做轻量脱敏,保留一点可辨识尾巴方便排查。""" + text = str(value or "") + if not text: + return "" + if len(text) <= 6: + return "*" * len(text) + return f"{text[:2]}{'*' * (len(text) - 4)}{text[-2:]}" + + def _sanitize_config_tree(self, node, parent_key: str = ""): + """递归生成适合日志/后台展示的脱敏配置快照。""" + if isinstance(node, dict): + return { + key: self._sanitize_config_tree(value, str(key)) + for key, value in node.items() + } + if isinstance(node, list): + return [self._sanitize_config_tree(value, parent_key) for value in node] + if isinstance(node, str) and self._is_sensitive_key(parent_key): + return self._mask_secret_value(node) + return node + + def get_validation_report(self) -> dict: + """返回一份拷贝,避免外部误改内部状态。""" + return copy.deepcopy(self.validation_report) + + def has_fatal_issues(self) -> bool: + """是否存在阻止启动的致命配置错误。""" + return bool(self.validation_report.get("errors")) + + def get_sanitized_snapshot(self) -> dict: + """返回可安全打印/展示的脱敏配置快照。""" + return self._sanitize_config_tree(self.resolved_config) + def reload(self) -> None: - yconfig = self._load_config() + """重新加载配置,并刷新公开属性与校验结果。""" + self.raw_config = self._load_config() + self.unresolved_placeholders = [] + resolved_config = self._resolve_config_tree(copy.deepcopy(self.raw_config)) + self.resolved_config = self._normalize_config(resolved_config) - # DB config - self.mariadb = yconfig.get("db_config", {}) - self.redis = yconfig.get("redis_config", {}) + # 为了兼容现有调用方,这里继续保留原有的顶层属性映射; + # 后续如果逐步引入更严格的配置对象,也可以先不动业务代码。 + self.environment = str(self.resolved_config.get("environment", "development") or "development").strip() + self.plugin_dir = str(self.resolved_config.get("plugin_dir", "plugins") or "plugins").strip() + self.mariadb = self.resolved_config.get("db_config", {}) + self.redis = self.resolved_config.get("redis_config", {}) + self.email = self.resolved_config.get("email_config", {}) + self.glances = self.resolved_config.get("glances", {}) + self.wx_config = self.resolved_config.get("wx_config", {}) + self.llm = self.resolved_config.get("llm", {}) - # Email config - self.email = yconfig.get("email_config", {}) - # glances 监控配置 - self.glances = yconfig.get("glances", {}) - - # wx 相关配置 - self.wx_config = yconfig.get("wx_config", {}) - # LLM 集中配置 - self.llm = yconfig.get("llm", {}) + self.validation_report = self.validate() diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f92d04c..1a831f5 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -5,37 +5,43 @@ mkdir -p /app/logs if [ ! -f /app/config.yaml ]; then cat > /app/config.yaml < None: + """输出启动期配置校验结果。""" + validation_report = config.get_validation_report() + errors = list(validation_report.get("errors", []) or []) + warnings = list(validation_report.get("warnings", []) or []) + + logger.info( + "配置加载完成: " + f"environment={config.environment}, " + f"plugin_dir={config.plugin_dir}, " + f"errors={len(errors)}, " + f"warnings={len(warnings)}" + ) + + # 这里只打印脱敏后的配置快照: + # 1. 便于定位“到底加载了哪套配置”; + # 2. 同时避免把数据库密码、API Key 再写进日志; + # 3. 放在 DEBUG 级别,默认不会刷屏主日志。 + logger.debug(f"配置脱敏快照: {config.get_sanitized_snapshot()}") + + for warning in warnings: + logger.warning( + f"配置告警[{warning.get('code', 'unknown')}] " + f"{warning.get('path', 'root')}: {warning.get('message', '')}" + ) + + for error in errors: + logger.error( + f"配置错误[{error.get('code', 'unknown')}] " + f"{error.get('path', 'root')}: {error.get('message', '')}" + ) + + if errors: + raise ValueError("启动终止:存在未修复的致命配置错误,请先修正 config.yaml 或环境变量。") + + def main(): config = Config() + _log_config_validation(config) # 创建机器人实例 robot = Robot(config) diff --git a/产品需求说明书.md b/产品需求说明书.md index aeaf35b..030189d 100644 --- a/产品需求说明书.md +++ b/产品需求说明书.md @@ -62,9 +62,10 @@ ABOT 是一款功能丰富的微信机器人系统,旨在提升您的微信使 - 安装依赖包:`pip install -r requirements.txt` 4. **配置文件设置** - - 复制`config.yaml.template`为`config.yaml` + - 复制`config.example.yaml`为`config.yaml` - 使用文本编辑器打开`config.yaml` - - 按照注释说明配置AI模型API密钥、数据库连接等参数 + - 按照注释说明配置数据库连接、微信管理员与 AI 模型参数 + - 敏感信息优先通过环境变量注入,例如 `ABOT_DB_PASSWORD`、`ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY` ### 2.3 启动系统 @@ -555,4 +556,4 @@ ABOT 重视用户隐私保护,我们的隐私政策包括: --- -感谢您选择使用ABOT!我们致力于为您提供更好的微信自动化体验。如有任何问题或建议,欢迎随时联系我们。 \ No newline at end of file +感谢您选择使用ABOT!我们致力于为您提供更好的微信自动化体验。如有任何问题或建议,欢迎随时联系我们。