#!/usr/bin/env python3 """Transition-based health monitor for the local WeChat GUI automation chain.""" from __future__ import annotations import argparse import json import os import subprocess import sys import time from datetime import datetime from pathlib import Path from typing import Any HOME = Path.home() STATE_DIR = HOME / ".local" / "state" / "wechat-health-watch" STATE_FILE = STATE_DIR / "state.json" NOTIFICATION_CONFIG = Path( os.environ.get( "JWEBOT_HEALTH_NOTIFICATION_CONFIG", str( HOME / ".config" / "jwebot" / "current" / "generated" / "notification.json" ), ) ).expanduser() RECORDER_STATE = ( HOME / ".local" / "state" / "wechat-adaptive-ocr" / "recorder-state.json" ) FAILED_DIR = HOME / ".local" / "state" / "wechat-adaptive-ocr" / "failed" DISPLAY = ":1" XAUTHORITY = HOME / ".Xauthority" XDG_RUNTIME_DIR = Path(f"/run/user/{os.getuid()}") DBUS_ADDRESS = f"unix:path={XDG_RUNTIME_DIR}/bus" SYSTEM_SERVICES = ( "tigervncserver@:1.service", "wechat-novnc.service", "openclaw-tunnel.service", ) USER_SERVICES = ("wechat-ocr-adaptive-recorder.service",) SERVICE_CODES = { "tigervncserver@:1.service": "vnc_service", "wechat-novnc.service": "novnc_service", "openclaw-tunnel.service": "tunnel_service", } USER_SERVICE_CODES = { "wechat-ocr-adaptive-recorder.service": "adaptive_recorder_service", } CODE_LABELS = { "vnc_service": "VNC 服务", "novnc_service": "noVNC 服务", "adaptive_recorder_service": "自适应 OCR 录制器", "tunnel_service": "OpenClaw 隧道", "wechat_process": "微信客户端进程", "wechat_window": "微信主窗口", "wechat_login_required": "微信登录状态", "recorder_state": "OCR 录制状态", "recorder_stale": "OCR 捕获心跳", "responder_failed": "消息回复投递", } RECORDER_FRESHNESS_SECONDS = 180 MIN_READY_WINDOW_WIDTH = 700 MIN_READY_WINDOW_HEIGHT = 500 START_COOLDOWN_SECONDS = 15 * 60 NOTIFICATION_COOLDOWN_SECONDS = 15 * 60 REQUIRED_FAILURE_SCANS = 2 class NotificationDeliveryUncertain(RuntimeError): """The platform may have accepted a notification before CLI confirmation.""" def run( command: list[str], *, timeout: int = 30, data: str | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.run( command, input=data, text=True, capture_output=True, check=False, timeout=timeout, ) def load_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (FileNotFoundError, OSError, json.JSONDecodeError): return {} return value if isinstance(value, dict) else {} def atomic_json(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text( json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) os.chmod(temporary, 0o600) temporary.replace(path) def parse_timestamp(value: object) -> float | None: if not isinstance(value, str) or not value: return None try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None if parsed.tzinfo is None: return parsed.timestamp() return parsed.timestamp() def is_active(unit: str) -> bool: try: result = run(["/usr/bin/systemctl", "is-active", "--quiet", unit], timeout=10) except (OSError, subprocess.TimeoutExpired): return False return result.returncode == 0 def is_user_active(unit: str) -> bool: try: result = run( ["/usr/bin/systemctl", "--user", "is-active", "--quiet", unit], timeout=10, ) except (OSError, subprocess.TimeoutExpired): return False return result.returncode == 0 def wechat_process_present() -> bool: try: result = run(["/usr/bin/pgrep", "-u", str(os.getuid()), "-x", "wechat"], timeout=10) except (OSError, subprocess.TimeoutExpired): return False return result.returncode == 0 def weixin_window_geometry() -> tuple[int, int] | None: environment = os.environ | { "DISPLAY": DISPLAY, "XAUTHORITY": str(XAUTHORITY), "XDG_RUNTIME_DIR": str(XDG_RUNTIME_DIR), "DBUS_SESSION_BUS_ADDRESS": DBUS_ADDRESS, } try: search = subprocess.run( ["/usr/bin/xdotool", "search", "--onlyvisible", "--class", "wechat"], text=True, capture_output=True, check=False, timeout=10, env=environment, ) except (OSError, subprocess.TimeoutExpired): return False if search.returncode != 0: return False for identifier in search.stdout.splitlines(): candidate = identifier.strip() if not candidate: continue try: title = subprocess.run( ["/usr/bin/xdotool", "getwindowname", candidate], text=True, capture_output=True, check=False, timeout=10, env=environment, ) except (OSError, subprocess.TimeoutExpired): continue if title.returncode == 0 and title.stdout.strip() == "Weixin": try: geometry = subprocess.run( [ "/usr/bin/xdotool", "getwindowgeometry", "--shell", candidate, ], text=True, capture_output=True, check=False, timeout=10, env=environment, ) except (OSError, subprocess.TimeoutExpired): return None if geometry.returncode != 0: return None values = dict( line.split("=", 1) for line in geometry.stdout.splitlines() if "=" in line ) try: return int(values["WIDTH"]), int(values["HEIGHT"]) except (KeyError, ValueError): return None return None def recorder_is_fresh(now: float) -> tuple[bool, str]: state = load_json(RECORDER_STATE) if not state: return False, "recorder_state" captured_at = parse_timestamp(state.get("last_capture_at")) if captured_at is None: return False, "recorder_state" if now - captured_at > RECORDER_FRESHNESS_SECONDS: return False, "recorder_stale" return True, "" def has_unacknowledged_failed_delivery() -> bool: try: return any(FAILED_DIR.glob("*.json")) except OSError: return False def health_codes(now: float) -> list[str]: codes = [ SERVICE_CODES[unit] for unit in SYSTEM_SERVICES if not is_active(unit) ] codes.extend( USER_SERVICE_CODES[unit] for unit in USER_SERVICES if not is_user_active(unit) ) if not wechat_process_present(): codes.append("wechat_process") geometry = weixin_window_geometry() if geometry is None: codes.append("wechat_window") elif ( geometry[0] < MIN_READY_WINDOW_WIDTH or geometry[1] < MIN_READY_WINDOW_HEIGHT ): codes.append("wechat_login_required") recorder_ok, recorder_code = recorder_is_fresh(now) if not recorder_ok: codes.append(recorder_code) if has_unacknowledged_failed_delivery(): codes.append("responder_failed") return codes def should_retry(value: object, now: float, cooldown_seconds: int) -> bool: try: previous = float(value) except (TypeError, ValueError): return True return now - previous >= cooldown_seconds def start_wechat_once(state: dict[str, Any], now: float, *, dry_run: bool) -> str: if not should_retry(state.get("last_start_attempt_at"), now, START_COOLDOWN_SECONDS): return "cooldown" if dry_run: return "would_start" state["last_start_attempt_at"] = now try: result = run( [ "/usr/bin/systemctl", "--user", "start", "app-wechat@autostart.service", ], timeout=30, ) except (OSError, subprocess.TimeoutExpired): state["last_start_result"] = "error" return "error" state["last_start_result"] = "started" if result.returncode == 0 else "error" return str(state["last_start_result"]) def update_incident( state: dict[str, Any], *, codes: list[str], now: float, ) -> str | None: incident = state.setdefault("wechat_chain", {}) active = bool(incident.get("active")) if codes: incident["consecutive_failures"] = int(incident.get("consecutive_failures", 0)) + 1 incident["last_codes"] = list(codes) incident["last_seen_at"] = now if not active and incident["consecutive_failures"] >= REQUIRED_FAILURE_SCANS: incident["active"] = True incident["started_at"] = now incident["incident_pending"] = True active = True if active and not bool(incident.get("incident_sent")): incident["incident_pending"] = True if ( active and bool(incident.get("incident_pending")) and should_retry( incident.get("incident_last_notification_attempt_at"), now, NOTIFICATION_COOLDOWN_SECONDS, ) ): return "incident" return None incident["consecutive_failures"] = 0 incident["last_codes"] = [] incident["last_healthy_at"] = now if active: incident["active"] = False incident["incident_pending"] = False incident["recovered_at"] = now if bool(incident.get("incident_sent")): incident["recovery_pending"] = True if ( bool(incident.get("recovery_pending")) and should_retry( incident.get("recovery_last_notification_attempt_at"), now, NOTIFICATION_COOLDOWN_SECONDS, ) ): return "recovery" return None def notification_config() -> dict[str, Any]: value = load_json(NOTIFICATION_CONFIG) enabled = value.get("enabled", False) command = value.get("command", []) timeout = value.get("timeout_seconds", 30) if not isinstance(enabled, bool): raise RuntimeError("invalid notification enabled flag") if ( not isinstance(command, list) or any(not isinstance(argument, str) or not argument for argument in command) or len(command) > 32 ): raise RuntimeError("invalid notification command") if enabled and (not command or not command[0].startswith("/")): raise RuntimeError("notification command must be absolute") if not isinstance(timeout, int) or not 1 <= timeout <= 120: raise RuntimeError("invalid notification timeout") return { "enabled": enabled, "command": command, "timeout_seconds": timeout, } def notification_failure_code(result: subprocess.CompletedProcess[str]) -> str: output = f"{result.stdout}\n{result.stderr}".casefold() for code in ( "provider_prepare_failed", "provider_context_rejected", "channel_unavailable", "delivery_timeout", "delivery_command_failed", ): if f"delivery_error={code}" in output: return code if "prepare failed" in output: return "provider_prepare_failed" if "context_token" in output or "context token" in output: return "provider_context_rejected" if "unknown channel" in output or "unsupported channel" in output: return "channel_unavailable" if "timed out" in output or "timeout" in output: return "delivery_timeout" if result.returncode != 0: return "delivery_command_failed" return "delivery_unconfirmed" def send_official_notification(message: str) -> None: config = notification_config() if not config["enabled"]: raise RuntimeError("health notification is disabled") try: result = run( list(config["command"]), timeout=int(config["timeout_seconds"]), data=message, ) except subprocess.TimeoutExpired as error: raise NotificationDeliveryUncertain("delivery_timeout") from error if result.returncode != 0: code = notification_failure_code(result) if code in {"delivery_timeout", "delivery_unconfirmed"}: raise NotificationDeliveryUncertain(code) raise RuntimeError(f"official notification delivery failed: {code}") def labels_for(codes: list[str]) -> str: return "、".join(CODE_LABELS.get(code, code) for code in codes) def incident_message(codes: list[str], start_result: str) -> str: lines = [ "微信自动链路告警", "状态:异常", f"检查项:{labels_for(codes)}", ] if start_result in {"started", "would_start"}: lines.append("处理:已尝试启动微信客户端") lines.extend( [ "请打开 noVNC 检查;如显示二维码,请扫码登录。", "若未收到后续官方通知,请在此私聊发送 aaa 刷新通道。", ] ) return "\n".join(lines) def recovery_message() -> str: return "\n".join( [ "微信自动链路已恢复", "状态:VNC、微信窗口与 OCR 捕获检查正常。", "若未收到后续官方通知,请在此私聊发送 aaa 刷新通道。", ] ) def test_message() -> str: return "\n".join( [ "微信自动链路通知测试", "状态:投递链路正常。", ] ) def finish_notification( state: dict[str, Any], phase: str, *, codes: list[str], start_result: str, now: float, dry_run: bool, ) -> bool: if phase == "incident": message = incident_message(codes, start_result) elif phase == "recovery": message = recovery_message() else: raise ValueError(f"unknown notification phase: {phase}") if dry_run: print(f"notification=would-send:{phase}") return True incident = state["wechat_chain"] incident[f"{phase}_last_notification_attempt_at"] = now try: send_official_notification(message) except NotificationDeliveryUncertain: incident["last_notification_result"] = "uncertain" if phase == "incident": incident["incident_pending"] = False incident["incident_sent"] = True else: incident["recovery_pending"] = False incident["incident_sent"] = False print(f"notification=uncertain:{phase}") return True except (OSError, subprocess.TimeoutExpired, RuntimeError): incident["last_notification_result"] = "failed" return False incident["last_notification_result"] = "sent" if phase == "incident": incident["incident_pending"] = False incident["incident_sent"] = True else: incident["recovery_pending"] = False incident["incident_sent"] = False print(f"notification=sent:{phase}") return True def monitor_once(*, dry_run: bool) -> int: now = time.time() state = load_json(STATE_FILE) codes = health_codes(now) start_result = "not_needed" if "wechat_process" in codes: start_result = start_wechat_once(state, now, dry_run=dry_run) phase = update_incident(state, codes=codes, now=now) print(f"health={'unhealthy' if codes else 'healthy'}") print(f"codes={','.join(codes) if codes else '-'}") print(f"wechat_start={start_result}") if phase: success = finish_notification( state, phase, codes=codes, start_result=start_result, now=now, dry_run=dry_run, ) if not dry_run: atomic_json(STATE_FILE, state) return 0 if success else 1 if not dry_run: atomic_json(STATE_FILE, state) return 0 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "--dry-run", action="store_true", help="evaluate health without starting WeChat or sending a notification", ) parser.add_argument( "--test-send", action="store_true", help="send one fixed notification through the configured adapter", ) args = parser.parse_args() if args.test_send: if args.dry_run: parser.error("--test-send cannot be combined with --dry-run") try: send_official_notification(test_message()) except NotificationDeliveryUncertain: print("notification=uncertain:test") return 2 print("notification=sent:test") return 0 return monitor_once(dry_run=args.dry_run) if __name__ == "__main__": try: raise SystemExit(main()) except KeyboardInterrupt: raise SystemExit(130)