# 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>` 即可对接。
