mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
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>
490 lines
17 KiB
Python
490 lines
17 KiB
Python
"""WhatsApp Baileys sidecar control helpers for admin / ops."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime
|
|
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 _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 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(_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")
|
|
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 _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:
|
|
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()
|
|
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":
|
|
lifecycle = "online"
|
|
elif connection == "qr":
|
|
lifecycle = "awaiting_scan"
|
|
elif connection == "connecting":
|
|
lifecycle = "connecting"
|
|
elif bound and connection == "logged_out":
|
|
lifecycle = "logged_out"
|
|
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 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 = ""
|
|
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():
|
|
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 = ""
|
|
stale_qr = False
|
|
if lifecycle == "awaiting_scan" and (qr or qr_data_url):
|
|
try:
|
|
updated = str(bridge.get("updated_at") or "").strip()
|
|
if updated:
|
|
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
|
|
stale_qr = (time.time() - ts) > 20
|
|
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": 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",
|
|
"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)
|
|
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,
|
|
{
|
|
"connection": "stopped",
|
|
"qr": "",
|
|
"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}
|
|
|
|
|
|
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",):
|
|
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,
|
|
},
|
|
replace=True,
|
|
)
|
|
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
|
|
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)
|
|
status = started.get("status") if isinstance(started.get("status"), dict) else 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,
|
|
}
|