WechatOnCloud/doc/WechatOnCloud-改造需求方案.md

1095 lines
34 KiB
Markdown
Raw Normal View History

# 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
#### PollResultPullerAdapter.poll 返回值)
```python
@dataclass(frozen=True)
class PollResult:
messages: tuple[dict[str, Any], ...] # 原始消息列表
next_cursor: str | None # 下次轮询的游标
error: TransportError | None # 错误信息
```
#### MessageContentInboundAdapter.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
```
#### SessionInfoSessionAdapter.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 / QrLoginWaitResultLoginAdapter
```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 APIchannels 侧新建 `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-<id>) │
│ │
│ 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=<timestamp>&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-<id>: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 隔离重启 |
---
## 十五、实施路线
### 阶段 1MVP1-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://<woc面板>/api/bridge/<实例id>` 即可对接。