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