1095 lines
34 KiB
Markdown
1095 lines
34 KiB
Markdown
|
|
# 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-<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 隔离重启 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 十五、实施路线
|
|||
|
|
|
|||
|
|
### 阶段 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://<woc面板>/api/bridge/<实例id>` 即可对接。
|