release: public alpha 0.6.0 baseline

This commit is contained in:
JWeBot Contributors
2026-10-15 03:58:31 +08:00
commit d4242ae07c
238 changed files with 37724 additions and 0 deletions
+572
View File
@@ -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)