534 lines
28 KiB
Python
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()
|