From 5ab69e88d3f8ccb1b88f8d80fc50fe48ac87a010 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 28 Apr 2026 22:58:50 +0800 Subject: [PATCH] Update channel dispatch UX and harden sidecar startup behavior. This aligns Weixin/WhatsApp with admin-managed model routing by adding channel/account-level specialist controls, improves OSS startup resilience by skipping missing sidecars gracefully, and refreshes runbook/readme guidance for the new default behavior and Weixin OpenAI-key silent handling. Made-with: Cursor --- README.md | 5 + docs/RUNBOOK.md | 67 +++++ interfaces/admin/chat_api.py | 64 +++++ interfaces/admin/static/app.js | 182 +++++++++++- interfaces/admin/static/chat.js | 45 ++- interfaces/http/weixin_ilink_api.py | 32 ++- .../application/gateway/inbound_service.py | 95 +++++- runtime/operations/scripts/start_all.ps1 | 23 +- runtime/operations/scripts/status_all.ps1 | 13 +- runtime/operations/scripts/stop_all.ps1 | 12 +- runtime/operations/scripts/stop_desktop.ps1 | 11 +- runtime/operations/scripts/weixin_install.ps1 | 27 +- runtime/operations/scripts/weixin_login.ps1 | 10 +- runtime/operations/scripts/weixin_start.ps1 | 26 +- runtime/operations/scripts/weixin_status.ps1 | 10 - runtime/operations/scripts/weixin_stop.ps1 | 38 ++- .../operations/scripts/whatsapp_install.ps1 | 52 ++++ runtime/operations/scripts/whatsapp_login.ps1 | 32 +++ runtime/operations/scripts/whatsapp_start.ps1 | 57 ++++ .../operations/scripts/whatsapp_status.ps1 | 18 ++ runtime/operations/scripts/whatsapp_stop.ps1 | 37 +++ runtime/operations/weixin_bridge/login.ts | 10 +- runtime/operations/whatsapp_bridge/auth.ts | 13 + .../whatsapp_bridge/baileys_runner.ts | 270 ++++++++++++++++++ runtime/operations/whatsapp_bridge/qr.ts | 7 + tests/test_channel_dispatch_defaults.py | 51 ++++ tests/test_weixin_ilink_api.py | 40 +++ tests/test_whatsapp_inbound_payload_shape.py | 40 +++ tests/test_whatsapp_ops_scripts.py | 39 +++ 29 files changed, 1226 insertions(+), 100 deletions(-) create mode 100644 runtime/operations/scripts/whatsapp_install.ps1 create mode 100644 runtime/operations/scripts/whatsapp_login.ps1 create mode 100644 runtime/operations/scripts/whatsapp_start.ps1 create mode 100644 runtime/operations/scripts/whatsapp_status.ps1 create mode 100644 runtime/operations/scripts/whatsapp_stop.ps1 create mode 100644 runtime/operations/whatsapp_bridge/auth.ts create mode 100644 runtime/operations/whatsapp_bridge/baileys_runner.ts create mode 100644 runtime/operations/whatsapp_bridge/qr.ts create mode 100644 tests/test_channel_dispatch_defaults.py create mode 100644 tests/test_whatsapp_inbound_payload_shape.py create mode 100644 tests/test_whatsapp_ops_scripts.py diff --git a/README.md b/README.md index 35da7b89..0e97bdb6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_log powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_start.ps1 ``` +Notes: +- `weixin_install.ps1 -UseOpenclawCli` does **not** require global `openclaw` CLI installation; runtime deps are installed locally in sidecar workspace. +- `.\scripts\start_all.ps1 -Background` now skips missing Weixin/WhatsApp sidecars gracefully (warn + continue), so Admin/Chat can still boot on fresh installs. +- Admin supports channel dispatch controls for Weixin/WhatsApp (bind specialist / comprehensive), with default `generalist`. + Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”. ## Layers diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 761c738c..425ed1dd 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -36,6 +36,7 @@ - 网关(Admin + Chat) - 微信(Personal WeChat)收消息、回消息(官方插件 + 本地原生宿主 `/weixin/native/reply`) +- WhatsApp(实验):收消息、回消息(本仓库 Baileys sidecar + 本地 `/inbound/whatsapp`) ### 1.1.1 前置依赖 @@ -88,6 +89,9 @@ powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_log 按提示扫码完成绑定(会写入账号 ID / token 等状态到 `%USERPROFILE%\.openclaw\openclaw-weixin\`)。 +> 说明:这里的 `-UseOpenclawCli` **不要求你全局安装 openclaw**(不需要 `npm install -g openclaw`)。 +> 脚本会在 `data/channel_sidecar/oclaw-weixin/` 下本地安装 `openclaw` npm 包,并用 `npx openclaw ...` 驱动官方插件完成扫码与状态写入。 + ### 1.1.6 启动微信 sidecar(原生模式) ```powershell @@ -142,6 +146,8 @@ Linux/macOS: `powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background` +> 开源默认友好行为:若本机尚未安装微信/WhatsApp sidecar,`start_all.ps1` 会自动跳过对应通道并打印 `[WARN] ... sidecar skipped`,不会中断 gateway + desktop 启动。 + 默认即包含微信 sidecar: `powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background` @@ -150,6 +156,10 @@ Linux/macOS: `powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithoutWeixin` +不启动 WhatsApp sidecar(可选): + +`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithoutWhatsApp` + (含 wiki worker): `powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWikiWorker` @@ -166,6 +176,10 @@ Linux/macOS: `powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithoutWeixin` +不检查 WhatsApp sidecar(可选): + +`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithoutWhatsApp` + (含 wiki worker): `powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWikiWorker` @@ -182,6 +196,10 @@ Linux/macOS: `powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithoutWeixin` +不停止 WhatsApp sidecar(可选): + +`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithoutWhatsApp` + (含 wiki worker): `powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWikiWorker` @@ -222,6 +240,55 @@ Linux/macOS: - 官方收发与媒体模块 - 本仓库本地 reply 宿主适配 +Admin 可视化调度(新增): + +- `Stack` 页面提供 `Weixin dispatch` 控制卡。 +- 可直接选择专家并执行: + - `绑定专家`(写入 `expert + specialist`) + - `综合`(写入 `comprehensive + specialist`) +- 通道默认值:`expert + generalist`。 + +账号级调度(新增): + +- 在 `用户/渠道绑定` 页面选择 `channel=weixin` 后,可按账号配置: + - 专家(默认 `generalist`) + - 模式:`绑定专家` / `综合` +- 生效优先级:**账号级配置 > 通道全局配置 > 默认值**。 + +注意:当模型侧返回“OpenAI key 缺失”兜底文本时,微信通道会静默抑制该类回复(不向微信用户下发错误文案)。 + +### 4.2 WhatsApp(实验接入) + +当前默认链路为 **本仓库自研 WhatsApp Web(Baileys)sidecar**(不依赖 openclaw): + +- `runtime/operations/scripts/whatsapp_install.ps1` +- `runtime/operations/scripts/whatsapp_login.ps1` +- `runtime/operations/scripts/whatsapp_start.ps1` +- `runtime/operations/scripts/whatsapp_status.ps1` +- `runtime/operations/scripts/whatsapp_stop.ps1` + +常用命令: + +```powershell +powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_install.ps1 +powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_login.ps1 +powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_start.ps1 +powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_status.ps1 +powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_stop.ps1 +``` + +说明: + +- `whatsapp_login.ps1` 会在控制台打印二维码,请用 WhatsApp 手机端的“关联设备”扫码完成绑定。 +- 登录态会落盘在 `data/channel_sidecar/whatsapp/state/auth/`,重启后无需重复扫码。 +- sidecar 收到消息后会调用本地网关 `POST /inbound/whatsapp` 获取 `replies[]` 并回发。 + +Admin 可视化调度(新增): + +- `Stack` 页面提供 `WhatsApp dispatch` 控制卡(`绑定专家` / `综合`)。 +- `用户/渠道绑定` 页面选择 `channel=whatsapp` 后可按账号单独配置专家/模式。 +- 默认值同微信:`expert + generalist`。 + --- ## 5. 仅启动网关 diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index 4edc2bd1..29989590 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -108,6 +108,23 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = { } _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON" _SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON" +_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode." +_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist." + + +def _channel_dispatch_interaction_key(channel: str) -> str: + return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}" + + +def _channel_dispatch_specialist_key(channel: str) -> str: + return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}" + + +def _normalize_channel_dispatch_channel(raw: Any) -> str: + ch = str(raw or "").strip().lower() + if ch not in {"weixin", "whatsapp"}: + raise HTTPException(status_code=400, detail="invalid_channel") + return ch def _chat_specialist_ids() -> tuple[str, ...]: return tuple(str(x) for x in specialist_ids() if str(x).strip()) DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000 @@ -1289,6 +1306,53 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor flags = _specialist_flags_with_overrides(store) return {"ok": True, "saved": clean, "flags": flags} + @chat.get("/settings/channel-dispatch/{channel}") + def api_chat_get_channel_dispatch( + channel: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + ch = _normalize_channel_dispatch_channel(channel) + interaction_mode = normalize_interaction_mode( + store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert" + ) + specialist = normalize_requested_specialist( + store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist" + ) + specialist = _apply_specialist_flags(store, specialist) + return { + "ok": True, + "channel": ch, + "interaction_mode": interaction_mode, + "specialist": specialist, + "available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))], + } + + @chat.post("/settings/channel-dispatch/{channel}") + def api_chat_set_channel_dispatch( + channel: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + body = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + ch = _normalize_channel_dispatch_channel(channel) + interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert") + specialist = normalize_requested_specialist(body.get("specialist") or "generalist") + specialist = _apply_specialist_flags(store, specialist) + store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode) + store.set_setting(_channel_dispatch_specialist_key(ch), specialist) + return { + "ok": True, + "channel": ch, + "interaction_mode": interaction_mode, + "specialist": specialist, + } + @chat.get("/settings/attachment-limits") def api_chat_get_attachment_limits( authorization: str | None = Header(default=None), diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 2377c456..f6362df3 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -1424,12 +1424,15 @@ function markPrewarmReminder(reason) { } async function renderStack() { - const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp] = await Promise.all([ + const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp, channelSpecResp, weixinDispatchResp, whatsappDispatchResp] = await Promise.all([ apiGet("/admin/api/stack/status"), apiGet("/admin/api/runtime/anomalies"), apiGet("/admin/api/runtime/scan-artifacts"), apiGet("/admin/api/runtime/prewarm/status"), apiGet("/admin/api/runtime/prewarm/prompts?role=manager"), + apiGet("/admin/api/chat/settings/specialist-flags"), + apiGet("/admin/api/chat/settings/channel-dispatch/weixin"), + apiGet("/admin/api/chat/settings/channel-dispatch/whatsapp"), ]); const requiredServices = ["gateway", "channel:wecom"]; const runningNames = new Set( @@ -1471,6 +1474,54 @@ async function renderStack() { await apiPost("/admin/api/stack/down", {}); router(); }}); + const availableDispatchSpecialists = Array.isArray(channelSpecResp && channelSpecResp.available_specialists) && channelSpecResp.available_specialists.length + ? channelSpecResp.available_specialists.map((x) => String(x || "").trim()).filter(Boolean) + : ["generalist"]; + const createChannelDispatchCard = (channel, title, initial) => { + const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert"; + const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist"; + const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) => + el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }), + )); + const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist}` }); + const saveExpertBtn = el("button", { + class: "btn", + text: currentLang === "zh" ? "绑定专家" : "Bind specialist", + onclick: async () => { + const specialist = String(specialistSel.value || "generalist").trim() || "generalist"; + const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, { + interaction_mode: "expert", + specialist, + }); + status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)}`; + }, + }); + const saveComprehensiveBtn = el("button", { + class: "btn btn--primary", + text: currentLang === "zh" ? "综合" : "Comprehensive", + onclick: async () => { + const specialist = String(specialistSel.value || "generalist").trim() || "generalist"; + const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, { + interaction_mode: "comprehensive", + specialist, + }); + status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)}`; + }, + }); + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: title }), + el("div", { class: "row" }, [ + el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }), + specialistSel, + saveExpertBtn, + saveComprehensiveBtn, + ]), + status, + el("div", { class: "muted", text: currentLang === "zh" ? "默认绑定通用专家;综合模式下由全能者分派。" : "Defaults to generalist; comprehensive mode lets manager dispatch." }), + ]); + }; + const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {}); + const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {}); const cleanupStatus = el("div", { class: "muted", text: "" }); const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => { const resp = await apiPost("/admin/api/runtime/cleanup", {}); @@ -1643,6 +1694,8 @@ async function renderStack() { el("tbody", {}, items), ]), ]), + weixinDispatchCard, + whatsappDispatchCard, el("div", { class: "card" }, [ el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }), el("div", { class: "muted", text: prewarmSummary }), @@ -1815,15 +1868,24 @@ async function renderUserManagement() { placeholder: t("users.wecomUserEmpty"), }); const accountBody = el("tbody"); + const accountChannelInput = el("select", { class: "input" }, [ + el("option", { value: "wecom", text: "wecom" }), + el("option", { value: "weixin", text: "weixin" }), + el("option", { value: "whatsapp", text: "whatsapp" }), + ]); const accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") }); const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") }); const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") }); + const botSecretField = el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]); const clearBotChk = el("input", { type: "checkbox" }); + const clearBotField = el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]); const accountActiveInput = el("input", { type: "checkbox" }); + const accountSpecialistInput = el("select", { class: "input" }, [el("option", { value: "generalist", text: "generalist" })]); accountActiveInput.checked = true; let selectedUserId = ""; let selectedUsername = ""; let selectedDisplayName = ""; + let availableAccountSpecialists = ["generalist"]; const refreshSelectedUserField = () => { if (!selectedUserId) { @@ -1840,6 +1902,26 @@ async function renderUserManagement() { botSecretInput.value = ""; clearBotChk.checked = false; accountActiveInput.checked = true; + accountSpecialistInput.value = "generalist"; + }; + const currentAccountChannel = () => String(accountChannelInput.value || "wecom").trim().toLowerCase() || "wecom"; + const refreshAccountFormByChannel = () => { + const isWecom = currentAccountChannel() === "wecom"; + botSecretField.style.display = isWecom ? "" : "none"; + clearBotField.style.display = isWecom ? "" : "none"; + }; + const setAccountSpecialistOptions = (specialists) => { + const opts = Array.isArray(specialists) && specialists.length + ? specialists.map((x) => String(x || "").trim()).filter(Boolean) + : ["generalist"]; + const uniq = Array.from(new Set(["generalist", ...opts])); + availableAccountSpecialists = uniq; + const old = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; + accountSpecialistInput.innerHTML = ""; + uniq.forEach((sid) => { + accountSpecialistInput.appendChild(el("option", { value: sid, text: sid, selected: sid === old ? "selected" : undefined })); + }); + if (!uniq.includes(old)) accountSpecialistInput.value = "generalist"; }; const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") }); @@ -1888,6 +1970,12 @@ async function renderUserManagement() { } else { accountActiveInput.checked = true; } + const mode = String(((inst && inst.config) || {}).interaction_mode || "expert").trim().toLowerCase(); + const specialist = String(((inst && inst.config) || {}).specialist || "generalist").trim().toLowerCase() || "generalist"; + accountSpecialistInput.value = availableAccountSpecialists.includes(specialist) ? specialist : "generalist"; + if (mode === "comprehensive") { + accountStatus.textContent = currentLang === "zh" ? "当前账号模式:综合" : "Current account mode: comprehensive"; + } botSecretInput.value = ""; clearBotChk.checked = false; }; @@ -1898,18 +1986,19 @@ async function renderUserManagement() { const tenantId = getTenantId(); if (!selectedUserId) { accountStatus.textContent = ""; - accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); + accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "10" })])); return; } - const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=wecom&user_id=${encodeURIComponent(selectedUserId)}`; - const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=wecom&include_inactive=1`; - const [bindResp, accResp] = await Promise.all([apiGet(bindUrl), apiGet(accUrl)]); - const bindings = Array.isArray(bindResp.bindings) ? bindResp.bindings : []; + const ch = currentAccountChannel(); + const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=${encodeURIComponent(ch)}&user_id=${encodeURIComponent(selectedUserId)}`; + const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=${encodeURIComponent(ch)}&include_inactive=1`; + const [bindResp, accResp] = await Promise.all([ch === "wecom" ? apiGet(bindUrl) : Promise.resolve({ bindings: [] }), apiGet(accUrl)]); + const bindings = Array.isArray(bindResp && bindResp.bindings) ? bindResp.bindings : []; const items = Array.isArray(accResp.items) ? accResp.items : []; - const merged = mergeWecomBindingAndInstances(bindings, items); + const merged = ch === "wecom" ? mergeWecomBindingAndInstances(bindings, items) : items.map((it) => ({ binding: null, instance: it })); accountStatus.textContent = ""; if (!merged.length) { - accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "8" })])); + accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "10" })])); return; } merged.forEach((m) => { @@ -1919,6 +2008,9 @@ async function renderUserManagement() { const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || ""; const extUid = bind ? String(bind.external_user_id || "") : ""; const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || ""; + const cfg = (inst && inst.config && typeof inst.config === "object") ? inst.config : {}; + const modeText = String(cfg.interaction_mode || "expert"); + const specialistText = String(cfg.specialist || "generalist"); const btnFill = el("button", { class: "btn", text: t("users.loadForm"), @@ -1929,16 +2021,54 @@ async function renderUserManagement() { }); const deleteCell = el("td", {}); if (inst) { + const btnBindExpert = el("button", { + class: "btn", + text: currentLang === "zh" ? "绑定专家" : "Bind specialist", + onclick: async (e) => { + e.stopPropagation(); + const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; + await apiPost("/admin/api/user-channel-accounts/upsert", { + tenant_id: tenantId, + user_id: selectedUserId, + channel: ch, + account_id: accountId, + name: displayName, + is_active: !!inst.is_active, + config: { interaction_mode: "expert", specialist }, + }); + await loadAccounts(); + }, + }); + const btnComprehensive = el("button", { + class: "btn btn--primary", + text: currentLang === "zh" ? "综合" : "Comprehensive", + onclick: async (e) => { + e.stopPropagation(); + const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; + await apiPost("/admin/api/user-channel-accounts/upsert", { + tenant_id: tenantId, + user_id: selectedUserId, + channel: ch, + account_id: accountId, + name: displayName, + is_active: !!inst.is_active, + config: { interaction_mode: "comprehensive", specialist }, + }); + await loadAccounts(); + }, + }); const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => { e.stopPropagation(); await apiPost("/admin/api/user-channel-accounts/delete", { tenant_id: tenantId, user_id: selectedUserId, - channel: "wecom", + channel: ch, account_id: accountId, }); await loadAccounts(); }}); + deleteCell.appendChild(btnBindExpert); + deleteCell.appendChild(btnComprehensive); deleteCell.appendChild(btnDeleteAccount); } else { deleteCell.appendChild(document.createTextNode("—")); @@ -1947,8 +2077,10 @@ async function renderUserManagement() { tdCell(accountId, 22), tdCell(displayName, 16), tdCell(extUid || "—", 18), - tdCell(inst ? (inst.has_bot_secret ? "Y" : "-") : "—", 4), + tdCell(inst ? (ch === "wecom" ? (inst.has_bot_secret ? "Y" : "-") : "-") : "—", 4), tdCell(inst ? (inst.is_active ? "1" : "0") : "—", 4), + tdCell(modeText || "expert", 8), + tdCell(specialistText || "generalist", 10), tdCell(ts, 20), el("td", {}, [btnFill]), deleteCell, @@ -1967,16 +2099,22 @@ async function renderUserManagement() { return; } const tenantId = getTenantId(); + const ch = currentAccountChannel(); + const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; const resp = await apiPost("/admin/api/user-channel-accounts/upsert", { tenant_id: tenantId, user_id: selectedUserId, - channel: "wecom", + channel: ch, account_id: aid, name: accountNameInput.value.trim(), - wecom_mode: "bot_api", + wecom_mode: ch === "wecom" ? "bot_api" : undefined, bot_secret: botSecretInput.value.trim(), clear_bot_secret: !!clearBotChk.checked, is_active: !!accountActiveInput.checked, + config: { + interaction_mode: "expert", + specialist, + }, }); accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error"); if (resp.ok) { @@ -1986,6 +2124,11 @@ async function renderUserManagement() { await loadList(); } }}); + accountChannelInput.addEventListener("change", async () => { + clearWecomForm(); + refreshAccountFormByChannel(); + await loadAccounts(); + }); const loadList = async () => { if (!hasPermission("admin:user:read")) { @@ -2201,8 +2344,15 @@ async function renderUserManagement() { }); }); await loadBindingsAndCodes(); + try { + const sf = await apiGet("/admin/api/chat/settings/specialist-flags"); + setAccountSpecialistOptions(sf.available_specialists || ["generalist"]); + } catch (_) { + setAccountSpecialistOptions(["generalist"]); + } await loadList(); refreshSelectedUserField(); + refreshAccountFormByChannel(); await loadAccounts(); const tenantsListHint = allTenants.length > 1 @@ -2250,11 +2400,13 @@ async function renderUserManagement() { el("div", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]), el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }), el("div", { class: "row row--wecom-form" }, [ + el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: "channel" }), accountChannelInput]), el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotId") }), accountIdInput]), el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomInstanceName") }), accountNameInput]), - el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]), + el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: currentLang === "zh" ? "专家" : "Specialist" }), accountSpecialistInput]), + botSecretField, el("label", { class: "kv row--wecom-form__chk" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]), - el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]), + clearBotField, btnSaveAccount, ]), accountStatus, @@ -2265,6 +2417,8 @@ async function renderUserManagement() { el("th", { text: t("table.externalUserId") }), el("th", { text: t("users.hasBotSecret") }), el("th", { text: t("users.accountActive") }), + el("th", { text: currentLang === "zh" ? "模式" : "Mode" }), + el("th", { text: t("table.specialist") }), el("th", { text: t("table.timestamp") }), el("th", { text: t("users.loadForm") }), el("th", { text: t("users.accountActions") }), diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index 61058f16..f1dc133a 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -2584,6 +2584,49 @@ async function renderChatUi() { backdrop.appendChild(card); document.body.appendChild(backdrop); }); + const promptChatText = (message, initialValue = "") => + new Promise((resolve) => { + const backdrop = el("div", { class: "chat-confirm-backdrop" }); + const card = el("div", { class: "chat-confirm-card" }); + const text = el("div", { class: "chat-confirm-text", text: String(message || "") }); + const input = el("input", { + class: "input", + type: "text", + value: String(initialValue || ""), + style: "width:100%;margin-top:10px;", + }); + const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") }); + const btnOk = el("button", { type: "button", class: "btn btn--primary", text: t("chat.dispatchLabelsSave") }); + const close = (ok) => { + if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); + resolve(ok ? String(input.value || "") : null); + }; + btnCancel.addEventListener("click", () => close(false)); + btnOk.addEventListener("click", () => close(true)); + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + close(true); + } else if (ev.key === "Escape") { + ev.preventDefault(); + close(false); + } + }); + backdrop.addEventListener("click", (ev) => { + if (ev.target === backdrop) close(false); + }); + card.appendChild(text); + card.appendChild(input); + card.appendChild(el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [btnCancel, btnOk])); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + setTimeout(() => { + try { + input.focus(); + input.select(); + } catch (_) {} + }, 0); + }); const adoptCreatedSession = (resp) => { const s = resp && resp.session ? resp.session : {}; @@ -2748,7 +2791,7 @@ async function renderChatUi() { }); menu.appendChild( mk(t("chat.rename"), async () => { - const nv = window.prompt(t("chat.rename"), title); + const nv = await promptChatText(t("chat.rename"), title); if (nv == null) return; try { await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() }); diff --git a/interfaces/http/weixin_ilink_api.py b/interfaces/http/weixin_ilink_api.py index 90755037..42cb979a 100644 --- a/interfaces/http/weixin_ilink_api.py +++ b/interfaces/http/weixin_ilink_api.py @@ -131,8 +131,8 @@ def _extract_inbound_identity(body: dict[str, Any]) -> tuple[str, str]: return user_id, chat_id -def _build_native_reply_payload(body: dict[str, Any]) -> dict[str, Any]: - channel = _normalize_channel(body.get("channel")) +def _build_native_reply_payload(body: dict[str, Any], *, default_channel: str, source: str) -> dict[str, Any]: + channel = _normalize_channel(body.get("channel") or default_channel) account_id = _resolve_account_id(body) ctx = body.get("ctx") if isinstance(body.get("ctx"), dict) else {} metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {} @@ -155,10 +155,10 @@ def _build_native_reply_payload(body: dict[str, Any]) -> dict[str, Any]: ).strip() text = str(body.get("text") or ctx.get("Body") or ctx.get("CommandBody") or "").strip() if not metadata: - metadata = {"source": "weixin_official_native"} + metadata = {"source": source} else: metadata = dict(metadata) - metadata.setdefault("source", "weixin_official_native") + metadata.setdefault("source", source) if ctx: metadata["weixin_ctx"] = ctx @@ -463,7 +463,29 @@ async def weixin_native_reply( authorization: str | None = Header(default=None, alias="Authorization"), ) -> dict[str, Any]: _require_ilink_auth(authorization_type=authorizationtype, authorization=authorization) - payload = _build_native_reply_payload(body) + payload = _build_native_reply_payload(body, default_channel="wechat", source="weixin_official_native") + if not str(payload.get("user_id") or "").strip(): + return {"ok": False, "error": "missing user_id", "replies": []} + if not str(payload.get("account_id") or "").strip(): + return {"ok": False, "error": "missing account_id", "replies": []} + out = await asyncio.to_thread(_process_inbound_payload_usecase, payload) + replies = out.get("replies") if isinstance(out, dict) else [] + if not isinstance(replies, list): + replies = [] + return { + "ok": bool((out or {}).get("ok", True)) if isinstance(out, dict) else True, + "replies": [r for r in replies if isinstance(r, dict)], + } + + +@router.post("/whatsapp/native/reply") +async def whatsapp_native_reply( + body: dict[str, Any], + authorizationtype: str | None = Header(default=None, alias="AuthorizationType"), + authorization: str | None = Header(default=None, alias="Authorization"), +) -> dict[str, Any]: + _require_ilink_auth(authorization_type=authorizationtype, authorization=authorization) + payload = _build_native_reply_payload(body, default_channel="whatsapp", source="whatsapp_official_native") if not str(payload.get("user_id") or "").strip(): return {"ok": False, "error": "missing user_id", "replies": []} if not str(payload.get("account_id") or "").strip(): diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 507bad7f..9754c304 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -1,24 +1,53 @@ from __future__ import annotations import hashlib -import threading from typing import Any from oclaw.interfaces.channels.base import InboundMessage, OutboundMessage from oclaw.interfaces.channels.wecom.wecom_bridge import WeComAdapter +from oclaw.runtime.types import normalize_interaction_mode, normalize_requested_specialist -_GATEWAY_AGENT_LOCK = threading.Lock() -_GATEWAY_AGENT: Any | None = None +_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode." +_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist." -def _get_gateway_agent(store: Any) -> Any: - global _GATEWAY_AGENT - with _GATEWAY_AGENT_LOCK: - if _GATEWAY_AGENT is None: - from oclaw.runtime.agents.factory import build_gateway_executor +def _channel_dispatch_interaction_key(channel: str) -> str: + return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}" - _GATEWAY_AGENT = build_gateway_executor(store) - return _GATEWAY_AGENT + +def _channel_dispatch_specialist_key(channel: str) -> str: + return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}" + + +def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, Any] | None) -> tuple[str, str]: + ch = str(channel or "").strip().lower() + interaction_mode = normalize_interaction_mode(store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert") + specialist = normalize_requested_specialist(store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist") + cfg = (account or {}).get("config") + if isinstance(cfg, dict): + cfg_mode = cfg.get("interaction_mode") + cfg_specialist = cfg.get("specialist") + if cfg_mode is not None: + interaction_mode = normalize_interaction_mode(cfg_mode) + if cfg_specialist is not None: + specialist = normalize_requested_specialist(cfg_specialist) + return interaction_mode, specialist + + +def _build_admin_gateway_executor(store: Any, *, tenant_id: str, specialist: str, session_id: str) -> Any: + from oclaw.runtime.agents.factory import build_gateway_executor + + return build_gateway_executor( + store, + lang="zh", + specialist=specialist, + viewer_user_id=None, + viewer_username="administrator", + viewer_tenant_id=(tenant_id or None), + policy_session_id=session_id, + path_policy_tenant_id=(tenant_id or None), + path_policy_user_id=None, + ) def _menu_text() -> str: @@ -270,6 +299,21 @@ def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> Inboun ) +def _should_suppress_channel_reply(*, channel: str, text: str) -> bool: + ch = str(channel or "").strip().lower() + if ch not in {"wechat", "weixin"}: + return False + t = str(text or "").strip() + if not t: + return True + low = t.lower() + if 'missing api key for provider "openai"' in low: + return True + if "openai / 兼容 api" in t and "api key" in low: + return True + return False + + def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os @@ -408,7 +452,9 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: from oclaw.runtime.gateway import OclawGateway from oclaw.runtime.types import StandardMessage - agent = _get_gateway_agent(store) + interaction_mode, selected_specialist = _resolve_channel_dispatch( + store, channel=inbound.channel, account=account + ) gw = OclawGateway(store=store) msg = StandardMessage( session_id=str(session_id), @@ -424,9 +470,31 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: "channel": inbound.channel, "role": role, "account_id": account_id, + "interaction_mode": interaction_mode, + "selected_specialist": selected_specialist, }, ) - reply = str(gw.handle_turn(msg=msg, lang="zh", executor=agent).reply_text or "").strip() + manager = _build_admin_gateway_executor( + store, + tenant_id=tenant_id, + specialist="generalist", + session_id=str(session_id), + ) + specialist_factory = lambda sid: _build_admin_gateway_executor( + store, + tenant_id=tenant_id, + specialist=sid, + session_id=str(session_id), + ) + reply = str( + gw.handle_turn( + msg=msg, + lang="zh", + executor=manager, + specialist_executor_factory=specialist_factory, + ).reply_text + or "" + ).strip() except Exception as e: reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}" else: @@ -437,6 +505,9 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: else: reply = f"{preface}\n\n{_menu_text()}" + if _should_suppress_channel_reply(channel=inbound.channel, text=reply): + return {"ok": True, "replies": []} + if adapter is not None: replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))] else: diff --git a/runtime/operations/scripts/start_all.ps1 b/runtime/operations/scripts/start_all.ps1 index 14292f14..46812173 100644 --- a/runtime/operations/scripts/start_all.ps1 +++ b/runtime/operations/scripts/start_all.ps1 @@ -4,9 +4,11 @@ [switch]$SkipInstall = $false, [switch]$Background = $false, [switch]$WithoutWeixin = $false, + [switch]$WithoutWhatsApp = $false, [bool]$WithWikiWorker = $true, [string]$WeixinChannelId = "oclaw-weixin", - [string]$WeixinGatewayBaseUrl = "" + [string]$WeixinGatewayBaseUrl = "", + [string]$WhatsAppChannelId = "whatsapp" ) $ErrorActionPreference = "Stop" @@ -15,6 +17,10 @@ function Write-Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan } +function Warn([string]$msg) { + Write-Host "[WARN] $msg" -ForegroundColor Yellow +} + $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoParent = Split-Path -Parent $repoRoot Set-Location $repoRoot @@ -43,7 +49,20 @@ if (-not $WithoutWeixin) { $gwBase = "http://$BindHost`:$Port" } Write-Step "Starting weixin sidecar" - & "$PSScriptRoot/weixin_start.ps1" -ChannelId $WeixinChannelId -GatewayBaseUrl $gwBase + try { + & "$PSScriptRoot/weixin_start.ps1" -ChannelId $WeixinChannelId -GatewayBaseUrl $gwBase + } catch { + Warn "weixin sidecar skipped: $($_.Exception.Message)" + } +} +if (-not $WithoutWhatsApp) { + $waBase = "http://$BindHost`:$Port" + Write-Step "Starting whatsapp sidecar" + try { + & "$PSScriptRoot/whatsapp_start.ps1" -ChannelId $WhatsAppChannelId -GatewayBaseUrl $waBase + } catch { + Warn "whatsapp sidecar skipped: $($_.Exception.Message)" + } } Write-Host "" Write-Host "All started." -ForegroundColor Green diff --git a/runtime/operations/scripts/status_all.ps1 b/runtime/operations/scripts/status_all.ps1 index 4fd68852..19782cc3 100644 --- a/runtime/operations/scripts/status_all.ps1 +++ b/runtime/operations/scripts/status_all.ps1 @@ -2,8 +2,10 @@ param( [string]$BindHost = "127.0.0.1", [int]$Port = 8787, [switch]$WithoutWeixin = $false, + [switch]$WithoutWhatsApp = $false, [switch]$WithWikiWorker = $false, - [string]$WeixinChannelId = "oclaw-weixin" + [string]$WeixinChannelId = "oclaw-weixin", + [string]$WhatsAppChannelId = "whatsapp" ) $ErrorActionPreference = "Stop" @@ -127,6 +129,15 @@ if (-not $WithoutWeixin) { Warn "weixin status failed: $($_.Exception.Message)" } } +if (-not $WithoutWhatsApp) { + Write-Host "" + Write-Step "WhatsApp sidecar" + try { + & "$PSScriptRoot/whatsapp_status.ps1" -ChannelId $WhatsAppChannelId + } catch { + Warn "whatsapp status failed: $($_.Exception.Message)" + } +} if ($WithWikiWorker) { Write-Host "" diff --git a/runtime/operations/scripts/stop_all.ps1 b/runtime/operations/scripts/stop_all.ps1 index b5cda1a9..008e63f2 100644 --- a/runtime/operations/scripts/stop_all.ps1 +++ b/runtime/operations/scripts/stop_all.ps1 @@ -2,8 +2,10 @@ param( [int]$Port = 8787, [switch]$Force = $false, [switch]$WithoutWeixin = $false, + [switch]$WithoutWhatsApp = $false, [switch]$WithWikiWorker = $false, - [string]$WeixinChannelId = "oclaw-weixin" + [string]$WeixinChannelId = "oclaw-weixin", + [string]$WhatsAppChannelId = "whatsapp" ) $ErrorActionPreference = "Stop" @@ -34,6 +36,14 @@ if (-not $WithoutWeixin) { if (-not $Force) { throw } } } +if (-not $WithoutWhatsApp) { + Write-Step "Stopping whatsapp sidecar" + try { + & "$PSScriptRoot/whatsapp_stop.ps1" -ChannelId $WhatsAppChannelId -Force:$Force + } catch { + if (-not $Force) { throw } + } +} if ($WithWikiWorker) { Write-Step "Stopping wiki worker" try { diff --git a/runtime/operations/scripts/stop_desktop.ps1 b/runtime/operations/scripts/stop_desktop.ps1 index 8fc24cf3..0c7fa0cf 100644 --- a/runtime/operations/scripts/stop_desktop.ps1 +++ b/runtime/operations/scripts/stop_desktop.ps1 @@ -32,8 +32,15 @@ if ($procId -le 0) { } try { - # Kill the whole process tree (cmd -> npm -> electron/node) - & taskkill /PID $procId /T /F | Out-Null + # Kill the whole process tree (cmd -> npm -> electron/node). + # Some shells promote native command non-zero exit codes to terminating errors. + $oldNativePref = $PSNativeCommandUseErrorActionPreference + try { + $PSNativeCommandUseErrorActionPreference = $false + & taskkill /PID $procId /T /F 2>$null | Out-Null + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativePref + } Write-Host "Stopped PID=$procId (tree)" -ForegroundColor Green Remove-Item $pidFile -Force -ErrorAction SilentlyContinue exit 0 diff --git a/runtime/operations/scripts/weixin_install.ps1 b/runtime/operations/scripts/weixin_install.ps1 index a95ed6c2..26abdbdc 100644 --- a/runtime/operations/scripts/weixin_install.ps1 +++ b/runtime/operations/scripts/weixin_install.ps1 @@ -37,15 +37,9 @@ function Ensure-OfficialPluginRuntimeDeps { } if ($UseOpenclawCli) { - $openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue - if (-not $openclawCmd) { - throw "openclaw command not found. Install first: npm install -g openclaw" - } - npx -y @tencent-weixin/openclaw-weixin-cli@latest install - if ($LASTEXITCODE -ne 0) { - throw "openclaw-weixin-cli install failed with exit code $LASTEXITCODE" - } - Ensure-OfficialPluginRuntimeDeps + # Do not require a globally-installed openclaw CLI. + # We install openclaw into the sidecar runtime and put node_modules/.bin on PATH + # so the official installer can run `openclaw ...` commands. Push-Location $sidecarRoot try { if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) { @@ -54,6 +48,21 @@ if ($UseOpenclawCli) { throw "npm init failed with exit code $LASTEXITCODE" } } + npm.cmd install openclaw@latest --save + if ($LASTEXITCODE -ne 0) { + throw "npm install openclaw failed with exit code $LASTEXITCODE" + } + $env:PATH = (Join-Path $sidecarRoot "node_modules\\.bin") + ";" + $env:PATH + npx.cmd -y @tencent-weixin/openclaw-weixin-cli@latest install + if ($LASTEXITCODE -ne 0) { + throw "openclaw-weixin-cli install failed with exit code $LASTEXITCODE" + } + } finally { + Pop-Location + } + Ensure-OfficialPluginRuntimeDeps + Push-Location $sidecarRoot + try { npm.cmd install openclaw@latest --save tsx@4.21.0 typescript@6.0.3 if ($LASTEXITCODE -ne 0) { throw "npm install bridge runtime deps failed with exit code $LASTEXITCODE" diff --git a/runtime/operations/scripts/weixin_login.ps1 b/runtime/operations/scripts/weixin_login.ps1 index aacbb275..eb5589dc 100644 --- a/runtime/operations/scripts/weixin_login.ps1 +++ b/runtime/operations/scripts/weixin_login.ps1 @@ -27,15 +27,7 @@ try { npm.cmd exec -- tsx login.ts exit 0 } - $openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue - if (-not $openclawCmd) { - throw "official mode requires openclaw command. Install first: npm install -g openclaw" - } - $systemNodeDir = "C:\\Program Files\\nodejs" - if (Test-Path (Join-Path $systemNodeDir "node.exe")) { - $env:PATH = "$systemNodeDir;$env:PATH" - } - openclaw channels login --channel openclaw-weixin + throw "login.ts missing. Re-run weixin_install.ps1 -UseOpenclawCli to install sidecar runtime." } finally { Pop-Location } diff --git a/runtime/operations/scripts/weixin_start.ps1 b/runtime/operations/scripts/weixin_start.ps1 index 75decd41..5de82b55 100644 --- a/runtime/operations/scripts/weixin_start.ps1 +++ b/runtime/operations/scripts/weixin_start.ps1 @@ -42,12 +42,14 @@ function Get-SidecarProcesses { function Stop-SidecarProcesses { $procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending) - foreach ($proc in $procs) { - try { - taskkill.exe /PID $proc.ProcessId /T /F | Out-Null - } catch { - # Best-effort cleanup; keep going if a process already exited. + $oldNativePref = $PSNativeCommandUseErrorActionPreference + try { + $PSNativeCommandUseErrorActionPreference = $false + foreach ($proc in $procs) { + taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null } + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativePref } return $procs.Count } @@ -112,17 +114,5 @@ if ((Test-Path (Join-Path $sidecarRoot "runner.ts")) -or (Test-Path (Join-Path $ exit 0 } -$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue -if (-not $openclawCmd) { - throw "official mode requires openclaw command. Install first: npm install -g openclaw" -} -# Ensure OpenClaw runs on the real Node.js runtime (includes npm layout). -$systemNodeDir = "C:\\Program Files\\nodejs" -if (Test-Path (Join-Path $systemNodeDir "node.exe")) { - $env:PATH = "$systemNodeDir;$env:PATH" -} -$args = @("/c", "openclaw gateway --allow-unconfigured") -$p = Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $oclawRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath -Set-Content -Path $pidFile -Value $p.Id -Write-Host "[ok] started openclaw gateway pid=$($p.Id) cleaned=$cleaned out=$logPath err=$errPath" +throw "No runner.ts/official_runner.ts found in sidecar root. Re-run weixin_install.ps1 -UseOpenclawCli." diff --git a/runtime/operations/scripts/weixin_status.ps1 b/runtime/operations/scripts/weixin_status.ps1 index a992ef83..84b9d464 100644 --- a/runtime/operations/scripts/weixin_status.ps1 +++ b/runtime/operations/scripts/weixin_status.ps1 @@ -69,16 +69,6 @@ if ($exists) { Write-Host "status=orphaned stale_pid=$procId count=$($sidecarProcs.Count) pids=$pids" exit 0 } - $openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue - if ($openclawCmd) { - try { - $txt = (& openclaw channels status --probe) -join "`n" - if ($txt -match "openclaw-weixin .*running") { - Write-Host "status=running mode=official" - exit 0 - } - } catch {} - } Write-Host "status=stale_pid pid=$procId" } diff --git a/runtime/operations/scripts/weixin_stop.ps1 b/runtime/operations/scripts/weixin_stop.ps1 index 67636303..834865d5 100644 --- a/runtime/operations/scripts/weixin_stop.ps1 +++ b/runtime/operations/scripts/weixin_stop.ps1 @@ -42,16 +42,22 @@ function Stop-SidecarProcesses { [switch]$ForceKill ) $procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending) - foreach ($proc in $procs) { - try { + $oldNativePref = $PSNativeCommandUseErrorActionPreference + try { + $PSNativeCommandUseErrorActionPreference = $false + foreach ($proc in $procs) { if ($ForceKill) { - taskkill.exe /PID $proc.ProcessId /T /F | Out-Null - } else { - taskkill.exe /PID $proc.ProcessId /T | Out-Null + taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null + continue + } + taskkill.exe /PID $proc.ProcessId /T 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + # Some process trees require force kill on Windows. + taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null } - } catch { - # Ignore already-exited processes and continue best-effort cleanup. } + } finally { + $PSNativeCommandUseErrorActionPreference = $oldNativePref } return $procs.Count } @@ -73,21 +79,23 @@ if (-not $procId) { exit 0 } +$oldNativePref = $PSNativeCommandUseErrorActionPreference try { + $PSNativeCommandUseErrorActionPreference = $false if ($Force) { - taskkill.exe /PID $procId /T /F | Out-Null + taskkill.exe /PID $procId /T /F 2>$null | Out-Null } else { - taskkill.exe /PID $procId /T | Out-Null + taskkill.exe /PID $procId /T 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + # Retry with /F to avoid noisy parent/child kill failures. + taskkill.exe /PID $procId /T /F 2>$null | Out-Null + } } -} catch { - # Ignore if already dead. +} finally { + $PSNativeCommandUseErrorActionPreference = $oldNativePref } $killed = Stop-SidecarProcesses -ForceKill:$Force Remove-Item -Force $pidFile -ErrorAction SilentlyContinue -$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue -if ($openclawCmd) { - try { openclaw gateway stop | Out-Null } catch {} -} Write-Host "[ok] stopped pid=$procId extra_cleaned=$killed" diff --git a/runtime/operations/scripts/whatsapp_install.ps1 b/runtime/operations/scripts/whatsapp_install.ps1 new file mode 100644 index 00000000..b7b75616 --- /dev/null +++ b/runtime/operations/scripts/whatsapp_install.ps1 @@ -0,0 +1,52 @@ +param( + [string]$ChannelId = "whatsapp" +) + +$ErrorActionPreference = "Stop" + +$systemNodeDir = "C:\\Program Files\\nodejs" +if (Test-Path (Join-Path $systemNodeDir "node.exe")) { + $env:PATH = "$systemNodeDir;$env:PATH" +} + +function Resolve-RepoRoot { + $here = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $here "..\\..\\..")).Path +} + +$oclawRoot = Resolve-RepoRoot +$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" +$stateDir = Join-Path $sidecarRoot "state" +$logDir = Join-Path $sidecarRoot "logs" +$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge" + +New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null +New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +New-Item -ItemType Directory -Force -Path $logDir | Out-Null + +Push-Location $sidecarRoot +try { + if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) { + npm.cmd init -y | Out-Null + if ($LASTEXITCODE -ne 0) { throw "npm init failed with exit code $LASTEXITCODE" } + } + npm.cmd install --save @whiskeysockets/baileys@latest qrcode-terminal@latest + if ($LASTEXITCODE -ne 0) { throw "npm install deps failed with exit code $LASTEXITCODE" } + npm.cmd install --save https-proxy-agent@latest + if ($LASTEXITCODE -ne 0) { throw "npm install proxy deps failed with exit code $LASTEXITCODE" } + npm.cmd install --save tsx@latest typescript@latest + if ($LASTEXITCODE -ne 0) { throw "npm install dev deps failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location +} + +if (Test-Path $bridgeSrc) { + foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) { + $srcPath = Join-Path $bridgeSrc $name + if (Test-Path $srcPath) { + Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force + } + } +} + +Write-Host "[ok] installed whatsapp baileys sidecar runtime into $sidecarRoot" diff --git a/runtime/operations/scripts/whatsapp_login.ps1 b/runtime/operations/scripts/whatsapp_login.ps1 new file mode 100644 index 00000000..21ada231 --- /dev/null +++ b/runtime/operations/scripts/whatsapp_login.ps1 @@ -0,0 +1,32 @@ +param( + [string]$ChannelId = "whatsapp" +) + +$ErrorActionPreference = "Stop" + +$systemNodeDir = "C:\\Program Files\\nodejs" +if (Test-Path (Join-Path $systemNodeDir "node.exe")) { + $env:PATH = "$systemNodeDir;$env:PATH" +} + +function Resolve-RepoRoot { + $here = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $here "..\\..\\..")).Path +} + +$oclawRoot = Resolve-RepoRoot +$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" +$stateDir = Join-Path $sidecarRoot "state" + +if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) { + throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first." +} + +Push-Location $sidecarRoot +try { + $env:OCLAW_STATE_DIR = $stateDir + $args = @("/c", "set OCLAW_STATE_DIR=$stateDir&& npx.cmd -y tsx baileys_runner.ts --login") + Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $sidecarRoot -Wait +} finally { + Pop-Location +} diff --git a/runtime/operations/scripts/whatsapp_start.ps1 b/runtime/operations/scripts/whatsapp_start.ps1 new file mode 100644 index 00000000..5828e282 --- /dev/null +++ b/runtime/operations/scripts/whatsapp_start.ps1 @@ -0,0 +1,57 @@ +param( + [string]$ChannelId = "whatsapp", + [string]$GatewayBaseUrl = "http://127.0.0.1:8787", + [int]$GatewayWaitSeconds = 20 +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + $here = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $here "..\\..\\..")).Path +} + +$oclawRoot = Resolve-RepoRoot +$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" +$stateDir = Join-Path $sidecarRoot "state" +$logDir = Join-Path $sidecarRoot "logs" +$sidecarPidFile = Join-Path $sidecarRoot "pid.txt" + +New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null +New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +New-Item -ItemType Directory -Force -Path $logDir | Out-Null + +$logPath = Join-Path $logDir "whatsapp_sidecar.log" +$errPath = Join-Path $logDir "whatsapp_sidecar.err.log" +$systemNodeDir = "C:\\Program Files\\nodejs" +if (Test-Path (Join-Path $systemNodeDir "node.exe")) { + $env:PATH = "$systemNodeDir;$env:PATH" +} + +if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) { + throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first." +} + +$healthUrl = ($GatewayBaseUrl.TrimEnd("/") + "/health") +for ($i = 0; $i -lt $GatewayWaitSeconds; $i++) { + try { + $resp = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 3 + if ($resp -and $resp.StatusCode -eq 200) { + break + } + } catch {} + if ($i -lt ($GatewayWaitSeconds - 1)) { + Start-Sleep -Seconds 1 + } +} +if (-not $resp -or $resp.StatusCode -ne 200) { + throw "oclaw gateway is not reachable at $healthUrl. Start it first: powershell -ExecutionPolicy Bypass -File .\\scripts\\start_gateway.ps1 -SkipInstall -Background" +} + +$args = @( + "/c", + "cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& npx.cmd -y tsx baileys_runner.ts" +) +$p = Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath +Set-Content -Path $sidecarPidFile -Value $p.Id +Write-Host "[ok] started whatsapp sidecar pid=$($p.Id) mode=baileys out=$logPath err=$errPath" diff --git a/runtime/operations/scripts/whatsapp_status.ps1 b/runtime/operations/scripts/whatsapp_status.ps1 new file mode 100644 index 00000000..0b95d928 --- /dev/null +++ b/runtime/operations/scripts/whatsapp_status.ps1 @@ -0,0 +1,18 @@ +param( + [string]$ChannelId = "whatsapp" +) + +$ErrorActionPreference = "Stop" + +$oclawRoot = (Resolve-Path (Join-Path (Split-Path -Parent $PSCommandPath) "..\\..\\..")).Path +$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" +$sidecarPidFile = Join-Path $sidecarRoot "pid.txt" +if (Test-Path $sidecarPidFile) { + $procId = (Get-Content -Path $sidecarPidFile -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($procId -and (Get-Process -Id $procId -ErrorAction SilentlyContinue)) { + Write-Host "status=running mode=baileys channel=$ChannelId" + exit 0 + } +} + +Write-Host "status=stopped mode=baileys channel=$ChannelId" diff --git a/runtime/operations/scripts/whatsapp_stop.ps1 b/runtime/operations/scripts/whatsapp_stop.ps1 new file mode 100644 index 00000000..9e4eda4b --- /dev/null +++ b/runtime/operations/scripts/whatsapp_stop.ps1 @@ -0,0 +1,37 @@ +param( + [string]$ChannelId = "whatsapp", + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + $here = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $here "..\\..\\..")).Path +} + +$systemNodeDir = "C:\\Program Files\\nodejs" +if (Test-Path (Join-Path $systemNodeDir "node.exe")) { + $env:PATH = "$systemNodeDir;$env:PATH" +} + +$oclawRoot = Resolve-RepoRoot +$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" +$sidecarPidFile = Join-Path $sidecarRoot "pid.txt" + +if (Test-Path $sidecarPidFile) { + $procId = (Get-Content -Path $sidecarPidFile -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($procId) { + if ($Force) { + taskkill.exe /PID $procId /T /F 2>$null | Out-Null + } else { + taskkill.exe /PID $procId /T 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + taskkill.exe /PID $procId /T /F 2>$null | Out-Null + } + } + } + Remove-Item -Force $sidecarPidFile -ErrorAction SilentlyContinue +} + +Write-Host "[ok] stopped whatsapp channel=$ChannelId" diff --git a/runtime/operations/weixin_bridge/login.ts b/runtime/operations/weixin_bridge/login.ts index e6c9a333..bb343959 100644 --- a/runtime/operations/weixin_bridge/login.ts +++ b/runtime/operations/weixin_bridge/login.ts @@ -1,8 +1,16 @@ import { spawn } from "node:child_process"; +import path from "node:path"; + +function resolveLocalOpenclawBin(): string { + // Prefer local openclaw installed in the sidecar runtime (no global CLI required). + // Windows: node_modules/.bin/openclaw.cmd + return path.join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "openclaw.cmd" : "openclaw"); +} function run(): Promise { return new Promise((resolve, reject) => { - const child = spawn("openclaw", ["channels", "login", "--channel", "openclaw-weixin"], { + const openclawBin = resolveLocalOpenclawBin(); + const child = spawn(openclawBin, ["channels", "login", "--channel", "openclaw-weixin"], { stdio: "inherit", shell: true, }); diff --git a/runtime/operations/whatsapp_bridge/auth.ts b/runtime/operations/whatsapp_bridge/auth.ts new file mode 100644 index 00000000..485dd052 --- /dev/null +++ b/runtime/operations/whatsapp_bridge/auth.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { useMultiFileAuthState } from "@whiskeysockets/baileys"; + +export type AuthState = Awaited>; + +export function resolveAuthDir(stateDir: string): string { + return path.join(stateDir, "auth"); +} + +export async function loadAuthState(stateDir: string): Promise { + return await useMultiFileAuthState(resolveAuthDir(stateDir)); +} + diff --git a/runtime/operations/whatsapp_bridge/baileys_runner.ts b/runtime/operations/whatsapp_bridge/baileys_runner.ts new file mode 100644 index 00000000..27110e8c --- /dev/null +++ b/runtime/operations/whatsapp_bridge/baileys_runner.ts @@ -0,0 +1,270 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import dns from "node:dns/promises"; + +import makeWASocket, { + Browsers, + DisconnectReason, + fetchLatestBaileysVersion, + jidNormalizedUser, + proto, +} from "@whiskeysockets/baileys"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +import { loadAuthState } from "./auth"; +import { printQrToTerminal } from "./qr"; + +type Json = Record; + +const LOCAL_BASE_URL = (process.env.AIA_GATEWAY_BASE_URL || "http://127.0.0.1:8787").trim(); +const ACCOUNT_ID = (process.env.AIA_WHATSAPP_ACCOUNT_ID || "wa-default").trim(); +const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim(); +const LOGIN_ONLY = process.argv.includes("--login") || String(process.env.AIA_WHATSAPP_LOGIN_ONLY || "").trim() === "1"; +const VERBOSE = process.argv.includes("--verbose") || String(process.env.AIA_WHATSAPP_VERBOSE || "").trim() === "1"; +const PROXY_URL = ( + process.env.AIA_WHATSAPP_PROXY_URL || + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy || + "" +).trim(); + +function log(msg: string): void { + process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`); +} + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +function pickText(m: proto.IMessage | null | undefined): string { + if (!m) return ""; + const c = (m.conversation || "").trim(); + if (c) return c; + const ext = (m.extendedTextMessage?.text || "").trim(); + if (ext) return ext; + const imgCap = (m.imageMessage?.caption || "").trim(); + if (imgCap) return imgCap; + const vidCap = (m.videoMessage?.caption || "").trim(); + if (vidCap) return vidCap; + return ""; +} + +function isStatusOrBroadcastJid(jid: string): boolean { + const low = String(jid || "").toLowerCase(); + return low === "status@broadcast" || low.endsWith("@broadcast"); +} + +function buildInboundPayload(params: { + chatId: string; + userId: string; + text: string; + raw: unknown; +}): Json { + return { + channel: "whatsapp", + account_id: ACCOUNT_ID, + user_id: params.userId, + chat_id: params.chatId, + text: params.text, + metadata: { + source: "whatsapp_baileys", + raw: params.raw, + }, + }; +} + +async function postInbound(payload: Json): Promise { + const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/inbound/whatsapp`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`inbound ${res.status}: ${text.slice(0, 300)}`); + } + return text ? (JSON.parse(text) as Json) : {}; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function _ipLooksHijackedOrUnroutable(ip: string): boolean { + const s = String(ip || "").trim(); + if (!s) return false; + if (s.startsWith("198.18.") || s.startsWith("198.19.")) return true; // reserved benchmark range + if (s.startsWith("0.") || s.startsWith("127.") || s.startsWith("10.")) return true; + if (s.startsWith("192.168.") || s.startsWith("169.254.")) return true; + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(s)) return true; + return false; +} + +async function logNetworkHints(): Promise { + try { + const a = await dns.resolve4("web.whatsapp.com").catch(() => []); + const aaaa = await dns.resolve6("web.whatsapp.com").catch(() => []); + const sample = [...a, ...aaaa].slice(0, 6); + if (sample.length > 0) { + log(`dns web.whatsapp.com => ${sample.join(", ")}`); + const bad = sample.find(_ipLooksHijackedOrUnroutable); + if (bad) { + log( + `WARNING: DNS looks suspicious (e.g. ${bad}). This often causes WhatsApp Web handshake failures. Try switching system DNS (1.1.1.1/8.8.8.8) or check proxy/hosts rules.`, + ); + } + } + } catch { + // best-effort + } + const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || ""; + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy || ""; + if (httpProxy || httpsProxy) { + log(`proxy env detected HTTP_PROXY=${httpProxy ? "set" : "unset"} HTTPS_PROXY=${httpsProxy ? "set" : "unset"}`); + } + if (PROXY_URL) { + log(`proxy configured for Baileys via PROXY_URL=${PROXY_URL}`); + } +} + +function makeDeduper(params: { ttlMs: number; max: number }) { + const seen = new Map(); + const prune = (now: number): void => { + for (const [k, ts] of seen) { + if (now - ts > params.ttlMs) { + seen.delete(k); + } + } + if (seen.size <= params.max) return; + const ordered = Array.from(seen.entries()).sort((a, b) => a[1] - b[1]); + const drop = ordered.slice(0, Math.max(0, ordered.length - params.max)); + for (const [k] of drop) seen.delete(k); + }; + return { + has: (id: string): boolean => { + const now = Date.now(); + prune(now); + const ts = seen.get(id); + return typeof ts === "number" && now - ts <= params.ttlMs; + }, + add: (id: string): void => { + const now = Date.now(); + seen.set(id, now); + prune(now); + }, + }; +} + +async function main(): Promise { + ensureDir(STATE_DIR); + const { state, saveCreds } = await loadAuthState(STATE_DIR); + const { version } = await fetchLatestBaileysVersion(); + const dedupe = makeDeduper({ ttlMs: 10 * 60 * 1000, max: 20_000 }); + + let reconnectAttempt = 0; + let sock: ReturnType | null = null; + const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined; + + const connectOnce = async () => { + sock = makeWASocket({ + auth: state, + browser: Browsers.windows("oclaw"), + version, + agent: wsAgent as any, + printQRInTerminal: false, + generateHighQualityLinkPreview: false, + }); + + sock.ev.on("creds.update", saveCreds); + + sock.ev.on("connection.update", async (update) => { + if (update.qr) { + log("QR received. Scan it from WhatsApp -> Linked devices."); + printQrToTerminal(update.qr); + } + if (update.connection === "open") { + reconnectAttempt = 0; + const me = sock?.user?.id ? jidNormalizedUser(sock.user.id) : ""; + log(`connected. me=${me || "unknown"} loginOnly=${LOGIN_ONLY}`); + if (LOGIN_ONLY) { + log("login-only mode: exiting after successful link."); + process.exit(0); + } + } + if (update.connection === "close") { + const statusCode = (update.lastDisconnect?.error as any)?.output?.statusCode as number | undefined; + const reason = statusCode ? (DisconnectReason as any)[statusCode] || String(statusCode) : "unknown"; + const errText = String(update.lastDisconnect?.error || "").slice(0, 200); + log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`); + if (statusCode === DisconnectReason.loggedOut) { + log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link."); + return; + } + reconnectAttempt += 1; + const base = Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt))); + const jitter = Math.floor(Math.random() * 500); + const delay = base + jitter; + log(`reconnecting in ${delay}ms attempt=${reconnectAttempt}`); + await sleep(delay); + await connectOnce(); + } + }); + + sock.ev.on("messages.upsert", async (upsert) => { + const msgs = Array.isArray(upsert.messages) ? upsert.messages : []; + for (const msg of msgs) { + try { + const key = msg.key; + const id = String(key.id || "").trim(); + const remoteJid = String(key.remoteJid || "").trim(); + if (!id || !remoteJid) continue; + if (isStatusOrBroadcastJid(remoteJid)) continue; + if (key.fromMe) continue; + if (dedupe.has(id)) continue; + dedupe.add(id); + + const text = pickText(msg.message); + if (!text) continue; + + const isGroup = remoteJid.endsWith("@g.us"); + const from = isGroup ? String(key.participant || "").trim() : remoteJid; + const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid); + const chatId = jidNormalizedUser(remoteJid); + + const raw = { + id, + remoteJid, + participant: key.participant || null, + pushName: (msg as any).pushName || null, + messageTimestamp: (msg as any).messageTimestamp || null, + }; + + const inbound = buildInboundPayload({ chatId, userId, text, raw }); + if (VERBOSE) log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`); + const out = await postInbound(inbound); + const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : []; + if (VERBOSE) log(`inbound ok replies=${replies.length}`); + for (const r of replies) { + const outText = String((r as any).text || "").trim(); + if (!outText) continue; + const deliverTo = String((r as any).chat_id || chatId).trim() || chatId; + await sock?.sendMessage(deliverTo, { text: outText }); + } + } catch (err) { + log(`handle message error: ${String(err)}`); + } + } + }); + }; + + log(`runner started local=${LOCAL_BASE_URL} stateDir=${STATE_DIR} verbose=${VERBOSE} node=${process.version}`); + await logNetworkHints(); + await connectOnce(); +} + +void main(); + diff --git a/runtime/operations/whatsapp_bridge/qr.ts b/runtime/operations/whatsapp_bridge/qr.ts new file mode 100644 index 00000000..b19d51a1 --- /dev/null +++ b/runtime/operations/whatsapp_bridge/qr.ts @@ -0,0 +1,7 @@ +import qrcode from "qrcode-terminal"; + +export function printQrToTerminal(qr: string): void { + // Keep it simple and dependency-light for Windows terminals. + qrcode.generate(qr, { small: true }); +} + diff --git a/tests/test_channel_dispatch_defaults.py b/tests/test_channel_dispatch_defaults.py new file mode 100644 index 00000000..cd7d27eb --- /dev/null +++ b/tests/test_channel_dispatch_defaults.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from oclaw.runtime.application.gateway.inbound_service import ( + _resolve_channel_dispatch, +) + + +class _DummyStore: + def __init__(self, data: dict[str, str] | None = None) -> None: + self._data = dict(data or {}) + + def get_setting(self, key: str) -> str: + return str(self._data.get(key) or "") + + +def test_channel_dispatch_defaults_to_expert_and_generalist() -> None: + store = _DummyStore() + interaction_mode, specialist = _resolve_channel_dispatch(store, channel="weixin", account=None) + assert interaction_mode == "expert" + assert specialist == "generalist" + + +def test_channel_dispatch_uses_global_settings() -> None: + store = _DummyStore( + { + "channel.dispatch.interaction_mode.whatsapp": "comprehensive", + "channel.dispatch.specialist.whatsapp": "ops", + } + ) + interaction_mode, specialist = _resolve_channel_dispatch(store, channel="whatsapp", account=None) + assert interaction_mode == "comprehensive" + assert specialist == "ops" + + +def test_channel_dispatch_account_config_overrides_global() -> None: + store = _DummyStore( + { + "channel.dispatch.interaction_mode.weixin": "comprehensive", + "channel.dispatch.specialist.weixin": "ops", + } + ) + account = { + "config": { + "interaction_mode": "expert", + "specialist": "generalist", + } + } + interaction_mode, specialist = _resolve_channel_dispatch(store, channel="weixin", account=account) + assert interaction_mode == "expert" + assert specialist == "generalist" + diff --git a/tests/test_weixin_ilink_api.py b/tests/test_weixin_ilink_api.py index 109775bb..60b3a1a9 100644 --- a/tests/test_weixin_ilink_api.py +++ b/tests/test_weixin_ilink_api.py @@ -121,6 +121,46 @@ class WeixinIlinkApiTests(unittest.TestCase): finally: weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment] + def test_whatsapp_native_reply_returns_sync_replies(self) -> None: + old_usecase = weixin_ilink_api._process_inbound_payload_usecase + + def _fake_usecase(payload: dict[str, object]) -> dict[str, object]: + text = str(payload.get("text") or "") + self.assertEqual(str(payload.get("channel") or ""), "whatsapp") + self.assertEqual(str(payload.get("user_id") or ""), "8613800138000@s.whatsapp.net") + return { + "ok": True, + "replies": [ + { + "chat_id": str(payload.get("chat_id") or ""), + "text": f"wa:{text}", + } + ], + } + + try: + weixin_ilink_api._process_inbound_payload_usecase = _fake_usecase # type: ignore[assignment] + r = self.client.post( + "/whatsapp/native/reply", + headers=self.headers, + json={ + "account_id": "wa-bot-1", + "ctx": { + "From": "8613800138000@s.whatsapp.net", + "To": "8613800138000@s.whatsapp.net", + "Body": "hello whatsapp", + }, + }, + ) + self.assertEqual(r.status_code, 200, r.text) + data = r.json() or {} + self.assertTrue(data.get("ok"), data) + replies = data.get("replies") if isinstance(data.get("replies"), list) else [] + self.assertEqual(len(replies), 1, data) + self.assertEqual(str((replies[0] or {}).get("text") or ""), "wa:hello whatsapp") + finally: + weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment] + if __name__ == "__main__": unittest.main() diff --git a/tests/test_whatsapp_inbound_payload_shape.py b/tests/test_whatsapp_inbound_payload_shape.py new file mode 100644 index 00000000..1e0bbb06 --- /dev/null +++ b/tests/test_whatsapp_inbound_payload_shape.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from oclaw.interfaces.http.fastapi_app import create_app +from oclaw.interfaces.http import fastapi_app + + +def test_inbound_whatsapp_accepts_basic_payload_and_returns_replies() -> None: + client = TestClient(create_app()) + old_usecase = fastapi_app.process_inbound_payload_usecase + + def _fake_usecase(payload: dict[str, object]) -> dict[str, object]: + assert str(payload.get("channel") or "") == "whatsapp" + assert str(payload.get("account_id") or "") == "wa-default" + assert str(payload.get("user_id") or "") == "111@s.whatsapp.net" + assert str(payload.get("chat_id") or "") == "111@s.whatsapp.net" + return {"ok": True, "replies": [{"chat_id": payload.get("chat_id"), "text": "ok"}]} + + try: + fastapi_app.process_inbound_payload_usecase = _fake_usecase # type: ignore[assignment] + r = client.post( + "/inbound/whatsapp", + json={ + "account_id": "wa-default", + "user_id": "111@s.whatsapp.net", + "chat_id": "111@s.whatsapp.net", + "text": "hello", + "metadata": {"source": "test"}, + }, + ) + assert r.status_code == 200, r.text + data = r.json() or {} + assert data.get("ok") is True + replies = data.get("replies") if isinstance(data.get("replies"), list) else [] + assert replies and isinstance(replies[0], dict) + assert str(replies[0].get("text") or "") == "ok" + finally: + fastapi_app.process_inbound_payload_usecase = old_usecase # type: ignore[assignment] + diff --git a/tests/test_whatsapp_ops_scripts.py b/tests/test_whatsapp_ops_scripts.py new file mode 100644 index 00000000..290643d9 --- /dev/null +++ b/tests/test_whatsapp_ops_scripts.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _read(rel: str) -> str: + return (REPO_ROOT / rel).read_text(encoding="utf-8") + + +def test_whatsapp_ops_scripts_exist_and_use_openclaw() -> None: + for rel in ( + "runtime/operations/scripts/whatsapp_install.ps1", + "runtime/operations/scripts/whatsapp_login.ps1", + "runtime/operations/scripts/whatsapp_start.ps1", + "runtime/operations/scripts/whatsapp_status.ps1", + "runtime/operations/scripts/whatsapp_stop.ps1", + ): + text = _read(rel) + assert "openclaw" not in text.lower() + start_text = _read("runtime/operations/scripts/whatsapp_start.ps1") + assert "baileys_runner.ts" in start_text + assert "AIA_GATEWAY_BASE_URL" in start_text + + +def test_runbook_mentions_whatsapp_scripts() -> None: + text = _read("docs/RUNBOOK.md") + assert "whatsapp_install.ps1" in text + assert "whatsapp_login.ps1" in text + assert "whatsapp_start.ps1" in text + assert "/inbound/whatsapp" in text + + +def test_start_all_gracefully_skips_missing_channel_sidecars() -> None: + text = _read("runtime/operations/scripts/start_all.ps1") + assert 'Warn "weixin sidecar skipped:' in text + assert 'Warn "whatsapp sidecar skipped:' in text