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:
oliver 2026-08-17 23:54:48 +08:00
parent 5c6833612f
commit 9cf026f605
12 changed files with 593 additions and 101 deletions

View file

@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title> <title>oliver</title>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). --> <!-- 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/styles.css?v=20260817-wa12" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa6" /> <link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa12" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa6" /> <link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa12" />
<style> <style>
/* First paint before app.js runs: keep admin from showing a blank dark screen. */ /* First paint before app.js runs: keep admin from showing a blank dark screen. */
.admin-boot-splash { .admin-boot-splash {
@ -129,33 +129,33 @@
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
"/admin/assets/app.js": "/admin/assets/app.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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6", "/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-wa6" "/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260817-wa12"
} }
} }
</script> </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> </body>
</html> </html>

View file

@ -3,4 +3,4 @@
* - interfaces/admin/static/index.html importmap + CSS/JS ?v= * - interfaces/admin/static/index.html importmap + CSS/JS ?v=
* - interfaces/admin/static/app.js entry query (optional if importmap covers it) * - 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";

View file

@ -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.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.sessionRebindConfirm": "Clear the old session and show a new QR code. Continue?",
"wa.sessionNotInstalled": "Sidecar not installed. Run whatsapp_install.ps1 first.", "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.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.unbound": "Not bound",
"wa.lifecycle.bound_stopped": "Bound (sidecar stopped)", "wa.lifecycle.bound_stopped": "Bound (sidecar stopped)",
"wa.lifecycle.bound_offline": "Bound (offline / reconnecting)", "wa.lifecycle.bound_offline": "Bound (offline / reconnecting)",
@ -806,6 +806,9 @@ export default {
"wa.lifecycle.needs_rebind": "Session invalid (re-scan required)", "wa.lifecycle.needs_rebind": "Session invalid (re-scan required)",
"wa.sessionPolling": "Refreshing status…", "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.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.autoBind": "Auto bind on first message",
"wecom.autoBindRole": "Auto bind role", "wecom.autoBindRole": "Auto bind role",
"wecom.autoBindTenant": "Auto bind team", "wecom.autoBindTenant": "Auto bind team",

View file

@ -793,9 +793,9 @@ export default {
"wa.sessionUnbindConfirm": "将停止 sidecar 并删除本机 WhatsApp 登录态(state/auth)。之后需重新扫码绑定,确认继续?", "wa.sessionUnbindConfirm": "将停止 sidecar 并删除本机 WhatsApp 登录态(state/auth)。之后需重新扫码绑定,确认继续?",
"wa.sessionRebindConfirm": "将清除旧登录态并重新出二维码,确认继续?", "wa.sessionRebindConfirm": "将清除旧登录态并重新出二维码,确认继续?",
"wa.sessionNotInstalled": "sidecar 未安装,请先运行 whatsapp_install.ps1", "wa.sessionNotInstalled": "sidecar 未安装,请先运行 whatsapp_install.ps1",
"wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。", "wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。码大约 20 秒过期,页面会自动换成新码,请扫最新一张。",
"wa.sessionQrWaiting": "等待二维码…", "wa.sessionQrWaiting": "等待二维码…",
"wa.sessionQrStale": "二维码可能已过期,请点「扫码绑定」刷新。", "wa.sessionQrStale": "当前码可能已过期,正在自动刷新新码…",
"wa.lifecycle.unbound": "未绑定", "wa.lifecycle.unbound": "未绑定",
"wa.lifecycle.bound_stopped": "已绑定(sidecar 已停)", "wa.lifecycle.bound_stopped": "已绑定(sidecar 已停)",
"wa.lifecycle.bound_offline": "已绑定(离线重连中)", "wa.lifecycle.bound_offline": "已绑定(离线重连中)",
@ -806,6 +806,9 @@ export default {
"wa.lifecycle.needs_rebind": "登录态失效(需重新扫码)", "wa.lifecycle.needs_rebind": "登录态失效(需重新扫码)",
"wa.sessionPolling": "正在刷新状态…", "wa.sessionPolling": "正在刷新状态…",
"wa.sessionNeedsRebind": "WhatsApp 拒绝了当前登录态(disconnect=405 等)。请点「重新扫码绑定」,或在手机端删除旧的 oclaw 关联设备后再扫。", "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.autoBind": "首条消息自动绑定",
"wecom.autoBindRole": "自动绑定角色", "wecom.autoBindRole": "自动绑定角色",
"wecom.autoBindTenant": "自动绑定团队", "wecom.autoBindTenant": "自动绑定团队",

View file

@ -95,6 +95,9 @@ async function renderStack() {
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {}); const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {}; let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {};
let waSessionBindInProgress = false; let waSessionBindInProgress = false;
let waSessionBindRequested = false;
let waSessionWatchGen = 0;
let waLastQrKey = "";
const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" }); const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" });
const paintWaSession = (st) => { const paintWaSession = (st) => {
const s = st && typeof st === "object" ? st : {}; const s = st && typeof st === "object" ? st : {};
@ -109,46 +112,74 @@ async function renderStack() {
reconnect: Number(s.reconnect_attempt || 0), reconnect: Number(s.reconnect_attempt || 0),
}); });
waSessionStatus.replaceChildren(...Array.from(chips.childNodes)); 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 = [ const detailBits = [
s.display_name ? `name=${s.display_name}` : "", s.display_name ? `name=${s.display_name}` : "",
s.me ? `me=${s.me}` : "", s.me ? `me=${s.me}` : "",
s.last_error ? `err=${String(s.last_error).slice(0, 120)}` : "", showDisconnect && errText ? `err=${errText.slice(0, 120)}` : "",
s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "", 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}` : "", s.bridge_updated_at ? `updated=${s.bridge_updated_at}` : "",
].filter(Boolean); ].filter(Boolean);
waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled")); waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled"));
if (lifecycle === "needs_rebind" || lifecycle === "logged_out") { if (lifecycle === "needs_rebind" || lifecycle === "logged_out") {
waSessionHintAlert.textContent = t("wa.sessionNeedsRebind"); waSessionHintAlert.textContent = t("wa.sessionNeedsRebind");
waSessionHintAlert.classList.remove("u-hidden"); 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 { } else {
waSessionHintAlert.textContent = ""; waSessionHintAlert.textContent = "";
waSessionHintAlert.classList.add("u-hidden"); waSessionHintAlert.classList.add("u-hidden");
} }
const qrUrl = String(s.qr_data_url || "").trim(); 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"; waSessionQrWrap.style.display = awaiting ? "" : "none";
waSessionQrHint.textContent = awaiting waSessionQrHint.textContent = awaiting
? (s.qr_stale ? t("wa.sessionQrStale") : t("wa.sessionScanHint")) ? (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) { if (awaiting && qrUrl) {
waSessionQrImg.src = qrUrl; if (qrKey !== waLastQrKey) {
waLastQrKey = qrKey;
waSessionQrImg.src = qrUrl;
}
waSessionQrImg.style.display = ""; waSessionQrImg.style.display = "";
waSessionQrWaiting.style.display = "none"; waSessionQrWaiting.style.display = "none";
} else if (awaiting) { } else if (awaiting) {
waLastQrKey = "";
waSessionQrImg.removeAttribute("src"); waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none"; waSessionQrImg.style.display = "none";
waSessionQrWaiting.style.display = ""; waSessionQrWaiting.style.display = "";
waSessionQrWaiting.textContent = t("wa.sessionQrWaiting"); waSessionQrWaiting.textContent = t("wa.sessionQrWaiting");
} else { } else {
waLastQrKey = "";
waSessionQrImg.removeAttribute("src"); waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none"; waSessionQrImg.style.display = "none";
waSessionQrWaiting.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) { if (waSessionBindBtn) {
waSessionBindBtn.textContent = bindModes.has(lifecycle) waSessionBindBtn.textContent = rebindModes.has(lifecycle)
? t("wa.sessionBind") ? t("wa.sessionRebind")
: t("wa.sessionRebind"); : t("wa.sessionBind");
waSessionBindBtn.disabled = Boolean(waSessionBindInProgress); waSessionBindBtn.disabled = Boolean(waSessionBindInProgress);
} }
}; };
@ -175,12 +206,14 @@ async function renderStack() {
const stepMs = Number(opts.stepMs || 2000); const stepMs = Number(opts.stepMs || 2000);
const stopWhen = opts.stopWhen || ((st) => { const stopWhen = opts.stopWhen || ((st) => {
const life = String((st && st.lifecycle) || ""); 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"); waSessionActionStatus.textContent = t("wa.sessionPolling");
let waited = 0; let waited = 0;
let latest = waSessionLatest; let latest = waSessionLatest;
const watchGen = Number(opts.gen || 0);
while (waited < maxMs) { while (waited < maxMs) {
if (watchGen && watchGen !== waSessionWatchGen) return latest;
const st = await apiGet("/admin/api/whatsapp/session"); const st = await apiGet("/admin/api/whatsapp/session");
paintWaSession(st); paintWaSession(st);
latest = st; latest = st;
@ -229,32 +262,59 @@ async function renderStack() {
class: "btn", class: "btn",
text: t("wa.sessionStop"), text: t("wa.sessionStop"),
onclick: async () => { onclick: async () => {
waSessionBindRequested = false;
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false }); await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false });
}, },
}); });
const waSessionBindBtnClick = async () => { const watchWaBindUntilOnline = async () => {
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim(); const gen = ++waSessionWatchGen;
const autoClear = ["needs_rebind", "logged_out"].includes(curLife); waSessionBindRequested = true;
const needClear = autoClear || (curLife && !["unbound", "awaiting_scan"].includes(curLife));
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
waSessionBindInProgress = true; waSessionBindInProgress = true;
paintWaSession(waSessionLatest); paintWaSession(waSessionLatest);
try { try {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", { await pollWaSession({
clear_auth: Boolean(needClear), maxMs: 10 * 60 * 1000,
}), { stepMs: 1500,
poll: { gen,
maxMs: 120000, stopWhen: (st) => {
stopWhen: (st) => { const life = String((st && st.lifecycle) || "");
const life = String((st && st.lifecycle) || ""); if (life === "online") return true;
if (life === "online") return true; if (life === "bound_stopped") return true;
return life === "awaiting_scan" && Boolean(st && (st.qr_data_url || st.qr)); return false;
},
}, },
}); });
} finally { } 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; waSessionBindInProgress = false;
waSessionBindRequested = false;
paintWaSession(waSessionLatest); paintWaSession(waSessionLatest);
throw err;
} }
}; };
waSessionBindBtn.onclick = waSessionBindBtnClick; waSessionBindBtn.onclick = waSessionBindBtnClick;
@ -263,6 +323,7 @@ async function renderStack() {
text: t("wa.sessionUnbind"), text: t("wa.sessionUnbind"),
onclick: async () => { onclick: async () => {
if (!window.confirm(t("wa.sessionUnbindConfirm"))) return; if (!window.confirm(t("wa.sessionUnbindConfirm"))) return;
waSessionBindRequested = false;
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false }); await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false });
}, },
}); });
@ -289,7 +350,7 @@ async function renderStack() {
paintWaSession(whatsappSessionResp || {}); paintWaSession(whatsappSessionResp || {});
if (whatsappSessionResp && whatsappSessionResp.sidecar_running) { if (whatsappSessionResp && whatsappSessionResp.sidecar_running) {
const initLife = String(whatsappSessionResp.lifecycle || ""); const initLife = String(whatsappSessionResp.lifecycle || "");
if (["connecting", "bound_offline"].includes(initLife)) { if (initLife === "bound_offline" || initLife === "online") {
void pollWaSession({ maxMs: 30000 }); void pollWaSession({ maxMs: 30000 });
} }
} }

View 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
}

View file

@ -23,16 +23,53 @@ if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first." 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 Push-Location $sidecarRoot
try { try {
$env:OCLAW_STATE_DIR = $stateDir $env:OCLAW_STATE_DIR = $stateDir
Write-Host "[info] whatsapp login started (foreground)." 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." Write-Host "[info] If QR appears, scan it in WhatsApp -> Linked devices."
$runnerArgs = @("-y", "tsx", "baileys_runner.ts", "--login") $runnerArgs = @()
if ($VerboseRunner) { if (Test-Path $tsxCli) {
$runnerArgs += "--verbose" $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) { if ($LASTEXITCODE -ne 0) {
throw "whatsapp login runner exited with code $LASTEXITCODE" throw "whatsapp login runner exited with code $LASTEXITCODE"
} }

View file

@ -18,6 +18,7 @@ $sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
$env:PYTHONPATH = $oclawRoot $env:PYTHONPATH = $oclawRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") . (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
. (Join-Path $PSScriptRoot "lib\ResolveWhatsAppProxy.ps1")
$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot $runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null 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" throw "oclaw gateway is not reachable at $healthUrl. Start it first: powershell -ExecutionPolicy Bypass -File .\\scripts\\start_gateway.ps1 -SkipInstall -Background"
} }
$args = @( $proxyUrl = Get-OclawWhatsAppProxyUrl -StateDir $stateDir -Persist
"/c", if ($proxyUrl) {
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& npx.cmd -y tsx baileys_runner.ts" $env:AIA_WHATSAPP_PROXY_URL = $proxyUrl
) $env:HTTPS_PROXY = $proxyUrl
$p = Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath $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 Set-Content -Path $sidecarPidFile -Value $p.Id
Write-Host "[ok] started whatsapp sidecar pid=$($p.Id) mode=baileys out=$logPath err=$errPath" Write-Host "[ok] started whatsapp sidecar pid=$($p.Id) mode=baileys out=$logPath err=$errPath"

View file

@ -46,4 +46,14 @@ if (Test-Path $sidecarPidFile) {
Remove-Item -Force $sidecarPidFile -ErrorAction SilentlyContinue 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" Write-Host "[ok] stopped whatsapp channel=$ChannelId"

View file

@ -9,13 +9,14 @@ import makeWASocket, {
downloadMediaMessage, downloadMediaMessage,
extensionForMediaMessage, extensionForMediaMessage,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
fetchLatestWaWebVersion,
getContentType, getContentType,
jidNormalizedUser, jidNormalizedUser,
proto, proto,
} from "@whiskeysockets/baileys"; } from "@whiskeysockets/baileys";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { loadAuthState } from "./auth"; import { loadAuthState, resolveAuthDir } from "./auth";
import { printQrToTerminal, writeQrImage } from "./qr"; import { printQrToTerminal, writeQrImage } from "./qr";
import { writeBridgeStatus } from "./status"; import { writeBridgeStatus } from "./status";
@ -1052,6 +1053,62 @@ function _ipLooksHijackedOrUnroutable(ip: string): boolean {
return false; 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> { async function logNetworkHints(): Promise<void> {
try { try {
const a = await dns.resolve4("web.whatsapp.com").catch(() => []); const a = await dns.resolve4("web.whatsapp.com").catch(() => []);
@ -1076,6 +1133,11 @@ async function logNetworkHints(): Promise<void> {
} }
if (PROXY_URL) { if (PROXY_URL) {
log(`proxy configured for Baileys via PROXY_URL=${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> { async function main(): Promise<void> {
ensureDir(STATE_DIR); ensureDir(STATE_DIR);
const { state, saveCreds } = await loadAuthState(STATE_DIR); log(
const { version } = await fetchLatestBaileysVersion(); `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 }); const dedupe = makeDeduper({ ttlMs: 10 * 60 * 1000, max: 20_000 });
let reconnectAttempt = 0; let reconnectAttempt = 0;
@ -1169,6 +1245,7 @@ async function main(): Promise<void> {
sock.ev.on("connection.update", async (update) => { sock.ev.on("connection.update", async (update) => {
if (update.qr) { if (update.qr) {
reconnectAttempt = 0;
log("QR received. Scan it from WhatsApp -> Linked devices."); log("QR received. Scan it from WhatsApp -> Linked devices.");
printQrToTerminal(update.qr); printQrToTerminal(update.qr);
const qrArt = await writeQrImage(STATE_DIR, update.qr); const qrArt = await writeQrImage(STATE_DIR, update.qr);
@ -1177,6 +1254,9 @@ async function main(): Promise<void> {
qr: String(update.qr || ""), qr: String(update.qr || ""),
qr_data_url: qrArt.dataUrl, qr_data_url: qrArt.dataUrl,
qr_png: qrArt.pngPath ? path.basename(qrArt.pngPath) : "", qr_png: qrArt.pngPath ? path.basename(qrArt.pngPath) : "",
last_error: "",
last_disconnect_reason: "",
last_disconnect_status: null,
reconnect_attempt: reconnectAttempt, reconnect_attempt: reconnectAttempt,
login_only: LOGIN_ONLY, login_only: LOGIN_ONLY,
}); });
@ -1216,10 +1296,16 @@ async function main(): Promise<void> {
const reason = statusCode ? (DisconnectReason as any)[statusCode] || String(statusCode) : "unknown"; const reason = statusCode ? (DisconnectReason as any)[statusCode] || String(statusCode) : "unknown";
const errText = String(update.lastDisconnect?.error || "").slice(0, 200); const errText = String(update.lastDisconnect?.error || "").slice(0, 200);
log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`); 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) { if (statusCode === DisconnectReason.loggedOut) {
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link."); log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
writeBridgeStatus(STATE_DIR, { writeBridgeStatus(STATE_DIR, {
connection: "logged_out", connection: "logged_out",
qr: "",
qr_data_url: "",
qr_png: "",
last_disconnect_reason: String(reason), last_disconnect_reason: String(reason),
last_disconnect_status: typeof statusCode === "number" ? statusCode : null, last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
last_error: errText, last_error: errText,
@ -1233,10 +1319,13 @@ async function main(): Promise<void> {
DisconnectReason.badSession, DisconnectReason.badSession,
405, 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.`); log(`session invalid statusCode=${statusCode}: clear state/auth and scan QR again.`);
writeBridgeStatus(STATE_DIR, { writeBridgeStatus(STATE_DIR, {
connection: "needs_rebind", connection: "needs_rebind",
qr: "",
qr_data_url: "",
qr_png: "",
last_disconnect_reason: String(reason), last_disconnect_reason: String(reason),
last_disconnect_status: statusCode, last_disconnect_status: statusCode,
last_error: errText, last_error: errText,
@ -1245,16 +1334,55 @@ async function main(): Promise<void> {
}); });
return; 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; reconnectAttempt += 1;
writeBridgeStatus(STATE_DIR, { writeBridgeStatus(STATE_DIR, {
connection: "close", connection: hasLinkedSession ? "close" : "connecting",
qr: "",
qr_data_url: "",
qr_png: "",
last_disconnect_reason: String(reason), last_disconnect_reason: String(reason),
last_disconnect_status: typeof statusCode === "number" ? statusCode : null, last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
last_error: errText, last_error: errText,
reconnect_attempt: reconnectAttempt, reconnect_attempt: reconnectAttempt,
login_only: LOGIN_ONLY, 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 jitter = Math.floor(Math.random() * 500);
const delay = base + jitter; const delay = base + jitter;
log(`reconnecting in ${delay}ms attempt=${reconnectAttempt}`); 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(); await logNetworkHints();
startOutboundPoller(() => sock); startOutboundPoller(() => sock);
await connectOnce(); await connectOnce();

View file

@ -7,6 +7,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -59,18 +60,39 @@ def _read_pid(channel_id: str = DEFAULT_CHANNEL_ID) -> int:
return 0 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 = bridge_status_file(channel_id)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
prev: dict[str, Any] = {} prev: dict[str, Any] = {}
if path.exists(): if not replace and path.exists():
try: try:
obj = json.loads(path.read_text(encoding="utf-8")) obj = json.loads(path.read_text(encoding="utf-8"))
if isinstance(obj, dict): if isinstance(obj, dict):
prev = obj prev = obj
except Exception: except Exception:
prev = {} prev = {}
next_obj = dict(prev) next_obj = dict(_empty_bridge_status() if replace else prev)
next_obj.update(patch or {}) next_obj.update(patch or {})
next_obj["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) 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") 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() 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: def _needs_rebind_disconnect(bridge: dict[str, Any]) -> bool:
code = bridge.get("last_disconnect_status") code = bridge.get("last_disconnect_status")
try: try:
@ -210,11 +243,25 @@ def session_status(
auth_exists = auth_dir(cid).exists() and any(auth_dir(cid).glob("*.json")) auth_exists = auth_dir(cid).exists() and any(auth_dir(cid).glob("*.json"))
bound = bool(bound_info.get("me") or auth_exists) bound = bool(bound_info.get("me") or auth_exists)
connection = str(bridge.get("connection") or "").strip().lower() 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 not running:
if bound: if bound:
lifecycle = "bound_stopped" lifecycle = "bound_stopped"
else: else:
lifecycle = "unbound" lifecycle = "unbound"
last_error = ""
last_disconnect_reason = ""
last_disconnect_status = None
connection = "stopped" connection = "stopped"
else: else:
if connection == "open": if connection == "open":
@ -223,23 +270,30 @@ def session_status(
lifecycle = "awaiting_scan" lifecycle = "awaiting_scan"
elif connection == "connecting": elif connection == "connecting":
lifecycle = "connecting" lifecycle = "connecting"
elif connection == "logged_out": elif bound and connection == "logged_out":
lifecycle = "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" lifecycle = "needs_rebind"
elif bound: elif bound:
lifecycle = "bound_offline" lifecycle = "bound_offline"
if not connection: if not connection:
connection = "close" connection = "close"
else: else:
# Auth already cleared; leftover 405/close must not keep the UI on "needs rebind".
lifecycle = "connecting" lifecycle = "connecting"
if not connection: if connection not in ("connecting", "qr"):
if connection == "stopped" and not last_error:
last_error = "runner_waiting"
connection = "connecting" connection = "connecting"
me = str(bridge.get("me") or bound_info.get("me") or "").strip() me = str(bridge.get("me") or bound_info.get("me") or "").strip()
phone = str(bridge.get("phone") or bound_info.get("phone") or "").strip() phone = str(bridge.get("phone") or bound_info.get("phone") or "").strip()
name = str(bound_info.get("name") or "").strip() name = str(bound_info.get("name") or "").strip()
qr = str(bridge.get("qr") 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_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" 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(): 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") qr_data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
except Exception: except Exception:
qr_data_url = "" qr_data_url = ""
if (not qr_data_url) and qr:
qr_data_url = _qr_to_data_url(qr)
stale_qr = False stale_qr = False
if qr or qr_data_url: if lifecycle == "awaiting_scan" and (qr or qr_data_url):
try: try:
updated = str(bridge.get("updated_at") or "").strip() updated = str(bridge.get("updated_at") or "").strip()
if updated: if updated:
# QR older than 2 minutes is usually expired.
from datetime import datetime, timezone
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp() ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
stale_qr = (time.time() - ts) > 120 stale_qr = (time.time() - ts) > 20
except Exception: except Exception:
stale_qr = False stale_qr = False
log_path = oclaw_log_root() / "whatsapp_sidecar.log" log_path = oclaw_log_root() / "whatsapp_sidecar.log"
@ -282,13 +331,14 @@ def session_status(
"qr": qr if lifecycle == "awaiting_scan" else "", "qr": qr if lifecycle == "awaiting_scan" else "",
"qr_data_url": qr_data_url 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, "qr_stale": bool(stale_qr) if lifecycle == "awaiting_scan" else False,
"last_disconnect_reason": str(bridge.get("last_disconnect_reason") or ""), "last_disconnect_reason": last_disconnect_reason,
"last_disconnect_status": bridge.get("last_disconnect_status"), "last_disconnect_status": last_disconnect_status,
"last_error": str(bridge.get("last_error") or ""), "last_error": last_error,
"reconnect_attempt": int(bridge.get("reconnect_attempt") or 0), "reconnect_attempt": int(bridge.get("reconnect_attempt") or 0),
"bridge_updated_at": str(bridge.get("updated_at") or ""), "bridge_updated_at": str(bridge.get("updated_at") or ""),
"auth_dir": str(auth_dir(cid)), "auth_dir": str(auth_dir(cid)),
"log_path": str(log_path), "log_path": str(log_path),
"login_only": login_only,
"status_hint": { "status_hint": {
"unbound": "not_bound", "unbound": "not_bound",
"bound_stopped": "bound_but_sidecar_stopped", "bound_stopped": "bound_but_sidecar_stopped",
@ -308,6 +358,11 @@ def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) ->
if force: if force:
args.append("-Force") args.append("-Force")
result = _run_ps1("whatsapp_stop.ps1", args, timeout_s=60.0) 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( _write_local_status(
cid, cid,
{ {
@ -316,8 +371,11 @@ def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) ->
"qr_data_url": "", "qr_data_url": "",
"qr_png": "", "qr_png": "",
"last_error": "", "last_error": "",
"last_disconnect_reason": "",
"last_disconnect_status": None,
"reconnect_attempt": 0, "reconnect_attempt": 0,
}, },
replace=True,
) )
return {"ok": bool(result.get("ok")), "action": "stop", **result} return {"ok": bool(result.get("ok")), "action": "stop", **result}
@ -360,7 +418,7 @@ def unbind_session(
if auth.exists(): if auth.exists():
shutil.rmtree(auth, ignore_errors=True) shutil.rmtree(auth, ignore_errors=True)
removed_auth = not auth.exists() 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: try:
if extra.exists(): if extra.exists():
extra.unlink() extra.unlink()
@ -380,6 +438,7 @@ def unbind_session(
"last_error": "", "last_error": "",
"reconnect_attempt": 0, "reconnect_attempt": 0,
}, },
replace=True,
) )
status = session_status(channel_id=cid, gateway_base_url=gateway_base_url) status = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
return { return {
@ -400,21 +459,28 @@ def start_bind(
"""Ensure sidecar is running so a QR can appear (optionally after clearing auth).""" """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 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 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: if clear_auth:
unbind_session(cid, gateway_base_url=base) unbind_session(cid, gateway_base_url=base)
else: else:
stop_sidecar(cid, force=True) stop_sidecar(cid, force=True)
started = start_sidecar(cid, gateway_base_url=base) 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) 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 { return {
"ok": bool(started.get("ok")), "ok": bool(started.get("ok")),
"action": "bind", "action": "bind",

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from runtime.operations import whatsapp_sidecar as wa 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 assert "qrcode@$qrcodeVersion" in text
start = (REPO_ROOT / "runtime/operations/scripts/whatsapp_start.ps1").read_text(encoding="utf-8") start = (REPO_ROOT / "runtime/operations/scripts/whatsapp_start.ps1").read_text(encoding="utf-8")
assert "status.ts" in start 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: 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.mkdir(parents=True)
(auth / "creds.json").write_text("{}", encoding="utf-8") (auth / "creds.json").write_text("{}", encoding="utf-8")
(root / "state" / "qr.png").write_bytes(b"png") (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") out = wa.unbind_session(channel_id="whatsapp")
assert out["ok"] is True assert out["ok"] is True
assert out["removed_auth"] is True assert out["removed_auth"] is True
assert not auth.exists() assert not auth.exists()
assert out["status"]["lifecycle"] == "unbound" 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: 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" 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: def test_qr_to_data_url_generates_png() -> None:
url = wa._qr_to_data_url("2@testqr") url = wa._qr_to_data_url("2@testqr")
assert url.startswith("data:image/png;base64,") 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: def test_admin_routes_expose_whatsapp_session_apis() -> None:
text = (REPO_ROOT / "interfaces/admin/routes.py").read_text(encoding="utf-8") text = (REPO_ROOT / "interfaces/admin/routes.py").read_text(encoding="utf-8")
assert '/admin/api/whatsapp/session"' in text assert '/admin/api/whatsapp/session"' in text
@ -137,3 +248,8 @@ def test_admin_routes_expose_whatsapp_session_apis() -> None:
assert "pollWaSession" in stack assert "pollWaSession" in stack
runner = (REPO_ROOT / "runtime/operations/whatsapp_bridge/baileys_runner.ts").read_text(encoding="utf-8") runner = (REPO_ROOT / "runtime/operations/whatsapp_bridge/baileys_runner.ts").read_text(encoding="utf-8")
assert "needs_rebind" in runner 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