# WechatOnCloud 改造需求方案 > 版本:v2.0 > 日期:2026-07-05 > 状态:方案待评审 > 变更:基于 channels 渠道插件协议深度调研,重新设计 API 契约,新增两条改造路径对比 ## 一、背景与目标 ### 1.1 现状 [WechatOnCloud](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main) 是一个容器化的「服务端微信」项目,核心能力是: - 在 Docker 容器里运行官方 Linux 原版微信(`/config/wechat/opt/wechat/wechat`) - 通过 Xvfb 虚拟显示 + KasmVNC 把桌面画面串流到浏览器 - 面板(panel)作为唯一对外入口,管理多实例、子账号权限、反向代理 - **不修改微信客户端**,依靠 xdotool/xclip 已具备 UI 自动化基础设施 但 WechatOnCloud 目前**只暴露了 KasmVNC 的桌面串流(3000 端口)**,没有提供任何业务 API。外部系统无法通过 HTTP 调用「发消息 / 读消息 / 查联系人」等能力。 ### 1.2 目标 改造 WechatOnCloud,使其在保留现有桌面串流能力的基础上,**原生提供业务 HTTP API**,供 ForcePilot channels 模块集成,实现个人微信的自动收发消息闭环。 ### 1.3 核心诉求 | 能力 | 优先级 | channels 对接的 adapter | 说明 | |---|---|---|---| | 发送文本消息 | P0 | OutboundAdapter | 通过 xdotool 操作微信窗口发送文本 | | 拉取增量消息 | P0 | PullerAdapter | 通过读取微信本地 DB 获取新消息 | | 查询实例状态 | P0 | ProbeableAdapter / StatusAdapter | 微信窗口状态、登录态、bridge 服务健康 | | 扫码登录 | P0 | LoginAdapter | 复用 KasmVNC 桌面串流 + 截图二维码 | | 联系人/群聊查询 | P1 | SessionAdapter / DirectoryAdapter | 通过 DB 查询 | | 发送图片/文件 | P1 | OutboundAdapter | 通过 xdotool 模拟拖拽 | | 下载媒体文件 | P1 | InboundAdapter.downloadAttachment | 读取微信媒体文件并返回 | | 诊断 | P1 | DoctorAdapter | 连通性、登录态、DB 可达性诊断 | | 多账号管理 | P2 | LifecycleAdapter | 一个面板管理多个实例 | ### 1.4 非目标 - **不修改微信客户端本身**(保持「官方原版」的核心原则) - **不实现微信私有协议**(不做协议逆向) - **不替代 KasmVNC 桌面串流**(保留人工查看/扫码登录入口) - **不直接对接 channels**(channels 侧插件开发是独立项目) --- ## 二、channels 渠道插件协议调研结论 ### 2.1 channels 插件需要实现的 Adapter channels 一个完整渠道插件需实现以下 adapter(参考 [wechat_ilink](file:///d:/ForcePilot-v1.1/backend/package/yuxi/channels/plugins/wechat_ilink/) 参考实现): | Adapter | 必选性 | 核心方法 | 对 WechatOnCloud 的 API 需求 | |---|---|---|---| | **PullerAdapter** | 必选 | `poll(account_id, cursor)` / `getPollingConfig()` | 增量消息拉取接口 | | **InboundAdapter** | 必选 | `normalizeInbound(raw_event)` / `verifySignature` / `downloadAttachment` | 消息格式归一化(由插件层做,bridge 只提供原始数据) | | **OutboundAdapter** | 必选 | `sendMessage(account_id, peer_id, payload)` / `formatOutbound` | 发送消息接口 | | **SessionAdapter** | 必选 | `parseSession(raw_event)` / `getPeerId` / `getChatType` | 联系人/会话查询接口 | | **StatusAdapter** | 必选 | `classifyEvent(raw_event)` / `extractStatusPayload` | 状态事件分类(由插件层做) | | **LoginAdapter** | 必选 | `loginWithQrStart` / `loginWithQrWait` / `logout` | 二维码获取 + 扫码状态轮询 | | **LifecycleAdapter** | 必选 | `resolveAccountId` / `applyAccountConfig` / `afterAccountConfigWritten` | 账户生命周期管理 | | **ProbeableAdapter** | 必选 | `probe()` | 探活接口 | | **DoctorAdapter** | 必选 | `checkConnectivity` / `getDiagnosticItems` / `runItem` / `autoFix` | 诊断接口 | | **WizardAdapter** | 必选 | `getWizardSteps` / `validateStep` / `applyStep` | 配置向导(由插件层做) | | **WhitelistAdapter** | 可选 | `getWhitelist` / `addToWhitelist` / `removeFromWhitelist` | 白名单(存储在 channels 侧,无需 bridge) | ### 2.2 channels 关键 DTO 字段 bridge 返回的数据需能映射到以下 DTO: #### PollResult(PullerAdapter.poll 返回值) ```python @dataclass(frozen=True) class PollResult: messages: tuple[dict[str, Any], ...] # 原始消息列表 next_cursor: str | None # 下次轮询的游标 error: TransportError | None # 错误信息 ``` #### MessageContent(InboundAdapter.normalizeInbound 返回值) ```python @dataclass(frozen=True) class MessageContent: text: str format: MessageFormat = MessageFormat.TEXT # TEXT / MARKDOWN / RICH attachments: tuple[Attachment, ...] = () metadata: dict[str, Any] | None = None @dataclass(frozen=True) class Attachment: type: Literal["image", "file", "audio", "video"] url: str mime_type: str | None = None size: int | None = None filename: str | None = None width: int | None = None height: int | None = None duration_ms: int | None = None ``` #### SessionInfo(SessionAdapter.parseSession 返回值) ```python @dataclass(frozen=True) class SessionInfo: channel_type: ChannelType account_id: str peer_id: str # 聊天对象 wxid chat_type: Literal["p2p", "group"] group_id: str | None = None # 群聊 ID topic_id: str | None = None # 话题 ID(群内话题) ``` #### QrLoginStartResult / QrLoginWaitResult(LoginAdapter) ```python @dataclass(frozen=True) class QrLoginStartResult: qr_data_url: str # 二维码图片 data URL message: str connected: bool @dataclass(frozen=True) class QrLoginWaitResult: connected: bool message: str qr_data_url: str | None credentials: dict[str, str] | None # 登录成功后返回的凭据 ``` ### 2.3 ChannelManifest 关键字段 参考 [wechat_ilink/manifest.json](file:///d:/ForcePilot-v1.1/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json),WechatOnCloud 插件的 manifest 需声明: | 字段 | wechat_ilink 的值 | WechatOnCloud 建议值 | |---|---|---| | `id` | `com.yuxi.channels.wechat_ilink` | `com.yuxi.channels.wechat_woc` | | `channel_type` | `wechat_ilink` | `wechat_woc` | | `credential_strategy.type` | `dynamic` | `static` | | `credential_strategy.acquire_method` | `qr_login` | `manual` | | `credential_strategy.required_fields` | `["bot_token", "ilink_bot_id", ...]` | `["bridge_url"]` | | `capabilities.supports_qr_login` | `true` | `true`(通过 KasmVNC 截图) | | `capabilities.doctor` | `true` | `true` | | `capabilities.probeable` | `true` | `true` | | `capabilities.wizard` | `true` | `true` | | `capabilities.lifecycle` | `true` | `true` | | `max_message_length` | `4000` | `2000`(个人微信限制) | | `config_schema` | 12 字段 | 见 §5.2 | --- ## 三、两条改造路径对比 ### 路径 A:协议兼容(WechatOnCloud 实现 iLink 协议) **思路**:让 WechatOnCloud 的 bridge 服务实现与 iLink 完全相同的 8 个 HTTP 端点,wechat_ilink 插件零改动。 | 端点 | iLink 协议 | WechatOnCloud bridge 实现 | |---|---|---| | `POST /ilink/bot/getupdates` | 长轮询拉取消息 | 读 DB 增量消息,构造相同响应结构 | | `POST /ilink/bot/sendmessage` | 发送消息 | 调 xdotool 发送,返回 `client_id` | | `GET /ilink/bot/get_bot_qrcode` | 获取二维码 | 截图微信窗口,返回二维码 data URL | | `GET /ilink/bot/get_qrcode_status` | 查询扫码状态 | 检测微信登录态 | | `POST /ilink/bot/getconfig` | 探活 | 返回微信账号信息 | | `POST /ilink/bot/getuploadurl` | CDN 上传地址 | 本地文件路径直通 | | CDN 上传端点 | 加密上传 | 本地文件写入 | | CDN 下载端点 | 加密下载 | 本地文件读取 | **优点**: - channels 侧零改动,复用现有 wechat_ilink 插件 - 上线最快 **缺点**: - 需逆向 iLink 协议的请求/响应结构、鉴权头、错误码 - iLink 协议有加密(AES-128-ECB)、防重放头、IDC 重定向等复杂逻辑 - 协议绑定深,iLink 协议变更时 WechatOnCloud 也需跟着改 - WechatOnCloud 的能力被 iLink 协议天花板限制 - **不推荐**:工程量未必更小,且引入协议兼容包袱 ### 路径 B:原生 API + 新建插件(推荐) **思路**:WechatOnCloud 设计自己的原生 RESTful API,channels 侧新建 `wechat_woc` 插件对接。 **优点**: - API 设计自由,贴合 WechatOnCloud 实际能力 - 不背 iLink 协议包袱 - channels 插件层职责清晰 - 长期可维护性好 **缺点**: - 需新增 channels 插件(约 1000-1500 行 Python) - 上线周期稍长 ### 推荐结论 **采用路径 B**。原因: 1. iLink 协议的加密、防重放、IDC 重定向等复杂逻辑在 WechatOnCloud 场景下完全无意义 2. WechatOnCloud 是本地容器,无需 CDN 加密上传下载,直接读本地文件即可 3. 新增 channels 插件的成本可控,且能获得更干净的设计 4. 后续 WechatOnCloud 能力扩展(如群聊、媒体)不被 iLink 协议束缚 --- ## 四、改造方案(路径 B) ### 4.1 总体架构 ``` ┌──────────────────────────────────────────────────────────────┐ │ WechatOnCloud 实例容器 (woc-wx-) │ │ │ │ s6-rc 服务集 │ │ ┌────────────────────┐ ┌──────────────────────────────┐ │ │ │ init / Xvfb │ │ svc-woc-bridge (新增 longrun)│ │ │ │ openbox autostart │ │ ┌────────────────────────┐ │ │ │ │ KasmVNC (:3000) │ │ │ woc-bridge (FastAPI) │ │ │ │ │ wechat (主进程) │ │ │ :8088 │ │ │ │ └────────────────────┘ │ │ ├─ /api/status │ │ │ │ ▲ │ │ ├─ /api/messages/* │ │ │ │ │ X11 :1 │ │ ├─ /api/send/* │ │ │ │ └────────────────┼──┤ ├─ /api/contacts/* │ │ │ │ │ │ ├─ /api/media/* │ │ │ │ │ │ ├─ /api/login/* │ │ │ │ │ │ └─ /api/diagnostic/* │ │ │ │ │ └────────────────────────┘ │ │ │ └──────────────────────────────┘ │ │ │ │ /config/.config/xwechat/ ←── 微信 DB │ └──────────────────────────────────────────────────────────────┘ ↑ ↑ │ 3000 (桌面串流) │ 8088 (业务 API) │ │ ┌──────────────────────────────────────────────────────────────┐ │ 面板容器 (woc-panel) │ │ 反代 /desktop/:id/* → :3000 │ │ 反代 /api/bridge/:id/* → :8088 ← 新增 │ └──────────────────────────────────────────────────────────────┘ ``` ### 4.2 改动清单 | 文件/目录 | 改动类型 | 说明 | |---|---|---| | `bridge/` | **新增** | FastAPI 服务,提供业务 API | | `bridge/server.py` | 新增 | API 主入口 | | `bridge/xdotool_driver.py` | 新增 | xdotool 操作封装 | | `bridge/db_reader.py` | 新增 | 微信 DB 读取 | | `bridge/qr_capture.py` | 新增 | 微信窗口二维码截图 | | `bridge/send_queue.py` | 新增 | 串行化发送队列 | | `bridge/requirements.txt` | 新增 | Python 依赖 | | `bridge/s6/woc-bridge/run` | 新增 | s6 longrun 启动脚本 | | `bridge/s6/woc-bridge/type` | 新增 | 内容为 `longrun` | | `docker/Dockerfile` | **修改** | 安装 Python + 依赖,COPY bridge,注册 s6 服务,EXPOSE 8088 | | `panel/server/src/docker.ts` | **修改** | `ExposedPorts` 增加 `8088/tcp` | | `panel/server/src/index.ts` | **修改** | 新增 `/api/bridge/:id/*` 反代路由 | | `.env.example` | **修改** | 增加 `WOC_BRIDGE_*` 配置项 | --- ## 五、woc-bridge HTTP API 契约 ### 5.1 通用约定 - 所有响应 `Content-Type: application/json`(媒体下载除外) - 所有时间戳为 Unix 秒(UTC) - 所有 `wxid` 为字符串 - 错误响应统一结构: ```json { "success": false, "error": { "code": "WECHAT_NOT_RUNNING", "message": "微信窗口未找到", "details": {} } } ``` ### 5.2 状态接口(对应 ProbeableAdapter) ``` GET /api/status ``` **响应**: ```json { "bridge_version": "1.0.0", "wechat_running": true, "wechat_window_found": true, "login_state": "logged_in", "db_accessible": true, "current_wxid": "wxid_abc123", "current_nickname": "张三", "uptime_seconds": 3600, "display": ":1" } ``` **`login_state` 枚举**:`not_running` / `not_logged_in` / `logging_in` / `logged_in` / `logged_out` **对应 channels 调用**:`ProbeableAdapter.probe()`、`DoctorAdapter.checkConnectivity()` ### 5.3 消息拉取接口(对应 PullerAdapter) ``` GET /api/messages/since?cursor=&limit=50 ``` **参数**: - `cursor`:上次拉取的最后一条消息的 `create_time`(Unix 秒),首次传 `0` - `limit`:单次拉取上限,默认 50,最大 200 **响应**: ```json { "messages": [ { "msg_id": "wxid_abc_1700000000_123", "talker": "wxid_abc123", "sender": "wxid_sender", "is_sender": false, "type": 1, "render_type": "text", "content": "你好", "create_time": 1700000000, "session_type": "p2p" } ], "next_cursor": "1700000123", "has_more": false } ``` **消息类型映射**(`type` + `render_type`): | 微信 type | render_type | channels MessageFormat | 说明 | |---|---|---|---| | 1 | `text` | TEXT | 文本消息 | | 3 | `image` | TEXT + Attachment(image) | 图片消息 | | 34 | `voice` | TEXT + Attachment(audio) | 语音消息 | | 43 | `video` | TEXT + Attachment(video) | 视频消息 | | 49 | `file` | TEXT + Attachment(file) | 文件消息 | | 49 | `link` | TEXT | 链接卡片 | | 49 | `card` | TEXT | 名片 | | 10002 | `system` | TEXT | 系统消息 | | 10000 | `system` | TEXT | 系统消息(撤回等) | **对应 channels 调用**:`PullerAdapter.poll(account_id, cursor)` → `PollResult` ### 5.4 发送消息接口(对应 OutboundAdapter) #### 5.4.1 发送文本 ``` POST /api/send/text Content-Type: application/json { "to_wxid": "wxid_abc123", "content": "你好" } ``` **响应**: ```json { "success": true, "channel_msg_id": "local_1700000000_123", "error": null } ``` #### 5.4.2 发送图片 ``` POST /api/send/image Content-Type: application/json { "to_wxid": "wxid_abc123", "file_path": "/config/Desktop/image.jpg" } ``` #### 5.4.3 发送文件 ``` POST /api/send/file Content-Type: application/json { "to_wxid": "wxid_abc123", "file_path": "/config/Desktop/doc.pdf" } ``` **对应 channels 调用**:`OutboundAdapter.sendMessage(account_id, peer_id, payload)` → `channel_msg_id` ### 5.5 联系人接口(对应 SessionAdapter / DirectoryAdapter) #### 5.5.1 查询联系人 ``` GET /api/contacts?keyword=张三&limit=50 ``` **响应**: ```json { "contacts": [ { "wxid": "wxid_abc123", "nickname": "张三", "remark": "张三-客户", "avatar_url": "/api/media/avatar/wxid_abc123", "type": "friend" } ], "total": 1 } ``` #### 5.5.2 联系人详情 ``` GET /api/contacts/{wxid} ``` #### 5.5.3 群聊列表 ``` GET /api/groups?limit=50 ``` #### 5.5.4 群成员 ``` GET /api/groups/{wxid}/members ``` **响应**: ```json { "group_wxid": "12345@chatroom", "members": [ { "wxid": "wxid_abc123", "nickname": "张三", "display_name": "张三", "is_admin": false } ], "total": 50 } ``` **对应 channels 调用**:`SessionAdapter.parseSession()`、`DirectoryAdapter.getGroupMembers()` ### 5.6 媒体接口(对应 InboundAdapter.downloadAttachment) ``` GET /api/media/{msg_id} ``` 返回二进制文件流(`Content-Type` 根据文件类型设置)。 **对应 channels 调用**:`InboundAdapter.downloadAttachment(attachment)` → `Attachment` ### 5.7 登录接口(对应 LoginAdapter) #### 5.7.1 获取登录二维码 ``` POST /api/login/qr/start ``` **响应**: ```json { "qr_data_url": "data:image/png;base64,iVBOR...", "message": "请使用微信扫描二维码", "connected": false } ``` **实现**:bridge 截图微信窗口,裁剪出二维码区域,编码为 base64 data URL。 #### 5.7.2 轮询扫码状态 ``` GET /api/login/qr/wait?timeout=30 ``` **响应**: ```json { "connected": false, "message": "等待扫码", "qr_data_url": null, "credentials": null } ``` 扫码成功时: ```json { "connected": true, "message": "登录成功", "qr_data_url": null, "credentials": { "wxid": "wxid_abc123", "nickname": "张三" } } ``` **对应 channels 调用**:`LoginAdapter.loginWithQrStart()` / `loginWithQrWait()` ### 5.8 诊断接口(对应 DoctorAdapter) #### 5.8.1 连通性检查 ``` GET /api/diagnostic/connectivity ``` **响应**: ```json { "reachable": true, "latency_ms": 50, "error": null } ``` #### 5.8.2 诊断项列表 ``` GET /api/diagnostic/items ``` **响应**: ```json { "items": [ { "check_id": "wechat_running", "name": "微信进程检查", "severity": "critical", "description": "检查微信客户端是否运行", "auto_repairable": true }, { "check_id": "login_state", "name": "登录状态检查", "severity": "critical", "description": "检查微信是否已登录", "auto_repairable": false }, { "check_id": "db_accessible", "name": "数据库可达性", "severity": "error", "description": "检查微信 DB 是否可读", "auto_repairable": false }, { "check_id": "xdotool_available", "name": "xdotool 可用性", "severity": "error", "description": "检查 xdotool 是否可用", "auto_repairable": true } ] } ``` #### 5.8.3 执行诊断项 ``` POST /api/diagnostic/run/{check_id} ``` **响应**: ```json { "check_id": "wechat_running", "passed": true, "severity": "critical", "message": "微信进程运行中,PID=12345", "auto_repairable": true, "repair_plan": null } ``` #### 5.8.4 自动修复 ``` POST /api/diagnostic/autofix/{check_id} ``` **响应**: ```json { "check_id": "wechat_running", "passed": true, "message": "已重启微信进程" } ``` **对应 channels 调用**:`DoctorAdapter.checkConnectivity()` / `getDiagnosticItems()` / `runItem()` / `autoFix()` ### 5.9 截图接口(调试用) ``` POST /api/screenshot ``` 返回当前微信窗口截图(PNG)。 --- ## 六、错误码枚举 | 错误码 | 说明 | HTTP 状态 | |---|---|---| | `WECHAT_NOT_RUNNING` | 微信进程未启动 | 503 | | `WECHAT_NOT_LOGGED_IN` | 微信未登录 | 401 | | `WINDOW_NOT_FOUND` | 找不到微信窗口 | 503 | | `CONTACT_NOT_FOUND` | 联系人不存在 | 404 | | `SEND_FAILED` | 发送失败 | 500 | | `DB_LOCKED` | DB 被锁定 | 503 | | `DB_NOT_FOUND` | DB 文件不存在 | 500 | | `INVALID_PARAMS` | 参数校验失败 | 400 | | `BRIDGE_INTERNAL_ERROR` | bridge 内部错误 | 500 | | `LOGIN_TIMEOUT` | 登录超时 | 408 | | `MEDIA_NOT_FOUND` | 媒体文件不存在 | 404 | | `RATE_LIMITED` | 触发限流 | 429 | --- ## 七、配置项 ### 7.1 新增环境变量 在 `.env.example` 追加: ```bash # ===== woc-bridge 业务 API ===== WOC_BRIDGE_PORT=8088 WOC_BRIDGE_SEND_DELAY_MS=800 WOC_BRIDGE_MAX_BATCH_SIZE=50 WOC_BRIDGE_POLL_INTERVAL_MS=2000 WOC_BRIDGE_MAX_CALLS_PER_SEC=10 ``` ### 7.2 channels 侧 config_schema(参考) WechatOnCloud 插件的 `config_schema`(由 channels 侧 manifest 声明,非 WechatOnCloud 侧): | key | type | required | default | hot_reloadable | 说明 | |---|---|---|---|---|---| | `bridge_url` | str | true | - | true | bridge 服务地址 | | `poll_interval_ms` | int | false | 2000 | true | 轮询间隔 | | `max_batch_size` | int | false | 50 | true | 单次拉取上限 | | `send_delay_ms` | int | false | 800 | true | 发送间隔 | | `max_message_length` | int | false | 2000 | true | 最大消息长度 | | `enable_media` | bool | false | true | true | 是否启用媒体下载 | | `enable_group` | bool | false | true | true | 是否启用群聊支持 | --- ## 八、Dockerfile 改造 在 [docker/Dockerfile](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/docker/Dockerfile) 现有内容基础上追加: ```dockerfile # ===== 新增:woc-bridge 业务 API 服务 ===== RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ python3 python3-pip python3-venv \ sqlite3; \ pip3 install --break-system-packages --no-cache-dir \ fastapi==0.115.0 \ uvicorn[standard]==0.30.0 \ python-multipart==0.0.9 \ pillow==10.4.0; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* COPY bridge/ /opt/woc-bridge/ RUN chmod 755 /opt/woc-bridge/server.py # 注册为 s6-rc longrun 服务 RUN mkdir -p /etc/s6-overlay/s6-rc.d/svc-woc-bridge COPY bridge/s6/woc-bridge/run /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run COPY bridge/s6/woc-bridge/type /etc/s6-overlay/s6-rc.d/svc-woc-bridge/type RUN chmod 755 /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run \ && touch /etc/s6-overlay/s6-rc.d/user/contents.d/svc-woc-bridge EXPOSE 3000 3001 8088 ``` --- ## 九、s6 启动脚本 `bridge/s6/woc-bridge/run`: ```bash #!/usr/bin/with-contenv bash # 等待微信窗口出现 while ! xdotool search --name "微信" >/dev/null 2>&1; do sleep 2 done # 以 abc 用户身份运行(与微信同 X 会话) exec s6-setuidgid abc \ DISPLAY=${DISPLAY:-:1} \ XAUTHORITY=/config/.Xauthority \ python3 /opt/woc-bridge/server.py \ --listen 0.0.0.0:8088 \ --wechat-db /config/.config/xwechat \ --display ${DISPLAY:-:1} ``` --- ## 十、面板端口暴露改造 [panel/server/src/docker.ts](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/panel/server/src/docker.ts) L256: ```typescript // 改造前 ExposedPorts: { '3000/tcp': {} }, // 改造后 ExposedPorts: { '3000/tcp': {}, '8088/tcp': {}, }, ``` --- ## 十一、面板反代改造 [panel/server/src/index.ts](file:///d:/ForcePilot-v1.1/docs/source-code/WechatOnCloud-main/panel/server/src/index.ts) 新增反代路由: ```typescript // ---------- 反向代理到实例的 woc-bridge 业务 API ---------- // /api/bridge/:id/* → http://woc-wx-:8088/* // 仅管理员可访问(业务 API 等同微信会话凭据) const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => { if (!requireAdmin(req, reply)) return; const parsed = parseBridgeUrl(req.raw.url || ''); if (!parsed) { reply.code(404).send({ error: 'not found' }); return; } const inst = findInstance(parsed.id); if (!inst) { reply.code(404).send({ error: '实例不存在' }); return; } reply.hijack(); req.raw.url = parsed.rest; proxy.web(req.raw, reply.raw, { target: `http://${inst.containerName}:8088`, }); }; function parseBridgeUrl(rawUrl: string): { id: string; rest: string } | null { const m = rawUrl.match(/^\/api\/bridge\/([0-9a-f]{6,})(\/.*|\?.*|)?$/); if (!m) return null; const id = m[1]; let rest = m[2] || '/'; if (rest.startsWith('?')) rest = '/' + rest; if (rest === '') rest = '/'; return { id, rest }; } app.all('/api/bridge/:id', bridgeHandler); app.all('/api/bridge/:id/*', bridgeHandler); ``` --- ## 十二、关键技术实现细节 ### 12.1 发送文本消息(xdotool + xclip) ```python async def send_text(to_wxid: str, content: str) -> str: """通过 xdotool 操作微信窗口发送文本消息""" # 1. 激活微信窗口 subprocess.run( ["xdotool", "search", "--name", "微信", "windowactivate", "--sync"], check=True, ) # 2. Ctrl+F 打开搜索 subprocess.run(["xdotool", "key", "ctrl+f"], check=True) await asyncio.sleep(0.5) # 3. 用 xclip 粘贴 wxid(避免输入法干扰) _paste_via_xclip(to_wxid) await asyncio.sleep(0.8) # 4. 回车进入会话 subprocess.run(["xdotool", "key", "Return"], check=True) await asyncio.sleep(0.5) # 5. 粘贴消息内容 _paste_via_xclip(content) await asyncio.sleep(0.2) # 6. 回车发送 subprocess.run(["xdotool", "key", "Return"], check=True) # 7. 从 DB 读取刚发送的消息 ID return await _query_last_sent_msg_id(to_wxid) def _paste_via_xclip(text: str) -> None: """通过 xclip 剪贴板粘贴,避开 X11 keysym 中文限制""" b64 = base64.b64encode(text.encode("utf-8")).decode() subprocess.run( ["bash", "-c", f"echo '{b64}' | base64 -d | xclip -selection clipboard -i"], check=True, ) subprocess.run(["xdotool", "key", "--clearmodifiers", "ctrl+v"], check=True) ``` ### 12.2 读取消息(DB 快照) ```python async def get_messages_since(cursor: int, limit: int = 50) -> dict: """从微信 DB 读取增量消息""" db_path = "/config/.config/xwechat/msg_0.db" snapshot = _snapshot_db(db_path) conn = sqlite3.connect(snapshot) try: rows = conn.execute( "SELECT msg_id, talker, is_sender, type, content, create_time " "FROM message WHERE create_time > ? ORDER BY create_time ASC LIMIT ?", (cursor, limit), ).fetchall() finally: conn.close() os.unlink(snapshot) messages = [_row_to_dict(r) for r in rows] next_cursor = str(messages[-1]["create_time"]) if messages else str(cursor) return { "messages": messages, "next_cursor": next_cursor, "has_more": len(messages) == limit, } ``` ### 12.3 二维码截图 ```python async def capture_qr_code() -> str: """截图微信窗口的二维码区域,返回 data URL""" # 1. 激活微信窗口 subprocess.run( ["xdotool", "search", "--name", "微信", "windowactivate", "--sync"], check=True, ) # 2. 截图整个窗口 window_geom = subprocess.check_output( ["xdotool", "search", "--name", "微信", "getwindowgeometry", "--shell"] ).decode() # 3. 用 import 命令截图 screenshot_path = "/tmp/woc_qr.png" subprocess.run(["import", "-window", "root", screenshot_path], check=True) # 4. 用 Pillow 裁剪二维码区域(需根据微信窗口布局定位) img = Image.open(screenshot_path) # 二维码通常在窗口中央偏上,具体坐标需实测 qr_region = (x, y, x + w, y + h) qr_img = img.crop(qr_region) # 5. 编码为 base64 data URL buf = io.BytesIO() qr_img.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode() return f"data:image/png;base64,{b64}" ``` ### 12.4 发送队列串行化 ```python class SendQueue: """串行化发送队列,避免 xdotool 操作冲突""" def __init__(self, send_delay_ms: int = 800): self._queue: asyncio.Queue = asyncio.Queue() self._send_delay_ms = send_delay_ms self._worker: asyncio.Task | None = None async def start(self): self._worker = asyncio.create_task(self._run()) async def enqueue(self, task: SendTask) -> SendResult: future = asyncio.get_event_loop().create_future() await self._queue.put((task, future)) return await future async def _run(self): while True: task, future = await self._queue.get() try: result = await task.execute() future.set_result(result) except Exception as e: future.set_exception(e) await asyncio.sleep(self._send_delay_ms / 1000) ``` --- ## 十三、安全设计 ### 13.1 鉴权 - bridge API 反代路由 `/api/bridge/:id/*` **仅管理员可访问** - bridge 服务本身监听 `0.0.0.0:8088`,但在 docker 网络内,只有面板能访问 - 可选:bridge 支持 API Key 鉴权(通过环境变量配置) ### 13.2 风控策略 - 发送消息串行化,避免并发 - 可配置发送间隔(默认 800ms) - 单实例每秒发送上限(默认 10 条) - 异常时自动降速或暂停 ### 13.3 数据安全 - DB 只读访问,不写入 - 不记录消息内容到日志 - 截图功能仅管理员可用 - 不暴露微信凭据 --- ## 十四、风险与限制 | 风险 | 影响 | 应对 | |---|---|---| | xdotool 操作时序不稳定 | 发送失败或发错人 | 用 wxid 而非昵称;加状态检测;失败重试;记录截图 | | 微信 DB 加密 | 无法直接读消息 | 阶段 1 用 UI 截图 + OCR;阶段 2 引入 `libwcdb.so` | | 微信窗口未就绪 | bridge 启动失败 | s6 longrun 等待窗口出现;健康检查 | | 中文输入异常 | 发送乱码 | 强制走 xclip 粘贴路径 | | 多消息并发 | UI 操作冲突 | bridge 内部串行化发送队列 | | Linux 微信版本更新 | DB 结构或快捷键变化 | 固定版本;建立版本适配测试 | | 二维码区域定位 | 截图裁剪坐标不准 | 实测校准;提供手动框选 fallback | | bridge 崩溃影响微信 | 微信异常 | 独立进程,s6 隔离重启 | --- ## 十五、实施路线 ### 阶段 1:MVP(1-2 周) **目标**:跑通"收消息 → 发消息"最小闭环 - [ ] 实现 `bridge/server.py` FastAPI 骨架 - [ ] 实现 `GET /api/status` - [ ] 实现 `POST /api/send/text`(xdotool + xclip) - [ ] 实现 `GET /api/messages/since`(DB 读取) - [ ] 改造 Dockerfile + s6 服务 - [ ] 改造面板端口暴露 + 反代 - [ ] 端到端验证:能收到消息、能发出消息 ### 阶段 2:登录与完善(2-3 周) - [ ] 实现 `POST /api/login/qr/start` + `GET /api/login/qr/wait` - [ ] 联系人/群聊查询 - [ ] 图片/文件发送 - [ ] 媒体文件下载 - [ ] 发送队列 + 限流 - [ ] 错误恢复 + 重试 - [ ] 截图诊断 ### 阶段 3:稳定性(持续) - [ ] 健康检查 + 自动恢复 - [ ] 监控告警 - [ ] 多账号支持验证 - [ ] DB 加密适配(如需) - [ ] 面板 UI 集成(可选) --- ## 十六、验收标准 ### 16.1 阶段 1 验收 | 验收项 | 标准 | |---|---| | bridge 服务启动 | 容器启动后 30s 内 bridge 就绪,`GET /api/status` 返回 200 | | 发送文本消息 | 调用 `POST /api/send/text`,对方在 5s 内收到消息 | | 拉取消息 | 调用 `GET /api/messages/since`,能返回最近 50 条消息 | | 桌面串流不受影响 | 原有 KasmVNC 访问正常,扫码登录正常 | | 面板反代可用 | 通过 `/api/bridge/:id/*` 能访问到 bridge API | ### 16.2 阶段 2 验收 | 验收项 | 标准 | |---|---| | 扫码登录 | 能获取二维码 data URL,扫码后返回 `connected=true` | | 联系人查询 | 能按 wxid 或昵称查到联系人 | | 图片发送 | 能发送图片,对方收到且可查看 | | 媒体下载 | 能下载图片/语音/视频文件 | | 并发安全 | 10 条消息连续发送不冲突 | | 错误恢复 | bridge 崩溃后 10s 内自动重启 | --- ## 十七、附录 ### 17.1 文件清单 ``` WechatOnCloud-main/ ├── bridge/ ← 新增 │ ├── server.py # FastAPI 主服务 │ ├── xdotool_driver.py # xdotool 操作封装 │ ├── db_reader.py # 微信 DB 读取 │ ├── qr_capture.py # 二维码截图 │ ├── send_queue.py # 串行化发送队列 │ ├── models.py # 数据模型 │ ├── requirements.txt │ └── s6/ │ └── woc-bridge/ │ ├── run # s6 启动脚本 │ └── type # 内容为 "longrun" ├── docker/ │ └── Dockerfile ← 修改(追加 Python + bridge) ├── panel/ │ └── server/src/ │ ├── docker.ts ← 修改(ExposedPorts 加 8088) │ └── index.ts ← 修改(加 /api/bridge/:id/* 反代) ├── .env.example ← 修改(加 WOC_BRIDGE_* 配置) └── doc/ └── bridge-API.md ← 新增(API 文档) ``` ### 17.2 不改动的文件 - `docker/autostart` —— bridge 由 s6 管理,不依赖 openbox autostart - `docker/woc-identity.sh` —— 设备伪装逻辑不变 - `docker/wechat-ctl.sh` —— 微信下载安装逻辑不变 - `docker/app-defs.sh` —— 应用定义不变 - 面板前端代码 —— bridge 状态展示为可选项,非必须 ### 17.3 channels 侧 config_schema 与 WechatOnCloud 侧环境变量对应关系 | channels config_schema key | WechatOnCloud 环境变量 | 说明 | |---|---|---| | `bridge_url` | 无(由面板反代地址决定) | channels 侧配置 | | `poll_interval_ms` | `WOC_BRIDGE_POLL_INTERVAL_MS` | 默认 2000 | | `max_batch_size` | `WOC_BRIDGE_MAX_BATCH_SIZE` | 默认 50 | | `send_delay_ms` | `WOC_BRIDGE_SEND_DELAY_MS` | 默认 800 | ### 17.4 与 channels 集成的关系 本方案**只负责把 WechatOnCloud 改造成支持业务 API**,不涉及 channels 侧的插件开发。 channels 侧的 `wechat_woc` 插件开发是**独立的另一个项目**,它消费本方案产出的 HTTP API。两者关系: ``` [channels wechat_woc 插件] ← 本方案不涉及 ↓ HTTP [WechatOnCloud bridge API] ← 本方案产出 ↓ xdotool / DB [微信客户端] ``` channels 插件只需配置 `bridge_url = http:///api/bridge/<实例id>` 即可对接。