包含以下核心内容: 1. 完整的Docker容器化部署方案,支持多架构amd64/arm64 2. 自研Web管理面板,包含登录认证、实例管理、权限控制 3. 微信实例容器镜像,内置中文字体与输入法修复 4. 飞牛OS应用打包适配 5. 完整的文档与运维指南
34 KiB
WechatOnCloud 改造需求方案
版本:v2.0 日期:2026-07-05 状态:方案待评审 变更:基于 channels 渠道插件协议深度调研,重新设计 API 契约,新增两条改造路径对比
一、背景与目标
1.1 现状
WechatOnCloud 是一个容器化的「服务端微信」项目,核心能力是:
- 在 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 参考实现):
| 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 返回值)
@dataclass(frozen=True)
class PollResult:
messages: tuple[dict[str, Any], ...] # 原始消息列表
next_cursor: str | None # 下次轮询的游标
error: TransportError | None # 错误信息
MessageContent(InboundAdapter.normalizeInbound 返回值)
@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 返回值)
@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)
@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,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。原因:
- iLink 协议的加密、防重放、IDC 重定向等复杂逻辑在 WechatOnCloud 场景下完全无意义
- WechatOnCloud 是本地容器,无需 CDN 加密上传下载,直接读本地文件即可
- 新增 channels 插件的成本可控,且能获得更干净的设计
- 后续 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为字符串 - 错误响应统一结构:
{
"success": false,
"error": {
"code": "WECHAT_NOT_RUNNING",
"message": "微信窗口未找到",
"details": {}
}
}
5.2 状态接口(对应 ProbeableAdapter)
GET /api/status
响应:
{
"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 秒),首次传0limit:单次拉取上限,默认 50,最大 200
响应:
{
"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": "你好"
}
响应:
{
"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
响应:
{
"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
响应:
{
"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
响应:
{
"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
响应:
{
"connected": false,
"message": "等待扫码",
"qr_data_url": null,
"credentials": null
}
扫码成功时:
{
"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
响应:
{
"reachable": true,
"latency_ms": 50,
"error": null
}
5.8.2 诊断项列表
GET /api/diagnostic/items
响应:
{
"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}
响应:
{
"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}
响应:
{
"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 追加:
# ===== 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 现有内容基础上追加:
# ===== 新增: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:
#!/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 L256:
// 改造前
ExposedPorts: { '3000/tcp': {} },
// 改造后
ExposedPorts: {
'3000/tcp': {},
'8088/tcp': {},
},
十一、面板反代改造
panel/server/src/index.ts 新增反代路由:
// ---------- 反向代理到实例的 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)
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 快照)
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 二维码截图
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 发送队列串行化
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.pyFastAPI 骨架 - 实现
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 autostartdocker/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> 即可对接。