完善配置密钥治理与启动校验

- 为 configuration.py 增加环境变量占位符解析、配置归一化、脱敏快照与启动校验\n- 在 main.py 启动阶段接入配置校验日志,并在致命缺项时阻止进程继续启动\n- 新增 config.example.yaml,并将默认 config.yaml 改为安全占位模板,移除仓库内明文敏感信息\n- 调整 docker-entrypoint.sh 与文档,统一说明配置复制、环境变量注入与当前优化进展
This commit is contained in:
liuwei
2026-04-30 15:44:53 +08:00
parent cb99e94493
commit c6d72cbb69
8 changed files with 672 additions and 117 deletions

View File

@@ -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
## 🙏 致谢
感谢所有为本项目做出贡献的开发者。
感谢所有为本项目做出贡献的开发者。

141
config.example.yaml Normal file
View File

@@ -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}"
# 新配置统一使用 portprot 仅作为历史兼容字段保留。
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"

View File

@@ -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}"
# 新配置统一使用 portprot 仅作为历史兼容字段保留。
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"

View File

@@ -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()

View File

@@ -5,37 +5,43 @@ mkdir -p /app/logs
if [ ! -f /app/config.yaml ]; then
cat > /app/config.yaml <<EOF
environment: "\${ABOT_ENVIRONMENT:production}"
plugin_dir: "\${ABOT_PLUGIN_DIR:plugins}"
db_config:
host: "${DB_HOST}"
prot: "${DB_PORT}"
user: "${DB_USER}"
password: "${DB_PASSWORD}"
database: "${DB_NAME}"
pool_name: "\${ABOT_DB_POOL_NAME:wechat_boot_pool}"
pool_size: "\${ABOT_DB_POOL_SIZE:10}"
host: "\${DB_HOST:127.0.0.1}"
port: "\${DB_PORT:3306}"
prot: "\${DB_PORT:3306}"
user: "\${DB_USER:root}"
password: "\${DB_PASSWORD}"
database: "\${DB_NAME:message_archive}"
charset: "utf8mb4"
use_unicode: true
get_warnings: true
pool_reset_session: true
redis_config:
host: "${REDIS_HOST}"
port: ${REDIS_PORT}
password: "${REDIS_PASSWORD}"
db: ${REDIS_DB}
host: "\${REDIS_HOST:127.0.0.1}"
port: "\${REDIS_PORT:6379}"
password: "\${REDIS_PASSWORD:}"
db: "\${REDIS_DB:0}"
decode_responses: true
email_config:
smtp_server: ""
smtp_port: 465
sender_email: ""
sender_password: ""
alert_recipient: ""
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: "127.0.0.1"
port: 61208
host: "\${ABOT_GLANCES_HOST:127.0.0.1}"
port: "\${ABOT_GLANCES_PORT:61208}"
wx_config:
admin: [ "admin" ]
admin: [ "\${ABOT_WX_ADMIN:admin}" ]
EOF
fi

View File

@@ -21,6 +21,7 @@
- 已补充 MySQL / Redis 连接探测与统一 LLM 最近调用快照,基础设施与 AI 运行态可直接在首页查看
- 已将 `trace_id` 通过异步上下文继续贯穿到统一 LLM 调用与微信发送动作,链路追踪粒度进一步提升
- 已补充后台登录失败限流、会话超时、默认弱口令强提醒与密码复杂度校验,后台安全基线进一步收紧
- 已引入全局配置环境变量注入、启动期完整性校验与 `config.example.yaml`,默认配置不再直接携带仓库内明文密钥
## 2. 项目现状判断
@@ -242,6 +243,13 @@
- 后台展示配置时自动脱敏
- 区分开发、测试、生产环境配置
当前进展:
- 第一阶段已完成:`configuration.py` 已支持 `${ENV_NAME}` / `${ENV_NAME:默认值}` 形式的环境变量注入
- 第一阶段已完成:启动时已增加 MySQL、Redis、LLM、邮件等关键配置完整性检查致命缺项会直接阻止启动
- 第一阶段已完成:已补充 `config.example.yaml`,并将仓库内默认 `config.yaml` 改为安全占位模板
- 后续可继续补充后台配置查看脱敏、分环境配置切换与插件级配置治理
预期收益:
- 大幅降低密钥泄露风险
@@ -293,6 +301,10 @@
- 让每次改动后都有固定、可重复执行的验证步骤
当前排期说明:
- 按当前优化策略,该项暂时后置处理,放在本轮工程治理工作的最后再集中补齐
建议内容:
- 建立“日常改动验证清单”

37
main.py
View File

@@ -63,8 +63,45 @@ logger.add(
)
def _log_config_validation(config: Config) -> 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)

View File

@@ -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我们致力于为您提供更好的微信自动化体验。如有任何问题或建议欢迎随时联系我们。
感谢您选择使用ABOT我们致力于为您提供更好的微信自动化体验。如有任何问题或建议欢迎随时联系我们。