diff --git a/.env.docker.example b/.env.docker.example index cb65b84..4dc3f12 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -20,6 +20,8 @@ DASHBOARD_PORT=8888 WECHAT_SERVER_URL=http://host.docker.internal:8059/ WECHAT_SERVER_IP=host.docker.internal WECHAT_SERVER_PORT=8059 +WECHAT_SERVER_TYPE=legacy_855 WECHAT_WXID= -WECHAT_DEVICE_NAME=ABOTPad +WECHAT_DEVICE_NAME= WECHAT_DEVICE_ID= +WECHAT_STATE_FILE=temp/wechat_ipad/config.toml diff --git a/.env.example b/.env.example index 7a86ee7..1075790 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,16 @@ ABOT_GLANCES_PORT=61208 ABOT_WX_ADMIN=admin +WECHAT_SERVER_URL=http://127.0.0.1:8059/ +WECHAT_SERVER_IP=127.0.0.1 +WECHAT_SERVER_PORT=8059 +WECHAT_SERVER_TYPE=legacy_855 +# 以下三项可留空,首次登录后会自动写入本地状态缓存文件。 +WECHAT_WXID= +WECHAT_DEVICE_NAME= +WECHAT_DEVICE_ID= +WECHAT_STATE_FILE=temp/wechat_ipad/config.toml + ABOT_LLM_DEFAULT_BACKEND=dify_workflow_chat ABOT_LLM_DIFY_API_BASE_URL=http://127.0.0.1:8080/v1 ABOT_LLM_DIFY_WORKFLOW_CHAT_API_KEY= @@ -56,13 +66,3 @@ ABOT_LLM_IMAGE_MODEL=gpt-image-1 # 可选:若希望后台登录会话在重启后保持稳定,建议显式配置。 ABOT_DASHBOARD_SECRET_KEY= -# Docker 场景下 wechat_ipad 的连接参数: -# 1. 这组变量主要用于 docker-entrypoint 生成 wechat_ipad/config.toml; -# 2. 本地直跑仍可继续使用现有 wechat_ipad/config.toml; -# 3. 若 server 运行在宿主机,Docker Desktop / 新版 Linux Docker 可使用 host.docker.internal。 -WECHAT_SERVER_URL=http://127.0.0.1:8059/ -WECHAT_SERVER_IP=127.0.0.1 -WECHAT_SERVER_PORT=8059 -WECHAT_WXID= -WECHAT_DEVICE_NAME=ABOTPad -WECHAT_DEVICE_ID= diff --git a/.gitignore b/.gitignore index 761c132..c19bfbe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/ *.log.* .DS_Store temp/ +wechat_ipad/config.toml diff --git a/README.MD b/README.MD index ae8794b..c5537ed 100644 --- a/README.MD +++ b/README.MD @@ -34,7 +34,7 @@ Windows PowerShell: Copy-Item .env.docker.example .env ``` -2. 按实际环境修改 `.env` 中的数据库密码、`WECHAT_SERVER_URL` 等参数 +2. 按实际环境修改 `.env` 中的数据库密码、`WECHAT_SERVER_URL`、`WECHAT_SERVER_TYPE` 等参数 3. 启动服务 @@ -93,13 +93,18 @@ python main.py ### wechat_ipad 配置 -当前仓库仍保留 [wechat_ipad/config.toml](/d:/learn/abot/wechat_ipad/config.toml:1) 作为现有登录态与设备信息的本地配置文件。 +现在 `wechat_ipad` 的静态连接参数已经统一走 `.env` / `config.yaml`: -说明: +- `WECHAT_SERVER_URL` +- `WECHAT_SERVER_IP` +- `WECHAT_SERVER_PORT` +- `WECHAT_SERVER_TYPE` -- Docker 部署会通过环境变量在首次启动时生成该文件 -- 本地直跑继续兼容原有读取方式 -- 本轮未强制把全部 `wechat_ipad` 配置迁移进 `.env` +登录后的 `wxid / device_id / device_name` 不再要求你手工维护,它们会自动写入本地状态文件: + +- 默认路径:`temp/wechat_ipad/config.toml` +- 可通过 `WECHAT_STATE_FILE` 覆盖 +- 启动时会自动兼容历史 `wechat_ipad/config.toml` 中已有的登录态 ## 目录结构 @@ -131,7 +136,8 @@ abot/ - 应用、MariaDB、Redis 已拆分为独立服务 - 提供 `docker-compose.yml`、`.dockerignore`、`.env.docker.example` -- 保留 `wechat_ipad/config.toml` 的挂载方式,避免这次改动影响你现有登录态逻辑 +- 静态连接配置统一通过 `.env` 注入,更适合开源仓库和多环境部署 +- wechat 登录态缓存单独落到 `temp/wechat_ipad/config.toml` 当前仍建议你在正式生产前继续补充: @@ -159,7 +165,7 @@ abot/ - `.env` - `config.yaml` -- `wechat_ipad/config.toml` 中的真实 `wxid` / `device_id` +- `temp/wechat_ipad/config.toml` 中的真实 `wxid` / `device_id` - 真实数据库密码、LLM API Key、Webhook Token ## 路线图 diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 59e1fb6..7c0d8d0 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -53,6 +53,6 @@ 每次准备公开发布新版本时,至少做一次以下检查: -1. 检查是否误提交 `.env`、`config.yaml`、`wechat_ipad/config.toml` +1. 检查是否误提交 `.env`、`config.yaml`、`temp/wechat_ipad/config.toml` 2. 检查仓库中是否仍包含真实账号、真实 token、真实 webhook 密钥 3. 检查新增的二进制、字体、图片、模板文件是否有明确来源与许可证 diff --git a/config.example.yaml b/config.example.yaml index 5bb4424..9b46686 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -43,6 +43,21 @@ wx_config: # 微信管理账号,用于接收部分管理员指令。 admin: [ "${ABOT_WX_ADMIN:admin}" ] +wechat_ipad: + # wechat_ipad 静态连接配置统一走环境变量: + # 1. 用户只需要维护 `.env`,不必再手工同步独立 TOML; + # 2. 登录态缓存会单独写入 `state_file`,避免把运行期字段混进用户配置; + # 3. `legacy_config_path` 仅用于兼容历史仓库中的 `wechat_ipad/config.toml`。 + server_url: "${WECHAT_SERVER_URL:http://127.0.0.1:8059/}" + server_ip: "${WECHAT_SERVER_IP:127.0.0.1}" + server_port: "${WECHAT_SERVER_PORT:8059}" + server_type: "${WECHAT_SERVER_TYPE:legacy_855}" + wxid: "${WECHAT_WXID:}" + device_name: "${WECHAT_DEVICE_NAME:}" + device_id: "${WECHAT_DEVICE_ID:}" + state_file: "${WECHAT_STATE_FILE:temp/wechat_ipad/config.toml}" + legacy_config_path: "${WECHAT_LEGACY_CONFIG_PATH:wechat_ipad/config.toml}" + llm: default_backend: "${ABOT_LLM_DEFAULT_BACKEND:dify_workflow_chat}" backends: diff --git a/config.yaml b/config.yaml index 5bb4424..9b46686 100644 --- a/config.yaml +++ b/config.yaml @@ -43,6 +43,21 @@ wx_config: # 微信管理账号,用于接收部分管理员指令。 admin: [ "${ABOT_WX_ADMIN:admin}" ] +wechat_ipad: + # wechat_ipad 静态连接配置统一走环境变量: + # 1. 用户只需要维护 `.env`,不必再手工同步独立 TOML; + # 2. 登录态缓存会单独写入 `state_file`,避免把运行期字段混进用户配置; + # 3. `legacy_config_path` 仅用于兼容历史仓库中的 `wechat_ipad/config.toml`。 + server_url: "${WECHAT_SERVER_URL:http://127.0.0.1:8059/}" + server_ip: "${WECHAT_SERVER_IP:127.0.0.1}" + server_port: "${WECHAT_SERVER_PORT:8059}" + server_type: "${WECHAT_SERVER_TYPE:legacy_855}" + wxid: "${WECHAT_WXID:}" + device_name: "${WECHAT_DEVICE_NAME:}" + device_id: "${WECHAT_DEVICE_ID:}" + state_file: "${WECHAT_STATE_FILE:temp/wechat_ipad/config.toml}" + legacy_config_path: "${WECHAT_LEGACY_CONFIG_PATH:wechat_ipad/config.toml}" + llm: default_backend: "${ABOT_LLM_DEFAULT_BACKEND:dify_workflow_chat}" backends: diff --git a/configuration.py b/configuration.py index 7ab5031..eb412d4 100644 --- a/configuration.py +++ b/configuration.py @@ -203,6 +203,28 @@ class Config(object): plugin_hot_reload["interval_seconds"] = self._safe_int(plugin_hot_reload.get("interval_seconds", 600), 600) normalized["plugin_hot_reload"] = plugin_hot_reload + # wechat_ipad 配置归一化: + # 1. 静态连接参数现在统一走 config.yaml + .env,而不是要求用户维护独立 TOML; + # 2. 登录后的 wxid / device_id / device_name 会落到本地状态文件,因此这里保留 state_file 配置; + # 3. legacy_config_path 仅用于兼容历史仓库中的 `wechat_ipad/config.toml`,迁移完成后可逐步淡出。 + wechat_ipad_config = dict(normalized.get("wechat_ipad", {}) or {}) + wechat_ipad_config["server_port"] = self._safe_int(wechat_ipad_config.get("server_port", 8059), 8059) + wechat_ipad_config["server_url"] = str(wechat_ipad_config.get("server_url", "") or "").strip() + wechat_ipad_config["server_ip"] = str(wechat_ipad_config.get("server_ip", "") or "").strip() + wechat_ipad_config["server_type"] = str( + wechat_ipad_config.get("server_type", "legacy_855") or "legacy_855" + ).strip() + wechat_ipad_config["wxid"] = str(wechat_ipad_config.get("wxid", "") or "").strip() + wechat_ipad_config["device_name"] = str(wechat_ipad_config.get("device_name", "") or "").strip() + wechat_ipad_config["device_id"] = str(wechat_ipad_config.get("device_id", "") or "").strip() + wechat_ipad_config["state_file"] = str( + wechat_ipad_config.get("state_file", "temp/wechat_ipad/config.toml") or "temp/wechat_ipad/config.toml" + ).strip() + wechat_ipad_config["legacy_config_path"] = str( + wechat_ipad_config.get("legacy_config_path", "wechat_ipad/config.toml") or "wechat_ipad/config.toml" + ).strip() + normalized["wechat_ipad"] = wechat_ipad_config + return normalized @classmethod @@ -317,6 +339,37 @@ class Config(object): "已配置告警接收人,但发件邮箱配置不完整,告警链路不可用。", ) + def _validate_wechat_ipad_config(self, report: dict) -> None: + """检查 wechat_ipad 静态连接配置是否完整。""" + wechat_ipad_config = self.wechat_ipad or {} + server_url = str(wechat_ipad_config.get("server_url", "") or "").strip() + server_ip = str(wechat_ipad_config.get("server_ip", "") or "").strip() + server_port = wechat_ipad_config.get("server_port", 0) + + if not server_url: + self._append_issue( + report["errors"], + "missing_wechat_server_url", + "wechat_ipad.server_url", + "wechat_ipad server_url 未配置,机器人无法连接 wechat_ipad server。", + ) + + if not server_ip: + self._append_issue( + report["errors"], + "missing_wechat_server_ip", + "wechat_ipad.server_ip", + "wechat_ipad server_ip 未配置,机器人无法连接 wechat_ipad server。", + ) + + if not server_port: + self._append_issue( + report["errors"], + "missing_wechat_server_port", + "wechat_ipad.server_port", + "wechat_ipad server_port 未配置,机器人无法连接 wechat_ipad server。", + ) + def _validate_llm_config(self, report: dict) -> None: """检查 LLM 配置的完整性与路由一致性。""" llm_config = self.llm or {} @@ -396,6 +449,7 @@ class Config(object): self._validate_unresolved_placeholders(report) self._validate_required_sections(report) self._validate_email_config(report) + self._validate_wechat_ipad_config(report) self._validate_llm_config(report) self._validate_plaintext_secrets(report) return report @@ -451,6 +505,7 @@ class Config(object): self.redis = self.resolved_config.get("redis_config", {}) self.email = self.resolved_config.get("email_config", {}) self.wx_config = self.resolved_config.get("wx_config", {}) + self.wechat_ipad = self.resolved_config.get("wechat_ipad", {}) self.llm = self.resolved_config.get("llm", {}) self.validation_report = self.validate() diff --git a/docker-compose.yml b/docker-compose.yml index 3559ddb..d191e22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,16 +80,18 @@ services: WECHAT_SERVER_URL: ${WECHAT_SERVER_URL:-http://host.docker.internal:8059/} WECHAT_SERVER_IP: ${WECHAT_SERVER_IP:-host.docker.internal} WECHAT_SERVER_PORT: ${WECHAT_SERVER_PORT:-8059} + WECHAT_SERVER_TYPE: ${WECHAT_SERVER_TYPE:-legacy_855} WECHAT_WXID: ${WECHAT_WXID:-} - WECHAT_DEVICE_NAME: ${WECHAT_DEVICE_NAME:-ABOTPad} + WECHAT_DEVICE_NAME: ${WECHAT_DEVICE_NAME:-} WECHAT_DEVICE_ID: ${WECHAT_DEVICE_ID:-} + WECHAT_STATE_FILE: ${WECHAT_STATE_FILE:-temp/wechat_ipad/config.toml} ports: - "${DASHBOARD_PORT:-8888}:8888" volumes: # 日志目录映射到宿主机,方便排障与运维备份。 - ./logs:/app/logs - # 保留 wechat_ipad 的本地配置文件,避免容器重建后丢失登录态。 - - ./wechat_ipad/config.toml:/app/wechat_ipad/config.toml + # 保留 wechat_ipad 的本地登录态缓存,避免容器重建后丢失 wxid / device 信息。 + - ./temp:/app/temp extra_hosts: # 兼容 Linux 环境下通过 host.docker.internal 访问宿主机上的 wechat_ipad server。 - "host.docker.internal:host-gateway" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6493b0b..e73816b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -46,24 +46,24 @@ glances: wx_config: admin: [ "\${ABOT_WX_ADMIN:admin}" ] + +wechat_ipad: + server_url: "\${WECHAT_SERVER_URL:http://host.docker.internal:8059/}" + server_ip: "\${WECHAT_SERVER_IP:host.docker.internal}" + server_port: "\${WECHAT_SERVER_PORT:8059}" + server_type: "\${WECHAT_SERVER_TYPE:legacy_855}" + wxid: "\${WECHAT_WXID:}" + device_name: "\${WECHAT_DEVICE_NAME:}" + device_id: "\${WECHAT_DEVICE_ID:}" + state_file: "\${WECHAT_STATE_FILE:temp/wechat_ipad/config.toml}" + legacy_config_path: "\${WECHAT_LEGACY_CONFIG_PATH:wechat_ipad/config.toml}" EOF fi -# wechat_ipad 配置保留为独立文件: -# 1. 兼容现有代码对 wechat_ipad/config.toml 的读取方式; -# 2. 仅在文件缺失时生成,避免覆盖用户已有的登录态与设备信息; -# 3. 这样既支持 Docker 一键部署,也不强行改动用户本地运行方式。 -mkdir -p /app/wechat_ipad -if [ ! -f /app/wechat_ipad/config.toml ]; then - cat > /app/wechat_ipad/config.toml < dict: + """构建 wechat_ipad 的运行时配置快照。 + + 合并顺序说明: + 1. 先取 `config.yaml + .env` 里的静态连接配置,作为新的唯一人工维护入口; + 2. 再补本地状态缓存中的 wxid / device 信息,避免每次启动都重新扫码; + 3. 最后兼容历史 `wechat_ipad/config.toml`,让老环境升级后可以平滑迁移。 + """ + base_config = dict(getattr(self.config, "wechat_ipad", {}) or {}) + state_path = str(base_config.get("state_file", "temp/wechat_ipad/config.toml") or "temp/wechat_ipad/config.toml") + legacy_config_path = str( + base_config.get("legacy_config_path", "wechat_ipad/config.toml") or "wechat_ipad/config.toml" + ) + + state_config = self._load_toml_config_if_exists(state_path) + legacy_config = {} + if os.path.abspath(state_path) != os.path.abspath(legacy_config_path): + legacy_config = self._load_toml_config_if_exists(legacy_config_path) + + merged_config = dict(base_config) + + # 静态字段优先级:`.env/config.yaml` > 历史文件。 + # 这样每个人只要改 `.env` 就能切换自己的 server,不需要再同步别处。 + for field_name in ("server_url", "server_ip", "server_port", "server_type"): + if not str(merged_config.get(field_name, "") or "").strip(): + legacy_value = legacy_config.get(field_name) + if legacy_value not in (None, ""): + merged_config[field_name] = legacy_value + + # 动态字段优先级:显式环境变量 > 新状态文件 > 历史 config.toml。 + # 这样既支持用户手工覆盖,也保留现有登录缓存迁移能力。 + for field_name in ("wxid", "device_name", "device_id", "login_time"): + current_value = merged_config.get(field_name) + if str(current_value or "").strip(): + continue + + state_value = state_config.get(field_name) + if state_value not in (None, ""): + merged_config[field_name] = state_value + continue + + legacy_value = legacy_config.get(field_name) + if legacy_value not in (None, ""): + merged_config[field_name] = legacy_value + + merged_config["state_file"] = state_path + merged_config["legacy_config_path"] = legacy_config_path + return merged_config + + def _load_toml_config_if_exists(self, file_path: str) -> dict: + """安全读取一个 TOML 文件,缺失或格式异常时回退为空配置。""" + normalized_path = str(file_path or "").strip() + if not normalized_path or not os.path.exists(normalized_path): + return {} + + try: + with open(normalized_path, "rb") as f: + return tomllib.load(f) + except Exception as e: + self.LOG.warning(f"读取 TOML 配置失败,将按空配置继续: path={normalized_path}, error={e}") + return {} + async def _on_ipad_login_ready(self, login_identity: dict) -> None: """处理 provider 登录成功后的项目侧初始化动作。 diff --git a/wechat_ipad/config.toml b/wechat_ipad/config.toml deleted file mode 100644 index 7728f4c..0000000 --- a/wechat_ipad/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -server_url = "http://192.168.2.170:8059/" -wxid = "wxid_72ow1edm3kea22" -device_id = "4978fc0fd191cf45a4e55fae7936b153" -device_name = "shui niu's Pad" -server_ip = "192.168.2.170" -server_port = "8059" -login_time = "2025-05-15 10:51:22" diff --git a/wechat_ipad/config.toml.example b/wechat_ipad/config.toml.example new file mode 100644 index 0000000..9d0f4b2 --- /dev/null +++ b/wechat_ipad/config.toml.example @@ -0,0 +1,8 @@ +server_url = "http://127.0.0.1:8059/" +server_ip = "127.0.0.1" +server_port = "8059" +server_type = "legacy_855" +wxid = "" +device_id = "" +device_name = "" +login_time = "" diff --git a/wechat_ipad/providers/legacy_855/runtime.py b/wechat_ipad/providers/legacy_855/runtime.py index b01e21b..803bf82 100644 --- a/wechat_ipad/providers/legacy_855/runtime.py +++ b/wechat_ipad/providers/legacy_855/runtime.py @@ -1,4 +1,5 @@ import asyncio +import os import time from typing import Any, Awaitable, Callable @@ -40,7 +41,7 @@ class Legacy855RuntimeMixin: self, *, ipad_config: dict, - config_path: str, + state_path: str, logger, on_login_ready: AsyncCallback, on_history_message: AsyncCallback, @@ -52,7 +53,7 @@ class Legacy855RuntimeMixin: """启动 855 provider 的完整运行时。 参数说明: - 1. `ipad_config` 与 `config_path` 由上层传入,provider 只负责更新和落盘登录态; + 1. `ipad_config` 与 `state_path` 由上层传入,provider 只负责更新和落盘登录态; 2. `on_*` 回调保持尽量少,只暴露业务层真正需要接手的几个时机; 3. 这样既避免 `Robot` 再写协议细节,也不额外引入复杂的事件总线或状态机层。 """ @@ -70,7 +71,7 @@ class Legacy855RuntimeMixin: device_name=device_name, device_id=device_id, ipad_config=ipad_config, - config_path=config_path, + state_path=state_path, logger=logger, ) @@ -160,7 +161,7 @@ class Legacy855RuntimeMixin: device_name: str, device_id: str, ipad_config: dict, - config_path: str, + state_path: str, logger, ) -> None: """保证当前 provider 已完成登录,并把登录结果写回配置。 @@ -168,7 +169,7 @@ class Legacy855RuntimeMixin: 这里沿用现有 855 的行为: 1. 优先复用缓存唤醒; 2. 唤醒失败或无缓存时回退到二维码登录; - 3. 登录成功后继续把 wxid / device 信息写回 `config.toml`,保持现有部署习惯不变。 + 3. 登录成功后只把 wxid / device 信息写回本地状态文件,不再要求用户手工维护 TOML。 """ if await self.is_logged_in(wxid): self.wxid = wxid @@ -212,10 +213,39 @@ class Legacy855RuntimeMixin: ipad_config["device_name"] = device_name ipad_config["device_id"] = device_id ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - with open(config_path, "w", encoding="utf-8") as f: - toml.dump(ipad_config, f) + self._save_runtime_state( + state_path=state_path, + state_payload={ + "wxid": self.wxid, + "device_name": device_name, + "device_id": device_id, + "login_time": ipad_config["login_time"], + }, + logger=logger, + ) break + @staticmethod + def _save_runtime_state(*, state_path: str, state_payload: dict[str, Any], logger) -> None: + """把运行期登录状态写入本地缓存文件。 + + 这里刻意只保存动态字段: + 1. server_url / server_ip / server_port 已经统一走 `.env`; + 2. 本地状态文件只承载登录缓存,避免用户再维护两套静态配置; + 3. 路径所在目录不存在时自动创建,兼容首次启动与 Docker 挂载目录。 + """ + try: + normalized_path = str(state_path or "").strip() + if not normalized_path: + return + state_dir = os.path.dirname(normalized_path) + if state_dir: + os.makedirs(state_dir, exist_ok=True) + with open(normalized_path, "w", encoding="utf-8") as f: + toml.dump(state_payload, f) + except Exception as e: + logger.warning(f"写入 wechat_ipad 本地状态失败: path={state_path}, error={e}") + def _apply_login_result(self, *, data: dict, logger) -> None: """把登录接口返回的用户信息统一写回当前 provider。""" acct_section = data.get("acctSectResp", {}) or {}