Files
kefu/wechat_rpa/packaging_checks.py
T
2026-09-21 10:34:06 +08:00

534 lines
28 KiB
Python

"""Release probes: synthetic data and loopback HTTPS only, never real accounts."""
import io
import json
import tempfile
from pathlib import Path
from unittest import mock
import backend_client as backend
def check_login_routing(*, legacy_terminal: bool = False) -> None:
"""Exercise config -> identity login -> desktop exchange through real _request."""
identity_url = "https://identity.example.invalid"
backend_url = backend.PACKAGED_SERVER_URL
calls = []
def transport(request, **kwargs):
body = json.loads(request.data) if request.data else {}
calls.append((request.method, request.full_url, body))
if request.full_url == backend_url + backend.DESKTOP_AUTH_CONFIG_PATH:
payload = {"zyt_api_url": identity_url, "terminal": 7}
elif request.full_url == identity_url + backend.ZYT_LOGIN_PATH:
payload = (
{"code": 0, "msg": "terminal unsupported"}
if legacy_terminal and body.get("terminal") == 7
else {"code": 1, "data": {"token": "probe-identity-token"}}
)
elif request.full_url == backend_url + backend.DESKTOP_AUTH_EXCHANGE_PATH:
if request.get_header("Authorization") != "Bearer probe-identity-token":
raise RuntimeError("Desktop exchange did not receive the identity token")
if "password" in body or "account" in body:
raise RuntimeError("Password payload was sent to the desktop backend")
payload = {"access_token": "probe-desktop-token", "account": {"id": 1}}
else:
raise RuntimeError("Unexpected authentication request destination")
response = io.BytesIO(json.dumps(payload).encode("utf-8"))
response.status = 200
return response
with tempfile.TemporaryDirectory(prefix="wecom-login-probe-") as temporary, \
mock.patch.object(backend, "is_frozen", return_value=True), \
mock.patch.object(backend, "CONNECTION_FILE", Path(temporary) / "connection.json"), \
mock.patch.object(backend, "device_id", return_value="probe-device"), \
mock.patch.object(backend, "_protect_secret", return_value="probe-protected"), \
mock.patch.object(backend.urllib.request, "urlopen", side_effect=transport):
result = backend.desktop_login("http://ignored.example.invalid", "probe-account", "probe-password")
if result["access_token"] != "probe-desktop-token":
raise RuntimeError("Desktop login flow did not complete")
expected = [
("GET", backend_url + backend.DESKTOP_AUTH_CONFIG_PATH),
("POST", identity_url + backend.ZYT_LOGIN_PATH),
]
if legacy_terminal:
expected.append(("POST", identity_url + backend.ZYT_LOGIN_PATH))
expected.append(("POST", backend_url + backend.DESKTOP_AUTH_EXCHANGE_PATH))
if [(method, url) for method, url, _ in calls] != expected:
raise RuntimeError("Authentication requests were routed incorrectly")
def check_database_initialization() -> None:
"""Exercise an empty installed data directory with a synthetic local database."""
import sqlite3
import os
import wxwork_db as database
import wxwork_key
import wxwork_local_setup as setup
import wxwork_message_browser as browser
with tempfile.TemporaryDirectory(prefix="wecom-db-probe-") as temporary:
root = Path(temporary)
source = root / "WXWork"
message = source / "test-account" / "Data" / "message.db"
message.parent.mkdir(parents=True)
connection = sqlite3.connect(message)
connection.execute("PRAGMA page_size=4096")
connection.execute("CREATE TABLE message_table(sender_id TEXT, conversation_id TEXT, content_type INT, send_time INT, content TEXT)")
connection.execute("INSERT INTO message_table VALUES('test','M:test',2,123,'probe')")
connection.commit()
connection.close()
# 合成数据库和合成内存,覆盖打包后的密钥提取/持久化/解密链路。
from Crypto.Cipher import AES
from wxwork_crypto import page_key, generate_iv
key = bytes(range(1, 17))
encrypted = bytearray()
raw = message.read_bytes()
for offset in range(0, len(raw), 4096):
page = offset // 4096 + 1
block = bytearray(raw[offset:offset + 4096])
def encode(value):
return AES.new(page_key(key, page), AES.MODE_CBC, generate_iv(page)).encrypt(value)
if page == 1:
header = bytes(block[16:24])
block[:16] = encode(bytes(block[:16]))
block[16:] = encode(bytes(block[16:]))
block[8:16] = block[16:24]
block[16:24] = header
else:
block[:] = encode(bytes(block))
encrypted.extend(block)
message.write_bytes(encrypted)
memory = b"raw:" + key
process = mock.Mock()
process.regions.return_value = [(0, len(memory))]
process.read.side_effect = lambda base, size: memory[base:base + size]
def acquire():
if setup.key_worker_main() != 0:
raise RuntimeError("Synthetic database key worker failed")
data = root / "installed-data"
with mock.patch.object(database, "_SCRIPT_DIR", str(data)), \
mock.patch.object(database, "KEYS_FILE", str(data / "wxwork_keys.json")), \
mock.patch.object(setup, "application_data_dir", return_value=data), \
mock.patch.object(browser, "application_data_dir", return_value=data), \
mock.patch.dict(os.environ, {"WECOM_ARCHIVE_SOURCE_DIR": ""}), \
mock.patch.object(database, "_windows_documents_dirs", return_value=[str(root)]), \
mock.patch.object(wxwork_key, "find_wxwork_pids", return_value=[123]), \
mock.patch.object(wxwork_key, "_ProcessMemory", return_value=process), \
mock.patch.object(setup, "acquire_local_keys", side_effect=acquire) as acquired:
state = browser.load_browser_snapshot(cache_roots=[data / "wxwork_decrypted"], auto_initialize=True)
acquired.assert_called_once_with()
if state["manualSetupRequired"] or not (data / "wxwork_keys.json").is_file():
raise RuntimeError("Packaged automatic key acquisition failed")
if message.read_bytes() != encrypted:
raise RuntimeError("Live database was unexpectedly modified")
if state["error"] or state["accountCount"] != 1 or state["messageCount"] != 1:
raise RuntimeError("Packaged database initialization failed")
(data / "wxwork_keys.json").unlink()
_check_archive_upload(source, data)
if acquired.call_count != 2:
raise RuntimeError("Archive upload did not initialize missing keys")
_check_live_database_reply(source, data)
def check_https_runtime() -> None:
"""Exercise real urllib HTTPS, TLS and certificate verification on loopback."""
import datetime
import ipaddress
import ssl
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
if not hasattr(urllib.request, "HTTPSHandler"):
raise RuntimeError("Python HTTPS handler is missing")
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
now = datetime.datetime.now(datetime.timezone.utc)
certificate = (
x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
.public_key(key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost"),
x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
.sign(key, hashes.SHA256())
)
class ProbeHandler(BaseHTTPRequestHandler):
def do_GET(self):
payload = b'{"https_ready": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *_args):
pass
with tempfile.TemporaryDirectory(prefix="wecom-tls-probe-") as temporary:
root = Path(temporary)
cert_path = root / "localhost.pem"
key_path = root / "localhost-key.pem"
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(cert_path, key_path)
server = ThreadingHTTPServer(("127.0.0.1", 0), ProbeHandler)
server.socket = server_context.wrap_socket(server.socket, server_side=True)
worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
worker.start()
try:
url = f"https://127.0.0.1:{server.server_port}"
# Trust this probe certificate only, keeping hostname/chain checks enabled.
client_context = ssl.create_default_context(cafile=str(cert_path))
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=client_context))
with mock.patch.object(backend.urllib.request, "urlopen", side_effect=opener.open):
status, payload = backend._request("GET", url, "/probe", identity_service=True, timeout=5)
if status != 200 or payload != {"https_ready": True}:
raise RuntimeError("Real HTTPS request did not complete")
# The same self-signed endpoint must be rejected without explicit trust.
untrusted = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=ssl.create_default_context()))
try:
with untrusted.open(url + "/probe", timeout=5):
raise RuntimeError("Untrusted HTTPS certificate was accepted")
except urllib.error.URLError as exc:
if not isinstance(exc.reason, ssl.SSLCertVerificationError):
raise RuntimeError("HTTPS failed before certificate verification") from exc
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
def _check_archive_upload(source: Path, data: Path) -> None:
"""Encrypted synthetic DB -> actual exporter/API client -> mocked cloud transport."""
import archive_auto_backup as archive
import requests
cloud = backend.PACKAGED_SERVER_URL
config = archive.AutoBackupConfig(
exporter_root=archive._discover_exporter_root(), source_root=source,
work_root=data / "archive_auto_backup", api_urls=(cloud,), batch_size=1,
)
cursor = {}
uploaded = []
calls = []
def transport(session, request, **kwargs):
nonlocal cursor
if not request.url.startswith(cloud + "/api/v2/"):
raise RuntimeError("Archive request escaped the configured backend")
if request.headers.get("Authorization") != "Bearer probe-archive-token":
raise RuntimeError("Archive request lost the desktop credential")
from urllib.parse import urlsplit
path = urlsplit(request.url).path
calls.append((request.method, path))
payload = json.loads(request.body) if request.body else {}
if path == "/api/v2/health":
result = {"status": "ok"}
elif path.endswith("/checkpoint"):
if request.method == "POST":
cursor = payload["checkpoint"]
result = {"checkpoint": cursor}
elif path.endswith("/imports/metadata"):
result = {"people_synced": len(payload.get("people", [])), "conversations_updated": 0}
elif path.endswith("/imports/messages"):
uploaded.extend(payload["messages"])
cursor = payload["checkpoint"]
result = {"received": len(payload["messages"]), "inserted": len(payload["messages"]), "duplicates": 0}
elif path.endswith("/pending-attachments"):
result = {"source_message_ids": []}
else:
raise RuntimeError("Unexpected archive endpoint: " + path)
response = requests.Response()
response.status_code = 200
response._content = json.dumps(result).encode("utf-8")
response.request = request
response.url = request.url
return response
with mock.patch.object(archive, "_configured_source_root", return_value=source), \
mock.patch.object(archive, "_load_exporter_keys", return_value={}), \
mock.patch.object(archive, "application_data_dir", return_value=data), \
mock.patch.object(archive, "_backup_policy", return_value=(True, 1)), \
mock.patch.object(archive, "is_frozen", return_value=True), \
mock.patch.object(backend, "is_frozen", return_value=True), \
mock.patch.object(backend, "desktop_access_token", return_value="probe-archive-token"), \
mock.patch.object(requests.Session, "send", new=transport):
if archive._api_candidates("http://127.0.0.1:8766") != [cloud]:
raise RuntimeError("Installed archive destination is incorrect")
first = archive.run_backup_once(config)
second = archive.run_backup_once(config)
if first["summary"]["inserted"] != 1 or second["summary"]["inserted"] != 0:
raise RuntimeError("Archive incremental upload failed")
if len(uploaded) != 1 or not cursor.get("rowid"):
raise RuntimeError("Cloud archive checkpoint was not respected")
if archive.read_backup_status()["status"] != "completed":
raise RuntimeError("Cloud upload result was not available to the UI")
if ("POST", "/api/v2/archive/desktop/imports/messages") not in calls:
raise RuntimeError("No message import was sent through the API client")
def check_message_navigation() -> None:
"""Bundled OCR must identify Messages below Assistant; all input is mocked."""
check_cross_machine_capture()
check_reply_feedback()
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from runtime_paths import resource_path
from wechat_bot import WeChatBot
surface = Image.new("RGB", (1200, 740), (245, 245, 245))
draw = ImageDraw.Draw(surface)
draw.rectangle((0, 0, 115, 739), fill=(215, 232, 250))
font = ImageFont.truetype(str(resource_path("assets/fonts/HarmonyOS_Sans_SC_Regular.ttf")), 24)
draw.ellipse((36, 138, 80, 182), fill=(125, 140, 156))
draw.text((10, 192), "智能助理", font=font, fill=(80, 98, 118), anchor="lt")
draw.rounded_rectangle((8, 240, 103, 335), radius=12, fill=(206, 224, 245))
draw.ellipse((36, 252, 80, 296), fill=(36, 126, 238))
draw.text((34, 305), "消息", font=font, fill=(36, 126, 238), anchor="lt")
image = np.ascontiguousarray(np.asarray(surface)[:, :, ::-1])
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.hwnd = 100
bot._strict_visual_actions = True
y1, y2, located = bot._message_nav_band(image)
if not located or not 240 < (y1 + y2) // 2 < 336:
raise RuntimeError("Bundled OCR did not locate Messages below Assistant")
if not bot._message_nav_selected(image):
raise RuntimeError("Bundled navigation did not recognize selected Messages")
gray = image.copy()
background = np.all(gray == (250, 232, 215), axis=2)
gray[background] = 190
y1, y2, located = bot._message_nav_band(gray)
if not located or not 240 < (y1 + y2) // 2 < 336 or not bot._message_nav_selected(gray):
raise RuntimeError("Bundled OCR could not locate Messages on a gray sidebar")
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._capture_full_window = mock.Mock(return_value=image)
bot._refresh_message_geometry = mock.Mock()
with mock.patch("wechat_bot.safe_set_foreground", return_value=True), \
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100), \
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 1200, 740)), \
mock.patch("wechat_bot.time.sleep"), \
mock.patch("wechat_bot.pyautogui.click") as click:
if not bot._open_messages_page("安装包自检:"):
raise RuntimeError("Verified Messages navigation did not complete")
if click.call_count != 1:
raise RuntimeError("Navigation attempted multiple clicks")
x, y = click.call_args.args
if not 0 < x < 116 or not 240 < y < 336:
raise RuntimeError("Navigation targeted Assistant or the conversation list")
click.reset_mock()
with mock.patch.object(bot, "_locate_message_nav_band_by_text", return_value=None):
if bot._open_messages_page("安装包自检:") or click.called:
raise RuntimeError("Unidentified navigation used a fallback click")
def check_cross_machine_capture() -> None:
"""Synthetic local pixels -> actual unread detection -> mocked mouse, no uploads."""
import numpy as np
from PIL import Image
import wechat_bot as module
for scale in (1.0, 1.25, 1.5, 2.0):
for fallback in (False, True):
width, height = int(600 * scale), int(500 * scale)
left, top = (-1600 if fallback else 400), 180
rect = (left, top, left + width, top + height)
pixels = np.full((height, width, 3), 245, dtype=np.uint8)
pixels[:, :int(58 * scale)] = 190 # No blue sidebar assumption.
lx, ly, lw, lh = (int(n * scale) for n in (58, 100, 245, 350))
bx, by, radius = int(48 * scale), int(90 * scale), max(4, int(6 * scale))
yy, xx = np.ogrid[:height, :width]
pixels[(xx-lx-bx)**2 + (yy-ly-by)**2 <= radius**2] = (81, 81, 250)
# Simulate a virtualized PrintWindow bitmap with undefined zero alpha.
high = np.repeat(np.repeat(pixels, 2, axis=0), 2, axis=1)
high = np.concatenate((high, np.zeros((*high.shape[:2], 1), dtype=np.uint8)), axis=2)
if fallback:
high.fill(0)
bot = module.WeChatBot.__new__(module.WeChatBot)
bot.scale, bot.hwnd = scale, 100
bot.L, bot.T, bot.R, bot.B = rect
bot._list_x, bot._list_y, bot._list_w, bot._list_h = lx, ly, lw, lh
bot.list_region = {"top": top + ly}
bot.list_click_x = left + lx + int(120 * scale)
bot.badge_scan_x_start = int(35 * scale)
bot.badge_scan_x_end = int(80 * scale)
target = b"synthetic-customer"
# Identity is isolated here; navigation OCR is verified separately.
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._message_workspace_selected = mock.Mock(return_value=True)
bot._session_fingerprint = mock.Mock(return_value=target)
bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"])
bot._raw_selected_session_fingerprint = mock.Mock(return_value=target)
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with mock.patch.object(module.win32gui, "GetWindowRect", return_value=rect), \
mock.patch.object(module, "window_dpi_virtualization_factor", return_value=2), \
mock.patch.object(module, "_capture_window_bitmap", return_value=high), \
mock.patch.object(module, "_window_screen_capture_allowed", return_value=True), \
mock.patch("PIL.ImageGrab.grab", return_value=Image.fromarray(pixels[:, :, ::-1])) as screen, \
mock.patch.object(module.pyautogui, "click") as click, \
mock.patch.object(module.time, "sleep"):
rows = bot.detect_badge_rows(bot.capture_session_list())
if len(rows) != 1 or abs(rows[0] - by) > 1:
raise RuntimeError(f"Unread badge lost at scale {scale}, fallback={fallback}")
# Feed a stale row: the actual click must re-find the badge.
if not bot.click_session(0, expected_fp=target):
raise RuntimeError("Unread click failed after local capture recovery")
click.assert_called_once()
x, y = click.call_args.args
if x != bot.list_click_x or abs(y - (top + ly + by + int(16 * scale))) > 1:
raise RuntimeError("Unread click used a different coordinate space")
if bool(screen.called) != fallback:
raise RuntimeError("Screen fallback was not selected correctly")
def _check_live_database_reply(source: Path, data: Path) -> None:
"""Frozen daemon reader -> DB event -> actual reply queue/content; no desktop input."""
import threading
import time
import reply_database
from engine_b import DataEngine
from wechat_bot import WeChatBot
from wxwork_db import session_fp_from_name
bot = WeChatBot.__new__(WeChatBot)
bot._pending_lock = threading.RLock()
bot._pending_reply_sessions = {}
bot._cancelled_reply_sessions = set()
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._log_queue_event = mock.Mock()
bot._live_render_ids_for = mock.Mock(return_value=[])
bot.identity_by_name = True
bot._open_chat_display_name = mock.Mock(return_value="test")
bot.report_operation = mock.Mock()
bot._reply_wakeup = threading.Event()
fp = bytes.fromhex(session_fp_from_name("test"))
bot._active_session_fp = fp
with mock.patch.object(reply_database, "application_data_dir", return_value=data):
service = reply_database.LiveReplyDatabase(read_timeout=5)
service._since = 0 # Synthetic message timestamp.
bot._db_source = service
try:
engine = DataEngine(bot=bot, db_source=service, data_source_mode="db")
engine._poll_db_once()
if engine.enqueued_count != 1 or not service.health_check():
raise RuntimeError("Frozen background database detection failed")
pending = bot._pending_reply_sessions.get(fp.hex(), {})
if pending.get("identity_signature") or not pending.get("database_event"):
raise RuntimeError("Database task bound the wrong foreground identity")
if not bot._reply_wakeup.is_set():
raise RuntimeError("New database message did not wake reply processing")
bot._session_identity_trustworthy = mock.Mock(return_value=True)
bot._row_display_name = mock.Mock(return_value="test@微信")
if bot._session_fingerprint(None, 0) != fp:
raise RuntimeError("External-contact label broke database task navigation")
bot._composer_geometry_valid = False
with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, \
mock.patch("wechat_bot.pyautogui.dragTo") as drag:
text = bot.extract_chat_text()
if "probe" not in text or hotkey.called or drag.called:
raise RuntimeError("Frozen reply path did not prefer database over clipboard")
if not bot._has_pending_customer_message(text, fp):
raise RuntimeError("Database message direction was not preserved")
engine._poll_db_once()
if engine.enqueued_count != 1:
raise RuntimeError("Frozen reader duplicated the same database row")
finally:
service.close()
if service._thread:
service._thread.join(5)
def check_reply_feedback() -> None:
"""Completed archives and outgoing messages must never start another reply."""
import ai_config
from conversation_store import ConversationStore
from engine_b import DataEngine
import wechat_bot as module
with tempfile.TemporaryDirectory(prefix="wecom-feedback-probe-") as temporary:
store = ConversationStore(str(Path(temporary) / "conversations.json"))
receiver = mock.Mock()
receiver.has_active_pending.return_value = False
receiver.enqueue_detected.return_value = (True, "queued")
engine = DataEngine(bot=receiver, conversations_path=store.path)
for i in range(8):
store.append_exchange_once("aa", "synthetic question", "synthetic answer", f"probe-{i}")
engine.poll_once()
receiver.enqueue_detected.assert_not_called()
# Restarting the detector must not revive a completed exchange either.
DataEngine(bot=receiver, conversations_path=store.path).poll_once()
receiver.enqueue_detected.assert_not_called()
store.append("aa", "user", "synthetic question")
store.save()
engine.poll_once()
receiver.enqueue_detected.assert_called_once()
bot = module.WeChatBot.__new__(module.WeChatBot)
fp = b"p" * 40
bot._active_session_fp = fp
bot._active_identity_signature = b"title"
bot._pending_reply_sessions = {fp.hex(): {
"confirmed_unread": True, "display_name": "probe customer",
"identity_signature": b"title", "generation_surface_signature": b"chat",
}}
bot._known_outgoing_speakers = mock.Mock(return_value={"configured staff"})
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
bot._open_chat_display_name = mock.Mock(return_value="probe customer")
bot.extract_chat_text = mock.Mock(return_value="different staff 14:12:01\nsynthetic answer")
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
bot._log_queue_event = mock.Mock()
bot.report_operation = mock.Mock()
bot._set_task_stage = mock.Mock(return_value=True)
bot._reply_task_cancelled = mock.Mock(return_value=False)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._await_send_gate = mock.Mock(return_value=True)
bot.wait_before_send = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(return_value=None)
bot._message_workspace_selected = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"title")
bot._chat_surface_signature = mock.Mock(return_value=b"chat")
bot._chat_target_matches = mock.Mock(return_value=True)
bot._orchestrated_reply = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
with mock.patch.object(ai_config, "AI_ENABLED", True), \
mock.patch.object(module.pyautogui, "click") as click, \
mock.patch.object(module.pyautogui, "hotkey") as hotkey, \
mock.patch.object(module.pyautogui, "press") as press:
if bot._generate_ai_reply_impl(fp, chat_text=bot.extract_chat_text(), confirmed_unread=True):
raise RuntimeError("Outgoing tail generated another reply")
bot._orchestrated_reply.assert_not_called()
if bot.send_reply("must not send", session_id=fp.hex(), expected_fp=fp):
raise RuntimeError("Outgoing tail was sent another reply")
if bot._last_send_failure_reason != "最后一条已是我方消息,取消残留回复任务":
raise RuntimeError("Send did not reach the outgoing-message guard")
click.assert_not_called()
hotkey.assert_not_called()
press.assert_not_called()
bot._begin_bot_mouse.assert_not_called()