mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-19 08:03:19 +08:00
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
This commit is contained in:
parent
b1a0f94cd6
commit
5ab69e88d3
29 changed files with 1226 additions and 100 deletions
|
|
@ -38,6 +38,11 @@ powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_log
|
||||||
powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_start.ps1
|
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` → “开源快速安装(从零到跑起来)”.
|
Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”.
|
||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
|
|
||||||
- 网关(Admin + Chat)
|
- 网关(Admin + Chat)
|
||||||
- 微信(Personal WeChat)收消息、回消息(官方插件 + 本地原生宿主 `/weixin/native/reply`)
|
- 微信(Personal WeChat)收消息、回消息(官方插件 + 本地原生宿主 `/weixin/native/reply`)
|
||||||
|
- WhatsApp(实验):收消息、回消息(本仓库 Baileys sidecar + 本地 `/inbound/whatsapp`)
|
||||||
|
|
||||||
### 1.1.1 前置依赖
|
### 1.1.1 前置依赖
|
||||||
|
|
||||||
|
|
@ -88,6 +89,9 @@ powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\weixin_log
|
||||||
|
|
||||||
按提示扫码完成绑定(会写入账号 ID / token 等状态到 `%USERPROFILE%\.openclaw\openclaw-weixin\`)。
|
按提示扫码完成绑定(会写入账号 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(原生模式)
|
### 1.1.6 启动微信 sidecar(原生模式)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -142,6 +146,8 @@ Linux/macOS:
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
|
||||||
|
|
||||||
|
> 开源默认友好行为:若本机尚未安装微信/WhatsApp sidecar,`start_all.ps1` 会自动跳过对应通道并打印 `[WARN] ... sidecar skipped`,不会中断 gateway + desktop 启动。
|
||||||
|
|
||||||
默认即包含微信 sidecar:
|
默认即包含微信 sidecar:
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
|
`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`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithoutWeixin`
|
||||||
|
|
||||||
|
不启动 WhatsApp sidecar(可选):
|
||||||
|
|
||||||
|
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithoutWhatsApp`
|
||||||
|
|
||||||
(含 wiki worker):
|
(含 wiki worker):
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWikiWorker`
|
`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`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithoutWeixin`
|
||||||
|
|
||||||
|
不检查 WhatsApp sidecar(可选):
|
||||||
|
|
||||||
|
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithoutWhatsApp`
|
||||||
|
|
||||||
(含 wiki worker):
|
(含 wiki worker):
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWikiWorker`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWikiWorker`
|
||||||
|
|
@ -182,6 +196,10 @@ Linux/macOS:
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithoutWeixin`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithoutWeixin`
|
||||||
|
|
||||||
|
不停止 WhatsApp sidecar(可选):
|
||||||
|
|
||||||
|
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithoutWhatsApp`
|
||||||
|
|
||||||
(含 wiki worker):
|
(含 wiki worker):
|
||||||
|
|
||||||
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWikiWorker`
|
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWikiWorker`
|
||||||
|
|
@ -222,6 +240,55 @@ Linux/macOS:
|
||||||
- 官方收发与媒体模块
|
- 官方收发与媒体模块
|
||||||
- 本仓库本地 reply 宿主适配
|
- 本仓库本地 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. 仅启动网关
|
## 5. 仅启动网关
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,23 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
|
||||||
}
|
}
|
||||||
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_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, ...]:
|
def _chat_specialist_ids() -> tuple[str, ...]:
|
||||||
return tuple(str(x) for x in specialist_ids() if str(x).strip())
|
return tuple(str(x) for x in specialist_ids() if str(x).strip())
|
||||||
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
|
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)
|
flags = _specialist_flags_with_overrides(store)
|
||||||
return {"ok": True, "saved": clean, "flags": flags}
|
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")
|
@chat.get("/settings/attachment-limits")
|
||||||
def api_chat_get_attachment_limits(
|
def api_chat_get_attachment_limits(
|
||||||
authorization: str | None = Header(default=None),
|
authorization: str | None = Header(default=None),
|
||||||
|
|
|
||||||
|
|
@ -1424,12 +1424,15 @@ function markPrewarmReminder(reason) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderStack() {
|
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/stack/status"),
|
||||||
apiGet("/admin/api/runtime/anomalies"),
|
apiGet("/admin/api/runtime/anomalies"),
|
||||||
apiGet("/admin/api/runtime/scan-artifacts"),
|
apiGet("/admin/api/runtime/scan-artifacts"),
|
||||||
apiGet("/admin/api/runtime/prewarm/status"),
|
apiGet("/admin/api/runtime/prewarm/status"),
|
||||||
apiGet("/admin/api/runtime/prewarm/prompts?role=manager"),
|
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 requiredServices = ["gateway", "channel:wecom"];
|
||||||
const runningNames = new Set(
|
const runningNames = new Set(
|
||||||
|
|
@ -1471,6 +1474,54 @@ async function renderStack() {
|
||||||
await apiPost("/admin/api/stack/down", {});
|
await apiPost("/admin/api/stack/down", {});
|
||||||
router();
|
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 cleanupStatus = el("div", { class: "muted", text: "" });
|
||||||
const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => {
|
const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => {
|
||||||
const resp = await apiPost("/admin/api/runtime/cleanup", {});
|
const resp = await apiPost("/admin/api/runtime/cleanup", {});
|
||||||
|
|
@ -1643,6 +1694,8 @@ async function renderStack() {
|
||||||
el("tbody", {}, items),
|
el("tbody", {}, items),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
weixinDispatchCard,
|
||||||
|
whatsappDispatchCard,
|
||||||
el("div", { class: "card" }, [
|
el("div", { class: "card" }, [
|
||||||
el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }),
|
el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }),
|
||||||
el("div", { class: "muted", text: prewarmSummary }),
|
el("div", { class: "muted", text: prewarmSummary }),
|
||||||
|
|
@ -1815,15 +1868,24 @@ async function renderUserManagement() {
|
||||||
placeholder: t("users.wecomUserEmpty"),
|
placeholder: t("users.wecomUserEmpty"),
|
||||||
});
|
});
|
||||||
const accountBody = el("tbody");
|
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 accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") });
|
||||||
const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") });
|
const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") });
|
||||||
const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") });
|
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 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 accountActiveInput = el("input", { type: "checkbox" });
|
||||||
|
const accountSpecialistInput = el("select", { class: "input" }, [el("option", { value: "generalist", text: "generalist" })]);
|
||||||
accountActiveInput.checked = true;
|
accountActiveInput.checked = true;
|
||||||
let selectedUserId = "";
|
let selectedUserId = "";
|
||||||
let selectedUsername = "";
|
let selectedUsername = "";
|
||||||
let selectedDisplayName = "";
|
let selectedDisplayName = "";
|
||||||
|
let availableAccountSpecialists = ["generalist"];
|
||||||
|
|
||||||
const refreshSelectedUserField = () => {
|
const refreshSelectedUserField = () => {
|
||||||
if (!selectedUserId) {
|
if (!selectedUserId) {
|
||||||
|
|
@ -1840,6 +1902,26 @@ async function renderUserManagement() {
|
||||||
botSecretInput.value = "";
|
botSecretInput.value = "";
|
||||||
clearBotChk.checked = false;
|
clearBotChk.checked = false;
|
||||||
accountActiveInput.checked = true;
|
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") });
|
const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") });
|
||||||
|
|
@ -1888,6 +1970,12 @@ async function renderUserManagement() {
|
||||||
} else {
|
} else {
|
||||||
accountActiveInput.checked = true;
|
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 = "";
|
botSecretInput.value = "";
|
||||||
clearBotChk.checked = false;
|
clearBotChk.checked = false;
|
||||||
};
|
};
|
||||||
|
|
@ -1898,18 +1986,19 @@ async function renderUserManagement() {
|
||||||
const tenantId = getTenantId();
|
const tenantId = getTenantId();
|
||||||
if (!selectedUserId) {
|
if (!selectedUserId) {
|
||||||
accountStatus.textContent = "";
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=wecom&user_id=${encodeURIComponent(selectedUserId)}`;
|
const ch = currentAccountChannel();
|
||||||
const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=wecom&include_inactive=1`;
|
const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=${encodeURIComponent(ch)}&user_id=${encodeURIComponent(selectedUserId)}`;
|
||||||
const [bindResp, accResp] = await Promise.all([apiGet(bindUrl), apiGet(accUrl)]);
|
const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=${encodeURIComponent(ch)}&include_inactive=1`;
|
||||||
const bindings = Array.isArray(bindResp.bindings) ? bindResp.bindings : [];
|
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 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 = "";
|
accountStatus.textContent = "";
|
||||||
if (!merged.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
merged.forEach((m) => {
|
merged.forEach((m) => {
|
||||||
|
|
@ -1919,6 +2008,9 @@ async function renderUserManagement() {
|
||||||
const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || "";
|
const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || "";
|
||||||
const extUid = bind ? String(bind.external_user_id || "") : "";
|
const extUid = bind ? String(bind.external_user_id || "") : "";
|
||||||
const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || "";
|
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", {
|
const btnFill = el("button", {
|
||||||
class: "btn",
|
class: "btn",
|
||||||
text: t("users.loadForm"),
|
text: t("users.loadForm"),
|
||||||
|
|
@ -1929,16 +2021,54 @@ async function renderUserManagement() {
|
||||||
});
|
});
|
||||||
const deleteCell = el("td", {});
|
const deleteCell = el("td", {});
|
||||||
if (inst) {
|
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) => {
|
const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await apiPost("/admin/api/user-channel-accounts/delete", {
|
await apiPost("/admin/api/user-channel-accounts/delete", {
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
user_id: selectedUserId,
|
user_id: selectedUserId,
|
||||||
channel: "wecom",
|
channel: ch,
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
});
|
});
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
}});
|
}});
|
||||||
|
deleteCell.appendChild(btnBindExpert);
|
||||||
|
deleteCell.appendChild(btnComprehensive);
|
||||||
deleteCell.appendChild(btnDeleteAccount);
|
deleteCell.appendChild(btnDeleteAccount);
|
||||||
} else {
|
} else {
|
||||||
deleteCell.appendChild(document.createTextNode("—"));
|
deleteCell.appendChild(document.createTextNode("—"));
|
||||||
|
|
@ -1947,8 +2077,10 @@ async function renderUserManagement() {
|
||||||
tdCell(accountId, 22),
|
tdCell(accountId, 22),
|
||||||
tdCell(displayName, 16),
|
tdCell(displayName, 16),
|
||||||
tdCell(extUid || "—", 18),
|
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(inst ? (inst.is_active ? "1" : "0") : "—", 4),
|
||||||
|
tdCell(modeText || "expert", 8),
|
||||||
|
tdCell(specialistText || "generalist", 10),
|
||||||
tdCell(ts, 20),
|
tdCell(ts, 20),
|
||||||
el("td", {}, [btnFill]),
|
el("td", {}, [btnFill]),
|
||||||
deleteCell,
|
deleteCell,
|
||||||
|
|
@ -1967,16 +2099,22 @@ async function renderUserManagement() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tenantId = getTenantId();
|
const tenantId = getTenantId();
|
||||||
|
const ch = currentAccountChannel();
|
||||||
|
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
|
||||||
const resp = await apiPost("/admin/api/user-channel-accounts/upsert", {
|
const resp = await apiPost("/admin/api/user-channel-accounts/upsert", {
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
user_id: selectedUserId,
|
user_id: selectedUserId,
|
||||||
channel: "wecom",
|
channel: ch,
|
||||||
account_id: aid,
|
account_id: aid,
|
||||||
name: accountNameInput.value.trim(),
|
name: accountNameInput.value.trim(),
|
||||||
wecom_mode: "bot_api",
|
wecom_mode: ch === "wecom" ? "bot_api" : undefined,
|
||||||
bot_secret: botSecretInput.value.trim(),
|
bot_secret: botSecretInput.value.trim(),
|
||||||
clear_bot_secret: !!clearBotChk.checked,
|
clear_bot_secret: !!clearBotChk.checked,
|
||||||
is_active: !!accountActiveInput.checked,
|
is_active: !!accountActiveInput.checked,
|
||||||
|
config: {
|
||||||
|
interaction_mode: "expert",
|
||||||
|
specialist,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error");
|
accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error");
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
|
@ -1986,6 +2124,11 @@ async function renderUserManagement() {
|
||||||
await loadList();
|
await loadList();
|
||||||
}
|
}
|
||||||
}});
|
}});
|
||||||
|
accountChannelInput.addEventListener("change", async () => {
|
||||||
|
clearWecomForm();
|
||||||
|
refreshAccountFormByChannel();
|
||||||
|
await loadAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
const loadList = async () => {
|
const loadList = async () => {
|
||||||
if (!hasPermission("admin:user:read")) {
|
if (!hasPermission("admin:user:read")) {
|
||||||
|
|
@ -2201,8 +2344,15 @@ async function renderUserManagement() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await loadBindingsAndCodes();
|
await loadBindingsAndCodes();
|
||||||
|
try {
|
||||||
|
const sf = await apiGet("/admin/api/chat/settings/specialist-flags");
|
||||||
|
setAccountSpecialistOptions(sf.available_specialists || ["generalist"]);
|
||||||
|
} catch (_) {
|
||||||
|
setAccountSpecialistOptions(["generalist"]);
|
||||||
|
}
|
||||||
await loadList();
|
await loadList();
|
||||||
refreshSelectedUserField();
|
refreshSelectedUserField();
|
||||||
|
refreshAccountFormByChannel();
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
|
|
||||||
const tenantsListHint = allTenants.length > 1
|
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", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]),
|
||||||
el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }),
|
el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }),
|
||||||
el("div", { class: "row row--wecom-form" }, [
|
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.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.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" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]),
|
||||||
el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]),
|
clearBotField,
|
||||||
btnSaveAccount,
|
btnSaveAccount,
|
||||||
]),
|
]),
|
||||||
accountStatus,
|
accountStatus,
|
||||||
|
|
@ -2265,6 +2417,8 @@ async function renderUserManagement() {
|
||||||
el("th", { text: t("table.externalUserId") }),
|
el("th", { text: t("table.externalUserId") }),
|
||||||
el("th", { text: t("users.hasBotSecret") }),
|
el("th", { text: t("users.hasBotSecret") }),
|
||||||
el("th", { text: t("users.accountActive") }),
|
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("table.timestamp") }),
|
||||||
el("th", { text: t("users.loadForm") }),
|
el("th", { text: t("users.loadForm") }),
|
||||||
el("th", { text: t("users.accountActions") }),
|
el("th", { text: t("users.accountActions") }),
|
||||||
|
|
|
||||||
|
|
@ -2584,6 +2584,49 @@ async function renderChatUi() {
|
||||||
backdrop.appendChild(card);
|
backdrop.appendChild(card);
|
||||||
document.body.appendChild(backdrop);
|
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 adoptCreatedSession = (resp) => {
|
||||||
const s = resp && resp.session ? resp.session : {};
|
const s = resp && resp.session ? resp.session : {};
|
||||||
|
|
@ -2748,7 +2791,7 @@ async function renderChatUi() {
|
||||||
});
|
});
|
||||||
menu.appendChild(
|
menu.appendChild(
|
||||||
mk(t("chat.rename"), async () => {
|
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;
|
if (nv == null) return;
|
||||||
try {
|
try {
|
||||||
await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() });
|
await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() });
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,8 @@ def _extract_inbound_identity(body: dict[str, Any]) -> tuple[str, str]:
|
||||||
return user_id, chat_id
|
return user_id, chat_id
|
||||||
|
|
||||||
|
|
||||||
def _build_native_reply_payload(body: dict[str, Any]) -> dict[str, Any]:
|
def _build_native_reply_payload(body: dict[str, Any], *, default_channel: str, source: str) -> dict[str, Any]:
|
||||||
channel = _normalize_channel(body.get("channel"))
|
channel = _normalize_channel(body.get("channel") or default_channel)
|
||||||
account_id = _resolve_account_id(body)
|
account_id = _resolve_account_id(body)
|
||||||
ctx = body.get("ctx") if isinstance(body.get("ctx"), dict) else {}
|
ctx = body.get("ctx") if isinstance(body.get("ctx"), dict) else {}
|
||||||
metadata = body.get("metadata") if isinstance(body.get("metadata"), 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()
|
).strip()
|
||||||
text = str(body.get("text") or ctx.get("Body") or ctx.get("CommandBody") or "").strip()
|
text = str(body.get("text") or ctx.get("Body") or ctx.get("CommandBody") or "").strip()
|
||||||
if not metadata:
|
if not metadata:
|
||||||
metadata = {"source": "weixin_official_native"}
|
metadata = {"source": source}
|
||||||
else:
|
else:
|
||||||
metadata = dict(metadata)
|
metadata = dict(metadata)
|
||||||
metadata.setdefault("source", "weixin_official_native")
|
metadata.setdefault("source", source)
|
||||||
if ctx:
|
if ctx:
|
||||||
metadata["weixin_ctx"] = ctx
|
metadata["weixin_ctx"] = ctx
|
||||||
|
|
||||||
|
|
@ -463,7 +463,29 @@ async def weixin_native_reply(
|
||||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
_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():
|
if not str(payload.get("user_id") or "").strip():
|
||||||
return {"ok": False, "error": "missing user_id", "replies": []}
|
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||||
if not str(payload.get("account_id") or "").strip():
|
if not str(payload.get("account_id") or "").strip():
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,53 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from oclaw.interfaces.channels.base import InboundMessage, OutboundMessage
|
from oclaw.interfaces.channels.base import InboundMessage, OutboundMessage
|
||||||
from oclaw.interfaces.channels.wecom.wecom_bridge import WeComAdapter
|
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()
|
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||||
_GATEWAY_AGENT: Any | None = None
|
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||||
|
|
||||||
|
|
||||||
def _get_gateway_agent(store: Any) -> Any:
|
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||||
global _GATEWAY_AGENT
|
return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||||
with _GATEWAY_AGENT_LOCK:
|
|
||||||
if _GATEWAY_AGENT is None:
|
|
||||||
from oclaw.runtime.agents.factory import build_gateway_executor
|
|
||||||
|
|
||||||
_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:
|
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]:
|
def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
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.gateway import OclawGateway
|
||||||
from oclaw.runtime.types import StandardMessage
|
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)
|
gw = OclawGateway(store=store)
|
||||||
msg = StandardMessage(
|
msg = StandardMessage(
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
|
@ -424,9 +470,31 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"channel": inbound.channel,
|
"channel": inbound.channel,
|
||||||
"role": role,
|
"role": role,
|
||||||
"account_id": account_id,
|
"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:
|
except Exception as e:
|
||||||
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
|
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
|
||||||
else:
|
else:
|
||||||
|
|
@ -437,6 +505,9 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
else:
|
else:
|
||||||
reply = f"{preface}\n\n{_menu_text()}"
|
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:
|
if adapter is not None:
|
||||||
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@
|
||||||
[switch]$SkipInstall = $false,
|
[switch]$SkipInstall = $false,
|
||||||
[switch]$Background = $false,
|
[switch]$Background = $false,
|
||||||
[switch]$WithoutWeixin = $false,
|
[switch]$WithoutWeixin = $false,
|
||||||
|
[switch]$WithoutWhatsApp = $false,
|
||||||
[bool]$WithWikiWorker = $true,
|
[bool]$WithWikiWorker = $true,
|
||||||
[string]$WeixinChannelId = "oclaw-weixin",
|
[string]$WeixinChannelId = "oclaw-weixin",
|
||||||
[string]$WeixinGatewayBaseUrl = ""
|
[string]$WeixinGatewayBaseUrl = "",
|
||||||
|
[string]$WhatsAppChannelId = "whatsapp"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
@ -15,6 +17,10 @@ function Write-Step([string]$msg) {
|
||||||
Write-Host "==> $msg" -ForegroundColor Cyan
|
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))
|
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
|
||||||
$repoParent = Split-Path -Parent $repoRoot
|
$repoParent = Split-Path -Parent $repoRoot
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
@ -43,7 +49,20 @@ if (-not $WithoutWeixin) {
|
||||||
$gwBase = "http://$BindHost`:$Port"
|
$gwBase = "http://$BindHost`:$Port"
|
||||||
}
|
}
|
||||||
Write-Step "Starting weixin sidecar"
|
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 ""
|
||||||
Write-Host "All started." -ForegroundColor Green
|
Write-Host "All started." -ForegroundColor Green
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ param(
|
||||||
[string]$BindHost = "127.0.0.1",
|
[string]$BindHost = "127.0.0.1",
|
||||||
[int]$Port = 8787,
|
[int]$Port = 8787,
|
||||||
[switch]$WithoutWeixin = $false,
|
[switch]$WithoutWeixin = $false,
|
||||||
|
[switch]$WithoutWhatsApp = $false,
|
||||||
[switch]$WithWikiWorker = $false,
|
[switch]$WithWikiWorker = $false,
|
||||||
[string]$WeixinChannelId = "oclaw-weixin"
|
[string]$WeixinChannelId = "oclaw-weixin",
|
||||||
|
[string]$WhatsAppChannelId = "whatsapp"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
@ -127,6 +129,15 @@ if (-not $WithoutWeixin) {
|
||||||
Warn "weixin status failed: $($_.Exception.Message)"
|
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) {
|
if ($WithWikiWorker) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ param(
|
||||||
[int]$Port = 8787,
|
[int]$Port = 8787,
|
||||||
[switch]$Force = $false,
|
[switch]$Force = $false,
|
||||||
[switch]$WithoutWeixin = $false,
|
[switch]$WithoutWeixin = $false,
|
||||||
|
[switch]$WithoutWhatsApp = $false,
|
||||||
[switch]$WithWikiWorker = $false,
|
[switch]$WithWikiWorker = $false,
|
||||||
[string]$WeixinChannelId = "oclaw-weixin"
|
[string]$WeixinChannelId = "oclaw-weixin",
|
||||||
|
[string]$WhatsAppChannelId = "whatsapp"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
@ -34,6 +36,14 @@ if (-not $WithoutWeixin) {
|
||||||
if (-not $Force) { throw }
|
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) {
|
if ($WithWikiWorker) {
|
||||||
Write-Step "Stopping wiki worker"
|
Write-Step "Stopping wiki worker"
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,15 @@ if ($procId -le 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# Kill the whole process tree (cmd -> npm -> electron/node)
|
# Kill the whole process tree (cmd -> npm -> electron/node).
|
||||||
& taskkill /PID $procId /T /F | Out-Null
|
# 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
|
Write-Host "Stopped PID=$procId (tree)" -ForegroundColor Green
|
||||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,9 @@ function Ensure-OfficialPluginRuntimeDeps {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($UseOpenclawCli) {
|
if ($UseOpenclawCli) {
|
||||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
# Do not require a globally-installed openclaw CLI.
|
||||||
if (-not $openclawCmd) {
|
# We install openclaw into the sidecar runtime and put node_modules/.bin on PATH
|
||||||
throw "openclaw command not found. Install first: npm install -g openclaw"
|
# so the official installer can run `openclaw ...` commands.
|
||||||
}
|
|
||||||
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
|
|
||||||
Push-Location $sidecarRoot
|
Push-Location $sidecarRoot
|
||||||
try {
|
try {
|
||||||
if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) {
|
if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) {
|
||||||
|
|
@ -54,6 +48,21 @@ if ($UseOpenclawCli) {
|
||||||
throw "npm init failed with exit code $LASTEXITCODE"
|
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
|
npm.cmd install openclaw@latest --save tsx@4.21.0 typescript@6.0.3
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "npm install bridge runtime deps failed with exit code $LASTEXITCODE"
|
throw "npm install bridge runtime deps failed with exit code $LASTEXITCODE"
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,7 @@ try {
|
||||||
npm.cmd exec -- tsx login.ts
|
npm.cmd exec -- tsx login.ts
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
throw "login.ts missing. Re-run weixin_install.ps1 -UseOpenclawCli to install sidecar runtime."
|
||||||
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
|
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,14 @@ function Get-SidecarProcesses {
|
||||||
|
|
||||||
function Stop-SidecarProcesses {
|
function Stop-SidecarProcesses {
|
||||||
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
||||||
foreach ($proc in $procs) {
|
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||||
try {
|
try {
|
||||||
taskkill.exe /PID $proc.ProcessId /T /F | Out-Null
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
} catch {
|
foreach ($proc in $procs) {
|
||||||
# Best-effort cleanup; keep going if a process already exited.
|
taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||||
}
|
}
|
||||||
return $procs.Count
|
return $procs.Count
|
||||||
}
|
}
|
||||||
|
|
@ -112,17 +114,5 @@ if ((Test-Path (Join-Path $sidecarRoot "runner.ts")) -or (Test-Path (Join-Path $
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
throw "No runner.ts/official_runner.ts found in sidecar root. Re-run weixin_install.ps1 -UseOpenclawCli."
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,16 +69,6 @@ if ($exists) {
|
||||||
Write-Host "status=orphaned stale_pid=$procId count=$($sidecarProcs.Count) pids=$pids"
|
Write-Host "status=orphaned stale_pid=$procId count=$($sidecarProcs.Count) pids=$pids"
|
||||||
exit 0
|
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"
|
Write-Host "status=stale_pid pid=$procId"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,22 @@ function Stop-SidecarProcesses {
|
||||||
[switch]$ForceKill
|
[switch]$ForceKill
|
||||||
)
|
)
|
||||||
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
||||||
foreach ($proc in $procs) {
|
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||||
try {
|
try {
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
|
foreach ($proc in $procs) {
|
||||||
if ($ForceKill) {
|
if ($ForceKill) {
|
||||||
taskkill.exe /PID $proc.ProcessId /T /F | Out-Null
|
taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null
|
||||||
} else {
|
continue
|
||||||
taskkill.exe /PID $proc.ProcessId /T | Out-Null
|
}
|
||||||
|
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
|
return $procs.Count
|
||||||
}
|
}
|
||||||
|
|
@ -73,21 +79,23 @@ if (-not $procId) {
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||||
try {
|
try {
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
if ($Force) {
|
if ($Force) {
|
||||||
taskkill.exe /PID $procId /T /F | Out-Null
|
taskkill.exe /PID $procId /T /F 2>$null | Out-Null
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
# Ignore if already dead.
|
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||||
}
|
}
|
||||||
|
|
||||||
$killed = Stop-SidecarProcesses -ForceKill:$Force
|
$killed = Stop-SidecarProcesses -ForceKill:$Force
|
||||||
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
|
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"
|
Write-Host "[ok] stopped pid=$procId extra_cleaned=$killed"
|
||||||
|
|
||||||
|
|
|
||||||
52
runtime/operations/scripts/whatsapp_install.ps1
Normal file
52
runtime/operations/scripts/whatsapp_install.ps1
Normal file
|
|
@ -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"
|
||||||
32
runtime/operations/scripts/whatsapp_login.ps1
Normal file
32
runtime/operations/scripts/whatsapp_login.ps1
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
57
runtime/operations/scripts/whatsapp_start.ps1
Normal file
57
runtime/operations/scripts/whatsapp_start.ps1
Normal file
|
|
@ -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"
|
||||||
18
runtime/operations/scripts/whatsapp_status.ps1
Normal file
18
runtime/operations/scripts/whatsapp_status.ps1
Normal file
|
|
@ -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"
|
||||||
37
runtime/operations/scripts/whatsapp_stop.ps1
Normal file
37
runtime/operations/scripts/whatsapp_stop.ps1
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { spawn } from "node:child_process";
|
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<number> {
|
function run(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
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",
|
stdio: "inherit",
|
||||||
shell: true,
|
shell: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
13
runtime/operations/whatsapp_bridge/auth.ts
Normal file
13
runtime/operations/whatsapp_bridge/auth.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { useMultiFileAuthState } from "@whiskeysockets/baileys";
|
||||||
|
|
||||||
|
export type AuthState = Awaited<ReturnType<typeof useMultiFileAuthState>>;
|
||||||
|
|
||||||
|
export function resolveAuthDir(stateDir: string): string {
|
||||||
|
return path.join(stateDir, "auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAuthState(stateDir: string): Promise<AuthState> {
|
||||||
|
return await useMultiFileAuthState(resolveAuthDir(stateDir));
|
||||||
|
}
|
||||||
|
|
||||||
270
runtime/operations/whatsapp_bridge/baileys_runner.ts
Normal file
270
runtime/operations/whatsapp_bridge/baileys_runner.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<Json> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, number>();
|
||||||
|
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<void> {
|
||||||
|
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<typeof makeWASocket> | 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();
|
||||||
|
|
||||||
7
runtime/operations/whatsapp_bridge/qr.ts
Normal file
7
runtime/operations/whatsapp_bridge/qr.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
51
tests/test_channel_dispatch_defaults.py
Normal file
51
tests/test_channel_dispatch_defaults.py
Normal file
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -121,6 +121,46 @@ class WeixinIlinkApiTests(unittest.TestCase):
|
||||||
finally:
|
finally:
|
||||||
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
40
tests/test_whatsapp_inbound_payload_shape.py
Normal file
40
tests/test_whatsapp_inbound_payload_shape.py
Normal file
|
|
@ -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]
|
||||||
|
|
||||||
39
tests/test_whatsapp_ops_scripts.py
Normal file
39
tests/test_whatsapp_ops_scripts.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue