Files
JWeBot/wechat_adaptive_recorder.py
T
JWeBot Contributors ac630576c7
check / local-gate (push) Failing after 59s
Release beta 1.2.2.1019
2026-10-19 17:40:46 +08:00

1135 lines
38 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 random
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
MULTI_REPLY_DELAY_MIN_SECONDS = 0.5
MULTI_REPLY_DELAY_MAX_SECONDS = 1.0
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
if response.get("status") == "ignored":
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", "")),
outcome="ignored",
duplicate=bool(response.get("duplicate")),
)
return "NO_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}",
)
if index < len(texts):
time.sleep(
random.uniform(
MULTI_REPLY_DELAY_MIN_SECONDS,
MULTI_REPLY_DELAY_MAX_SECONDS,
)
)
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()