1113 lines
38 KiB
Python
Executable File
1113 lines
38 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Run the WeChat OCR/reply chain against the current VNC window geometry."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.machinery
|
||
import importlib.util
|
||
import json
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import queue
|
||
import socket
|
||
import sys
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from types import ModuleType
|
||
from typing import Any, Callable
|
||
|
||
import jwebot_edge
|
||
|
||
|
||
HOME = Path.home()
|
||
UPSTREAM = Path(
|
||
os.environ.get(
|
||
"JWEBOT_OCR_UPSTREAM",
|
||
str(HOME / ".local" / "lib" / "wechat-health-watch" / "wechat_local_ocr.py"),
|
||
)
|
||
).expanduser()
|
||
STATE_ROOT = HOME / ".local" / "state" / "wechat-adaptive-ocr"
|
||
GUI_BRIDGE = HOME / ".local" / "lib" / "wechat-health-watch" / "wechat_adaptive_gui_bridge.py"
|
||
XDG_RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
|
||
GUI_SENDER_SOCKET = Path(
|
||
os.environ.get(
|
||
"JWEBOT_GUI_SENDER_SOCKET",
|
||
str(XDG_RUNTIME_DIR / "jwebot-gui-sender.sock"),
|
||
)
|
||
).expanduser()
|
||
GUI_SENDER_TIMEOUT_SECONDS = float(
|
||
os.environ.get("JWEBOT_GUI_SENDER_TIMEOUT_SECONDS", "55")
|
||
)
|
||
GUI_SENDER_MAX_PAYLOAD_BYTES = 65536
|
||
|
||
BASE_WIDTH = 1440
|
||
BASE_HEIGHT = 873
|
||
BASE_MESSAGE_BOX = (300, 80, 1439, 732)
|
||
BASE_TARGET_CHAT_BOX = (0, 33, 300, 147)
|
||
BASE_TITLE_BOX = (300, 33, 900, 80)
|
||
BASE_FOLLOW_X = 1432
|
||
BASE_FOLLOW_Y = 500
|
||
MIN_READY_WINDOW_WIDTH = 700
|
||
MIN_READY_WINDOW_HEIGHT = 500
|
||
READY_POLL_SECONDS = 10.0
|
||
READY_LOG_INTERVAL_SECONDS = 60.0
|
||
CAPTURE_HEARTBEAT_SECONDS = 45
|
||
FAST_ACK_CONFIRM_TIMEOUT_SECONDS = 1.0
|
||
GUI_SEND_PRIORITY_STOP = -1
|
||
GUI_SEND_PRIORITY_IMMEDIATE = 0
|
||
GUI_SEND_PRIORITY_FINAL = 1
|
||
TELEMETRY = jwebot_edge.RuntimeTelemetry(
|
||
component="adaptive-recorder",
|
||
enabled=os.environ.get("JWEBOT_TELEMETRY_ENABLED", "1").strip() != "0",
|
||
)
|
||
|
||
|
||
def configure_logging() -> None:
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(message)s",
|
||
force=False,
|
||
)
|
||
logging.getLogger().setLevel(logging.INFO)
|
||
logging.getLogger("jwebot.telemetry").setLevel(logging.INFO)
|
||
|
||
|
||
def elapsed_since_iso_ms(
|
||
value: object,
|
||
*,
|
||
now: datetime | None = None,
|
||
) -> float | None:
|
||
try:
|
||
observed = datetime.fromisoformat(str(value))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
current = now or datetime.now().astimezone()
|
||
if observed.tzinfo is None:
|
||
observed = observed.replace(tzinfo=current.tzinfo)
|
||
return max(0.0, (current - observed).total_seconds() * 1000)
|
||
|
||
|
||
def scale_box(
|
||
box: tuple[int, int, int, int],
|
||
*,
|
||
width: int,
|
||
height: int,
|
||
) -> tuple[int, int, int, int]:
|
||
left, top, right, bottom = box
|
||
scaled = (
|
||
round(left * width / BASE_WIDTH),
|
||
round(top * height / BASE_HEIGHT),
|
||
round(right * width / BASE_WIDTH),
|
||
round(bottom * height / BASE_HEIGHT),
|
||
)
|
||
return (
|
||
max(0, min(width - 1, scaled[0])),
|
||
max(0, min(height - 1, scaled[1])),
|
||
max(1, min(width, scaled[2])),
|
||
max(1, min(height, scaled[3])),
|
||
)
|
||
|
||
|
||
def load_upstream() -> ModuleType:
|
||
loader = importlib.machinery.SourceFileLoader(
|
||
"wechat_local_ocr_adaptive",
|
||
str(UPSTREAM),
|
||
)
|
||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||
if spec is None:
|
||
raise RuntimeError("cannot load the installed WeChat OCR module")
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def load_gui_bridge() -> ModuleType:
|
||
loader = importlib.machinery.SourceFileLoader(
|
||
"wechat_adaptive_gui_bridge_runtime",
|
||
str(GUI_BRIDGE),
|
||
)
|
||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||
if spec is None:
|
||
raise RuntimeError("cannot load the installed WeChat GUI bridge")
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def configure_state(module: ModuleType) -> None:
|
||
module.STATE_ROOT = STATE_ROOT
|
||
module.HISTORY_DIR = STATE_ROOT / "history"
|
||
module.QUEUE_DIR = STATE_ROOT / "queue"
|
||
module.FAILED_DIR = STATE_ROOT / "failed"
|
||
module.RECORDER_STATE = STATE_ROOT / "recorder-state.json"
|
||
module.RESPONDER_STATE = STATE_ROOT / "responder-state.json"
|
||
# Keep the shared GUI lock so this process never races another GUI sender.
|
||
module.GUI_LOCK_FILE = Path("/var/lib/wechat-local-ocr/gui-interaction.lock")
|
||
|
||
|
||
def configure_layout(
|
||
module: ModuleType,
|
||
*,
|
||
identifier: str | None = None,
|
||
) -> tuple[int, int]:
|
||
identifier = identifier or module.window_id()
|
||
width, height = module.window_geometry(identifier)
|
||
if width < MIN_READY_WINDOW_WIDTH or height < MIN_READY_WINDOW_HEIGHT:
|
||
raise RuntimeError("WeChat client is not in its ready main-window state")
|
||
|
||
module.EXPECTED_WIDTH = width
|
||
module.EXPECTED_HEIGHT = height
|
||
module.MESSAGE_BOX = scale_box(
|
||
BASE_MESSAGE_BOX,
|
||
width=width,
|
||
height=height,
|
||
)
|
||
module.TARGET_CHAT_BOX = scale_box(
|
||
BASE_TARGET_CHAT_BOX,
|
||
width=width,
|
||
height=height,
|
||
)
|
||
module.TITLE_BOX = scale_box(
|
||
BASE_TITLE_BOX,
|
||
width=width,
|
||
height=height,
|
||
)
|
||
module.FOLLOW_LATEST_MOUSE_X = round(BASE_FOLLOW_X * width / BASE_WIDTH)
|
||
module.FOLLOW_LATEST_MOUSE_Y = round(BASE_FOLLOW_Y * height / BASE_HEIGHT)
|
||
return width, height
|
||
|
||
|
||
def wait_for_ready(
|
||
module: ModuleType,
|
||
*,
|
||
poll_seconds: float = READY_POLL_SECONDS,
|
||
sleep: Callable[[float], None] = time.sleep,
|
||
monotonic: Callable[[], float] = time.monotonic,
|
||
) -> tuple[int, int]:
|
||
"""Keep the service alive while WeChat is absent or awaiting login."""
|
||
|
||
if poll_seconds <= 0:
|
||
raise ValueError("ready poll interval must be positive")
|
||
last_log_at: float | None = None
|
||
while True:
|
||
try:
|
||
layout = configure_layout(module)
|
||
except Exception as error:
|
||
now = monotonic()
|
||
if (
|
||
last_log_at is None
|
||
or now - last_log_at >= READY_LOG_INTERVAL_SECONDS
|
||
):
|
||
logging.warning(
|
||
"waiting for ready WeChat main window error_type=%s",
|
||
type(error).__name__,
|
||
)
|
||
last_log_at = now
|
||
sleep(poll_seconds)
|
||
continue
|
||
logging.info(
|
||
"ready WeChat main window width=%d height=%d",
|
||
layout[0],
|
||
layout[1],
|
||
)
|
||
return layout
|
||
|
||
|
||
def user_openclaw_command(
|
||
module: ModuleType,
|
||
*,
|
||
agent: str,
|
||
session: str,
|
||
timeout_seconds: int,
|
||
) -> list[str]:
|
||
cloud_host = os.environ.get("WECHAT_HEALTH_CLOUD_HOST", "").strip()
|
||
if not cloud_host:
|
||
raise RuntimeError("missing cloud host configuration")
|
||
return [
|
||
"/usr/bin/ssh",
|
||
"-F",
|
||
"/dev/null",
|
||
"-i",
|
||
str(module.SSH_KEY),
|
||
"-o",
|
||
"BatchMode=yes",
|
||
"-o",
|
||
"ConnectTimeout=8",
|
||
"-o",
|
||
"ConnectionAttempts=3",
|
||
"-o",
|
||
"StrictHostKeyChecking=yes",
|
||
"-o",
|
||
"ServerAliveInterval=5",
|
||
"-o",
|
||
"ServerAliveCountMax=2",
|
||
"-o",
|
||
"IPQoS=none",
|
||
f"root@{cloud_host}",
|
||
"docker",
|
||
"exec",
|
||
"-i",
|
||
"openclaw-gateway",
|
||
"node",
|
||
"/app/dist/index.js",
|
||
"agent",
|
||
"--agent",
|
||
agent,
|
||
"--session-key",
|
||
session,
|
||
"--message-file",
|
||
"/dev/stdin",
|
||
"--json",
|
||
"--timeout",
|
||
str(timeout_seconds),
|
||
]
|
||
|
||
|
||
def uses_cloud_prefix(event: dict[str, Any]) -> bool:
|
||
return jwebot_edge.event_question(event).lstrip().startswith(("#", "#"))
|
||
|
||
|
||
@dataclass
|
||
class GuiSendJob:
|
||
reply: str
|
||
expected_group: str
|
||
event_id: str = ""
|
||
source: str = "ocr"
|
||
before_send: Callable[[], None] | None = None
|
||
after_send: Callable[[], None] | None = None
|
||
enqueued_at: float = field(default_factory=time.monotonic)
|
||
done: threading.Event = field(default_factory=threading.Event)
|
||
payload: dict[str, Any] | None = None
|
||
error: BaseException | None = None
|
||
|
||
|
||
def gui_send_priority(source: str) -> int:
|
||
if source.startswith("ack-") or source == "ocr":
|
||
return GUI_SEND_PRIORITY_IMMEDIATE
|
||
return GUI_SEND_PRIORITY_FINAL
|
||
|
||
|
||
class GuiSender:
|
||
def __init__(
|
||
self,
|
||
upstream: ModuleType,
|
||
bridge: ModuleType,
|
||
*,
|
||
socket_path: Path = GUI_SENDER_SOCKET,
|
||
) -> None:
|
||
self.upstream = upstream
|
||
self.bridge = bridge
|
||
self.socket_path = socket_path
|
||
self.jobs: queue.PriorityQueue[
|
||
tuple[int, int, GuiSendJob | None]
|
||
] = queue.PriorityQueue()
|
||
self.sequence_lock = threading.Lock()
|
||
self.next_sequence = 0
|
||
self.stop_event = threading.Event()
|
||
self.worker = threading.Thread(
|
||
target=self._worker_loop,
|
||
name="wechat-gui-sender-worker",
|
||
daemon=True,
|
||
)
|
||
self.server = threading.Thread(
|
||
target=self._server_loop,
|
||
name="wechat-gui-sender-socket",
|
||
daemon=True,
|
||
)
|
||
|
||
def start(self) -> None:
|
||
self.worker.start()
|
||
self.server.start()
|
||
|
||
def stop(self) -> None:
|
||
self.stop_event.set()
|
||
with self.sequence_lock:
|
||
sequence = self.next_sequence
|
||
self.next_sequence += 1
|
||
self.jobs.put((GUI_SEND_PRIORITY_STOP, sequence, None))
|
||
try:
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||
connection.settimeout(0.2)
|
||
connection.connect(str(self.socket_path))
|
||
except OSError:
|
||
pass
|
||
self.worker.join(timeout=1)
|
||
self.server.join(timeout=1)
|
||
self.socket_path.unlink(missing_ok=True)
|
||
|
||
def send_reply(
|
||
self,
|
||
reply: str,
|
||
expected_group: str,
|
||
*,
|
||
before_send: Callable[[], None] | None = None,
|
||
after_send: Callable[[], None] | None = None,
|
||
event_id: object = "",
|
||
source: str = "ocr",
|
||
timeout_seconds: float = GUI_SENDER_TIMEOUT_SECONDS,
|
||
) -> dict[str, Any]:
|
||
if not reply or len(reply) > 900:
|
||
raise RuntimeError("invalid GUI reply")
|
||
if not expected_group:
|
||
raise RuntimeError("missing expected group fingerprint")
|
||
job = GuiSendJob(
|
||
reply=reply,
|
||
expected_group=expected_group,
|
||
event_id=str(event_id or ""),
|
||
source=source,
|
||
before_send=before_send,
|
||
after_send=after_send,
|
||
)
|
||
priority = gui_send_priority(job.source)
|
||
with self.sequence_lock:
|
||
sequence = self.next_sequence
|
||
self.next_sequence += 1
|
||
depth = self.jobs.qsize()
|
||
self.jobs.put((priority, sequence, job))
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.enqueue",
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
depth=depth,
|
||
source=job.source,
|
||
)
|
||
if not job.done.wait(timeout_seconds):
|
||
raise RuntimeError("GUI sender timed out")
|
||
if job.error is not None:
|
||
raise RuntimeError("GUI sender failed") from job.error
|
||
return job.payload or {"ok": True, "state": "sent", "timings": []}
|
||
|
||
def _worker_loop(self) -> None:
|
||
while True:
|
||
priority, sequence, job = self.jobs.get()
|
||
if job is None:
|
||
self.jobs.task_done()
|
||
break
|
||
started = time.monotonic()
|
||
try:
|
||
job.payload = self._deliver(job)
|
||
except BaseException as error:
|
||
job.error = error
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.total",
|
||
status="error",
|
||
elapsed_ms=(time.monotonic() - started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
source=job.source,
|
||
error_type=type(error).__name__,
|
||
)
|
||
else:
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.total",
|
||
elapsed_ms=(time.monotonic() - started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
source=job.source,
|
||
delivered=True,
|
||
)
|
||
finally:
|
||
job.done.set()
|
||
self.jobs.task_done()
|
||
|
||
def _deliver(self, job: GuiSendJob) -> dict[str, Any]:
|
||
wait_ms = (time.monotonic() - job.enqueued_at) * 1000
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.step",
|
||
elapsed_ms=wait_ms,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
phase="queue_wait",
|
||
source=job.source,
|
||
)
|
||
total_started = time.monotonic()
|
||
with self.upstream.gui_interaction_lock(blocking=True) as acquired:
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.step",
|
||
elapsed_ms=(time.monotonic() - total_started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
phase="lock_acquire",
|
||
source=job.source,
|
||
)
|
||
if not acquired:
|
||
raise RuntimeError("cannot acquire GUI interaction lock")
|
||
self._assert_group(job, phase="pre_draft")
|
||
|
||
def before_bridge_send() -> None:
|
||
self._assert_group(job, phase="post_draft")
|
||
if job.before_send is not None:
|
||
job.before_send()
|
||
|
||
try:
|
||
payload = self.bridge.deliver_text(
|
||
job.reply,
|
||
before_send=before_bridge_send,
|
||
)
|
||
except Exception as error:
|
||
timings = getattr(error, "timings", None)
|
||
if isinstance(timings, list):
|
||
self.upstream.emit_bridge_timings(
|
||
{"timings": timings},
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
source=job.source,
|
||
)
|
||
raise
|
||
self.upstream.emit_bridge_timings(
|
||
payload,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
source=job.source,
|
||
)
|
||
if job.after_send is not None:
|
||
step_started = time.monotonic()
|
||
job.after_send()
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.step",
|
||
elapsed_ms=(time.monotonic() - step_started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
phase="after_send_callback",
|
||
source=job.source,
|
||
)
|
||
return payload
|
||
|
||
def _assert_group(self, job: GuiSendJob, *, phase: str) -> None:
|
||
step_started = time.monotonic()
|
||
capture_title = getattr(
|
||
self.upstream,
|
||
"capture_title_fingerprint",
|
||
None,
|
||
)
|
||
if callable(capture_title):
|
||
current_group = capture_title()
|
||
capture_method = "title-region"
|
||
else:
|
||
current = self.upstream.capture_image()
|
||
current_group = self.upstream.title_fingerprint(current)
|
||
capture_method = "full-window"
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.step",
|
||
elapsed_ms=(time.monotonic() - step_started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
phase=f"{phase}_screenshot",
|
||
source=job.source,
|
||
method=capture_method,
|
||
)
|
||
step_started = time.monotonic()
|
||
matched = current_group == job.expected_group
|
||
self.upstream.telemetry_event(
|
||
"gui.sender.step",
|
||
elapsed_ms=(time.monotonic() - step_started) * 1000,
|
||
event_id=job.event_id,
|
||
group_id=job.expected_group,
|
||
queue="gui",
|
||
phase=f"{phase}_group_check",
|
||
outcome="matched" if matched else "changed",
|
||
source=job.source,
|
||
)
|
||
if not matched:
|
||
raise RuntimeError("selected chat changed; send aborted")
|
||
|
||
def _server_loop(self) -> None:
|
||
self.socket_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
self.socket_path.unlink(missing_ok=True)
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
|
||
listener.bind(str(self.socket_path))
|
||
os.chmod(self.socket_path, 0o600)
|
||
listener.listen(8)
|
||
listener.settimeout(1.0)
|
||
while not self.stop_event.is_set():
|
||
try:
|
||
connection, _ = listener.accept()
|
||
except TimeoutError:
|
||
continue
|
||
threading.Thread(
|
||
target=self._handle_connection,
|
||
args=(connection,),
|
||
name="wechat-gui-sender-client",
|
||
daemon=True,
|
||
).start()
|
||
self.socket_path.unlink(missing_ok=True)
|
||
|
||
def _handle_connection(self, connection: socket.socket) -> None:
|
||
with connection:
|
||
try:
|
||
request = self._read_request(connection)
|
||
reply = str(request.get("reply", ""))
|
||
expected_group = str(request.get("group_fingerprint", ""))
|
||
event_id = str(request.get("event_id", ""))
|
||
source = str(request.get("source", "delivery-helper"))[:40]
|
||
payload = self.send_reply(
|
||
reply,
|
||
expected_group,
|
||
event_id=event_id,
|
||
source=source,
|
||
)
|
||
response = {"ok": True, **payload}
|
||
except Exception as error:
|
||
response = {"ok": False, "error": type(error).__name__}
|
||
try:
|
||
connection.sendall(
|
||
(json.dumps(response, ensure_ascii=False) + "\n").encode()
|
||
)
|
||
except OSError:
|
||
pass
|
||
|
||
def _read_request(self, connection: socket.socket) -> dict[str, Any]:
|
||
connection.settimeout(2)
|
||
chunks: list[bytes] = []
|
||
size = 0
|
||
while True:
|
||
chunk = connection.recv(4096)
|
||
if not chunk:
|
||
break
|
||
chunks.append(chunk)
|
||
size += len(chunk)
|
||
if b"\n" in chunk:
|
||
break
|
||
if size > GUI_SENDER_MAX_PAYLOAD_BYTES:
|
||
raise RuntimeError("GUI sender payload is too large")
|
||
raw = b"".join(chunks).split(b"\n", 1)[0]
|
||
value = json.loads(raw.decode("utf-8"))
|
||
if not isinstance(value, dict):
|
||
raise RuntimeError("GUI sender request must be an object")
|
||
if value.get("op") != "send":
|
||
raise RuntimeError("unsupported GUI sender operation")
|
||
return value
|
||
|
||
|
||
def submit_gui_sender_request(
|
||
payload: dict[str, Any],
|
||
*,
|
||
socket_path: Path = GUI_SENDER_SOCKET,
|
||
timeout_seconds: float = GUI_SENDER_TIMEOUT_SECONDS,
|
||
) -> dict[str, Any]:
|
||
started = time.monotonic()
|
||
try:
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||
connection.settimeout(timeout_seconds)
|
||
connection.connect(str(socket_path))
|
||
connection.sendall((json.dumps(payload, ensure_ascii=False) + "\n").encode())
|
||
chunks: list[bytes] = []
|
||
size = 0
|
||
while True:
|
||
chunk = connection.recv(4096)
|
||
if not chunk:
|
||
break
|
||
chunks.append(chunk)
|
||
size += len(chunk)
|
||
if b"\n" in chunk:
|
||
break
|
||
if size > GUI_SENDER_MAX_PAYLOAD_BYTES:
|
||
raise RuntimeError("GUI sender response is too large")
|
||
except OSError as error:
|
||
raise RuntimeError("GUI sender socket unavailable") from error
|
||
try:
|
||
response = json.loads(b"".join(chunks).split(b"\n", 1)[0])
|
||
except (IndexError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||
raise RuntimeError("GUI sender response is invalid") from error
|
||
if not isinstance(response, dict):
|
||
raise RuntimeError("GUI sender response is invalid")
|
||
TELEMETRY.elapsed(
|
||
"gui.sender.client",
|
||
started,
|
||
status="ok" if response.get("ok") is True else "error",
|
||
event_id=payload.get("event_id", ""),
|
||
group_id=payload.get("group_fingerprint", ""),
|
||
transport="unix",
|
||
source=str(payload.get("source", "")),
|
||
outcome=str(response.get("state", response.get("error", ""))),
|
||
)
|
||
if response.get("ok") is not True:
|
||
raise RuntimeError("GUI sender returned an error")
|
||
return response
|
||
|
||
|
||
def edge_router_reply(
|
||
event: dict[str, Any],
|
||
*,
|
||
legacy_router: Any,
|
||
fast_ack_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None,
|
||
reply_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None,
|
||
) -> str:
|
||
"""Use the local edge if healthy, otherwise preserve the existing cloud route."""
|
||
|
||
started = time.monotonic()
|
||
event_id = str(event.get("event_id", ""))
|
||
group_id = str(event.get("group_fingerprint", ""))
|
||
if os.environ.get("JWEBOT_EDGE_ENABLED", "1").strip() == "0":
|
||
logging.info("edge router disabled event_hash=%s", hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12])
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
status="disabled",
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
fallback=uses_cloud_prefix(event),
|
||
)
|
||
if uses_cloud_prefix(event):
|
||
return legacy_router(event)
|
||
return "JWeBot Error: Local edge unavailable"
|
||
try:
|
||
response = jwebot_edge.request_edge(event)
|
||
except jwebot_edge.EdgeError as error:
|
||
logging.warning(
|
||
"edge router unavailable event_hash=%s error_type=%s",
|
||
hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
type(error).__name__,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
error_type=type(error).__name__,
|
||
fallback=uses_cloud_prefix(event),
|
||
)
|
||
if uses_cloud_prefix(event):
|
||
return legacy_router(event)
|
||
return "JWeBot Error: Local edge unavailable"
|
||
if response.get("status") == "reply":
|
||
texts = response.get("texts")
|
||
if (
|
||
isinstance(texts, list)
|
||
and len(texts) > 1
|
||
and all(isinstance(item, str) and 0 < len(item) <= 900 for item in texts)
|
||
):
|
||
if reply_sender is None:
|
||
logging.warning(
|
||
"multi reply sender is unavailable event_hash=%s",
|
||
hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
)
|
||
return "JWeBot Error: Multi reply unavailable"
|
||
if not reply_sender(event, response):
|
||
return "JWeBot Error: Multi reply delivery failed"
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
route=str(response.get("route", "")),
|
||
execution_path=str(response.get("execution_path", "")),
|
||
chars=sum(len(item) for item in texts),
|
||
messages=len(texts),
|
||
outcome="multi_reply",
|
||
)
|
||
return "NO_REPLY"
|
||
reply = str(response.get("text", ""))
|
||
if reply and len(reply) <= 900:
|
||
if response.get("fast_ack") is True:
|
||
if fast_ack_sender is None:
|
||
logging.warning(
|
||
"fast ack sender is unavailable event_hash=%s",
|
||
hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
)
|
||
return "JWeBot Error: Fast acknowledgement unavailable"
|
||
fast_ack_sender(event, response)
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
route=str(response.get("route", "")),
|
||
execution_path=str(
|
||
response.get("execution_path", "")
|
||
),
|
||
ack_kind=str(response.get("route", "")),
|
||
outcome="fast_ack",
|
||
)
|
||
return "NO_REPLY"
|
||
logging.info(
|
||
"edge router reply event_hash=%s route=%s chars=%d",
|
||
hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
str(response.get("route", "")),
|
||
len(reply),
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
route=str(response.get("route", "")),
|
||
execution_path=str(response.get("execution_path", "")),
|
||
chars=len(reply),
|
||
outcome="reply",
|
||
)
|
||
return reply
|
||
logging.warning(
|
||
"edge router fallback event_hash=%s status=%s",
|
||
hashlib.sha256(
|
||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
str(response.get("status", "")),
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.edge.request",
|
||
started,
|
||
status="fallback",
|
||
event_id=event_id,
|
||
group_id=group_id,
|
||
outcome=str(response.get("status", "")),
|
||
fallback=True,
|
||
)
|
||
if uses_cloud_prefix(event):
|
||
return legacy_router(event)
|
||
return "JWeBot Error: Local edge unavailable"
|
||
|
||
|
||
def capture_when_ready(
|
||
module: Any,
|
||
capture: Callable[..., Any],
|
||
identifier: str | None = None,
|
||
) -> Any:
|
||
if identifier is None:
|
||
wait_for_ready(module)
|
||
return capture()
|
||
configure_layout(module, identifier=identifier)
|
||
return capture(identifier)
|
||
|
||
|
||
def run() -> None:
|
||
configure_logging()
|
||
module = load_upstream()
|
||
configure_state(module)
|
||
bridge = load_gui_bridge()
|
||
|
||
original_capture = module.capture_image
|
||
|
||
def adaptive_capture(identifier: str | None = None) -> Any:
|
||
return capture_when_ready(module, original_capture, identifier)
|
||
|
||
module.capture_image = adaptive_capture
|
||
module.GUI_BRIDGE = str(GUI_BRIDGE)
|
||
module.openclaw_command = lambda **kwargs: user_openclaw_command(module, **kwargs)
|
||
legacy_router = module.ask_openclaw
|
||
gui_sender = GuiSender(module, bridge)
|
||
gui_sender.start()
|
||
module.send_reply = gui_sender.send_reply
|
||
|
||
def send_fast_ack(
|
||
event: dict[str, Any],
|
||
response: dict[str, Any],
|
||
) -> bool:
|
||
started = time.monotonic()
|
||
event_id = str(response.get("event_id", ""))
|
||
expected_group = str(event.get("group_fingerprint", ""))
|
||
acknowledgement = str(response.get("text", ""))
|
||
ack_kind = str(response.get("route", ""))
|
||
execution_path = str(response.get("execution_path", ""))
|
||
if not event_id or not acknowledgement or len(acknowledgement) > 900:
|
||
logging.error("edge fast ack response was invalid")
|
||
return False
|
||
try:
|
||
module.send_reply(
|
||
acknowledgement,
|
||
expected_group,
|
||
event_id=event_id,
|
||
source=f"ack-{ack_kind or 'unknown'}",
|
||
)
|
||
except Exception as error:
|
||
logging.warning(
|
||
"fast ack GUI delivery failed event_hash=%s error_type=%s",
|
||
hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12],
|
||
type(error).__name__,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.ack.gui",
|
||
started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type=type(error).__name__,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.fast_ack.gui",
|
||
started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type=type(error).__name__,
|
||
)
|
||
end_to_end_ms = elapsed_since_iso_ms(event.get("observed_at"))
|
||
if end_to_end_ms is not None:
|
||
TELEMETRY.emit(
|
||
"recorder.ack.end_to_end",
|
||
status="error",
|
||
elapsed_ms=end_to_end_ms,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type=type(error).__name__,
|
||
)
|
||
TELEMETRY.emit(
|
||
"recorder.fast_ack.end_to_end",
|
||
status="error",
|
||
elapsed_ms=end_to_end_ms,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type=type(error).__name__,
|
||
)
|
||
try:
|
||
jwebot_edge.request_edge(
|
||
{
|
||
"op": "fast_ack_failed",
|
||
"event_id": event_id,
|
||
"error": type(error).__name__,
|
||
},
|
||
timeout_seconds=FAST_ACK_CONFIRM_TIMEOUT_SECONDS,
|
||
)
|
||
except jwebot_edge.EdgeError:
|
||
logging.exception(
|
||
"fast ack failure could not be recorded event_hash=%s",
|
||
hashlib.sha256(
|
||
event_id.encode("utf-8", "replace")
|
||
).hexdigest()[:12],
|
||
)
|
||
return False
|
||
TELEMETRY.elapsed(
|
||
"recorder.ack.gui",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.fast_ack.gui",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
end_to_end_ms = elapsed_since_iso_ms(event.get("observed_at"))
|
||
if end_to_end_ms is not None:
|
||
TELEMETRY.emit(
|
||
"recorder.ack.end_to_end",
|
||
elapsed_ms=end_to_end_ms,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
TELEMETRY.emit(
|
||
"recorder.fast_ack.end_to_end",
|
||
elapsed_ms=end_to_end_ms,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
confirm_started = time.monotonic()
|
||
try:
|
||
confirmation = jwebot_edge.request_edge(
|
||
{"op": "fast_ack_sent", "event_id": event_id},
|
||
timeout_seconds=FAST_ACK_CONFIRM_TIMEOUT_SECONDS,
|
||
)
|
||
except jwebot_edge.EdgeError:
|
||
logging.exception(
|
||
"fast ack confirmation unavailable event_hash=%s",
|
||
hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12],
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.ack.confirm",
|
||
confirm_started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type="EdgeError",
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.fast_ack.confirm",
|
||
confirm_started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
error_type="EdgeError",
|
||
)
|
||
return False
|
||
if confirmation.get("status") != "ok":
|
||
logging.error(
|
||
"fast ack confirmation rejected event_hash=%s status=%s",
|
||
hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12],
|
||
str(confirmation.get("status", "")),
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.ack.confirm",
|
||
confirm_started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
outcome=str(confirmation.get("status", "")),
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.fast_ack.confirm",
|
||
confirm_started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
outcome=str(confirmation.get("status", "")),
|
||
)
|
||
return False
|
||
TELEMETRY.elapsed(
|
||
"recorder.ack.confirm",
|
||
confirm_started,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.fast_ack.confirm",
|
||
confirm_started,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
ack_kind=ack_kind,
|
||
execution_path=execution_path,
|
||
delivered=True,
|
||
)
|
||
return True
|
||
|
||
def send_multi_reply(
|
||
event: dict[str, Any],
|
||
response: dict[str, Any],
|
||
) -> bool:
|
||
started = time.monotonic()
|
||
event_id = str(response.get("event_id") or event.get("event_id", ""))
|
||
expected_group = str(event.get("group_fingerprint", ""))
|
||
texts = response.get("texts")
|
||
if (
|
||
not event_id
|
||
or not expected_group
|
||
or not isinstance(texts, list)
|
||
or not texts
|
||
or any(not isinstance(item, str) or not item or len(item) > 900 for item in texts)
|
||
):
|
||
logging.error("edge multi reply response was invalid")
|
||
return False
|
||
try:
|
||
for index, text in enumerate(texts, start=1):
|
||
module.send_reply(
|
||
text,
|
||
expected_group,
|
||
event_id=event_id,
|
||
source=f"local-multi-{index}",
|
||
)
|
||
except Exception as error:
|
||
logging.warning(
|
||
"multi reply GUI delivery failed event_hash=%s error_type=%s",
|
||
hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12],
|
||
type(error).__name__,
|
||
)
|
||
TELEMETRY.elapsed(
|
||
"recorder.multi_reply.gui",
|
||
started,
|
||
status="error",
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
messages=len(texts),
|
||
error_type=type(error).__name__,
|
||
)
|
||
return False
|
||
TELEMETRY.elapsed(
|
||
"recorder.multi_reply.gui",
|
||
started,
|
||
event_id=event_id,
|
||
group_id=expected_group,
|
||
messages=len(texts),
|
||
delivered=True,
|
||
)
|
||
return True
|
||
|
||
module.route_event = lambda event: edge_router_reply(
|
||
event,
|
||
legacy_router=legacy_router,
|
||
fast_ack_sender=send_fast_ack,
|
||
reply_sender=send_multi_reply,
|
||
)
|
||
wait_for_ready(module)
|
||
|
||
recorder = module.Recorder(queue_enabled=True)
|
||
responder = module.Responder()
|
||
original_capture_cycle = recorder.capture_cycle
|
||
|
||
def capture_cycle_with_heartbeat(*, stable_frames: int) -> None:
|
||
original_capture_cycle(stable_frames=stable_frames)
|
||
recorder.state["last_capture_at"] = module.now_iso()
|
||
recorder.save_state()
|
||
|
||
recorder.capture_cycle = capture_cycle_with_heartbeat
|
||
|
||
# Establish a no-reply baseline before XDamage starts tracking live deltas.
|
||
recorder.capture_cycle(stable_frames=1)
|
||
|
||
def heartbeat() -> None:
|
||
while True:
|
||
time.sleep(CAPTURE_HEARTBEAT_SECONDS)
|
||
recorder.state["last_capture_at"] = module.now_iso()
|
||
recorder.save_state()
|
||
|
||
threading.Thread(
|
||
target=heartbeat,
|
||
name="wechat-adaptive-heartbeat",
|
||
daemon=True,
|
||
).start()
|
||
thread = threading.Thread(
|
||
target=recorder.run_damage_monitor,
|
||
name="wechat-adaptive-recorder",
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
responder.run_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|