mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 21:13:22 +08:00
Fix WhatsApp Admin QR bind and show the scan prompt only after Bind.
Use a current WA protocol version, start the sidecar via local tsx with inherited proxy, and stop auto-opening the QR UI on unbound connecting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5c6833612f
commit
9cf026f605
12 changed files with 593 additions and 101 deletions
|
|
@ -5,9 +5,9 @@
|
|||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>oliver</title>
|
||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260817-wa6" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa6" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa6" />
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260817-wa12" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa12" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa12" />
|
||||
<style>
|
||||
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
||||
.admin-boot-splash {
|
||||
|
|
@ -129,33 +129,33 @@
|
|||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"/admin/assets/app.js": "/admin/assets/app.js?v=20260817-wa6",
|
||||
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260817-wa6",
|
||||
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260817-wa6",
|
||||
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260817-wa6",
|
||||
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260817-wa6",
|
||||
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260817-wa6",
|
||||
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260817-wa6",
|
||||
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260817-wa6",
|
||||
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260817-wa6",
|
||||
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260817-wa6"
|
||||
"/admin/assets/app.js": "/admin/assets/app.js?v=20260817-wa12",
|
||||
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260817-wa12",
|
||||
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260817-wa12",
|
||||
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260817-wa12",
|
||||
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260817-wa12",
|
||||
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260817-wa12",
|
||||
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260817-wa12",
|
||||
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260817-wa12",
|
||||
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260817-wa12",
|
||||
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260817-wa12"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/admin/assets/app.js?v=20260817-wa6" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||
<script type="module" src="/admin/assets/app.js?v=20260817-wa12" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@
|
|||
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
|
||||
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
|
||||
*/
|
||||
export const ADMIN_ASSET_V = "20260812-22";
|
||||
export const ADMIN_ASSET_V = "20260817-wa12";
|
||||
|
|
|
|||
|
|
@ -793,9 +793,9 @@ export default {
|
|||
"wa.sessionUnbindConfirm": "Stop sidecar and delete local WhatsApp auth (state/auth). You must scan again to rebind. Continue?",
|
||||
"wa.sessionRebindConfirm": "Clear the old session and show a new QR code. Continue?",
|
||||
"wa.sessionNotInstalled": "Sidecar not installed. Run whatsapp_install.ps1 first.",
|
||||
"wa.sessionScanHint": "On your phone: WhatsApp → Linked devices → Link a device, then scan the QR below.",
|
||||
"wa.sessionScanHint": "On your phone: WhatsApp → Linked devices → Link a device, then scan the QR below. Codes expire in about 20s; this page auto-refreshes to the latest one.",
|
||||
"wa.sessionQrWaiting": "Waiting for QR…",
|
||||
"wa.sessionQrStale": "QR may be expired. Click Scan to bind again.",
|
||||
"wa.sessionQrStale": "This QR may have expired. Waiting for a new one…",
|
||||
"wa.lifecycle.unbound": "Not bound",
|
||||
"wa.lifecycle.bound_stopped": "Bound (sidecar stopped)",
|
||||
"wa.lifecycle.bound_offline": "Bound (offline / reconnecting)",
|
||||
|
|
@ -806,6 +806,9 @@ export default {
|
|||
"wa.lifecycle.needs_rebind": "Session invalid (re-scan required)",
|
||||
"wa.sessionPolling": "Refreshing status…",
|
||||
"wa.sessionNeedsRebind": "WhatsApp rejected the stored session (e.g. disconnect=405). Click Re-scan bind, or remove the old oclaw linked device on your phone first.",
|
||||
"wa.sessionPairingFail": "WhatsApp rejected pairing before a QR (often 405). Retrying with fresh keys; if it keeps failing, check proxy/DNS then click Scan to bind.",
|
||||
"wa.sessionNetworkFail": "Cannot reach WhatsApp Web (certificate/timeout/ECONNRESET). Clash fake-ip is a common cause. Set HTTPS_PROXY or AIA_WHATSAPP_PROXY_URL to your local mixed-port proxy, then start the sidecar again.",
|
||||
"wa.sessionRunnerWaiting": "Sidecar process is up, but Baileys has not written status yet (npx hang or missing proxy). Wait a bit, or check data/logs/whatsapp_sidecar.log.",
|
||||
"wecom.autoBind": "Auto bind on first message",
|
||||
"wecom.autoBindRole": "Auto bind role",
|
||||
"wecom.autoBindTenant": "Auto bind team",
|
||||
|
|
|
|||
|
|
@ -793,9 +793,9 @@ export default {
|
|||
"wa.sessionUnbindConfirm": "将停止 sidecar 并删除本机 WhatsApp 登录态(state/auth)。之后需重新扫码绑定,确认继续?",
|
||||
"wa.sessionRebindConfirm": "将清除旧登录态并重新出二维码,确认继续?",
|
||||
"wa.sessionNotInstalled": "sidecar 未安装,请先运行 whatsapp_install.ps1",
|
||||
"wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。",
|
||||
"wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。码大约 20 秒过期,页面会自动换成新码,请扫最新一张。",
|
||||
"wa.sessionQrWaiting": "等待二维码…",
|
||||
"wa.sessionQrStale": "二维码可能已过期,请点「扫码绑定」刷新。",
|
||||
"wa.sessionQrStale": "当前码可能已过期,正在自动刷新新码…",
|
||||
"wa.lifecycle.unbound": "未绑定",
|
||||
"wa.lifecycle.bound_stopped": "已绑定(sidecar 已停)",
|
||||
"wa.lifecycle.bound_offline": "已绑定(离线重连中)",
|
||||
|
|
@ -806,6 +806,9 @@ export default {
|
|||
"wa.lifecycle.needs_rebind": "登录态失效(需重新扫码)",
|
||||
"wa.sessionPolling": "正在刷新状态…",
|
||||
"wa.sessionNeedsRebind": "WhatsApp 拒绝了当前登录态(disconnect=405 等)。请点「重新扫码绑定」,或在手机端删除旧的 oclaw 关联设备后再扫。",
|
||||
"wa.sessionPairingFail": "WhatsApp 在出码前拒绝了配对(常见 405)。正在换新密钥重试;若持续失败,请检查代理/DNS 后点「扫码绑定」。",
|
||||
"wa.sessionNetworkFail": "连不上 WhatsApp Web(证书/超时/ECONNRESET)。常见原因是 Clash fake-ip。请给 sidecar 设置 HTTPS_PROXY 或 AIA_WHATSAPP_PROXY_URL 指向本机混合端口后再启动。",
|
||||
"wa.sessionRunnerWaiting": "sidecar 进程已起来,但 Baileys 还没写出状态(常见于 npx 卡住或无代理)。请稍候,或看 data/logs/whatsapp_sidecar.log。",
|
||||
"wecom.autoBind": "首条消息自动绑定",
|
||||
"wecom.autoBindRole": "自动绑定角色",
|
||||
"wecom.autoBindTenant": "自动绑定团队",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ async function renderStack() {
|
|||
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
|
||||
let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {};
|
||||
let waSessionBindInProgress = false;
|
||||
let waSessionBindRequested = false;
|
||||
let waSessionWatchGen = 0;
|
||||
let waLastQrKey = "";
|
||||
const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" });
|
||||
const paintWaSession = (st) => {
|
||||
const s = st && typeof st === "object" ? st : {};
|
||||
|
|
@ -109,46 +112,74 @@ async function renderStack() {
|
|||
reconnect: Number(s.reconnect_attempt || 0),
|
||||
});
|
||||
waSessionStatus.replaceChildren(...Array.from(chips.childNodes));
|
||||
const showDisconnect = lifecycle !== "unbound" && lifecycle !== "awaiting_scan";
|
||||
const errText = String(s.last_error || "");
|
||||
const discStatus = Number(s.last_disconnect_status || 0);
|
||||
const pairingFail =
|
||||
lifecycle === "connecting" &&
|
||||
(errText.includes("pairing_rejected") || discStatus === 405 || /connection failure/i.test(errText));
|
||||
const networkFail =
|
||||
lifecycle === "connecting" &&
|
||||
/certificate|econnreset|timedOut|408|fake-ip|198\.18/i.test(`${errText} ${String(s.last_disconnect_reason || "")}`);
|
||||
const runnerWaiting =
|
||||
lifecycle === "connecting" &&
|
||||
/runner_waiting|runner_starting/i.test(errText);
|
||||
const detailBits = [
|
||||
s.display_name ? `name=${s.display_name}` : "",
|
||||
s.me ? `me=${s.me}` : "",
|
||||
s.last_error ? `err=${String(s.last_error).slice(0, 120)}` : "",
|
||||
s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "",
|
||||
showDisconnect && errText ? `err=${errText.slice(0, 120)}` : "",
|
||||
showDisconnect && s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "",
|
||||
s.log_path ? `log=${s.log_path}` : "",
|
||||
s.bridge_updated_at ? `updated=${s.bridge_updated_at}` : "",
|
||||
].filter(Boolean);
|
||||
waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled"));
|
||||
if (lifecycle === "needs_rebind" || lifecycle === "logged_out") {
|
||||
waSessionHintAlert.textContent = t("wa.sessionNeedsRebind");
|
||||
waSessionHintAlert.classList.remove("u-hidden");
|
||||
} else if (waSessionBindRequested && runnerWaiting) {
|
||||
waSessionHintAlert.textContent = t("wa.sessionRunnerWaiting");
|
||||
waSessionHintAlert.classList.remove("u-hidden");
|
||||
} else if (waSessionBindRequested && pairingFail) {
|
||||
waSessionHintAlert.textContent = t("wa.sessionPairingFail");
|
||||
waSessionHintAlert.classList.remove("u-hidden");
|
||||
} else if (waSessionBindRequested && networkFail) {
|
||||
waSessionHintAlert.textContent = t("wa.sessionNetworkFail");
|
||||
waSessionHintAlert.classList.remove("u-hidden");
|
||||
} else {
|
||||
waSessionHintAlert.textContent = "";
|
||||
waSessionHintAlert.classList.add("u-hidden");
|
||||
}
|
||||
const qrUrl = String(s.qr_data_url || "").trim();
|
||||
const awaiting = lifecycle === "awaiting_scan" || waSessionBindInProgress;
|
||||
const awaiting = Boolean(waSessionBindRequested) && (lifecycle === "awaiting_scan" || waSessionBindInProgress);
|
||||
waSessionQrWrap.style.display = awaiting ? "" : "none";
|
||||
waSessionQrHint.textContent = awaiting
|
||||
? (s.qr_stale ? t("wa.sessionQrStale") : t("wa.sessionScanHint"))
|
||||
: "";
|
||||
const qrKey = `${String(s.qr || "").slice(0, 48)}|${s.bridge_updated_at || ""}|${qrUrl.length}`;
|
||||
if (awaiting && qrUrl) {
|
||||
waSessionQrImg.src = qrUrl;
|
||||
if (qrKey !== waLastQrKey) {
|
||||
waLastQrKey = qrKey;
|
||||
waSessionQrImg.src = qrUrl;
|
||||
}
|
||||
waSessionQrImg.style.display = "";
|
||||
waSessionQrWaiting.style.display = "none";
|
||||
} else if (awaiting) {
|
||||
waLastQrKey = "";
|
||||
waSessionQrImg.removeAttribute("src");
|
||||
waSessionQrImg.style.display = "none";
|
||||
waSessionQrWaiting.style.display = "";
|
||||
waSessionQrWaiting.textContent = t("wa.sessionQrWaiting");
|
||||
} else {
|
||||
waLastQrKey = "";
|
||||
waSessionQrImg.removeAttribute("src");
|
||||
waSessionQrImg.style.display = "none";
|
||||
waSessionQrWaiting.style.display = "none";
|
||||
}
|
||||
const bindModes = new Set(["unbound", "logged_out", "awaiting_scan", "needs_rebind"]);
|
||||
const rebindModes = new Set(["needs_rebind", "logged_out", "bound_stopped", "bound_offline", "online"]);
|
||||
if (waSessionBindBtn) {
|
||||
waSessionBindBtn.textContent = bindModes.has(lifecycle)
|
||||
? t("wa.sessionBind")
|
||||
: t("wa.sessionRebind");
|
||||
waSessionBindBtn.textContent = rebindModes.has(lifecycle)
|
||||
? t("wa.sessionRebind")
|
||||
: t("wa.sessionBind");
|
||||
waSessionBindBtn.disabled = Boolean(waSessionBindInProgress);
|
||||
}
|
||||
};
|
||||
|
|
@ -175,12 +206,14 @@ async function renderStack() {
|
|||
const stepMs = Number(opts.stepMs || 2000);
|
||||
const stopWhen = opts.stopWhen || ((st) => {
|
||||
const life = String((st && st.lifecycle) || "");
|
||||
return ["online", "awaiting_scan", "needs_rebind", "logged_out", "bound_stopped", "unbound"].includes(life);
|
||||
return ["online", "needs_rebind", "logged_out", "bound_stopped", "unbound"].includes(life);
|
||||
});
|
||||
waSessionActionStatus.textContent = t("wa.sessionPolling");
|
||||
let waited = 0;
|
||||
let latest = waSessionLatest;
|
||||
const watchGen = Number(opts.gen || 0);
|
||||
while (waited < maxMs) {
|
||||
if (watchGen && watchGen !== waSessionWatchGen) return latest;
|
||||
const st = await apiGet("/admin/api/whatsapp/session");
|
||||
paintWaSession(st);
|
||||
latest = st;
|
||||
|
|
@ -229,32 +262,59 @@ async function renderStack() {
|
|||
class: "btn",
|
||||
text: t("wa.sessionStop"),
|
||||
onclick: async () => {
|
||||
waSessionBindRequested = false;
|
||||
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false });
|
||||
},
|
||||
});
|
||||
const waSessionBindBtnClick = async () => {
|
||||
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim();
|
||||
const autoClear = ["needs_rebind", "logged_out"].includes(curLife);
|
||||
const needClear = autoClear || (curLife && !["unbound", "awaiting_scan"].includes(curLife));
|
||||
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
|
||||
const watchWaBindUntilOnline = async () => {
|
||||
const gen = ++waSessionWatchGen;
|
||||
waSessionBindRequested = true;
|
||||
waSessionBindInProgress = true;
|
||||
paintWaSession(waSessionLatest);
|
||||
try {
|
||||
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", {
|
||||
clear_auth: Boolean(needClear),
|
||||
}), {
|
||||
poll: {
|
||||
maxMs: 120000,
|
||||
stopWhen: (st) => {
|
||||
const life = String((st && st.lifecycle) || "");
|
||||
if (life === "online") return true;
|
||||
return life === "awaiting_scan" && Boolean(st && (st.qr_data_url || st.qr));
|
||||
},
|
||||
await pollWaSession({
|
||||
maxMs: 10 * 60 * 1000,
|
||||
stepMs: 1500,
|
||||
gen,
|
||||
stopWhen: (st) => {
|
||||
const life = String((st && st.lifecycle) || "");
|
||||
if (life === "online") return true;
|
||||
if (life === "bound_stopped") return true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (gen === waSessionWatchGen) {
|
||||
waSessionBindInProgress = false;
|
||||
const life = String((waSessionLatest && waSessionLatest.lifecycle) || "");
|
||||
if (life === "online" || life === "unbound" || life === "bound_stopped") {
|
||||
waSessionBindRequested = false;
|
||||
}
|
||||
paintWaSession(waSessionLatest);
|
||||
}
|
||||
}
|
||||
};
|
||||
const waSessionBindBtnClick = async () => {
|
||||
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim();
|
||||
const autoClear = ["needs_rebind", "logged_out"].includes(curLife);
|
||||
const needClear = autoClear || ["bound_stopped", "bound_offline", "online"].includes(curLife);
|
||||
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
|
||||
waSessionBindRequested = true;
|
||||
waSessionBindInProgress = true;
|
||||
waLastQrKey = "";
|
||||
paintWaSession(waSessionLatest);
|
||||
const watch = watchWaBindUntilOnline();
|
||||
try {
|
||||
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", {
|
||||
clear_auth: Boolean(needClear),
|
||||
}), { poll: false });
|
||||
await watch;
|
||||
} catch (err) {
|
||||
waSessionWatchGen += 1;
|
||||
waSessionBindInProgress = false;
|
||||
waSessionBindRequested = false;
|
||||
paintWaSession(waSessionLatest);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
waSessionBindBtn.onclick = waSessionBindBtnClick;
|
||||
|
|
@ -263,6 +323,7 @@ async function renderStack() {
|
|||
text: t("wa.sessionUnbind"),
|
||||
onclick: async () => {
|
||||
if (!window.confirm(t("wa.sessionUnbindConfirm"))) return;
|
||||
waSessionBindRequested = false;
|
||||
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false });
|
||||
},
|
||||
});
|
||||
|
|
@ -289,7 +350,7 @@ async function renderStack() {
|
|||
paintWaSession(whatsappSessionResp || {});
|
||||
if (whatsappSessionResp && whatsappSessionResp.sidecar_running) {
|
||||
const initLife = String(whatsappSessionResp.lifecycle || "");
|
||||
if (["connecting", "bound_offline"].includes(initLife)) {
|
||||
if (initLife === "bound_offline" || initLife === "online") {
|
||||
void pollWaSession({ maxMs: 30000 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
runtime/operations/scripts/lib/ResolveWhatsAppProxy.ps1
Normal file
52
runtime/operations/scripts/lib/ResolveWhatsAppProxy.ps1
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Resolve a proxy URL for the WhatsApp sidecar without hardcoding a port.
|
||||
# Order: process env -> state\proxy.url -> Windows user Internet Settings.
|
||||
function Get-OclawWhatsAppProxyUrl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StateDir,
|
||||
[switch]$Persist
|
||||
)
|
||||
$found = ""
|
||||
foreach ($name in @("AIA_WHATSAPP_PROXY_URL", "HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy")) {
|
||||
$val = [Environment]::GetEnvironmentVariable($name, "Process")
|
||||
if ($val -and $val.Trim()) {
|
||||
$found = $val.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $found) {
|
||||
$proxyFile = Join-Path $StateDir "proxy.url"
|
||||
if (Test-Path $proxyFile) {
|
||||
$line = @(Get-Content -Path $proxyFile -ErrorAction SilentlyContinue) | Select-Object -First 1
|
||||
if ($line -and $line.Trim()) { $found = $line.Trim() }
|
||||
}
|
||||
}
|
||||
if (-not $found) {
|
||||
try {
|
||||
$reg = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -ErrorAction Stop
|
||||
$raw = [string]$reg.ProxyServer
|
||||
# Clash TUN often sets ProxyEnable=0 but leaves the last mixed-port address.
|
||||
if ($raw -and (($reg.ProxyEnable -eq 1) -or ($raw -match '(?i)127\.0\.0\.1|localhost|::1'))) {
|
||||
$hostport = ""
|
||||
if ($raw -match '(?i)https?=([^;]+)') {
|
||||
$hostport = $Matches[1].Trim()
|
||||
} elseif ($raw -match '^[^\s;]+$') {
|
||||
$hostport = $raw.Trim()
|
||||
}
|
||||
if ($hostport) {
|
||||
if ($hostport -notmatch '^[a-zA-Z][a-zA-Z0-9+.-]*://') {
|
||||
$hostport = "http://$hostport"
|
||||
}
|
||||
$found = $hostport
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if ($found -and $found.StartsWith('"') -and $found.EndsWith('"')) {
|
||||
$found = $found.Substring(1, $found.Length - 2)
|
||||
}
|
||||
if ($Persist -and $found) {
|
||||
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
||||
Set-Content -Path (Join-Path $StateDir "proxy.url") -Value $found -Encoding ascii
|
||||
}
|
||||
return $found
|
||||
}
|
||||
|
|
@ -23,16 +23,53 @@ if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
|
|||
throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first."
|
||||
}
|
||||
|
||||
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
||||
if (Test-Path $bridgeSrc) {
|
||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts", "status.ts")) {
|
||||
$srcPath = Join-Path $bridgeSrc $name
|
||||
if (Test-Path $srcPath) {
|
||||
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stopScript = Join-Path $PSScriptRoot "whatsapp_stop.ps1"
|
||||
if (Test-Path $stopScript) {
|
||||
Write-Host "[info] stopping existing sidecar so this console can print QR."
|
||||
& $stopScript -ChannelId $ChannelId -Force
|
||||
}
|
||||
|
||||
. (Join-Path $PSScriptRoot "lib\ResolveWhatsAppProxy.ps1")
|
||||
$proxyUrl = Get-OclawWhatsAppProxyUrl -StateDir $stateDir -Persist
|
||||
if ($proxyUrl) {
|
||||
$env:AIA_WHATSAPP_PROXY_URL = $proxyUrl
|
||||
$env:HTTPS_PROXY = $proxyUrl
|
||||
$env:HTTP_PROXY = $proxyUrl
|
||||
Write-Host "[info] whatsapp login proxy=$proxyUrl"
|
||||
}
|
||||
|
||||
Set-Content -Path (Join-Path $sidecarRoot "pid.txt") -Value $PID
|
||||
|
||||
$nodeExe = Join-Path $systemNodeDir "node.exe"
|
||||
if (-not (Test-Path $nodeExe)) { $nodeExe = "node.exe" }
|
||||
$tsxCli = Join-Path $sidecarRoot "node_modules\tsx\dist\cli.mjs"
|
||||
|
||||
Push-Location $sidecarRoot
|
||||
try {
|
||||
$env:OCLAW_STATE_DIR = $stateDir
|
||||
Write-Host "[info] whatsapp login started (foreground)."
|
||||
Write-Host "[info] QR prints here if WhatsApp issues one. Background sidecar logs: data\\logs\\whatsapp_sidecar.log"
|
||||
Write-Host "[info] If QR appears, scan it in WhatsApp -> Linked devices."
|
||||
$runnerArgs = @("-y", "tsx", "baileys_runner.ts", "--login")
|
||||
if ($VerboseRunner) {
|
||||
$runnerArgs += "--verbose"
|
||||
$runnerArgs = @()
|
||||
if (Test-Path $tsxCli) {
|
||||
$runnerArgs = @($tsxCli, "baileys_runner.ts", "--login")
|
||||
if ($VerboseRunner) { $runnerArgs += "--verbose" }
|
||||
& $nodeExe @runnerArgs
|
||||
} else {
|
||||
$runnerArgs = @("-y", "tsx", "baileys_runner.ts", "--login")
|
||||
if ($VerboseRunner) { $runnerArgs += "--verbose" }
|
||||
& npx.cmd @runnerArgs
|
||||
}
|
||||
& npx.cmd @runnerArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "whatsapp login runner exited with code $LASTEXITCODE"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ $sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
|
|||
|
||||
$env:PYTHONPATH = $oclawRoot
|
||||
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
|
||||
. (Join-Path $PSScriptRoot "lib\ResolveWhatsAppProxy.ps1")
|
||||
$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
|
||||
|
|
@ -61,10 +62,23 @@ 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
|
||||
$proxyUrl = Get-OclawWhatsAppProxyUrl -StateDir $stateDir -Persist
|
||||
if ($proxyUrl) {
|
||||
$env:AIA_WHATSAPP_PROXY_URL = $proxyUrl
|
||||
$env:HTTPS_PROXY = $proxyUrl
|
||||
$env:HTTP_PROXY = $proxyUrl
|
||||
Write-Host "[info] whatsapp sidecar proxy=$proxyUrl"
|
||||
}
|
||||
|
||||
$nodeExe = Join-Path $systemNodeDir "node.exe"
|
||||
if (-not (Test-Path $nodeExe)) { $nodeExe = "node.exe" }
|
||||
$tsxCli = Join-Path $sidecarRoot "node_modules\tsx\dist\cli.mjs"
|
||||
if (-not (Test-Path $tsxCli)) {
|
||||
throw "tsx not installed under $sidecarRoot. Run whatsapp_install.ps1 first."
|
||||
}
|
||||
|
||||
$env:OCLAW_STATE_DIR = $stateDir
|
||||
$env:AIA_GATEWAY_BASE_URL = $GatewayBaseUrl
|
||||
$p = Start-Process -FilePath $nodeExe -ArgumentList @($tsxCli, "baileys_runner.ts") -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"
|
||||
|
|
|
|||
|
|
@ -46,4 +46,14 @@ if (Test-Path $sidecarPidFile) {
|
|||
Remove-Item -Force $sidecarPidFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# cmd.exe wrapper can leave node/tsx children; sweep leftover runners for this channel.
|
||||
$needle = "channel_sidecar\$ChannelId"
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*$needle*" -and
|
||||
$_.CommandLine -like "*baileys_runner*"
|
||||
} | ForEach-Object {
|
||||
[void](Invoke-TaskKillQuiet -ProcessId ([string]$_.ProcessId) -ForceKill)
|
||||
}
|
||||
|
||||
Write-Host "[ok] stopped whatsapp channel=$ChannelId"
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import makeWASocket, {
|
|||
downloadMediaMessage,
|
||||
extensionForMediaMessage,
|
||||
fetchLatestBaileysVersion,
|
||||
fetchLatestWaWebVersion,
|
||||
getContentType,
|
||||
jidNormalizedUser,
|
||||
proto,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
import { loadAuthState } from "./auth";
|
||||
import { loadAuthState, resolveAuthDir } from "./auth";
|
||||
import { printQrToTerminal, writeQrImage } from "./qr";
|
||||
import { writeBridgeStatus } from "./status";
|
||||
|
||||
|
|
@ -1052,6 +1053,62 @@ function _ipLooksHijackedOrUnroutable(ip: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
const STALE_WA_BUILD = 1027934701;
|
||||
const FALLBACK_WA_VERSION: [number, number, number] = [2, 3000, 1043857760];
|
||||
|
||||
function isUsableWaVersion(version: unknown): version is [number, number, number] {
|
||||
if (!Array.isArray(version) || version.length < 3) return false;
|
||||
const build = Number(version[2]);
|
||||
return Number.isFinite(build) && build > STALE_WA_BUILD;
|
||||
}
|
||||
|
||||
async function proxyFetchInit(): Promise<Record<string, unknown>> {
|
||||
if (!PROXY_URL) return {};
|
||||
try {
|
||||
const undici = await import("node:undici");
|
||||
return { dispatcher: new undici.ProxyAgent(PROXY_URL) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWhatsAppVersion(): Promise<[number, number, number]> {
|
||||
const fromEnv = String(process.env.AIA_WHATSAPP_WA_VERSION || "").trim();
|
||||
if (fromEnv) {
|
||||
const parts = fromEnv.split(/[.,]/).map((n) => Number(n));
|
||||
if (isUsableWaVersion(parts)) {
|
||||
log(`wa version from AIA_WHATSAPP_WA_VERSION=${parts.join(".")}`);
|
||||
return [parts[0], parts[1], parts[2]];
|
||||
}
|
||||
log(`ignore invalid AIA_WHATSAPP_WA_VERSION=${fromEnv}`);
|
||||
}
|
||||
const fetchInit = await proxyFetchInit();
|
||||
try {
|
||||
const latest = await withTimeout(fetchLatestBaileysVersion(fetchInit as any), 8000, "fetchLatestBaileysVersion");
|
||||
if (isUsableWaVersion(latest.version)) {
|
||||
log(`wa version from Baileys master=${latest.version.join(".")} isLatest=${String(latest.isLatest)}`);
|
||||
return [latest.version[0], latest.version[1], latest.version[2]];
|
||||
}
|
||||
log(
|
||||
`fetchLatestBaileysVersion unusable version=${JSON.stringify(latest.version)} error=${String((latest as any).error || "")}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log(`fetchLatestBaileysVersion failed: ${String(err)}`);
|
||||
}
|
||||
try {
|
||||
const web = await withTimeout(fetchLatestWaWebVersion(fetchInit as any), 8000, "fetchLatestWaWebVersion");
|
||||
if (isUsableWaVersion(web.version)) {
|
||||
log(`wa version from WhatsApp Web=${web.version.join(".")}`);
|
||||
return [web.version[0], web.version[1], web.version[2]];
|
||||
}
|
||||
log(`fetchLatestWaWebVersion unusable version=${JSON.stringify(web.version)}`);
|
||||
} catch (err) {
|
||||
log(`fetchLatestWaWebVersion failed: ${String(err)}`);
|
||||
}
|
||||
log(`wa version fallback=${FALLBACK_WA_VERSION.join(".")} (rc.9 default ${STALE_WA_BUILD} is rejected with 405)`);
|
||||
return FALLBACK_WA_VERSION;
|
||||
}
|
||||
|
||||
async function logNetworkHints(): Promise<void> {
|
||||
try {
|
||||
const a = await dns.resolve4("web.whatsapp.com").catch(() => []);
|
||||
|
|
@ -1076,6 +1133,11 @@ async function logNetworkHints(): Promise<void> {
|
|||
}
|
||||
if (PROXY_URL) {
|
||||
log(`proxy configured for Baileys via PROXY_URL=${PROXY_URL}`);
|
||||
try {
|
||||
fs.writeFileSync(path.join(STATE_DIR, "proxy.url"), PROXY_URL, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1109,8 +1171,22 @@ function makeDeduper(params: { ttlMs: number; max: number }) {
|
|||
|
||||
async function main(): Promise<void> {
|
||||
ensureDir(STATE_DIR);
|
||||
const { state, saveCreds } = await loadAuthState(STATE_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
log(
|
||||
`runner booting local=${LOCAL_BASE_URL} stateDir=${STATE_DIR} verbose=${VERBOSE} node=${process.version} proxy=${PROXY_URL ? "set" : "unset"}`,
|
||||
);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "connecting",
|
||||
me: "",
|
||||
phone: "",
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_error: "runner_starting",
|
||||
reconnect_attempt: 0,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
let { state, saveCreds } = await loadAuthState(STATE_DIR);
|
||||
const version = await resolveWhatsAppVersion();
|
||||
const dedupe = makeDeduper({ ttlMs: 10 * 60 * 1000, max: 20_000 });
|
||||
|
||||
let reconnectAttempt = 0;
|
||||
|
|
@ -1169,6 +1245,7 @@ async function main(): Promise<void> {
|
|||
|
||||
sock.ev.on("connection.update", async (update) => {
|
||||
if (update.qr) {
|
||||
reconnectAttempt = 0;
|
||||
log("QR received. Scan it from WhatsApp -> Linked devices.");
|
||||
printQrToTerminal(update.qr);
|
||||
const qrArt = await writeQrImage(STATE_DIR, update.qr);
|
||||
|
|
@ -1177,6 +1254,9 @@ async function main(): Promise<void> {
|
|||
qr: String(update.qr || ""),
|
||||
qr_data_url: qrArt.dataUrl,
|
||||
qr_png: qrArt.pngPath ? path.basename(qrArt.pngPath) : "",
|
||||
last_error: "",
|
||||
last_disconnect_reason: "",
|
||||
last_disconnect_status: null,
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
|
|
@ -1216,10 +1296,16 @@ async function main(): Promise<void> {
|
|||
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}`);
|
||||
const hasLinkedSession = Boolean(
|
||||
jidPhone(String((state.creds as any)?.me?.id || sock?.user?.id || "")),
|
||||
);
|
||||
if (statusCode === DisconnectReason.loggedOut) {
|
||||
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "logged_out",
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_disconnect_reason: String(reason),
|
||||
last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
|
||||
last_error: errText,
|
||||
|
|
@ -1233,10 +1319,13 @@ async function main(): Promise<void> {
|
|||
DisconnectReason.badSession,
|
||||
405,
|
||||
]);
|
||||
if (typeof statusCode === "number" && rebindCodes.has(statusCode)) {
|
||||
if (hasLinkedSession && typeof statusCode === "number" && rebindCodes.has(statusCode)) {
|
||||
log(`session invalid statusCode=${statusCode}: clear state/auth and scan QR again.`);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "needs_rebind",
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_disconnect_reason: String(reason),
|
||||
last_disconnect_status: statusCode,
|
||||
last_error: errText,
|
||||
|
|
@ -1245,16 +1334,55 @@ async function main(): Promise<void> {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (!hasLinkedSession && statusCode === 405) {
|
||||
reconnectAttempt += 1;
|
||||
log(`pairing 405: WhatsApp rejected this identity. Resetting auth keys attempt=${reconnectAttempt}`);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "connecting",
|
||||
me: "",
|
||||
phone: "",
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_disconnect_reason: "405",
|
||||
last_disconnect_status: 405,
|
||||
last_error: "pairing_rejected_resetting_keys",
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
try {
|
||||
sock?.end(undefined as any);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sock = null;
|
||||
try {
|
||||
fs.rmSync(resolveAuthDir(STATE_DIR), { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const fresh = await loadAuthState(STATE_DIR);
|
||||
state = fresh.state;
|
||||
saveCreds = fresh.saveCreds;
|
||||
await sleep(2000);
|
||||
await connectOnce();
|
||||
return;
|
||||
}
|
||||
reconnectAttempt += 1;
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "close",
|
||||
connection: hasLinkedSession ? "close" : "connecting",
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_disconnect_reason: String(reason),
|
||||
last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
|
||||
last_error: errText,
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
const base = Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt)));
|
||||
const base = hasLinkedSession
|
||||
? Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt)))
|
||||
: 1500;
|
||||
const jitter = Math.floor(Math.random() * 500);
|
||||
const delay = base + jitter;
|
||||
log(`reconnecting in ${delay}ms attempt=${reconnectAttempt}`);
|
||||
|
|
@ -1379,7 +1507,9 @@ async function main(): Promise<void> {
|
|||
});
|
||||
};
|
||||
|
||||
log(`runner started local=${LOCAL_BASE_URL} stateDir=${STATE_DIR} verbose=${VERBOSE} node=${process.version}`);
|
||||
log(
|
||||
`runner started local=${LOCAL_BASE_URL} stateDir=${STATE_DIR} verbose=${VERBOSE} node=${process.version} waVersion=${version.join(".")}`,
|
||||
);
|
||||
await logNetworkHints();
|
||||
startOutboundPoller(() => sock);
|
||||
await connectOnce();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import os
|
|||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -59,18 +60,39 @@ def _read_pid(channel_id: str = DEFAULT_CHANNEL_ID) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _write_local_status(channel_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
def _empty_bridge_status() -> dict[str, Any]:
|
||||
return {
|
||||
"connection": "stopped",
|
||||
"me": "",
|
||||
"phone": "",
|
||||
"qr": "",
|
||||
"qr_data_url": "",
|
||||
"qr_png": "",
|
||||
"last_disconnect_reason": "",
|
||||
"last_disconnect_status": None,
|
||||
"last_error": "",
|
||||
"reconnect_attempt": 0,
|
||||
"login_only": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_local_status(
|
||||
channel_id: str,
|
||||
patch: dict[str, Any],
|
||||
*,
|
||||
replace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
path = bridge_status_file(channel_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prev: dict[str, Any] = {}
|
||||
if path.exists():
|
||||
if not replace and path.exists():
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(obj, dict):
|
||||
prev = obj
|
||||
except Exception:
|
||||
prev = {}
|
||||
next_obj = dict(prev)
|
||||
next_obj = dict(_empty_bridge_status() if replace else prev)
|
||||
next_obj.update(patch or {})
|
||||
next_obj["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
path.write_text(json.dumps(next_obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
|
@ -165,6 +187,17 @@ def installed(channel_id: str = DEFAULT_CHANNEL_ID) -> bool:
|
|||
return (sidecar_root(channel_id) / "baileys_runner.ts").exists()
|
||||
|
||||
|
||||
def _bridge_updated_age_s(bridge: dict[str, Any]) -> float | None:
|
||||
updated = str(bridge.get("updated_at") or "").strip()
|
||||
if not updated:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
|
||||
return max(0.0, time.time() - ts)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _needs_rebind_disconnect(bridge: dict[str, Any]) -> bool:
|
||||
code = bridge.get("last_disconnect_status")
|
||||
try:
|
||||
|
|
@ -210,11 +243,25 @@ def session_status(
|
|||
auth_exists = auth_dir(cid).exists() and any(auth_dir(cid).glob("*.json"))
|
||||
bound = bool(bound_info.get("me") or auth_exists)
|
||||
connection = str(bridge.get("connection") or "").strip().lower()
|
||||
last_error = str(bridge.get("last_error") or "")
|
||||
last_disconnect_reason = str(bridge.get("last_disconnect_reason") or "")
|
||||
last_disconnect_status = bridge.get("last_disconnect_status")
|
||||
login_only = bool(bridge.get("login_only"))
|
||||
qr_text = str(bridge.get("qr") or "").strip()
|
||||
age_s = _bridge_updated_age_s(bridge)
|
||||
# whatsapp_login.ps1 prints QR in the foreground and does not write pid.txt.
|
||||
live_qr = connection == "qr" and bool(qr_text) and (age_s is None or age_s <= 90)
|
||||
live_login = login_only and connection in ("qr", "connecting", "open") and (age_s is None or age_s <= 90)
|
||||
if live_qr or live_login:
|
||||
running = True
|
||||
if not running:
|
||||
if bound:
|
||||
lifecycle = "bound_stopped"
|
||||
else:
|
||||
lifecycle = "unbound"
|
||||
last_error = ""
|
||||
last_disconnect_reason = ""
|
||||
last_disconnect_status = None
|
||||
connection = "stopped"
|
||||
else:
|
||||
if connection == "open":
|
||||
|
|
@ -223,23 +270,30 @@ def session_status(
|
|||
lifecycle = "awaiting_scan"
|
||||
elif connection == "connecting":
|
||||
lifecycle = "connecting"
|
||||
elif connection == "logged_out":
|
||||
elif bound and connection == "logged_out":
|
||||
lifecycle = "logged_out"
|
||||
elif connection == "needs_rebind" or _needs_rebind_disconnect(bridge):
|
||||
elif bound and (connection == "needs_rebind" or _needs_rebind_disconnect(bridge)):
|
||||
lifecycle = "needs_rebind"
|
||||
elif bound:
|
||||
lifecycle = "bound_offline"
|
||||
if not connection:
|
||||
connection = "close"
|
||||
else:
|
||||
# Auth already cleared; leftover 405/close must not keep the UI on "needs rebind".
|
||||
lifecycle = "connecting"
|
||||
if not connection:
|
||||
if connection not in ("connecting", "qr"):
|
||||
if connection == "stopped" and not last_error:
|
||||
last_error = "runner_waiting"
|
||||
connection = "connecting"
|
||||
me = str(bridge.get("me") or bound_info.get("me") or "").strip()
|
||||
phone = str(bridge.get("phone") or bound_info.get("phone") or "").strip()
|
||||
name = str(bound_info.get("name") or "").strip()
|
||||
qr = str(bridge.get("qr") or "").strip()
|
||||
qr_data_url = str(bridge.get("qr_data_url") or "").strip()
|
||||
qr_data_url = ""
|
||||
if qr:
|
||||
qr_data_url = _qr_to_data_url(qr)
|
||||
if not qr_data_url:
|
||||
qr_data_url = str(bridge.get("qr_data_url") or "").strip()
|
||||
qr_png_name = str(bridge.get("qr_png") or "").strip()
|
||||
qr_png_path = state_dir(cid) / qr_png_name if qr_png_name else state_dir(cid) / "qr.png"
|
||||
if (not qr_data_url) and qr_png_path.exists():
|
||||
|
|
@ -250,18 +304,13 @@ def session_status(
|
|||
qr_data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
||||
except Exception:
|
||||
qr_data_url = ""
|
||||
if (not qr_data_url) and qr:
|
||||
qr_data_url = _qr_to_data_url(qr)
|
||||
stale_qr = False
|
||||
if qr or qr_data_url:
|
||||
if lifecycle == "awaiting_scan" and (qr or qr_data_url):
|
||||
try:
|
||||
updated = str(bridge.get("updated_at") or "").strip()
|
||||
if updated:
|
||||
# QR older than 2 minutes is usually expired.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
|
||||
stale_qr = (time.time() - ts) > 120
|
||||
stale_qr = (time.time() - ts) > 20
|
||||
except Exception:
|
||||
stale_qr = False
|
||||
log_path = oclaw_log_root() / "whatsapp_sidecar.log"
|
||||
|
|
@ -282,13 +331,14 @@ def session_status(
|
|||
"qr": qr if lifecycle == "awaiting_scan" else "",
|
||||
"qr_data_url": qr_data_url if lifecycle == "awaiting_scan" else "",
|
||||
"qr_stale": bool(stale_qr) if lifecycle == "awaiting_scan" else False,
|
||||
"last_disconnect_reason": str(bridge.get("last_disconnect_reason") or ""),
|
||||
"last_disconnect_status": bridge.get("last_disconnect_status"),
|
||||
"last_error": str(bridge.get("last_error") or ""),
|
||||
"last_disconnect_reason": last_disconnect_reason,
|
||||
"last_disconnect_status": last_disconnect_status,
|
||||
"last_error": last_error,
|
||||
"reconnect_attempt": int(bridge.get("reconnect_attempt") or 0),
|
||||
"bridge_updated_at": str(bridge.get("updated_at") or ""),
|
||||
"auth_dir": str(auth_dir(cid)),
|
||||
"log_path": str(log_path),
|
||||
"login_only": login_only,
|
||||
"status_hint": {
|
||||
"unbound": "not_bound",
|
||||
"bound_stopped": "bound_but_sidecar_stopped",
|
||||
|
|
@ -308,6 +358,11 @@ def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) ->
|
|||
if force:
|
||||
args.append("-Force")
|
||||
result = _run_ps1("whatsapp_stop.ps1", args, timeout_s=60.0)
|
||||
for _ in range(10):
|
||||
pid = _read_pid(cid)
|
||||
if not pid or not is_pid_running(pid):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
_write_local_status(
|
||||
cid,
|
||||
{
|
||||
|
|
@ -316,8 +371,11 @@ def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) ->
|
|||
"qr_data_url": "",
|
||||
"qr_png": "",
|
||||
"last_error": "",
|
||||
"last_disconnect_reason": "",
|
||||
"last_disconnect_status": None,
|
||||
"reconnect_attempt": 0,
|
||||
},
|
||||
replace=True,
|
||||
)
|
||||
return {"ok": bool(result.get("ok")), "action": "stop", **result}
|
||||
|
||||
|
|
@ -360,7 +418,7 @@ def unbind_session(
|
|||
if auth.exists():
|
||||
shutil.rmtree(auth, ignore_errors=True)
|
||||
removed_auth = not auth.exists()
|
||||
for extra in (state_dir(cid) / "qr.png", bridge_status_file(cid)):
|
||||
for extra in (state_dir(cid) / "qr.png",):
|
||||
try:
|
||||
if extra.exists():
|
||||
extra.unlink()
|
||||
|
|
@ -380,6 +438,7 @@ def unbind_session(
|
|||
"last_error": "",
|
||||
"reconnect_attempt": 0,
|
||||
},
|
||||
replace=True,
|
||||
)
|
||||
status = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
|
||||
return {
|
||||
|
|
@ -400,21 +459,28 @@ def start_bind(
|
|||
"""Ensure sidecar is running so a QR can appear (optionally after clearing auth)."""
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
base = str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/") or DEFAULT_GATEWAY_BASE_URL
|
||||
cur = session_status(channel_id=cid, gateway_base_url=base)
|
||||
# Keep an already-visible daemon QR. Replace login-only (cmd) so Admin can stay online after scan.
|
||||
if (
|
||||
not clear_auth
|
||||
and str(cur.get("lifecycle") or "") == "awaiting_scan"
|
||||
and (cur.get("qr") or cur.get("qr_data_url"))
|
||||
and not bool(cur.get("login_only"))
|
||||
and bool(cur.get("sidecar_running"))
|
||||
):
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "bind",
|
||||
"clear_auth": False,
|
||||
"already_awaiting_scan": True,
|
||||
"status": cur,
|
||||
}
|
||||
if clear_auth:
|
||||
unbind_session(cid, gateway_base_url=base)
|
||||
else:
|
||||
stop_sidecar(cid, force=True)
|
||||
started = start_sidecar(cid, gateway_base_url=base)
|
||||
# Poll for QR / online (fresh sidecar may take 10-40s to emit QR).
|
||||
status = started.get("status") if isinstance(started.get("status"), dict) else session_status(channel_id=cid, gateway_base_url=base)
|
||||
for _ in range(45):
|
||||
life = str(status.get("lifecycle") or "")
|
||||
if life == "online":
|
||||
break
|
||||
if life == "awaiting_scan" and (status.get("qr_data_url") or status.get("qr")):
|
||||
break
|
||||
time.sleep(1.0)
|
||||
status = session_status(channel_id=cid, gateway_base_url=base)
|
||||
return {
|
||||
"ok": bool(started.get("ok")),
|
||||
"action": "bind",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.operations import whatsapp_sidecar as wa
|
||||
|
|
@ -24,6 +25,12 @@ def test_whatsapp_install_copies_status_and_qrcode_pkg() -> None:
|
|||
assert "qrcode@$qrcodeVersion" in text
|
||||
start = (REPO_ROOT / "runtime/operations/scripts/whatsapp_start.ps1").read_text(encoding="utf-8")
|
||||
assert "status.ts" in start
|
||||
assert "ResolveWhatsAppProxy.ps1" in start
|
||||
assert "tsx\\dist\\cli.mjs" in start or "tsx/dist/cli.mjs" in start.replace("\\\\", "/")
|
||||
assert "npx.cmd -y tsx" not in start
|
||||
login = (REPO_ROOT / "runtime/operations/scripts/whatsapp_login.ps1").read_text(encoding="utf-8")
|
||||
assert "pid.txt" in login
|
||||
assert "ResolveWhatsAppProxy.ps1" in login
|
||||
|
||||
|
||||
def test_whatsapp_session_status_unbound(tmp_path, monkeypatch) -> None:
|
||||
|
|
@ -81,11 +88,48 @@ def test_whatsapp_unbind_removes_auth(tmp_path, monkeypatch) -> None:
|
|||
auth.mkdir(parents=True)
|
||||
(auth / "creds.json").write_text("{}", encoding="utf-8")
|
||||
(root / "state" / "qr.png").write_bytes(b"png")
|
||||
(root / "state" / "bridge_status.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"connection": "needs_rebind",
|
||||
"last_disconnect_status": 405,
|
||||
"last_error": "Error: Connection Failure",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
out = wa.unbind_session(channel_id="whatsapp")
|
||||
assert out["ok"] is True
|
||||
assert out["removed_auth"] is True
|
||||
assert not auth.exists()
|
||||
assert out["status"]["lifecycle"] == "unbound"
|
||||
assert not out["status"]["last_error"]
|
||||
assert not out["status"]["last_disconnect_reason"]
|
||||
|
||||
|
||||
def test_whatsapp_session_status_ignores_stale_405_when_unbound(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(wa, "is_pid_running", lambda pid: True)
|
||||
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||
state = root / "state"
|
||||
state.mkdir(parents=True)
|
||||
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||
(root / "pid.txt").write_text("4242\n", encoding="utf-8")
|
||||
(state / "bridge_status.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"connection": "needs_rebind",
|
||||
"last_disconnect_status": 405,
|
||||
"last_error": "Error: Connection Failure",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
st = wa.session_status(channel_id="whatsapp")
|
||||
assert st["bound"] is False
|
||||
assert st["lifecycle"] == "connecting"
|
||||
assert st["last_error"] == "Error: Connection Failure"
|
||||
assert int(st["last_disconnect_status"] or 0) == 405
|
||||
|
||||
|
||||
def test_whatsapp_session_status_needs_rebind_on_405(tmp_path, monkeypatch) -> None:
|
||||
|
|
@ -119,11 +163,78 @@ def test_whatsapp_session_status_needs_rebind_on_405(tmp_path, monkeypatch) -> N
|
|||
assert st["status_hint"] == "session_invalid_need_rebind"
|
||||
|
||||
|
||||
def test_whatsapp_session_status_qr_without_pid(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||
state = root / "state"
|
||||
state.mkdir(parents=True)
|
||||
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||
(state / "bridge_status.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"connection": "qr",
|
||||
"qr": "2@testqr",
|
||||
"login_only": True,
|
||||
"updated_at": "2026-08-17T15:29:18.721Z",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(wa.time, "time", lambda: datetime.fromisoformat("2026-08-17T15:29:21+00:00").timestamp())
|
||||
st = wa.session_status(channel_id="whatsapp")
|
||||
assert st["lifecycle"] == "awaiting_scan"
|
||||
assert st["sidecar_running"] is True
|
||||
assert st["qr"] == "2@testqr"
|
||||
assert str(st["qr_data_url"]).startswith("data:image/png;base64,")
|
||||
assert st["login_only"] is True
|
||||
|
||||
|
||||
def test_start_bind_keeps_existing_daemon_qr(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(wa, "is_pid_running", lambda pid: True)
|
||||
monkeypatch.setattr(wa, "stop_sidecar", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not stop")))
|
||||
monkeypatch.setattr(wa, "start_sidecar", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not start")))
|
||||
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||
state = root / "state"
|
||||
state.mkdir(parents=True)
|
||||
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||
(root / "pid.txt").write_text("4242\n", encoding="utf-8")
|
||||
(state / "bridge_status.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"connection": "qr",
|
||||
"qr": "2@keepme",
|
||||
"login_only": False,
|
||||
"updated_at": "2026-08-17T15:29:18.721Z",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(wa.time, "time", lambda: datetime.fromisoformat("2026-08-17T15:29:21+00:00").timestamp())
|
||||
out = wa.start_bind(channel_id="whatsapp", clear_auth=False)
|
||||
assert out["ok"] is True
|
||||
assert out["already_awaiting_scan"] is True
|
||||
assert out["status"]["qr"] == "2@keepme"
|
||||
|
||||
|
||||
def test_qr_to_data_url_generates_png() -> None:
|
||||
url = wa._qr_to_data_url("2@testqr")
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_start_bind_does_not_block_for_qr() -> None:
|
||||
text = (REPO_ROOT / "runtime/operations/whatsapp_sidecar.py").read_text(encoding="utf-8")
|
||||
fn = text[text.index("def start_bind") : text.index("def start_bind") + 1800]
|
||||
assert "for _ in range(45)" not in fn
|
||||
stack = (REPO_ROOT / "interfaces/admin/static/js/pages/stack.js").read_text(encoding="utf-8")
|
||||
assert "watchWaBindUntilOnline" in stack
|
||||
assert "waSessionBindRequested" in stack
|
||||
assert 'initLife === "awaiting_scan" || initLife === "connecting"' not in stack
|
||||
assert "10 * 60 * 1000" in stack
|
||||
assert 'clear_auth: Boolean(needClear)' in stack
|
||||
assert 'curLife === "unbound"' not in stack
|
||||
|
||||
|
||||
def test_admin_routes_expose_whatsapp_session_apis() -> None:
|
||||
text = (REPO_ROOT / "interfaces/admin/routes.py").read_text(encoding="utf-8")
|
||||
assert '/admin/api/whatsapp/session"' in text
|
||||
|
|
@ -137,3 +248,8 @@ def test_admin_routes_expose_whatsapp_session_apis() -> None:
|
|||
assert "pollWaSession" in stack
|
||||
runner = (REPO_ROOT / "runtime/operations/whatsapp_bridge/baileys_runner.ts").read_text(encoding="utf-8")
|
||||
assert "needs_rebind" in runner
|
||||
assert "pairing 405" in runner
|
||||
assert "Resetting auth keys" in runner
|
||||
assert "resolveWhatsAppVersion" in runner
|
||||
assert "1043857760" in runner
|
||||
assert "1027934701" in runner
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue