release: public alpha 0.6.0 baseline
This commit is contained in:
Executable
+572
@@ -0,0 +1,572 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user