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

@ -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."
}
$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"
}

View file

@ -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"

View file

@ -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"

View file

@ -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();

View file

@ -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",