mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
Add WhatsApp session bind/unbind to Admin Runtime, and fix related Admin JS errors.
Expose sidecar status, start/stop, QR bind, and unbind so 405 session failures are visible instead of a stuck connecting state. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1bdbfc3b85
commit
5c6833612f
16 changed files with 1123 additions and 72 deletions
|
|
@ -21,7 +21,8 @@ $bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
|||
|
||||
# Pin known-good versions to avoid breakage from upstream latest releases.
|
||||
$baileysVersion = "7.0.0-rc.9"
|
||||
$qrcodeVersion = "0.12.0"
|
||||
$qrcodeTerminalVersion = "0.12.0"
|
||||
$qrcodeVersion = "1.5.4"
|
||||
$httpsProxyAgentVersion = "7.0.6"
|
||||
$tsxVersion = "4.20.3"
|
||||
$typescriptVersion = "5.8.2"
|
||||
|
|
@ -38,7 +39,7 @@ try {
|
|||
# Remove problematic package when present (seen on some environments with latest dependency graph).
|
||||
npm.cmd remove whatsapp-rust-bridge | Out-Null
|
||||
|
||||
npm.cmd install --save --save-exact @whiskeysockets/baileys@$baileysVersion qrcode-terminal@$qrcodeVersion
|
||||
npm.cmd install --save --save-exact @whiskeysockets/baileys@$baileysVersion qrcode-terminal@$qrcodeTerminalVersion qrcode@$qrcodeVersion
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install deps failed with exit code $LASTEXITCODE" }
|
||||
npm.cmd install --save --save-exact https-proxy-agent@$httpsProxyAgentVersion
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install proxy deps failed with exit code $LASTEXITCODE" }
|
||||
|
|
@ -49,7 +50,7 @@ try {
|
|||
}
|
||||
|
||||
if (Test-Path $bridgeSrc) {
|
||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
|
|||
|
||||
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
||||
if (Test-Path $bridgeSrc) {
|
||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import makeWASocket, {
|
|||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
import { loadAuthState } from "./auth";
|
||||
import { printQrToTerminal } from "./qr";
|
||||
import { printQrToTerminal, writeQrImage } from "./qr";
|
||||
import { writeBridgeStatus } from "./status";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
|
|
@ -1118,6 +1119,18 @@ async function main(): Promise<void> {
|
|||
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
|
||||
const groupNameCache = new Map<string, { name: string; ts: number }>();
|
||||
const GROUP_NAME_TTL_MS = 10 * 60 * 1000;
|
||||
const initialMe = String((state.creds as any)?.me?.id || "").trim();
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "connecting",
|
||||
me: initialMe ? jidNormalizedUser(initialMe) : "",
|
||||
phone: jidPhone(initialMe),
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_error: "",
|
||||
reconnect_attempt: 0,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
|
||||
const resolveGroupName = async (chatId: string): Promise<string> => {
|
||||
const key = String(chatId || "").trim();
|
||||
|
|
@ -1158,6 +1171,15 @@ async function main(): Promise<void> {
|
|||
if (update.qr) {
|
||||
log("QR received. Scan it from WhatsApp -> Linked devices.");
|
||||
printQrToTerminal(update.qr);
|
||||
const qrArt = await writeQrImage(STATE_DIR, update.qr);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "qr",
|
||||
qr: String(update.qr || ""),
|
||||
qr_data_url: qrArt.dataUrl,
|
||||
qr_png: qrArt.pngPath ? path.basename(qrArt.pngPath) : "",
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
}
|
||||
if (update.connection === "open") {
|
||||
reconnectAttempt = 0;
|
||||
|
|
@ -1165,6 +1187,25 @@ async function main(): Promise<void> {
|
|||
const botIds = collectBotIdentityJids(sock, sock?.user?.id ? String(sock.user.id) : "", state.creds);
|
||||
cachedBotIdentityJids = botIds;
|
||||
log(`connected. me=${me || "unknown"} botIds=${botIds.join(",") || "none"} loginOnly=${LOGIN_ONLY}`);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "open",
|
||||
me,
|
||||
phone: jidPhone(me),
|
||||
qr: "",
|
||||
qr_data_url: "",
|
||||
qr_png: "",
|
||||
last_error: "",
|
||||
last_disconnect_reason: "",
|
||||
last_disconnect_status: null,
|
||||
reconnect_attempt: 0,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
try {
|
||||
const qrPng = path.join(STATE_DIR, "qr.png");
|
||||
if (fs.existsSync(qrPng)) fs.unlinkSync(qrPng);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (LOGIN_ONLY) {
|
||||
log("login-only mode: exiting after successful link.");
|
||||
process.exit(0);
|
||||
|
|
@ -1177,9 +1218,42 @@ async function main(): Promise<void> {
|
|||
log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`);
|
||||
if (statusCode === DisconnectReason.loggedOut) {
|
||||
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "logged_out",
|
||||
last_disconnect_reason: String(reason),
|
||||
last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
|
||||
last_error: errText,
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rebindCodes = new Set<number>([
|
||||
DisconnectReason.forbidden,
|
||||
DisconnectReason.badSession,
|
||||
405,
|
||||
]);
|
||||
if (typeof statusCode === "number" && rebindCodes.has(statusCode)) {
|
||||
log(`session invalid statusCode=${statusCode}: clear state/auth and scan QR again.`);
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "needs_rebind",
|
||||
last_disconnect_reason: String(reason),
|
||||
last_disconnect_status: statusCode,
|
||||
last_error: errText,
|
||||
reconnect_attempt: reconnectAttempt,
|
||||
login_only: LOGIN_ONLY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
reconnectAttempt += 1;
|
||||
writeBridgeStatus(STATE_DIR, {
|
||||
connection: "close",
|
||||
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 jitter = Math.floor(Math.random() * 500);
|
||||
const delay = base + jitter;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
export function printQrToTerminal(qr: string): void {
|
||||
|
|
@ -5,3 +7,20 @@ export function printQrToTerminal(qr: string): void {
|
|||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
|
||||
export async function writeQrImage(stateDir: string, qr: string): Promise<{ pngPath: string; dataUrl: string }> {
|
||||
const pngPath = path.join(stateDir, "qr.png");
|
||||
try {
|
||||
// Optional dependency: installed by whatsapp_install.ps1 when available.
|
||||
const QRCode = await import("qrcode");
|
||||
await QRCode.toFile(pngPath, qr, { width: 320, margin: 1, errorCorrectionLevel: "M" });
|
||||
const dataUrl = await QRCode.toDataURL(qr, { width: 320, margin: 1, errorCorrectionLevel: "M" });
|
||||
return { pngPath, dataUrl: String(dataUrl || "") };
|
||||
} catch {
|
||||
try {
|
||||
if (fs.existsSync(pngPath)) fs.unlinkSync(pngPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { pngPath: "", dataUrl: "" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
runtime/operations/whatsapp_bridge/status.ts
Normal file
61
runtime/operations/whatsapp_bridge/status.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type BridgeStatus = {
|
||||
connection: "connecting" | "qr" | "open" | "close" | "logged_out" | "needs_rebind" | "stopped";
|
||||
me: string;
|
||||
phone: string;
|
||||
qr: string;
|
||||
qr_data_url: string;
|
||||
qr_png: string;
|
||||
last_disconnect_reason: string;
|
||||
last_disconnect_status: number | null;
|
||||
last_error: string;
|
||||
reconnect_attempt: number;
|
||||
login_only: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const STATUS_FILE = "bridge_status.json";
|
||||
|
||||
function phoneFromMe(me: string): string {
|
||||
const head = String(me || "").split("@")[0]?.split(":")[0] || "";
|
||||
return head.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
export function statusPath(stateDir: string): string {
|
||||
return path.join(stateDir, STATUS_FILE);
|
||||
}
|
||||
|
||||
export function writeBridgeStatus(stateDir: string, patch: Partial<BridgeStatus>): BridgeStatus {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const p = statusPath(stateDir);
|
||||
let prev: Partial<BridgeStatus> = {};
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
prev = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<BridgeStatus>;
|
||||
}
|
||||
} catch {
|
||||
prev = {};
|
||||
}
|
||||
const me = String(patch.me ?? prev.me ?? "").trim();
|
||||
const next: BridgeStatus = {
|
||||
connection: (patch.connection || prev.connection || "connecting") as BridgeStatus["connection"],
|
||||
me,
|
||||
phone: String(patch.phone ?? prev.phone ?? phoneFromMe(me)).trim(),
|
||||
qr: String(patch.qr ?? prev.qr ?? ""),
|
||||
qr_data_url: String(patch.qr_data_url ?? prev.qr_data_url ?? ""),
|
||||
qr_png: String(patch.qr_png ?? prev.qr_png ?? ""),
|
||||
last_disconnect_reason: String(patch.last_disconnect_reason ?? prev.last_disconnect_reason ?? ""),
|
||||
last_disconnect_status:
|
||||
patch.last_disconnect_status === undefined
|
||||
? (typeof prev.last_disconnect_status === "number" ? prev.last_disconnect_status : null)
|
||||
: patch.last_disconnect_status,
|
||||
last_error: String(patch.last_error ?? prev.last_error ?? ""),
|
||||
reconnect_attempt: Number(patch.reconnect_attempt ?? prev.reconnect_attempt ?? 0) || 0,
|
||||
login_only: Boolean(patch.login_only ?? prev.login_only ?? false),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(p, JSON.stringify(next, null, 2), "utf8");
|
||||
return next;
|
||||
}
|
||||
424
runtime/operations/whatsapp_sidecar.py
Normal file
424
runtime/operations/whatsapp_sidecar.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""WhatsApp Baileys sidecar control helpers for admin / ops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from runtime.operations.runtime import is_pid_running
|
||||
from svc.config.log_paths import oclaw_log_root
|
||||
from svc.config.paths import PROJECT_ROOT
|
||||
|
||||
DEFAULT_CHANNEL_ID = "whatsapp"
|
||||
DEFAULT_ACCOUNT_ID = "wa-default"
|
||||
DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:8787"
|
||||
|
||||
|
||||
def sidecar_root(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
return (PROJECT_ROOT / "data" / "channel_sidecar" / cid).resolve()
|
||||
|
||||
|
||||
def state_dir(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||
return sidecar_root(channel_id) / "state"
|
||||
|
||||
|
||||
def auth_dir(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||
return state_dir(channel_id) / "auth"
|
||||
|
||||
|
||||
def pid_file(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||
return sidecar_root(channel_id) / "pid.txt"
|
||||
|
||||
|
||||
def bridge_status_file(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||
return state_dir(channel_id) / "bridge_status.json"
|
||||
|
||||
|
||||
def scripts_dir() -> Path:
|
||||
return (PROJECT_ROOT / "runtime" / "operations" / "scripts").resolve()
|
||||
|
||||
|
||||
def bridge_src_dir() -> Path:
|
||||
return (PROJECT_ROOT / "runtime" / "operations" / "whatsapp_bridge").resolve()
|
||||
|
||||
|
||||
def _read_pid(channel_id: str = DEFAULT_CHANNEL_ID) -> int:
|
||||
p = pid_file(channel_id)
|
||||
if not p.exists():
|
||||
return 0
|
||||
try:
|
||||
raw = (p.read_text(encoding="utf-8", errors="replace").strip().splitlines() or [""])[0].strip()
|
||||
return int(raw) if raw.isdigit() else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _write_local_status(channel_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
path = bridge_status_file(channel_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prev: dict[str, Any] = {}
|
||||
if 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.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")
|
||||
return next_obj
|
||||
|
||||
|
||||
def _read_bridge_status(channel_id: str = DEFAULT_CHANNEL_ID) -> dict[str, Any]:
|
||||
path = bridge_status_file(channel_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _read_bound_me(channel_id: str = DEFAULT_CHANNEL_ID) -> dict[str, str]:
|
||||
creds = auth_dir(channel_id) / "creds.json"
|
||||
if not creds.exists():
|
||||
return {"me": "", "phone": "", "name": ""}
|
||||
try:
|
||||
obj = json.loads(creds.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"me": "", "phone": "", "name": ""}
|
||||
me_obj = obj.get("me") if isinstance(obj, dict) else None
|
||||
if not isinstance(me_obj, dict):
|
||||
return {"me": "", "phone": "", "name": ""}
|
||||
me = str(me_obj.get("id") or "").strip()
|
||||
name = str(me_obj.get("name") or "").strip()
|
||||
phone = me.split("@")[0].split(":")[0]
|
||||
phone = "".join(ch for ch in phone if ch.isdigit())
|
||||
return {"me": me, "phone": phone, "name": name}
|
||||
|
||||
|
||||
def _sync_bridge_sources(channel_id: str = DEFAULT_CHANNEL_ID) -> None:
|
||||
root = sidecar_root(channel_id)
|
||||
src = bridge_src_dir()
|
||||
if not src.exists() or not root.exists():
|
||||
return
|
||||
for name in ("baileys_runner.ts", "auth.ts", "qr.ts", "status.ts"):
|
||||
sp = src / name
|
||||
if sp.exists():
|
||||
shutil.copy2(sp, root / name)
|
||||
|
||||
|
||||
def _run_ps1(script_name: str, args: list[str] | None = None, *, timeout_s: float = 120.0) -> dict[str, Any]:
|
||||
script = scripts_dir() / script_name
|
||||
if not script.exists():
|
||||
return {"ok": False, "error": f"script_missing:{script_name}"}
|
||||
cmd = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
*(args or []),
|
||||
]
|
||||
try:
|
||||
cp = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=max(5.0, float(timeout_s)),
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "timeout",
|
||||
"stdout": str(exc.stdout or ""),
|
||||
"stderr": str(exc.stderr or ""),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
out = (cp.stdout or "").strip()
|
||||
err = (cp.stderr or "").strip()
|
||||
return {
|
||||
"ok": int(cp.returncode or 0) == 0,
|
||||
"returncode": int(cp.returncode or 0),
|
||||
"stdout": out[-4000:],
|
||||
"stderr": err[-4000:],
|
||||
"error": "" if int(cp.returncode or 0) == 0 else (err or out or f"exit_{cp.returncode}"),
|
||||
}
|
||||
|
||||
|
||||
def installed(channel_id: str = DEFAULT_CHANNEL_ID) -> bool:
|
||||
return (sidecar_root(channel_id) / "baileys_runner.ts").exists()
|
||||
|
||||
|
||||
def _needs_rebind_disconnect(bridge: dict[str, Any]) -> bool:
|
||||
code = bridge.get("last_disconnect_status")
|
||||
try:
|
||||
n = int(code) if code is not None else None
|
||||
except (TypeError, ValueError):
|
||||
n = None
|
||||
if n in (403, 405, 500):
|
||||
return True
|
||||
err = str(bridge.get("last_error") or "").lower()
|
||||
return n == 405 and "connection failure" in err
|
||||
|
||||
|
||||
def _qr_to_data_url(qr_text: str) -> str:
|
||||
text = str(qr_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
|
||||
img = qrcode.make(text)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def session_status(
|
||||
*,
|
||||
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||
account_id: str = DEFAULT_ACCOUNT_ID,
|
||||
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||
) -> dict[str, Any]:
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or DEFAULT_ACCOUNT_ID).strip() or DEFAULT_ACCOUNT_ID
|
||||
pid = _read_pid(cid)
|
||||
running = bool(pid and is_pid_running(pid))
|
||||
bound_info = _read_bound_me(cid)
|
||||
bridge = _read_bridge_status(cid)
|
||||
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()
|
||||
if not running:
|
||||
if bound:
|
||||
lifecycle = "bound_stopped"
|
||||
else:
|
||||
lifecycle = "unbound"
|
||||
connection = "stopped"
|
||||
else:
|
||||
if connection == "open":
|
||||
lifecycle = "online"
|
||||
elif connection == "qr":
|
||||
lifecycle = "awaiting_scan"
|
||||
elif connection == "connecting":
|
||||
lifecycle = "connecting"
|
||||
elif connection == "logged_out":
|
||||
lifecycle = "logged_out"
|
||||
elif connection == "needs_rebind" or _needs_rebind_disconnect(bridge):
|
||||
lifecycle = "needs_rebind"
|
||||
elif bound:
|
||||
lifecycle = "bound_offline"
|
||||
if not connection:
|
||||
connection = "close"
|
||||
else:
|
||||
lifecycle = "connecting"
|
||||
if not connection:
|
||||
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_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():
|
||||
try:
|
||||
import base64
|
||||
|
||||
raw = qr_png_path.read_bytes()
|
||||
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:
|
||||
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
|
||||
except Exception:
|
||||
stale_qr = False
|
||||
log_path = oclaw_log_root() / "whatsapp_sidecar.log"
|
||||
return {
|
||||
"ok": True,
|
||||
"channel_id": cid,
|
||||
"account_id": aid,
|
||||
"gateway_base_url": str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/"),
|
||||
"installed": installed(cid),
|
||||
"sidecar_running": running,
|
||||
"pid": int(pid or 0) if running else 0,
|
||||
"bound": bound,
|
||||
"lifecycle": lifecycle,
|
||||
"connection": connection or ("stopped" if not running else "unknown"),
|
||||
"me": me,
|
||||
"phone": phone,
|
||||
"display_name": name,
|
||||
"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 ""),
|
||||
"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),
|
||||
"status_hint": {
|
||||
"unbound": "not_bound",
|
||||
"bound_stopped": "bound_but_sidecar_stopped",
|
||||
"bound_offline": "bound_but_offline",
|
||||
"online": "online",
|
||||
"awaiting_scan": "scan_qr",
|
||||
"logged_out": "logged_out_need_rebind",
|
||||
"needs_rebind": "session_invalid_need_rebind",
|
||||
"connecting": "connecting",
|
||||
}.get(lifecycle, lifecycle),
|
||||
}
|
||||
|
||||
|
||||
def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) -> dict[str, Any]:
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
args = ["-ChannelId", cid]
|
||||
if force:
|
||||
args.append("-Force")
|
||||
result = _run_ps1("whatsapp_stop.ps1", args, timeout_s=60.0)
|
||||
_write_local_status(
|
||||
cid,
|
||||
{
|
||||
"connection": "stopped",
|
||||
"qr": "",
|
||||
"qr_data_url": "",
|
||||
"qr_png": "",
|
||||
"last_error": "",
|
||||
"reconnect_attempt": 0,
|
||||
},
|
||||
)
|
||||
return {"ok": bool(result.get("ok")), "action": "stop", **result}
|
||||
|
||||
|
||||
def start_sidecar(
|
||||
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||
*,
|
||||
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||
) -> dict[str, Any]:
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
if not installed(cid):
|
||||
return {"ok": False, "error": "whatsapp_sidecar_not_installed", "action": "start"}
|
||||
_sync_bridge_sources(cid)
|
||||
# Avoid duplicate processes.
|
||||
cur = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
|
||||
if cur.get("sidecar_running"):
|
||||
return {"ok": True, "action": "start", "already_running": True, "status": cur}
|
||||
base = str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/") or DEFAULT_GATEWAY_BASE_URL
|
||||
result = _run_ps1(
|
||||
"whatsapp_start.ps1",
|
||||
["-ChannelId", cid, "-GatewayBaseUrl", base],
|
||||
timeout_s=90.0,
|
||||
)
|
||||
# Give runner a moment to write status.
|
||||
time.sleep(1.0)
|
||||
status = session_status(channel_id=cid, gateway_base_url=base)
|
||||
return {"ok": bool(result.get("ok")), "action": "start", "status": status, **result}
|
||||
|
||||
|
||||
def unbind_session(
|
||||
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||
*,
|
||||
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||
) -> dict[str, Any]:
|
||||
"""Stop sidecar and clear device auth so a fresh QR bind is required."""
|
||||
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||
stop = stop_sidecar(cid, force=True)
|
||||
auth = auth_dir(cid)
|
||||
removed_auth = False
|
||||
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)):
|
||||
try:
|
||||
if extra.exists():
|
||||
extra.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
_write_local_status(
|
||||
cid,
|
||||
{
|
||||
"connection": "stopped",
|
||||
"me": "",
|
||||
"phone": "",
|
||||
"qr": "",
|
||||
"qr_data_url": "",
|
||||
"qr_png": "",
|
||||
"last_disconnect_reason": "",
|
||||
"last_disconnect_status": None,
|
||||
"last_error": "",
|
||||
"reconnect_attempt": 0,
|
||||
},
|
||||
)
|
||||
status = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "unbind",
|
||||
"removed_auth": removed_auth,
|
||||
"stop": stop,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def start_bind(
|
||||
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||
*,
|
||||
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||
clear_auth: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""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
|
||||
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",
|
||||
"clear_auth": bool(clear_auth),
|
||||
"start": started,
|
||||
"status": status,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue