Files
kefu/wechat_rpa/test_reply_safety.py
2026-08-18 17:25:22 +08:00

8391 lines
378 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""自动回复短句净化、验证页识别与发送节流测试。"""
import io
import json
import os
import tempfile
import threading
import time
from collections import deque
from contextlib import ExitStack, redirect_stdout
from unittest import SkipTest, TestCase, main, mock
import numpy as np
from ai_chat import (
_dify_query_from_chat,
_history_messages,
_humanize,
_finalize_vision_reply,
_parse_ui_guard_decision,
_parse_wecom_layout_decision,
_repair_obvious_mismatch,
_user_turn,
classify_wecom_ui,
call_ai_vision,
detect_media_types,
latest_customer_message,
latest_customer_turn,
media_archive_text,
media_text_content,
safe_media_reply,
VISION_NO_INCOMING,
VISION_VOICE_NEEDS_TEXT,
_vision_chat_prompt,
)
import wechat_bot
from conversation_store import ConversationStore
from test_support import queue_log_redirect
from registration_store import RegistrationStore, process_registration_reply
from wechat_bot import (
CHAT_BOTTOM_FROM_BOTTOM,
INPUT_Y_FROM_BOTTOM,
WeChatBot,
composer_divider_matches,
find_blocking_modal_close,
find_wx_hwnd,
infer_composer_top,
infer_chat_pane_right,
infer_navigation_width,
infer_session_list_width,
looks_like_modal_scrim,
looks_like_security_verification,
fit_window_inside_work_area,
window_spans_multiple_displays,
window_looks_rendered,
)
class ReplyStyleTest(TestCase):
CHAT_SAMPLE = (
"高兴亮 7/28 15:54:54\n"
"抱歉刚才说得太复杂了,就是让您多休息,别太累着\n"
"一个小迷糊@微信@微信联系人 7/28 15:55:08\n"
"你多大了"
)
def test_removes_markdown_canned_closing_and_extra_questions(self):
reply = _humanize(
"### 回复\n"
"1. 您好,先别太着急,今天血糖大概多少?\n"
"2. 是空腹还是餐后测的?\n"
"3. 希望以上建议能帮到您。"
)
self.assertNotIn("###", reply)
self.assertNotIn("希望以上建议能帮到您", reply)
self.assertNotIn("您好", reply)
self.assertEqual(reply.count("") + reply.count("?"), 1)
self.assertLessEqual(len(reply), 100)
def test_emergency_reply_keeps_three_short_sentences(self):
reply = _humanize("先别慌。现在立即拨打120。不要自己开车。之后再联系我。")
self.assertIn("120", reply)
self.assertIn("不要自己开车", reply)
self.assertNotIn("之后再联系我", reply)
def test_extracts_the_real_latest_customer_sentence(self):
self.assertEqual(latest_customer_message(self.CHAT_SAMPLE), "你多大了")
def test_merges_five_consecutive_customer_bubbles_as_one_turn(self):
chat = (
"贴心管家 7/29 10:00:00\n您想问哪方面呢\n"
"客户甲 7/29 10:00:03\n我这两天总口渴\n"
"客户甲 7/29 10:00:07\n晚上也睡不好\n"
"客户甲 7/29 10:00:11\n空腹是8.6\n"
"客户甲 7/29 10:00:16\n饭后是12\n"
"客户甲 7/29 10:00:20\n应该怎么办"
)
merged = latest_customer_turn(chat)
self.assertEqual(
merged,
"我这两天总口渴\n晚上也睡不好\n空腹是8.6\n饭后是12\n应该怎么办",
)
self.assertNotIn("您想问哪方面", merged)
prompt = _user_turn(chat)["content"]
self.assertIn("客户本轮连续消息|统一回答对象", prompt)
self.assertIn("我这两天总口渴\n晚上也睡不好", prompt)
def test_old_raw_history_does_not_relabel_staff_reply_as_customer(self):
with mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True):
messages = _history_messages([
{"role": "user", "content": self.CHAT_SAMPLE},
{"role": "assistant", "content": "四十来岁啦。"},
])
self.assertEqual(messages[0]["content"], "你多大了")
def test_age_question_mismatch_is_repaired(self):
reply = _repair_obvious_mismatch(
"您别管我多大,把身体养好才是正经事。",
self.CHAT_SAMPLE,
)
self.assertEqual(reply, "四十来岁啦,怎么突然问这个?")
def test_casual_tired_message_is_not_redirected_to_hospital(self):
reply = _repair_obvious_mismatch(
"血糖波动会让人乏力,建议去医院让医生调整方案。",
"一个小迷糊 7/28 14:55:43\n好困啊",
)
self.assertEqual(reply, "困了就先眯一会儿,别硬撑着。")
def test_low_information_customer_turn_never_guesses_phone_mistouch(self):
chat = (
"一个小迷糊@微信@微信联系人 7/30 11:58:01\n\n"
"一个小迷糊@微信@微信联系人 7/30 11:58:08\n\n"
"一个小迷糊@微信@微信联系人 7/30 11:58:15\n啊"
)
reply = _repair_obvious_mismatch(
"这是怎么啦,是不是不小心碰到手机了?",
chat,
)
self.assertEqual(reply, "我在呢,您慢慢说,怎么啦?")
self.assertIn("【本轮信息很少】", _dify_query_from_chat(chat, history=[]))
def test_dify_query_marks_latest_message_as_only_target(self):
query = _dify_query_from_chat(self.CHAT_SAMPLE, history=[])
self.assertIn("【本轮是日常闲聊】", query)
self.assertIn("【客户本轮连续消息|统一回答对象】\n你多大了", query)
def test_clarification_does_not_ask_customer_to_repeat(self):
reply = _repair_obvious_mismatch(
"刚才没听清,您能再说一遍吗?",
"测试客户 7/28 15:54:46\n什么",
history=[
{"role": "user", "content": "好困啊"},
{"role": "assistant", "content": "困了就先眯一会儿,别硬撑着。"},
],
)
self.assertEqual(reply, "我是说,困了就先眯一会儿,别硬撑着。")
class MediaReplyTest(TestCase):
def test_media_markers_are_detected_but_unicode_emoji_stays_text(self):
self.assertEqual(detect_media_types("[图片],这个处方天数改成15天吧"), {"image"})
self.assertEqual(detect_media_types("[动画表情]"), {"sticker"})
self.assertEqual(detect_media_types("[表情包]"), {"sticker"})
self.assertEqual(detect_media_types("[语音]"), {"voice"})
self.assertEqual(detect_media_types("[语音 5秒]"), {"voice"})
self.assertEqual(detect_media_types("[语音消息 00:05]"), {"voice"})
self.assertEqual(detect_media_types("今天挺开心的😊"), set())
self.assertEqual(
media_text_content("客户甲 10:00:00\n[图片],这个处方天数改成15天吧"),
"这个处方天数改成15天吧",
)
def test_voice_duration_is_not_mistaken_for_transcription(self):
self.assertEqual(
media_text_content("客户甲 10:00:00\n[语音] 00:05"),
"",
)
self.assertEqual(
media_text_content("客户甲 10:00:00\n[语音]\n00:05\n明天下午可以吗"),
"明天下午可以吗",
)
self.assertEqual(
media_text_content("客户甲 10:00:00\n先说\n语音 5秒\n再说"),
"先说\n再说",
)
archived = media_archive_text(
"客户甲 10:00:00\n[语音]\n00:05\n明天下午可以吗",
media_types={"voice"},
)
self.assertIn("可见文字/转写", archived)
self.assertNotIn("未取得转写", archived)
def test_explicit_empty_media_set_does_not_rescan_old_history(self):
full = (
"客户甲 09:00:00\n[语音]\n"
"贴心管家 09:00:05\n麻烦打字说一下\n"
"客户甲 10:00:00\n今天挺开心的😊"
)
prompt = _vision_chat_prompt(full, media_types=set())
self.assertIn("【剪贴板检测到的媒体】剪贴板未识别具体类型", prompt)
archived = media_archive_text(full, media_types=set())
self.assertEqual(archived, "今天挺开心的😊")
self.assertNotIn("语音", archived)
def test_mixed_vision_without_contains_voice_is_rejected(self):
raw = (
'{"has_new_customer_message":true,"media_type":"mixed",'
'"voice_transcribed":false,"reply":"我看到了"}'
)
self.assertEqual(
_finalize_vision_reply(
raw,
chat_text="客户甲 10:00:00\n帮我看看",
media_types=set(),
),
"",
)
def test_vision_parser_ignores_analysis_json_before_protocol_result(self):
raw = (
'{"analysis":"先确认最末端左侧气泡"}\n'
'{"has_new_customer_message":true,"media_type":"image",'
'"media_types":["image"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"图片收到了,您想重点看哪一处?"}'
)
reply = _finalize_vision_reply(raw, chat_text="", media_types=set())
self.assertEqual(reply, "图片收到了,您想重点看哪一处?")
self.assertEqual(set(reply.media_types), {"image"})
def test_vision_parser_uses_last_protocol_object(self):
raw = (
'{"has_new_customer_message":false,"media_type":"unknown",'
'"reply":""}\n'
'{"has_new_customer_message":true,"media_type":"sticker",'
'"media_types":["sticker"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"看到您发的表情啦。"}'
)
reply = _finalize_vision_reply(raw, chat_text="", media_types=set())
self.assertEqual(reply, "看到您发的表情啦。")
self.assertEqual(set(reply.media_types), {"sticker"})
def test_nested_diagnostic_object_cannot_override_protocol_result(self):
raw = (
'{"has_new_customer_message":true,"media_type":"image",'
'"media_types":["image"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"图片收到了。",'
'"diagnostic":{"has_new_customer_message":false}}'
)
reply = _finalize_vision_reply(raw, chat_text="", media_types=set())
self.assertEqual(reply, "图片收到了。")
self.assertEqual(set(reply.media_types), {"image"})
def test_vision_protocol_conflicts_fail_closed(self):
conflicting_types = (
'{"has_new_customer_message":true,"media_type":"image",'
'"media_types":["sticker"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"我已经看懂了"}'
)
self.assertEqual(
_finalize_vision_reply(
conflicting_types,
chat_text="客户甲 10:00:00\n[图片]",
media_types={"image"},
),
"",
)
invalid_boolean = (
'{"has_new_customer_message":"maybe","media_type":"image",'
'"media_types":["image"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"不应发送"}'
)
self.assertEqual(
_finalize_vision_reply(invalid_boolean, chat_text="", media_types=set()),
"",
)
impossible_transcript = (
'{"has_new_customer_message":true,"media_type":"text",'
'"media_types":[],"contains_voice":false,'
'"voice_transcribed":true,"reply":"不应发送"}'
)
self.assertEqual(
_finalize_vision_reply(impossible_transcript, chat_text="有文字", media_types=set()),
"",
)
def test_voice_safety_result_preserves_other_mixed_media_types(self):
raw = (
'{"has_new_customer_message":true,"media_type":"mixed",'
'"media_types":["voice","image"],"contains_voice":true,'
'"voice_transcribed":false,"reply":"不应采用模型对语音的猜测"}'
)
reply = _finalize_vision_reply(
raw,
chat_text="客户甲 10:00:00\n[语音] 00:05\n[图片]",
media_types={"voice", "image"},
)
self.assertEqual(reply, VISION_VOICE_NEEDS_TEXT)
self.assertEqual(set(reply.media_types), {"voice", "image"})
def test_voice_control_labels_are_not_treated_as_transcription(self):
for marker in (
"语音消息 00:05",
"音频:5秒",
"audio 5s",
"语音 5",
):
self.assertEqual(detect_media_types(marker), {"voice"})
self.assertEqual(
media_text_content("客户甲 10:00:00\n[语音] 00:05\n转文字"),
"",
)
self.assertEqual(
media_text_content("客户甲 10:00:00\n[语音]\n识别中…"),
"",
)
self.assertEqual(
media_text_content("客户甲 10:00:00\n[语音]\n正在转文字…"),
"",
)
def test_repeated_identical_media_blocks_remain_a_nonempty_delta(self):
previous = ["客户甲 10:00:00", "[图片]"]
current = previous + ["客户甲 10:00:00", "[图片]"]
self.assertEqual(WeChatBot._delta_lines(previous, current), previous)
def test_animated_sticker_frames_keep_one_layout_signature(self):
first = np.full((360, 480, 4), 245, dtype=np.uint8)
first[:, :, 3] = 255
second = first.copy()
# 透明画布里的动画主体水平移动;原始像素和可见包围盒都会变化。
yy, xx = np.ogrid[:360, :480]
first[((yy - 135) ** 2 + (xx - 85) ** 2) < 45 ** 2, :3] = (40, 90, 220)
second[((yy - 135) ** 2 + (xx - 105) ** 2) < 45 ** 2, :3] = (30, 200, 80)
self.assertEqual(
WeChatBot._chat_layout_signature(first),
WeChatBot._chat_layout_signature(second),
)
with_new_bubble = second.copy()
with_new_bubble[270:315, 300:455, :3] = 180
self.assertNotEqual(
WeChatBot._chat_layout_signature(second),
WeChatBot._chat_layout_signature(with_new_bubble),
)
def test_selected_row_preview_detects_same_layout_media_replacement(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.session_item_h = 64
old = np.full((96, 230, 4), 245, dtype=np.uint8)
old[:, :, 3] = 255
changed_preview = old.copy()
# 第二行预览发生变化,代表新图片/表情到达;聊天区即使滚动后布局相同,
# 这个独立信号也必须变化。
old[40:45, 82:116, :3] = 25
changed_preview[47:52, 126:168, :3] = 25
# 第一行右侧时间自行变化不能触发回复。
time_only = old.copy()
time_only[40:55, 182:220, :3] = 25
self.assertNotEqual(
bot._session_row_activity_signature(old, 32),
bot._session_row_activity_signature(changed_preview, 32),
)
self.assertEqual(
bot._session_row_activity_signature(old, 32),
bot._session_row_activity_signature(time_only, 32),
)
def test_selected_row_preview_is_stable_when_row_moves(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.session_item_h = 64
image = np.full((128, 230, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
image[40:45, 82:116, :3] = 25
image[72:77, 82:116, :3] = 25
self.assertEqual(
bot._session_row_activity_signature(image, 32),
bot._session_row_activity_signature(image, 64),
)
def test_selected_row_detection_flicker_reuses_same_session_cache(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"f" * 40
bot.hwnd = 100
bot.scale = 1.0
bot.session_item_h = 64
bot._list_x = bot._list_y = 0
bot._list_w = 230
bot._list_h = 96
bot._chat_rel_x = 230
bot._chat_rel_y = 0
bot._chat_rel_w = 480
bot._chat_rel_h = 360
bot._active_session_fp = fp
bot._row_activity_cache = {}
session_list = np.full((96, 230, 4), 245, dtype=np.uint8)
session_list[:, :, 3] = 255
session_list[40:45, 82:116, :3] = 25
chat = np.full((360, 480, 4), 245, dtype=np.uint8)
chat[:, :, 3] = 255
chat[120:180, 40:120, :3] = 80
full = np.full((360, 710, 4), 245, dtype=np.uint8)
full[:, :, 3] = 255
full[:96, :230] = session_list
full[:, 230:710] = chat
bot._capture_full_window = mock.Mock(return_value=full)
bot.detect_selected_row = mock.Mock(side_effect=[32, -1])
bot._session_fingerprint = mock.Mock(return_value=fp)
first = bot._chat_surface_signature()
second = bot._chat_surface_signature()
self.assertEqual(first, second)
def test_openai_vision_receives_current_text_history_and_chat_screenshot(self):
response = mock.Mock()
response.raise_for_status = mock.Mock()
response.json.return_value = {
"choices": [{"message": {"content": (
'{"has_new_customer_message":true,"media_type":"image",'
'"voice_transcribed":false,"reply":"我看到了,先帮您核对具体信息。"}'
)}}]
}
history = [
{"role": "user", "content": "上次发过处方"},
{"role": "assistant", "content": "我看到了"},
]
with (
mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "openai"),
mock.patch("ai_chat.ai_config.AI_API_BASE", "https://api.example/v1"),
mock.patch("ai_chat.ai_config.AI_API_KEY", "secret"),
mock.patch("ai_chat.ai_config.AI_MODEL", "vision-model"),
mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.requests.post", return_value=response) as post,
):
reply = call_ai_vision(
b"png-data",
history=history,
chat_text="客户甲 10:00:00\n[图片],这个处方天数改成15天吧",
media_types={"image"},
)
self.assertIn("先帮您核对", reply)
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["messages"][1]["content"], "上次发过处方")
prompt = payload["messages"][-1]["content"][0]["text"]
self.assertIn("这个处方天数改成15天吧", prompt)
self.assertIn("不得仅凭图片声称已经修改、提交", prompt)
image_url = payload["messages"][-1]["content"][1]["image_url"]["url"]
self.assertTrue(image_url.startswith("data:image/png;base64,"))
def test_unknown_visual_result_cannot_guess_untranscribed_voice(self):
voice_result = (
'{"has_new_customer_message":true,"media_type":"voice",'
'"voice_transcribed":false,"reply":"我猜你在问血糖"}'
)
self.assertEqual(
_finalize_vision_reply(voice_result, chat_text="", media_types=set()),
VISION_VOICE_NEEDS_TEXT,
)
self.assertEqual(
_finalize_vision_reply("我猜你在问血糖", chat_text="", media_types=set()),
"",
)
self.assertEqual(
_finalize_vision_reply(
'{"has_new_customer_message":false,"media_type":"unknown",'
'"voice_transcribed":false,"reply":"不应发送"}',
chat_text="",
media_types=set(),
),
VISION_NO_INCOMING,
)
self.assertEqual(
_finalize_vision_reply(
'{"has_new_customer_message":true,"media_type":"mixed",'
'"voice_transcribed":false,"reply":"按我猜的语音回复"}',
chat_text="客户甲 10:00:00\n[语音]\n还有一张图",
media_types={"voice", "image"},
),
VISION_VOICE_NEEDS_TEXT,
)
def test_non_json_vision_text_is_never_treated_as_new_message_proof(self):
self.assertEqual(
_finalize_vision_reply(
"我看到了,已经帮您改好。",
chat_text="客户甲 10:00:00\n[图片],改成15天",
media_types={"image"},
),
"",
)
def test_dify_vision_query_contains_current_text_and_archive_history(self):
history = [
{"role": "user", "content": "上次的问题"},
{"role": "assistant", "content": "上次的回复"},
]
with (
mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "dify"),
mock.patch("ai_chat.ai_config.AI_API_BASE", "https://dify.example/v1"),
mock.patch("ai_chat.ai_config.AI_CONTEXT_ENABLED", True),
mock.patch(
"ai_chat._call_dify_with_image",
return_value=(
'{"has_new_customer_message":true,"media_type":"image",'
'"voice_transcribed":false,"reply":"看到了,您想让我重点核对哪一处?"}'
),
) as call,
):
call_ai_vision(
b"png-data",
history=history,
chat_text="客户甲 10:00:00\n[图片],请看这里",
media_types={"image"},
)
query = call.call_args.args[0]
self.assertIn("上次的问题", query)
self.assertIn("请看这里", query)
self.assertIn("媒体识别规则", query)
self.assertEqual(call.call_args.args[1], b"png-data")
def test_image_and_text_batch_automatically_uses_mixed_vision(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media001"
batch = "客户甲 10:00:00\n[图片],这个处方天数改成15天吧"
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value=batch)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value="我看到了,先核对后再告诉您。") as get_reply,
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("我看到了,先核对后再告诉您。", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp)
self.assertEqual(reply, "我看到了,先核对后再告诉您。")
bot.capture_chat_area.assert_called_once()
call_text.assert_not_called()
kwargs = get_reply.call_args.kwargs
self.assertEqual(kwargs["chat_text"], batch)
self.assertEqual(kwargs["image_bytes"], b"chat-png")
self.assertTrue(kwargs["force_vision"])
self.assertEqual(kwargs["media_types"], {"image"})
self.assertEqual(
bot._stage_exchange.call_args.args[1],
"(客户发来图片)\n这个处方天数改成15天吧",
)
def test_explicit_image_uses_safe_reply_when_chat_capture_fails(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media003"
batch = "客户甲 10:00:00\n[图片]"
expected = safe_media_reply({"image"})
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}}
bot.extract_context_for = mock.Mock(return_value=batch)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot.capture_chat_area = mock.Mock(side_effect=RuntimeError("capture failed"))
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply") as get_reply,
mock.patch(
"registration_store.process_registration_reply",
return_value=(expected, None),
),
mock.patch("wechat_bot.time.sleep"),
):
self.assertEqual(bot._generate_ai_reply(fp), expected)
get_reply.assert_not_called()
def test_pure_voice_never_asks_a_model_to_guess_audio(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"voice001"
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value="客户甲 10:00:00\n[语音]")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
expected = safe_media_reply({"voice"})
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply") as get_reply,
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=(expected, None),
),
mock.patch("wechat_bot.time.sleep"),
):
self.assertEqual(bot._generate_ai_reply(fp), expected)
bot.capture_chat_area.assert_not_called()
get_reply.assert_not_called()
call_text.assert_not_called()
self.assertEqual(
bot._stage_exchange.call_args.args[1],
"(客户发来语音,未取得转写)",
)
def test_voice_plus_visible_text_answers_text_without_guessing_audio(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"voice002"
batch = "客户甲 10:00:00\n[语音] 00:05\n明天下午能挂号吗"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.extract_context_for = mock.Mock(return_value=batch)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_VOICE_NEEDS_TEXT),
mock.patch("ai_chat.call_ai_text", return_value="明天下午可以,我先帮您登记。") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("明天下午可以,我先帮您登记。", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp)
self.assertEqual(reply, "明天下午可以,我先帮您登记。")
self.assertIn("不得猜测语音内容", call_text.call_args.args[0])
self.assertIn("明天下午能挂号吗", call_text.call_args.args[0])
def test_confirmed_unread_caption_without_placeholder_still_uses_vision(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media004"
batch = "客户甲 10:00:00\n帮我看看这个"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.extract_context_for = mock.Mock(return_value=batch)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value="我看到了,您想重点确认哪一处?") as get_reply,
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("我看到了,您想重点确认哪一处?", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, "我看到了,您想重点确认哪一处?")
bot.capture_chat_area.assert_called_once()
get_reply.assert_called_once()
call_text.assert_not_called()
def test_visual_no_incoming_conflict_never_sends_caption_without_media_warning(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media007"
batch = "客户甲 10:00:00\n帮我看看这个"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.extract_context_for = mock.Mock(return_value=batch)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING),
mock.patch("ai_chat.call_ai_text", return_value="您想让我重点看哪一处?") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("您想让我重点看哪一处?", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, "您想让我重点看哪一处?")
self.assertIn("不得猜测媒体内容", call_text.call_args.args[0])
def test_second_visual_no_incoming_keeps_confirmed_unread_and_uses_safe_media_reply(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"flat0001"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"requires_visual_proof": True,
"visual_rejection_count": 1,
}
}
bot._pending_reply_path = ""
bot._pending_exchanges = {}
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_rejected_session_fps = set()
bot.extract_context_for = mock.Mock(return_value="")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._ensure_session_archive_key = mock.Mock()
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
safe_question = "收到消息,请把重点打字说一下。"
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"ai_chat.safe_media_reply",
return_value=safe_question,
) as safe_reply,
mock.patch(
"registration_store.process_registration_reply",
return_value=(safe_question, None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, safe_question)
state = bot._pending_reply_sessions[fp.hex()]
self.assertTrue(state["confirmed_unread"])
self.assertEqual(state["visual_rejection_count"], 1)
self.assertFalse(state["requires_visual_proof"])
self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertIn(fp.hex(), bot._flat_verified_session_fps)
call_text.assert_not_called()
safe_reply.assert_called_once_with()
def test_second_visual_no_incoming_keeps_reliable_text_pending_and_uses_safe_text(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"flat0004"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": False,
"requires_visual_proof": True,
"visual_rejection_count": 1,
}
}
bot._pending_reply_path = ""
bot._pending_exchanges = {}
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_rejected_session_fps = set()
bot.extract_context_for = mock.Mock(
return_value="客户甲 10:00:00\n帮我看看这个"
)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._ensure_session_archive_key = mock.Mock()
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
safe_text_reply = "您想让我重点看哪一处?"
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING),
mock.patch(
"ai_chat.call_ai_text",
return_value=safe_text_reply,
) as call_text,
mock.patch("ai_chat.safe_media_reply") as safe_reply,
mock.patch(
"registration_store.process_registration_reply",
return_value=(safe_text_reply, None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(
fp,
force_media_check=True,
confirmed_unread=False,
reliable_text_pending=True,
)
self.assertEqual(reply, safe_text_reply)
state = bot._pending_reply_sessions[fp.hex()]
self.assertEqual(state["visual_rejection_count"], 1)
self.assertFalse(state["requires_visual_proof"])
self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertIn(fp.hex(), bot._flat_verified_session_fps)
self.assertIn("只回答本轮可见文字", call_text.call_args.args[0])
safe_reply.assert_not_called()
def test_second_visual_no_incoming_clears_unconfirmed_unreliable_candidate(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"flat0005"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": False,
"requires_visual_proof": True,
"visual_rejection_count": 1,
}
}
bot._pending_reply_path = ""
bot._pending_exchanges = {}
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_rejected_session_fps = set()
bot.extract_context_for = mock.Mock(return_value="")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._ensure_session_archive_key = mock.Mock()
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch("ai_chat.safe_media_reply") as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(
fp,
confirmed_unread=False,
reliable_text_pending=False,
)
self.assertIsNone(reply)
self.assertNotIn(fp.hex(), bot._pending_reply_sessions)
self.assertIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertNotIn(fp.hex(), bot._flat_visual_proof_fps)
call_text.assert_not_called()
safe_reply.assert_not_called()
bot._stage_exchange.assert_not_called()
def test_unknown_flat_avatar_vision_failure_never_uses_generic_fallback(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"flat0002"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"requires_visual_proof": True,
}
}
bot._pending_reply_path = ""
bot._pending_exchanges = {}
bot.extract_context_for = mock.Mock(return_value="")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._ensure_session_archive_key = mock.Mock()
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=""),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch("ai_chat.safe_media_reply") as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertIsNone(reply)
self.assertIn(fp.hex(), bot._pending_reply_sessions)
call_text.assert_not_called()
safe_reply.assert_not_called()
def test_explicit_customer_block_proves_flat_avatar_is_real_chat(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"flat0003"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"requires_visual_proof": True,
}
}
bot._pending_reply_path = ""
bot._pending_exchanges = {}
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot.extract_context_for = mock.Mock(
return_value="客户甲 7/29 10:00:00\n请问今天下午能挂号吗"
)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._ensure_session_archive_key = mock.Mock()
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=VISION_NO_INCOMING),
mock.patch("ai_chat.call_ai_text", return_value="可以,我先帮您登记。"),
mock.patch(
"registration_store.process_registration_reply",
return_value=("可以,我先帮您登记。", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, "可以,我先帮您登记。")
self.assertIn(fp.hex(), bot._flat_verified_session_fps)
self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertFalse(
bot._pending_reply_sessions[fp.hex()]["requires_visual_proof"]
)
def test_selected_caption_falls_back_to_safe_text_when_vision_fails(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media006"
batch = "客户甲 10:00:00\n帮我看看这个"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.extract_context_for = mock.Mock(return_value=batch)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=""),
mock.patch("ai_chat.call_ai_text", return_value="您想让我重点看哪一处?") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("您想让我重点看哪一处?", None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(
fp,
force_media_check=True,
reliable_text_pending=True,
)
self.assertEqual(reply, "您想让我重点看哪一处?")
self.assertIn("不得推测媒体内容", call_text.call_args.args[0])
def test_screenshot_only_image_type_is_preserved_in_archive(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media005"
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value="")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
visual_reply = _finalize_vision_reply(
'{"has_new_customer_message":true,"media_type":"image",'
'"media_types":["image"],"contains_voice":false,'
'"voice_transcribed":false,"reply":"图片我看到了,您想重点确认哪一处?"}',
chat_text="",
media_types=set(),
)
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value=visual_reply),
mock.patch(
"registration_store.process_registration_reply",
return_value=(str(visual_reply), None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, "图片我看到了,您想重点确认哪一处?")
self.assertEqual(bot._stage_exchange.call_args.args[1], "(客户发来图片)")
def test_screenshot_only_voice_type_is_preserved_for_safe_archive(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"voice003"
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value="")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
visual_reply = _finalize_vision_reply(
'{"has_new_customer_message":true,"media_type":"voice",'
'"media_types":["voice"],"contains_voice":true,'
'"voice_transcribed":false,"reply":""}',
chat_text="",
media_types=set(),
)
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value=visual_reply),
mock.patch(
"registration_store.process_registration_reply",
side_effect=lambda **kwargs: (kwargs["reply_text"], None),
),
mock.patch("wechat_bot.time.sleep"),
):
reply = bot._generate_ai_reply(fp, confirmed_unread=True)
self.assertEqual(reply, safe_media_reply({"voice"}))
self.assertEqual(
bot._stage_exchange.call_args.args[1],
"(客户发来语音,未取得转写)",
)
def test_unchanged_clipboard_text_is_not_replied_to_again(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.store.last_lines.return_value = ["客户甲 10:00:00", "上一轮问题"]
same = "客户甲 10:00:00\n上一轮问题"
self.assertEqual(bot.extract_context_for(b"same0001", pre_text=same), "")
bot.store.set_last_lines.assert_called_once()
def test_empty_clipboard_surface_change_enters_media_fallback(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"media002"
bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=-1)
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(return_value=b"new-surface")
bot._selected_tracking_initialized = True
bot._active_session_fp = fp
bot._active_identity_signature = b"identity"
bot._active_chat_signature = b"old-surface"
bot._pending_reply_sessions = {}
bot._activate_wx = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(side_effect=["", ""])
bot._wait_for_message_batch = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="请把重点打字说一下。")
bot.send_reply = mock.Mock(return_value=True)
bot._run_ai_page_guard = mock.Mock(return_value=False)
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(bot._check_selected_session(np.zeros((20, 20, 4), dtype=np.uint8)))
bot._run_ai_page_guard.assert_not_called()
self.assertTrue(bot._generate_ai_reply.call_args.kwargs["force_media_check"])
def test_visual_failure_does_not_reply_to_stale_clipboard_text(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"stale001"
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value="")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value=""),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch("ai_chat.safe_media_reply") as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(
fp,
chat_text="贴心管家 10:00:00\n上一轮已经发出的回复",
force_media_check=True,
)
self.assertIsNone(result)
call_text.assert_not_called()
safe_reply.assert_not_called()
def test_unconfirmed_empty_clipboard_never_sends_generic_media_reply(self):
bot = WeChatBot.__new__(WeChatBot)
bot.extract_context_for = mock.Mock(return_value="")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=""),
mock.patch("ai_chat.safe_media_reply") as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(
b"empty001",
chat_text="",
force_media_check=True,
confirmed_unread=False,
)
self.assertIsNone(result)
safe_reply.assert_not_called()
def test_confirmed_unread_empty_clipboard_uses_type_agnostic_safe_reply(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.extract_context_for = mock.Mock(return_value="")
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_chat.get_ai_reply", return_value=""),
mock.patch(
"ai_chat.safe_media_reply",
return_value="收到消息,请把重点打字说一下。",
) as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(
b"empty002",
chat_text="",
force_media_check=True,
confirmed_unread=True,
)
self.assertEqual(result, "收到消息,请把重点打字说一下")
safe_reply.assert_called_once_with()
def test_first_record_visual_failure_never_answers_visible_old_text(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"first001"
visible_old_text = "贴心管家 09:59:00\n上一轮已经发出的回复"
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot.get_session_history = mock.Mock(return_value=[])
bot.extract_context_for = mock.Mock(return_value=visible_old_text)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value="") as get_reply,
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"ai_chat.safe_media_reply",
return_value="收到消息,请把重点再发一句。",
) as safe_reply,
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(
fp,
chat_text=visible_old_text,
confirmed_unread=True,
)
self.assertEqual(result, "收到消息,请把重点再发一句")
get_reply.assert_called_once()
call_text.assert_not_called()
safe_reply.assert_called_once_with()
def test_first_record_explicit_customer_text_is_still_visually_confirmed(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"first002"
customer_text = "客户甲 7/29 10:00:00\n请问今天下午能挂号吗"
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot.extract_context_for = mock.Mock(return_value=customer_text)
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", False),
mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch(
"ai_chat.get_ai_reply",
return_value="可以的,我先帮您登记需求。",
) as get_reply,
mock.patch(
"registration_store.process_registration_reply",
return_value=("可以的,我先帮您登记需求。", None),
),
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(
fp,
confirmed_unread=True,
)
self.assertEqual(result, "可以的,我先帮您登记需求。")
call_text.assert_not_called()
get_reply.assert_called_once()
bot.capture_chat_area.assert_called_once()
def test_message_arriving_during_model_call_cancels_stale_reply(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"race0001"
batch = "客户甲 10:00:00\n[图片],请看一下"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}}
bot.extract_context_for = mock.Mock(return_value=batch)
bot.get_session_history = mock.Mock(return_value=[])
bot.capture_chat_area = mock.Mock(return_value=b"chat-png")
bot._chat_surface_signature = mock.Mock(
side_effect=[
b"before-extract",
b"before-extract",
b"after-new-message",
]
)
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.get_ai_reply", return_value="这是一条已经过时的回复"),
mock.patch("wechat_bot.time.sleep"),
):
result = bot._generate_ai_reply(fp)
self.assertIsNone(result)
bot._stage_exchange.assert_not_called()
self.assertFalse(bot._pending_reply_state(fp)["batch_ready"])
def test_message_arriving_during_final_extraction_skips_model(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"race0002"
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot._pending_reply_sessions = {fp.hex(): {"batch_ready": True}}
bot.extract_context_for = mock.Mock(
return_value="客户甲 10:00:00\n尚未包含后到的新消息"
)
bot._chat_surface_signature = mock.Mock(
side_effect=[b"before-extract", b"after-new-message"]
)
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.call_ai_text") as call_text,
mock.patch("ai_chat.get_ai_reply") as vision,
mock.patch("wechat_bot.time.sleep"),
):
self.assertIsNone(bot._generate_ai_reply(fp))
call_text.assert_not_called()
vision.assert_not_called()
self.assertFalse(bot._pending_reply_state(fp)["batch_ready"])
def test_pending_text_is_restored_after_model_or_send_failure(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"retry001"
cached = "客户甲 10:00:00\n上一轮尚未成功发送的问题"
bot._pending_reply_sessions = {
fp.hex(): {"batch_ready": True, "chat_text": cached}
}
bot.extract_context_for = mock.Mock(return_value="")
bot.get_session_history = mock.Mock(return_value=[])
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._stage_exchange = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.call_ai_text", return_value="这次继续回复。") as call_text,
mock.patch(
"registration_store.process_registration_reply",
return_value=("这次继续回复。", None),
),
mock.patch("wechat_bot.time.sleep"),
):
self.assertEqual(bot._generate_ai_reply(fp), "这次继续回复。")
call_text.assert_called_once_with(cached, history=[])
def test_snapshot_is_committed_only_after_successful_send(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"snap0001"
bot._pending_reply_sessions = {}
bot._pending_exchanges = {}
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.store.last_lines.return_value = ["客户甲 09:59:00", "旧消息"]
current = "客户甲 09:59:00\n旧消息\n客户甲 10:00:00\n新消息"
delta = bot.extract_context_for(
fp,
pre_text=current,
defer_snapshot=True,
)
self.assertIn("新消息", delta)
bot.store.set_last_lines.assert_not_called()
self.assertEqual(
bot._pending_reply_state(fp)["last_lines"][-1],
"新消息",
)
bot._commit_staged_exchange(fp.hex())
bot.store.set_last_lines.assert_called_once()
bot.store.save.assert_called_once()
def test_pending_unread_task_survives_process_restart(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
fp = b"p" * 40
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = path
bot._pending_reply_sessions = {}
bot._active_identity_signature = b"stable-title"
bot._mark_reply_pending(fp, batch_ready=True, confirmed_unread=True)
state = bot._pending_reply_state(fp)
state["chat_text"] = "客户甲 10:00:00\n崩溃前尚未回复"
state["last_lines"] = ["客户甲 10:00:00", "崩溃前尚未回复"]
state["send_state"] = "sending"
state["ctrl_enter_attempted"] = True
state["uncertain_since"] = 123.5
state["reply_text"] = "准备发送的回复"
state["staged_user_text"] = "客户甲的问题"
state["staged_reply_text"] = "准备发送的回复"
state["send_surface_signature"] = b"before-enter"
state["send_baseline_blocks"] = [
["客户甲 10:00:00", "崩溃前尚未回复"]
]
state["send_reply_was_visible"] = False
state["send_known_outgoing_speakers"] = ["高兴亮"]
bot._persist_pending_replies()
restored_bot = WeChatBot.__new__(WeChatBot)
restored_bot._pending_reply_path = path
restored = restored_bot._load_pending_replies()
self.assertIn(fp.hex(), restored)
self.assertTrue(restored[fp.hex()]["confirmed_unread"])
self.assertIn("尚未回复", restored[fp.hex()]["chat_text"])
self.assertEqual(restored[fp.hex()]["send_state"], "sending")
self.assertTrue(restored[fp.hex()]["ctrl_enter_attempted"])
self.assertEqual(restored[fp.hex()]["uncertain_since"], 123.5)
self.assertEqual(restored[fp.hex()]["reply_text"], "准备发送的回复")
self.assertEqual(
restored[fp.hex()]["send_surface_signature"],
b"before-enter",
)
self.assertEqual(
restored[fp.hex()]["send_baseline_blocks"],
[["客户甲 10:00:00", "崩溃前尚未回复"]],
)
self.assertEqual(
restored[fp.hex()]["send_known_outgoing_speakers"],
["高兴亮"],
)
with open(path, encoding="utf-8") as handle:
self.assertIn(fp.hex(), json.load(handle))
def test_unfinished_merge_deadline_survives_process_restart(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
fp = b"d" * 40
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = path
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": False,
"confirmed_unread": True,
"batch_started_at": 100.0,
"batch_deadline_at": 120.0,
"batch_window_seconds": 20.0,
"updated_at": 101.0,
},
}
self.assertTrue(bot._persist_pending_replies())
restored_bot = WeChatBot.__new__(WeChatBot)
restored_bot._pending_reply_path = path
restored = restored_bot._load_pending_replies()[fp.hex()]
self.assertFalse(restored["batch_ready"])
self.assertEqual(restored["batch_started_at"], 100.0)
self.assertEqual(restored["batch_deadline_at"], 120.0)
self.assertEqual(restored["batch_window_seconds"], 20.0)
def test_legacy_inflight_state_does_not_invent_an_empty_send_anchor(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
fp = b"l" * 40
with open(path, "w", encoding="utf-8") as handle:
json.dump(
{
fp.hex(): {
"confirmed_unread": True,
"send_state": "sent_uncommitted",
"reply_text": "历史回复",
"send_reply_match_count": 1,
"updated_at": time.time(),
}
},
handle,
ensure_ascii=False,
)
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = path
restored = bot._load_pending_replies()[fp.hex()]
self.assertNotIn("send_baseline_blocks", restored)
self.assertNotIn("send_reply_was_visible", restored)
def test_previous_16_byte_pending_key_is_loaded_instead_of_dropped(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
old_fp = b"a" * 8 + b"n" * 8
with open(path, "w", encoding="utf-8") as handle:
json.dump(
{
old_fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"chat_text": "客户甲 10:00:00\n旧版未回复内容",
"identity_signature": b"stable-title".hex(),
}
},
handle,
)
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = path
restored = bot._load_pending_replies()
self.assertIn(old_fp.hex(), restored)
self.assertEqual(
restored[old_fp.hex()]["identity_signature"],
b"stable-title",
)
class SessionViewerSnapshotTest(TestCase):
"""客户会话记录里那张画面的取路径规则。渲染本身交给 Qt,这里只管定位。"""
@classmethod
def setUpClass(cls):
try:
from wechat_gui_qt import SessionDetailDialog
except Exception as exc: # PySide6 缺失/无显示环境时跳过,别拖垮整个套件
raise SkipTest(f"PySide6 不可用: {exc}")
cls.dialog = SessionDetailDialog
def test_a_stored_snapshot_resolves_under_the_media_dir(self):
import wechat_gui_qt
with tempfile.TemporaryDirectory() as directory:
media = os.path.join(directory, "media")
os.makedirs(media)
with open(os.path.join(media, "shot.png"), "wb") as handle:
handle.write(b"PNG")
with mock.patch.object(wechat_gui_qt, "SCRIPT_DIR", directory):
self.assertEqual(
self.dialog._snapshot_path({"image": "shot.png"}),
os.path.join(media, "shot.png"),
)
def test_a_message_without_a_snapshot_resolves_to_nothing(self):
self.assertEqual(self.dialog._snapshot_path({"content": "在不在"}), "")
def test_a_missing_file_is_not_offered_to_the_viewer(self):
import wechat_gui_qt
with tempfile.TemporaryDirectory() as directory:
with mock.patch.object(wechat_gui_qt, "SCRIPT_DIR", directory):
self.assertEqual(
self.dialog._snapshot_path({"image": "没了.png"}), ""
)
def test_a_path_in_the_archive_never_escapes_the_media_dir(self):
"""档案是磁盘上的文件,可能被改。只认纯文件名,不认任何路径。"""
for evil in ("../../secret.png", "sub/shot.png", "C:\\Windows\\a.png"):
self.assertEqual(self.dialog._snapshot_path({"image": evil}), "")
class TransactionStoreTest(TestCase):
def test_exchange_id_is_committed_only_once_after_restart(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "conversations.json")
store = ConversationStore(path)
self.assertTrue(
store.append_exchange_once("session", "客户消息", "客服回复", "tx-1")
)
self.assertFalse(
store.append_exchange_once("session", "客户消息", "客服回复", "tx-1")
)
restored = ConversationStore(path)
self.assertEqual(len(restored.history("session")), 2)
def test_visually_proven_outgoing_speaker_survives_restart(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "conversations.json")
fp = b"k" * 40
store = ConversationStore(path)
self.assertTrue(store.add_outgoing_speaker(fp.hex(), "高兴亮"))
self.assertFalse(store.add_outgoing_speaker(fp.hex(), "高兴亮"))
restored = ConversationStore(path)
self.assertEqual(restored.outgoing_speakers(fp.hex()), ["高兴亮"])
bot = WeChatBot.__new__(WeChatBot)
bot.store = restored
bot._active_session_fp = fp
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertIn("高兴亮", bot._known_outgoing_speakers())
def test_registration_prepare_mode_does_not_write_before_send(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "registration.json")
store = RegistrationStore(path)
reply, lead = process_registration_reply(
session_id="customer-a",
user_text="我想挂号,最近总是口渴",
reply_text="好的,我先帮您预约。",
store=store,
persist=False,
)
self.assertIn("甄养堂互联网医院", reply)
self.assertIsNotNone(lead)
self.assertEqual(store.list_leads(), [])
store.add_or_update(**lead)
self.assertEqual(len(store.list_leads()), 1)
class SafetyGateTest(TestCase):
@staticmethod
def _verification_surface(with_nav=False):
"""登录页样张:正中一个真二维码。
原来这里放的是棋盘格。棋盘格能满足"够黑、够亮、跳变够多",但它不是
二维码——密集小字同样满足,于是一条长广告消息就能让机器人停机。判据改成
真的去找定位图案后,样张也必须是真码。
"""
import cv2
image = np.full((1000, 1600, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
if with_nav:
image[80:920, :96, :3] = 45
code = cv2.QRCodeEncoder_create().encode(
"https://work.weixin.qq.com/login/verify/abc123"
)
code = cv2.resize(code, (240, 240), interpolation=cv2.INTER_NEAREST)
image[380:620, 680:920, :3] = code[:, :, None]
return image
@staticmethod
def _modal_surface(with_close=True):
image = np.full((800, 1200, 4), 235, dtype=np.uint8)
image[:, :, 3] = 255
image[130:700, 280:920, :3] = 250
image[130:700, 919:922, :3] = 190
image[697:701, 280:922, :3] = 190
if with_close:
for offset in range(-7, 8):
image[175 + offset, 870 + offset, :3] = 70
image[175 + offset, 870 - offset, :3] = 70
return image
@staticmethod
def _nav_surface(message_selected: bool):
image = np.full((320, 500, 4), 242, dtype=np.uint8)
image[:, :, 3] = 255
if message_selected:
# BGR 蓝色块模拟消息行的选中背景。
image[68:102, 4:58, :3] = (238, 126, 36)
else:
# 其他入口被选中;消息位置只有灰色图标。
image[68:102, 4:58, :3] = (145, 145, 145)
image[210:255, 22:52, :3] = (238, 126, 36)
image[240:242, :, :3] = 230
image[242:, :, :3] = 255
return image
@staticmethod
def _mail_nav_surface():
image = SafetyGateTest._nav_surface(False)
# 邮件紧邻消息;旧检测框跨到这一行后会把邮件误判为消息。
image[110:142, 4:58, :3] = (238, 126, 36)
return image
def test_qr_page_without_main_navigation_is_blocked(self):
self.assertTrue(
looks_like_security_verification(self._verification_surface())
)
def test_normal_navigation_prevents_false_positive(self):
"""主界面还在时不停机。这道判断已从像素判据搬到 _security_gate_visible
浅色主题下左侧导航本来就是浅色,"导航够深"根本挡不住任何东西。"""
bot = WeChatBot.__new__(WeChatBot)
bot.security_verification_required = False
bot._security_log_emitted = False
bot.hwnd = 1
bot._message_nav_selected = mock.Mock(return_value=True)
surface = self._verification_surface(with_nav=True)
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 1600, 1000)),
mock.patch("wechat_bot.capture_window_region", return_value=surface),
):
self.assertFalse(bot._security_gate_visible())
def test_center_modal_close_button_is_detected(self):
candidate = find_blocking_modal_close(self._modal_surface())
self.assertIsNotNone(candidate)
self.assertLess(abs(candidate[0] - 870), 5)
self.assertLess(abs(candidate[1] - 175), 5)
def test_plain_surface_is_not_treated_as_modal(self):
self.assertIsNone(find_blocking_modal_close(self._modal_surface(False)))
@staticmethod
def _chat_bubble_surface():
"""聊天气泡里出现形似 × 的字形,且下方叠着一列气泡左边缘。
真实故障画面就是这个形状:客户消息「你怎么不回复啊」的“你”被当成
弹窗关闭按钮,而下方连续气泡的左边缘凑出了“竖直面板边缘”。
"""
image = np.full((900, 1600, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# 最上面一条客户消息气泡,× 字形落在气泡内部的留白里。
image[96:142, 700:1000, :3] = 255
for offset in range(-7, 8):
image[119 + offset, 755 + offset, :3] = 70
image[119 + offset, 755 - offset, :3] = 70
# 下方连续气泡:左边缘固定在 x=780,被气泡间隙反复打断。
for top in range(160, 560, 68):
image[top:top + 46, 780:1000, :3] = 255
return image
def test_chat_bubble_glyph_is_not_treated_as_modal_close(self):
self.assertIsNone(find_blocking_modal_close(self._chat_bubble_surface()))
def test_dimmed_background_is_recognised_as_modal_scrim(self):
image = np.full((900, 1600, 4), 120, dtype=np.uint8)
image[:, :, 3] = 255
image[200:700, 500:1100, :3] = 250
self.assertTrue(looks_like_modal_scrim(image))
def test_normal_bright_page_is_not_a_modal_scrim(self):
self.assertFalse(looks_like_modal_scrim(self._chat_bubble_surface()))
def test_unchanged_surface_after_escape_is_treated_as_false_positive(self):
"""Esc 没有改变任何画面时,绝不能把聊天内容当弹窗冻结整轮轮询。"""
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.L = 20
bot.T = 30
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._capture_full_window = mock.Mock(return_value=self._modal_surface())
bot._message_nav_selected = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.safe_set_foreground"),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=bot.hwnd),
mock.patch(
"wechat_bot.find_blocking_modal_close",
side_effect=[(870, 175), None, (870, 175)],
),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(bot._dismiss_internal_blocker("测试"))
# 已被证伪的画面不再重复按 Esc,也不再冻结后续轮询。
self.assertFalse(bot._dismiss_internal_blocker("测试"))
press.assert_called_once_with("esc")
click.assert_not_called()
def test_message_workspace_uses_selected_navigation_state(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
self.assertTrue(bot._message_nav_selected(self._nav_surface(True)))
self.assertFalse(bot._message_nav_selected(self._nav_surface(False)))
def test_message_workspace_accepts_sparse_200_percent_dpi_highlight(self):
"""Real 200% DPI rounded highlights expose only about 11% blue pixels."""
image = np.full((360, 600, 4), 242, dtype=np.uint8)
image[:, :, 3] = 255
# 1000 / (80 * 116) ~= 10.8%; the old 12% cutoff rejected this.
image[140:160, 8:58, :3] = (238, 126, 36)
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
self.assertTrue(bot._message_nav_selected(image))
def test_dynamic_navigation_width_supports_narrow_and_wide_sidebars(self):
def surface(nav_width):
image = np.full((420, 900, 4), 248, dtype=np.uint8)
image[:, :, 3] = 255
# 浅蓝导航背景(BGR),消息行右侧从这里突变为中性列表背景。
image[130:210, :nav_width, :3] = (250, 232, 215)
image[130:210, nav_width:, :3] = (245, 245, 245)
return image
self.assertEqual(infer_navigation_width(surface(136), 2.0)[0], 136)
self.assertEqual(infer_navigation_width(surface(320), 2.0)[0], 320)
def test_session_list_width_follows_the_persistent_visual_divider(self):
image = np.full((1300, 2200, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# 320px 导航后,会话列表宽 508px;右侧聊天区底色不同并带纵向阴影。
image[112:, :320, :3] = 232
image[112:, 320:820, :3] = 248
image[112:, 820:828, :3] = 220
image[112:, 828:, :3] = 238
detected, confidence = infer_session_list_width(image, 320, 2.0)
self.assertGreaterEqual(confidence, 0.60)
self.assertGreaterEqual(detected, 500)
self.assertLessEqual(detected, 512)
def test_flat_text_avatar_is_admitted_only_as_visual_proof_candidate(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot._flat_visual_proof_fps = set()
bot._flat_rejected_session_fps = set()
image = np.full((180, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# Rounded blue square with a simple white glyph: representative of a
# generated WeCom text avatar and deliberately flat-colour.
image[20:108, 22:108, :3] = (220, 120, 30)
image[14:25, 16:30, :3] = 245
image[14:25, 100:116, :3] = 245
image[100:114, 16:30, :3] = 245
image[100:114, 100:116, :3] = 245
image[42:82, 56:70, :3] = 250
image[54:68, 42:84, :3] = 250
fp = b"f" * 40
bot.detect_badge_rows = mock.Mock(return_value=[32])
bot._session_fingerprint = mock.Mock(return_value=fp)
self.assertFalse(bot._is_real_conversation(image, 32, quiet=True))
found = bot._target_from_session_image(image, set(), set())
self.assertEqual(found, (32, fp))
self.assertIn(fp.hex(), bot._flat_visual_proof_fps)
def test_selected_blue_row_uses_background_inside_rounded_highlight(self):
"""200% DPI selected rows must not compare avatar corners to outer white."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
image = np.full((180, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
selected_blue = np.array((242, 131, 48), dtype=np.uint8)
image[0:128, 8:, :3] = selected_blue
# Avatar bounds at 200% DPI are x=16..116, y=14..114. Paint a
# textured green avatar, then expose selected-blue rounded corners.
image[14:114, 16:116, :3] = (80, 190, 70)
for offset in range(14, 114):
image[offset, 28:104, 1] = 120 + (offset % 100)
image[14:24, 16:26, :3] = selected_blue
image[14:24, 106:116, :3] = selected_blue
image[104:114, 16:26, :3] = selected_blue
image[104:114, 106:116, :3] = selected_blue
# The old x=6 sample is white while all rounded corners are blue.
self.assertGreater(
int(np.abs(
bot._patch_color(image, 6, 64)
- bot._patch_color(image, 16, 14)
).sum()),
45,
)
self.assertTrue(
bot._is_real_conversation(
image,
64,
quiet=True,
row_center=True,
allow_flat=True,
)
)
bot._session_fingerprint = mock.Mock(return_value=b"b" * 40)
bot._pending_reply_sessions = {}
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot._session_render_ids = {}
bot._live_session_fp_aliases = set()
self.assertFalse(bot._is_tool_selected(image, 64))
def test_square_unread_avatar_is_kept_for_safe_visual_proof(self):
"""A verified unread + full composite identity must not be discarded by shape."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot._flat_visual_proof_fps = set()
bot._flat_rejected_session_fps = set()
image = np.full((180, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# Deliberately square/full-bleed so every corner differs from the row.
# Extend one pixel beyond the nominal bounds: a 5x5 corner patch then
# has an icon-colour majority, while the x=14 background patch still
# has a row-background majority.
image[13:115, 15:117, :3] = (70, 190, 80)
fp = b"s" * 40
bot.detect_badge_rows = mock.Mock(return_value=[32])
bot._session_fingerprint = mock.Mock(return_value=fp)
self.assertFalse(
bot._is_real_conversation(
image,
32,
quiet=True,
allow_flat=True,
)
)
self.assertEqual(
bot._target_from_session_image(image, set(), set()),
(32, fp),
)
self.assertIn(fp.hex(), bot._flat_visual_proof_fps)
def test_selected_square_avatar_only_bypasses_tool_filter_for_exact_pending(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"a" * 8 + b"n" * 32
different_name = b"a" * 8 + b"x" * 32
bot._pending_reply_sessions = {
target.hex(): {"requires_visual_proof": True},
}
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot._session_render_ids = {}
bot._live_session_fp_aliases = set()
bot._is_real_conversation = mock.Mock(return_value=False)
bot._session_fingerprint = mock.Mock(return_value=target)
page = np.zeros((128, 180, 4), dtype=np.uint8)
self.assertFalse(bot._is_tool_selected(page, 64))
bot._session_fingerprint.return_value = different_name
self.assertTrue(bot._is_tool_selected(page, 64))
def test_verified_square_avatar_stays_selectable_after_visual_proof_clears(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"v" * 40
bot._pending_reply_sessions = {}
bot._flat_verified_session_fps = {target.hex()}
bot._flat_rejected_session_fps = set()
bot._session_render_ids = {}
bot._live_session_fp_aliases = set()
bot._is_real_conversation = mock.Mock(return_value=False)
bot._session_fingerprint = mock.Mock(return_value=target)
bot.store = mock.Mock()
bot.store.has_record.return_value = False
self.assertFalse(
bot._is_tool_selected(np.zeros((128, 180, 4), dtype=np.uint8), 64)
)
def test_200_percent_dpi_input_point_is_inside_editor_body(self):
# Regression fixture from the user's original 2726x1756 WeCom image:
# the toolbar occupies roughly y=1510..1590. The old 95px offset
# landed at y=1566 (toolbar); the new point must be in the editor body.
input_y = 1756 - int(INPUT_Y_FROM_BOTTOM * 2.0)
self.assertGreaterEqual(input_y, 1600)
self.assertLess(input_y, 1756)
def test_200_percent_dpi_detects_both_resizable_composer_heights(self):
fixtures = (
(1756, 2726, 1510),
(1316, 2254, 1008),
)
for height, width, divider in fixtures:
image = np.full((height, width, 4), 247, dtype=np.uint8)
image[:, :, 3] = 255
image[divider:divider + 2, :, :3] = 231
image[divider + 2:, :, :3] = 255
detected = infer_composer_top(image, int(width * 0.30), 2.0)
self.assertEqual(detected, divider)
self.assertLess(detected - int(2 * 2.0), divider)
ambiguous = np.full((1316, 2254, 4), 247, dtype=np.uint8)
ambiguous[:, :, 3] = 255
for divider in (820, 1008):
ambiguous[divider:divider + 2, :, :3] = 231
ambiguous[divider + 2:, :, :3] = 255
# Repaint the band between the two rules so both remain independent.
ambiguous[822:1008, :, :3] = 247
ambiguous[1008:1010, :, :3] = 231
ambiguous[1010:, :, :3] = 255
# 聊天内容里的宽横边(大图/整行气泡边缘)也会形成候选。旧实现遇到
# 多个候选就放弃,小窗口下输入区永远识别不出来、回复功能无法启动。
# 现在选择最靠底部、下方是白色编辑区的那条分隔线。
self.assertEqual(
infer_composer_top(ambiguous, int(2254 * 0.30), 2.0),
1008,
)
def test_composer_found_despite_wide_content_edge_in_chat(self):
"""聊天区中部的全宽横边不再让分隔线检测放弃(用户实机截图场景)。"""
image = np.full((1196, 2048, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# 聊天中部一条内容横边,下方仍是灰色聊天背景。
image[510:512, :, :3] = 226
# 真正的输入区分隔线,下方是白色编辑区。
image[916:918, :, :3] = 231
image[918:, :, :3] = 255
self.assertEqual(
infer_composer_top(image, int(2048 * 0.30), 2.0),
916,
)
def test_chat_pane_right_detects_sidebar_divider(self):
"""右侧工具栏展开时,靠纵贯分隔线找到聊天面板右缘。"""
image = np.full((1196, 2048, 4), 246, dtype=np.uint8)
image[:, :, 3] = 255
# 聊天面板与工具栏之间的纵贯分隔线(比左右邻列暗)。
image[:, 1550:1552, :3] = 232
chat_left = 580
self.assertEqual(infer_chat_pane_right(image, chat_left, 2.0), 1550)
# 没有分隔线 → 聊天区延伸到窗口右缘。
plain = np.full((1196, 2048, 4), 246, dtype=np.uint8)
plain[:, :, 3] = 255
self.assertIsNone(infer_chat_pane_right(plain, chat_left, 2.0))
# 断续的短竖线(气泡边缘/滚动条)不能被当成面板分界。
broken = plain.copy()
broken[300:700, 1550:1552, :3] = 232
self.assertIsNone(infer_chat_pane_right(broken, chat_left, 2.0))
def test_composer_scan_is_bounded_inside_chat_pane(self):
"""右侧工具栏内容不再干扰输入区分隔线检测。"""
image = np.full((1196, 2048, 4), 246, dtype=np.uint8)
image[:, :, 3] = 255
chat_left = 400
# 工具栏(x>=1550)填充杂乱内容,破坏全宽 80% 变化率要求。
image[:, 1550:1552, :3] = 232
rng = np.random.default_rng(7)
image[:, 1560:, :3] = rng.integers(120, 250, (1196, 488, 3), dtype=np.uint8)
# 分隔线只存在于聊天面板内部。
image[916:918, chat_left:1550, :3] = 231
image[918:, chat_left:1550, :3] = 255
# 不限定右缘时扫描带跨进工具栏,检测失败(旧 bug)。
self.assertIsNone(infer_composer_top(image, chat_left, 2.0))
# 限定聊天面板右缘后成功。
self.assertEqual(
infer_composer_top(image, chat_left, 2.0, chat_right=1550),
916,
)
def test_post_paste_divider_check_ignores_sidebar_and_editor_text(self):
"""The final Enter gate must use the chat pane, not the whole window."""
image = np.full((1196, 2048, 4), 246, dtype=np.uint8)
image[:, :, 3] = 255
chat_left = 400
image[:, 1550:1552, :3] = 232
rng = np.random.default_rng(17)
image[:, 1560:, :3] = rng.integers(
120, 250, (1196, 488, 3), dtype=np.uint8
)
image[916:918, chat_left:1550, :3] = 231
image[918:, chat_left:1550, :3] = 255
# The reply has already been pasted into the upper editor body.
image[958:982, 460:920, :3] = 70
matched, detected = composer_divider_matches(
image,
chat_left,
916,
2.0,
chat_right=1550,
)
self.assertTrue(matched)
self.assertLessEqual(abs(int(detected) - 916), 8)
def test_refresh_geometry_shrinks_chat_region_when_sidebar_opens(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 0, 0, 2048, 1196
bot._list_x = 320
bot._selected_tracking_initialized = True
bot._active_session_fp = b"old"
bot._active_chat_signature = b"old"
bot._active_identity_signature = b"old"
bot._composer_geometry_valid = True
bot._composer_rel_top = 916
full = np.zeros((1196, 2048, 4), dtype=np.uint8)
with (
mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)),
mock.patch("wechat_bot.infer_chat_pane_right", return_value=1550),
mock.patch("wechat_bot.infer_composer_top", return_value=916),
):
# 工具栏从无到有属于几何变化,必须触发重算。
self.assertTrue(bot._refresh_message_geometry(full))
self.assertEqual(bot._chat_pane_right_rel, 1550)
region = bot._chat_region
self.assertLessEqual(region["left"] + region["width"], 1550)
# 输入点击必须落在聊天面板内,而不是工具栏里。
self.assertLess(bot.input_x, 1550)
# 视觉截图区域同样止步于面板右缘。
self.assertLessEqual(bot._chat_rel_x + bot._chat_rel_w, 1550)
def test_refresh_geometry_pane_unchanged_is_not_a_layout_change(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 0, 0, 2048, 1196
bot._list_x = 320
bot._composer_geometry_valid = True
bot._composer_rel_top = 916
bot._chat_pane_right_rel = None
full = np.zeros((1196, 2048, 4), dtype=np.uint8)
with (
mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)),
mock.patch("wechat_bot.infer_chat_pane_right", return_value=None),
mock.patch("wechat_bot.infer_composer_top", return_value=916),
):
self.assertFalse(bot._refresh_message_geometry(full))
def test_pending_square_avatar_can_be_found_after_unread_badge_disappears(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.session_item_h = 32
target = b"p" * 40
bot._pending_reply_sessions = {
target.hex(): {"requires_visual_proof": True},
}
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot._session_render_ids = {}
bot._live_session_fp_aliases = set()
page = np.zeros((64, 180, 4), dtype=np.uint8)
bot.detect_selected_row = mock.Mock(return_value=-1)
bot.detect_badge_rows = mock.Mock(return_value=[])
bot._session_fingerprint = mock.Mock(
side_effect=lambda _img, y, row_center=True: (
target if y == 16 else b"z" * 40
)
)
bot._is_real_conversation = mock.Mock(return_value=False)
self.assertEqual(bot._pending_rows_on_page(page, target), [16])
def test_rejected_flat_row_render_drift_does_not_starve_next_unread(self):
"""A proven tool row stays excluded after font/selection render drift."""
bot = WeChatBot.__new__(WeChatBot)
rejected = b"a" * 8 + bytes(32)
drifted_name = bytearray(32)
drifted_name[2] = 0b00000001 # allowed middle-render drift
drifted_name[8] = 0b00000001 # allowed absolute-grid drift
drifted = b"a" * 8 + bytes(drifted_name)
customer_name = bytearray(32)
customer_name[0] = 0b00000001 # distinct first-glyph identity
customer = b"b" * 8 + bytes(customer_name)
bot._flat_rejected_session_fps = {rejected.hex()}
# Persisted 40-byte archive keys stay byte-for-byte unchanged. Only a
# process-local render ID proves that the unread-bold and restored-row
# variants are the same system entry.
bot._session_render_ids = {
rejected.hex(): {"same-live-render"},
drifted.hex(): {"same-live-render"},
}
bot.detect_badge_rows = mock.Mock(return_value=[16, 80])
bot._session_fingerprint = mock.Mock(
side_effect=lambda _img, y: drifted if y == 16 else customer
)
bot._is_real_conversation = mock.Mock(return_value=True)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
page = np.zeros((128, 180, 4), dtype=np.uint8)
non_conversations = set()
self.assertEqual(
bot._target_from_session_image(page, set(), non_conversations),
(80, customer),
)
self.assertIn(drifted, non_conversations)
def test_badge_and_selected_row_use_the_same_avatar_center(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
image = np.full((180, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# badge y=32 映射到头像中心 y=64;构造一块不对称纹理头像。
for y in range(40, 88):
for x in range(20, 84):
image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255)
image[28:54, 170:220, :3] = 20
from_badge = bot._session_fingerprint(image, 32)
from_selected_center = bot._session_fingerprint(
image,
64,
row_center=True,
)
self.assertEqual(from_badge, from_selected_center)
@staticmethod
def _session_list_surface(rows=6, pitch=128, first_center=64):
"""构造多行会话列表:规整行距、各行纹理不同的头像方块与名称字形。"""
height = first_center + pitch * (rows - 1) + pitch // 2
image = np.full((height, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
for index in range(rows):
center = first_center + index * pitch
for y in range(center - 40, center + 40):
for x in range(20, 88):
image[y, x, :3] = (
(x * 3 + index * 29) % 255,
(y * 5) % 255,
(x + y + index * 11) % 255,
)
image[center - 38:center - 8, 122:158, :3] = 25
image[center + 4:center + 26, 122:200, :3] = 90
return image
def _fingerprint_bot(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
return bot
def test_row_estimate_error_no_longer_forks_the_archive_key(self):
"""行锚点估算偏差绝不能让同一个联系人算出第二个档案键。
档案查找是精确匹配,而头像感知哈希对锚点极敏感;锚点一偏,会话就被当成
“首次遇到”,复制到的聊天文字会被丢弃、只靠截图回复,历史也注入不了。
"""
bot = self._fingerprint_bot()
image = self._session_list_surface()
truth = bot._session_fingerprint(image, 192, row_center=True)
self.assertTrue(truth)
for offset in (-48, -32, -18, -6, -1, 1, 6, 18, 32, 48):
self.assertEqual(
bot._session_fingerprint(image, 192 + offset, row_center=True),
truth,
msg=f"行锚点偏 {offset}px 就换了档案键",
)
def test_badge_offset_error_keeps_the_unread_and_opened_keys_identical(self):
"""未读徽章按固定偏移推算行中心,偏差不能改写会话键。"""
bot = self._fingerprint_bot()
image = self._session_list_surface()
# 徽章画得比假定位置高 18px,推算出的行中心随之偏离真实头像中心。
badge_y = 192 - int(16 * bot.scale) - 18
self.assertNotEqual(bot._row_center_from_badge(image, badge_y), 192)
self.assertEqual(
bot._session_fingerprint(image, badge_y),
bot._session_fingerprint(image, 192, row_center=True),
)
def test_avatar_anchor_never_snaps_onto_a_neighbouring_row(self):
"""吸附只能收敛同一行的估算误差,绝不能跨行认错联系人。"""
bot = self._fingerprint_bot()
image = self._session_list_surface()
self.assertEqual(bot._avatar_anchor(image, 192), 192)
self.assertEqual(bot._avatar_anchor(image, 320), 320)
self.assertNotEqual(
bot._session_fingerprint(image, 192, row_center=True),
bot._session_fingerprint(image, 320, row_center=True),
)
def test_unconfirmed_navigation_width_mints_no_session_identity(self):
"""导航宽度未识别确认时绝不铸造会话键。
宽/窄侧栏的列表裁剪起点实测相差 184px,此时采到的“头像”其实是别处的
像素,算出的键与真实键相差 25 位(容差 6 位)。这种键一旦落盘就永久
多出一个认不回来的会话:档案查不到 → 每轮都算“首次遇到” → 复制到的
聊天文字被丢弃、只靠截图回复。
"""
bot = self._fingerprint_bot()
image = self._session_list_surface()
bot._nav_width_confirmed = False
bot._nav_width_gate_logged = False
self.assertEqual(bot._session_fingerprint(image, 192, row_center=True), b"")
bot._nav_width_confirmed = True
self.assertTrue(bot._session_fingerprint(image, 192, row_center=True))
# ── 头像 pHash 漂移不能把同一个联系人否掉 ────────────────────────────────
# 现场日志(2026-07-31 09:40:18→09:40:19):点击会话后 1 秒复核,名称字形
# 哈希逐位相同,头像 pHash 却漂了 15 位,于是被判成“打开的不是目标会话”。
# 每个会话回两次之后指纹一漂,档案和待回复任务就都认不回来了。
_DRIFT_NAME = bytes.fromhex("07fd03fb") + b"\x5a" * 28
_DRIFT_AVATAR_BEFORE = bytes.fromhex("05050d1d1f0f0700")
_DRIFT_AVATAR_AFTER = bytes.fromhex("000818181f1f0f0e")
def _fp_bot(self):
bot = WeChatBot.__new__(WeChatBot)
bot._live_session_fp_aliases = set()
return bot
def test_same_contact_survives_the_measured_avatar_hash_drift(self):
bot = self._fp_bot()
before = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
after = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME
distance = (
int.from_bytes(self._DRIFT_AVATAR_BEFORE, "big")
^ int.from_bytes(self._DRIFT_AVATAR_AFTER, "big")
).bit_count()
# 守住前提:这确实超出原来的 6 位容差,否则本测试就失去意义。
self.assertGreater(distance, bot._FP_HAMMING_TOL)
self.assertTrue(bot._session_fp_matches(before, after))
def test_the_drifted_avatar_canonicalizes_onto_the_existing_key(self):
"""归一化也必须认下这次漂移,否则同一个人当场被铸成第二把档案键。
`_session_fp_matches` 放行、`_canonical_session_fp` 却按 6 位头像容差把
已有键筛掉的话,落盘的仍是一把新键:档案查不到、待回复任务对不上,
这个联系人一样从此收不到回复。
"""
bot = self._fp_bot()
before = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
after = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME
bot._known_session_fps = {before}
self.assertEqual(bot._canonical_session_fp(after), before)
# ── 一次只服务一个会话:当前会话先收尾,其余按到达先后排队 ──────────────
def test_the_open_session_is_served_before_anyone_else(self):
"""正在回复的会话不能被别的会话抢走,否则这条回复要等下一轮重来。"""
pending = {
"aa" * 40: {"created_at": 100.0},
"bb" * 40: {"created_at": 200.0},
"cc" * 40: {"created_at": 300.0},
}
active = bytes.fromhex("cc" * 40)
order = [key for key, _ in WeChatBot._pending_queue_order(pending, active)]
self.assertEqual(order[0], "cc" * 40)
def test_the_rest_of_the_queue_is_first_come_first_served(self):
"""多个会话同时来消息就排队,按到达先后一条条处理。"""
pending = {
"cc" * 40: {"created_at": 300.0},
"aa" * 40: {"created_at": 100.0},
"bb" * 40: {"created_at": 200.0},
}
order = [key for key, _ in WeChatBot._pending_queue_order(pending, None)]
self.assertEqual(order, ["aa" * 40, "bb" * 40, "cc" * 40])
def test_a_missing_arrival_time_never_breaks_the_queue(self):
"""旧任务缺少 created_at 时排队不能崩,只能排在最前面等着被收掉。"""
pending = {
"aa" * 40: {},
"bb" * 40: {"created_at": "坏值"},
"cc" * 40: {"created_at": 200.0},
}
order = [key for key, _ in WeChatBot._pending_queue_order(pending, None)]
self.assertEqual(order[-1], "cc" * 40)
# ── 新会话只有一条消息,复制不出表头,仍要能证明回复发出去了 ────────────
# 现场(2026-07-31 12:11):新客户只发了“你好”,框选只能选中气泡内文字,
# 企微不给“发言人+时间”表头,消息块锚点建不起来,发送被永久拦下。
def _receipt_bot(self, visible_after, outgoing=True, surface_changed=True):
bot = WeChatBot.__new__(WeChatBot)
bot.extract_chat_text = lambda screens=1, wait_for_idle=False: visible_after
bot._last_visible_bubble_is_outgoing = lambda: outgoing
bot._chat_surface_signature = lambda: b"after" if surface_changed else b"before"
return bot
def test_a_headerless_chat_can_still_prove_the_reply_landed(self):
reply = "你好呀,我是贴心管家,有什么想问的您慢慢说"
bot = self._receipt_bot(f"你好\n{reply}")
self.assertIs(
bot._raw_text_receipt_matches(reply, "你好", b"before"),
True,
)
def test_a_reply_that_never_appeared_is_not_accepted(self):
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot("你好")
self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False)
def test_a_customer_followup_does_not_cancel_our_receipt(self):
"""客户在我们发完后马上又说一句,末条气泡就在左边。
现场(2026-07-31 12:38):回复实际已经发出去了,客户紧接着补了句
“你在哪呢”,末条落到左侧,回执被判定为失败,任务卡在待核对里既不重发
也不放行。这句回复是模型现生成的,发送前整屏文字里没有、现在有了,就
只能是我们发的;末条在左边只说明客户有新话要接。
"""
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot(f"你好\n{reply}\n你在哪呢", outgoing=False)
self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), True)
self.assertIs(bot._last_send_receipt_followup, True)
def test_a_quiet_chat_reports_no_followup_to_chase(self):
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot(f"你好\n{reply}", outgoing=True)
self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), True)
self.assertIsNone(bot._last_send_receipt_followup)
def test_a_reply_missing_from_the_screen_is_still_refused(self):
"""末条气泡不再有否决权,但"屏幕上根本没有这句话"仍然必须判失败。"""
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot("你好\n你在哪呢", outgoing=False)
self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False)
@staticmethod
def _login_page(qr_side=420, center=(0.5, 0.45), width=2258, height=1320):
"""一张真的登录页:白底、正中一个真二维码、没有任何主界面结构。"""
import cv2
page = np.full((height, width, 4), 245, dtype=np.uint8)
code = cv2.QRCodeEncoder_create().encode(
"https://work.weixin.qq.com/login/verify/abc123"
)
code = cv2.resize(
code, (qr_side, qr_side), interpolation=cv2.INTER_NEAREST
)
cx = int(width * center[0])
cy = int(height * center[1])
x0, y0 = cx - qr_side // 2, cy - qr_side // 2
page[y0:y0 + qr_side, x0:x0 + qr_side, :3] = code[:, :, None]
return page
def test_a_customer_image_is_kept_with_the_archived_message(self):
"""企微图片没有可复制文本,档案里只剩「(客户发来图片)」这行占位。
事后翻记录完全不知道客户发了什么。视觉模式本来就截了这一屏发给模型,
顺手落一份盘,会话记录才有东西可显示。
"""
from conversation_store import ConversationStore
with tempfile.TemporaryDirectory() as directory:
store = ConversationStore(os.path.join(directory, "conversations.json"))
store.append_exchange_once(
"abc", "(客户发来图片)", "收到啦", "tx1", user_image="shot.png"
)
history = store.history("abc")
self.assertEqual(history[0]["image"], "shot.png")
# 我方回复没有画面,不该凭空多出这个键
self.assertNotIn("image", history[1])
def test_a_text_only_exchange_stays_free_of_image_keys(self):
from conversation_store import ConversationStore
with tempfile.TemporaryDirectory() as directory:
store = ConversationStore(os.path.join(directory, "conversations.json"))
store.append_exchange_once("abc", "在不在", "在呢", "tx1")
self.assertNotIn("image", store.history("abc")[0])
def test_a_saved_snapshot_lands_on_disk_and_old_ones_are_pruned(self):
with tempfile.TemporaryDirectory() as directory:
with (
mock.patch.object(WeChatBot, "_MEDIA_DIR", directory),
mock.patch.object(WeChatBot, "_MEDIA_KEEP_FILES", 3),
):
bot = WeChatBot.__new__(WeChatBot)
names = [
bot._save_media_snapshot(b"f" * 40, b"PNG-%d" % index)
for index in range(6)
]
self.assertTrue(all(names))
left = [n for n in os.listdir(directory) if n.endswith(".png")]
self.assertLessEqual(len(left), 3)
# 留下的必须是最近几张,最早那几张才是该淘汰的
self.assertIn(names[-1], left)
self.assertNotIn(names[0], left)
def test_an_unwritable_media_dir_never_blocks_the_reply(self):
"""留存失败是小事,绝不能因此不回客户。"""
bot = WeChatBot.__new__(WeChatBot)
with mock.patch("wechat_bot.os.makedirs", side_effect=OSError("满了")):
self.assertEqual(bot._save_media_snapshot(b"f" * 40, b"PNG"), "")
self.assertEqual(bot._save_media_snapshot(b"f" * 40, b""), "")
def test_the_staged_snapshot_reaches_the_archive(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
bot._pending_exchanges = {}
bot._pending_reply_sessions = {
fp.hex(): {
"staged_user_text": "(客户发来图片)",
"staged_reply_text": "收到啦",
"staged_user_image": "shot.png",
"exchange_id": "tx1",
"archive_enabled": True,
}
}
bot.store = mock.Mock()
bot.store.append_exchange_once = mock.Mock(return_value=True)
bot._forget_uncertain_tracking = mock.Mock()
bot._persist_pending_replies = mock.Mock()
bot._commit_staged_exchange(fp.hex())
bot.store.append_exchange_once.assert_called_once_with(
fp.hex(),
"(客户发来图片)",
"收到啦",
"tx1",
user_image="shot.png",
)
def test_the_staged_snapshot_survives_a_restart(self):
"""发送和入档之间进程挂掉,重启后这张画面不能丢。"""
with tempfile.TemporaryDirectory() as directory:
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
bot._pending_reply_path = os.path.join(directory, "pending.json")
bot._pending_reply_sessions = {
fp.hex(): {
"confirmed_unread": True,
"staged_user_image": "shot.png",
"created_at": time.time(),
"updated_at": time.time(),
}
}
self.assertTrue(bot._persist_pending_replies())
restored = bot._load_pending_replies()
self.assertEqual(restored[fp.hex()]["staged_user_image"], "shot.png")
def _extract_with_screens(self, screen_lines):
"""跑一遍逐屏采集,screen_lines 依次是每屏框选到的内容。"""
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 1
bot._activate_wx = mock.Mock(return_value=True)
bot._select_visible_chat = mock.Mock(side_effect=list(screen_lines))
scrolls = []
with (
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.moveTo"),
mock.patch(
"wechat_bot.pyautogui.scroll",
side_effect=lambda amount, *a, **k: scrolls.append(amount),
),
mock.patch("wechat_bot.time.sleep"),
):
text = bot._extract_chat_text_locked(
1, 100, 100, 800, 600, 10, 500, 400, 130
)
return text, bot._select_visible_chat.call_count, scrolls
@staticmethod
def _screen_of_body(count, prefix="这是很长的一段留言"):
"""一屏纯正文,没有任何「发言人+时间」表头。"""
return [f"{prefix}{index}" for index in range(count)]
def test_a_message_taller_than_one_screen_is_scrolled_up_for(self):
"""满屏正文却没有表头,说明这条消息的开头还在上面,必须往上翻。
现场(2026-07-31 14:16):客户发来的长留言超过一屏,增量模式只取一屏
读到的是尾巴,档案里只剩「(首次会话的新消息已由聊天截图确认)」,
模型回了句"这句话没显示完整,您再发一遍吧"。
"""
first = self._screen_of_body(wechat_bot.CHAT_FULL_SCREEN_LINES + 4)
second = ["高瑞 14:16"] + self._screen_of_body(6, "开头那几句")
text, calls, scrolls = self._extract_with_screens([first, second])
self.assertEqual(calls, 2)
self.assertIn("高瑞 14:16", text)
self.assertIn("开头那几句0", text)
# 翻上去之后必须原路滚回底部,否则下一轮停在半空
self.assertTrue(any(amount > 0 for amount in scrolls))
self.assertTrue(any(amount < 0 for amount in scrolls))
def test_a_normal_screen_with_headers_never_scrolls(self):
"""能看到表头就说明这条消息是完整的,多翻一屏是白白浪费两秒。"""
screen = ["高瑞 14:16"] + self._screen_of_body(
wechat_bot.CHAT_FULL_SCREEN_LINES + 4
)
_text, calls, scrolls = self._extract_with_screens([screen])
self.assertEqual(calls, 1)
self.assertFalse(scrolls)
def test_a_sparse_headerless_chat_does_not_trigger_the_search(self):
"""新会话同样复制不出表头,但它行数很少,不该被当成被截断的长消息。"""
_text, calls, scrolls = self._extract_with_screens([["在不在"]])
self.assertEqual(calls, 1)
self.assertFalse(scrolls)
def test_the_upward_search_gives_up_instead_of_scrolling_forever(self):
"""对方粘了一整份文档时不能一直翻下去,把这个会话拖死。"""
full = self._screen_of_body(wechat_bot.CHAT_FULL_SCREEN_LINES + 4)
screens = [
self._screen_of_body(
wechat_bot.CHAT_FULL_SCREEN_LINES + 4, f"第{index}屏"
)
for index in range(20)
]
_text, calls, _scrolls = self._extract_with_screens(screens)
self.assertEqual(calls, 1 + WeChatBot._CLIPPED_MESSAGE_EXTRA_SCREENS)
self.assertTrue(WeChatBot._newest_message_is_clipped([full]))
def test_a_reordered_row_does_not_cost_everyone_else_the_round(self):
"""刷屏消息把列表搅乱时,打不开的那个会话不能拖着所有人一起等下一轮。
现场(2026-07-31 14:04):测试消息一密集,红点识别和点击之间列表就重排,
点开的不是目标,于是整轮 break——其他客户全部顺延。
"""
bot = WeChatBot.__new__(WeChatBot)
stuck = b"x" * 40
served = b"y" * 40
page = self._nav_surface(True)
bot.session_item_h = 32
bot.list_region = {"top": 0}
bot.list_click_x = 200
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = set()
bot._pending_reply_sessions = {}
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._mark_reply_pending = 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._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=page)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=page)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._hold_for_unfinished_active_session = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
bot._row_center_from_badge = mock.Mock(return_value=16)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._last_click_failure_reason = "selected_fingerprint_mismatch"
def next_unread(processed_fp, non_conv_fp, full=None):
for fp in (stuck, served):
if fp not in processed_fp and fp not in non_conv_fp:
return page, 16, fp
return None
bot._find_next_unread_session = mock.Mock(side_effect=next_unread)
bot.click_session = mock.Mock(return_value=False)
handled = []
bot.handle_session_reply = mock.Mock(
side_effect=lambda *a, **k: handled.append(a)
)
with mock.patch("wechat_bot.time.sleep"):
bot._poll_once()
# 打不开的那个被记进本轮跳过集合,扫描才会换人;否则一整轮的名额全耗在它身上
clicked = [call.args[1] if len(call.args) > 1 else call.kwargs["expected_fp"]
for call in bot.click_session.call_args_list]
self.assertIn(served, clicked)
def test_a_real_login_qr_still_stops_everything(self):
self.assertTrue(
wechat_bot.looks_like_security_verification(self._login_page())
)
def test_dense_chat_text_is_not_a_login_qr(self):
"""现场(2026-07-31 14:04):一条带表情的长广告消息让机器人整个停机。
旧判据靠"中央够黑 + 够亮 + 明暗跳变够多"猜二维码,实测这三个数是
0.0254 / 0.9203 / 0.0367,全部刚刚压线过关。密集小字就是长这样。
"""
rng = np.random.default_rng(20260731)
page = np.full((1320, 2258, 4), 250, dtype=np.uint8)
for row in range(120, 1100, 26):
for col in range(700, 1900, 11):
if rng.random() < 0.55:
page[row:row + 13, col:col + 7, :3] = 40
self.assertFalse(wechat_bot.looks_like_security_verification(page))
def test_a_payment_qr_sent_into_a_chat_is_not_a_login_page(self):
"""客户发一张收款码进来,机器人不能就此罢工等人扫码。"""
page = self._login_page(qr_side=220, center=(0.78, 0.5))
self.assertFalse(wechat_bot.looks_like_security_verification(page))
def test_the_message_workspace_overrides_any_qr_on_screen(self):
"""消息工作区还在,就绝不可能是登录页。"""
bot = WeChatBot.__new__(WeChatBot)
bot.security_verification_required = False
bot._security_log_emitted = False
bot.hwnd = 1
bot._message_nav_selected = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 2258, 1320)),
mock.patch(
"wechat_bot.capture_window_region",
return_value=self._login_page(),
),
):
self.assertFalse(bot._security_gate_visible())
self.assertFalse(bot.security_verification_required)
def test_a_login_page_without_a_workspace_locks_the_bot(self):
bot = WeChatBot.__new__(WeChatBot)
bot.security_verification_required = False
bot._security_log_emitted = False
bot._window_ready = True
bot.hwnd = 1
bot._message_nav_selected = mock.Mock(return_value=False)
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=(0, 0, 2258, 1320)),
mock.patch(
"wechat_bot.capture_window_region",
return_value=self._login_page(),
),
):
self.assertTrue(bot._security_gate_visible())
self.assertTrue(bot.security_verification_required)
def test_a_subscription_row_is_evicted_from_the_queue(self):
"""指纹分毫不差、但这一行根本不是聊天:再等下去也不会变。
现场探针(2026-07-31 12:47):打卡和行业资讯两个任务,头像与名称哈希
距离都是 0,被 _is_real_conversation 否掉,于是队列每一轮都为它们做一次
全列表分页扫描(6 个任务 21 秒),真正的客户全排在后面。
"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"s" * 40
bot._composerless_strikes = {}
bot._unrepliable_sessions = {}
bot._persist_unrepliable_sessions = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._note_unclickable_pending(fp)
self.assertFalse(bot._unrepliable_sessions)
bot._clear_reply_pending.assert_not_called()
bot._note_unclickable_pending(fp)
self.assertIn(fp.hex(), bot._unrepliable_sessions)
bot._clear_reply_pending.assert_called_once_with(fp)
bot._persist_unrepliable_sessions.assert_called_once()
def test_a_reopened_row_clears_its_own_strikes(self):
"""真会话偶尔判错一次,不能因此被误判成订阅号。"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"t" * 40
bot._composerless_strikes = {}
bot._unrepliable_sessions = {}
bot._persist_unrepliable_sessions = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._composer_geometry_valid = True
bot._note_unclickable_pending(fp)
self.assertTrue(bot._session_accepts_replies(fp))
self.assertNotIn(fp.hex(), bot._composerless_strikes)
bot._note_unclickable_pending(fp)
self.assertFalse(bot._unrepliable_sessions)
def test_a_task_with_no_matching_row_anywhere_expires(self):
"""列表里找不到对应会话的空壳任务不能永远占着队列。"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"g" * 40
bot._clear_reply_pending = mock.Mock()
state = {"resume_failures": bot._UNREACHABLE_RESUME_FAILURES - 1}
self.assertFalse(bot._expire_unreachable_pending(fp, state))
bot._clear_reply_pending.assert_not_called()
state["resume_failures"] = bot._UNREACHABLE_RESUME_FAILURES
self.assertTrue(bot._expire_unreachable_pending(fp, state))
bot._clear_reply_pending.assert_called_once_with(fp)
def test_a_findable_task_never_expires_on_a_bad_count(self):
bot = WeChatBot.__new__(WeChatBot)
bot._clear_reply_pending = mock.Mock()
self.assertFalse(
bot._expire_unreachable_pending(b"h" * 40, {"resume_failures": "坏值"})
)
bot._clear_reply_pending.assert_not_called()
def test_an_unchanged_screen_is_never_taken_as_a_send(self):
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot(f"你好\n{reply}", surface_changed=False)
self.assertIs(bot._raw_text_receipt_matches(reply, "你好", b"before"), False)
def test_a_baseline_already_holding_the_reply_proves_nothing(self):
"""基线里已经有这句话,发送前后长得一样,证明不了任何事。"""
reply = "你好呀,我是贴心管家"
bot = self._receipt_bot(f"你好\n{reply}")
self.assertIsNone(
bot._raw_text_receipt_matches(reply, f"你好 {reply}", b"before")
)
self.assertFalse(bot._raw_text_anchor_usable(f"你好 {reply}", reply))
def test_an_empty_baseline_never_unlocks_the_raw_anchor(self):
"""完全读不到聊天内容时不能放行,否则等于取消了重复发送保护。"""
bot = WeChatBot.__new__(WeChatBot)
self.assertFalse(bot._raw_text_anchor_usable("", "你好呀"))
self.assertFalse(bot._raw_text_anchor_usable("你好", ""))
def test_the_raw_anchor_survives_a_restart(self):
"""重启后还要能核对回执,否则任务只能挂成待核对、客户白等。"""
bot = WeChatBot.__new__(WeChatBot)
handle, path = tempfile.mkstemp(suffix=".json")
os.close(handle)
self.addCleanup(lambda: os.path.exists(path) and os.unlink(path))
fp = bytes.fromhex("aa" * 40)
bot._pending_reply_path = path
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"send_state": "sent_uncommitted",
"send_baseline_text": "你好",
"created_at": time.time(),
"updated_at": time.time(),
}
}
self.assertTrue(bot._persist_pending_replies())
restored = WeChatBot._load_pending_replies(bot)
self.assertEqual(restored[fp.hex()]["send_baseline_text"], "你好")
def test_block_anchors_keep_their_own_proven_path(self):
"""有消息块时必须走原来的路径,裸文本只在没有块时兜底。"""
bot = WeChatBot.__new__(WeChatBot)
called = []
bot._raw_text_receipt_matches = lambda *a, **k: called.append(a) or True
bot._visible_last_message_matches = lambda *a, **k: None
result = bot._send_receipt_matches(
reply_text="你好呀",
customer_speaker="",
before_surface=b"before",
before_blocks=[["高瑞 7/31 12:00:00", "你好"]],
before_text="你好",
)
self.assertEqual(called, [])
self.assertIsNone(result)
# ── 机器人自己遗留的草稿,不能当成人工草稿一路让路 ──────────────────────
# 现场(2026-07-31 12:11):上一轮贴好草稿后被中途打断,草稿留在输入框里。
# 下一轮机器人认不出这是自己写的,判成人工草稿取消发送,这个会话从此
# 每轮都调一次模型、又每轮都让路,客户永远等不到回复。
def test_our_own_abandoned_draft_is_cleared_and_resent(self):
bot = WeChatBot.__new__(WeChatBot)
reply = "在呢,您有什么事就直接说,我这边看着呢"
# 调用方传进来的永远是归一化后的草稿(全角标点已折半),这里照做
draft = bot._normalized_message_text(reply)
state = {"staged_reply_text": reply, "send_state": ""}
self.assertTrue(bot._draft_is_our_own(draft, state, reply))
def test_last_round_wording_is_still_recognised_as_ours(self):
"""模型每轮换一种说法,只比对新回复就永远认不出框里那句是自己写的。"""
bot = WeChatBot.__new__(WeChatBot)
stale = "在呢,您有什么事慢慢说,我这边听着呢"
fresh = "在呢,您说,我这边听着"
state = {
"staged_reply_text": fresh,
"last_pasted_draft": bot._normalized_message_text(stale),
"send_state": "",
}
draft = bot._normalized_message_text(stale)
self.assertTrue(bot._draft_is_our_own(draft, state, fresh))
def test_a_human_sentence_still_stops_the_bot_cold(self):
bot = WeChatBot.__new__(WeChatBot)
reply = "在呢,您有什么事就直接说"
state = {"staged_reply_text": reply, "send_state": ""}
draft = bot._normalized_message_text("我自己来回这条")
self.assertFalse(bot._draft_is_our_own(draft, state, reply))
def test_a_dispatched_transaction_keeps_its_draft_untouched(self):
"""已经按过回车的事务归回执核对管,这里清掉再发就是重复发送。"""
bot = WeChatBot.__new__(WeChatBot)
reply = "在呢,您有什么事就直接说"
draft = bot._normalized_message_text(reply)
state = {"staged_reply_text": reply, "send_state": "sent_uncommitted"}
self.assertFalse(bot._draft_is_our_own(draft, state, reply))
def test_a_session_without_a_task_never_gets_its_draft_wiped(self):
bot = WeChatBot.__new__(WeChatBot)
self.assertFalse(bot._draft_is_our_own("随便什么字", {}, "机器人的回复"))
self.assertFalse(bot._draft_is_our_own("随便什么字", None, "机器人的回复"))
# ── 只有一两条消息的新会话,框选起点必须落在真正有字的地方 ──────────────
# 现场(2026-07-31 11:51):新客户只发了一句“在不在”,气泡贴在聊天区最顶
# 上,其余全是空白。两条固定边距的拖拽路径都按在空白上,锚不到文字,
# “两次框选均为空”。复制不到就只能走视觉,视觉文本没有“发言人+时间”表头,
# 发送前锚点建不起来,于是不停调模型又不停取消,客户永远等不到回复。
def _ink_bot(self, chat_image):
bot = WeChatBot.__new__(WeChatBot)
bot.L, bot.T = 100, 200
bot.hwnd = 1
bot.scale = 1.0
return bot, mock.patch.object(
wechat_bot,
"capture_window_region",
return_value=chat_image,
)
@staticmethod
def _sparse_chat_image(height=800, width=600, bubble_bottom=60):
"""顶部一条气泡,其余全是背景色——新会话的真实样子。"""
img = np.full((height, width, 4), 246, dtype=np.uint8)
img[10:bubble_bottom, 40:160, :3] = 40
return img
def test_the_lone_bubble_at_the_top_is_found(self):
image = self._sparse_chat_image()
bot, patcher = self._ink_bot(image)
with patcher:
bounds = bot._chat_ink_bounds(100, 200, 600, 800)
self.assertIsNotNone(bounds)
ink_top, ink_bottom = bounds
# 屏幕坐标 = 区域左上角 + 墨迹行号
self.assertEqual(ink_top, 200 + 10)
self.assertEqual(ink_bottom, 200 + 59)
def test_an_empty_chat_reports_no_ink_instead_of_guessing(self):
image = np.full((800, 600, 4), 246, dtype=np.uint8)
bot, patcher = self._ink_bot(image)
with patcher:
self.assertIsNone(bot._chat_ink_bounds(100, 200, 600, 800))
def test_the_scrollbar_gutter_never_counts_as_a_message(self):
"""右缘滚动条会随鼠标淡入淡出,算进墨迹就会把范围撑满整个面板。"""
image = np.full((800, 600, 4), 246, dtype=np.uint8)
image[:, -6:, :3] = 120
bot, patcher = self._ink_bot(image)
with patcher:
self.assertIsNone(bot._chat_ink_bounds(100, 200, 600, 800))
def test_a_sparse_chat_tries_the_ink_anchored_drag_first(self):
"""消息稀疏时先走墨迹路径,省下两次注定选空的拖拽。"""
bot, patcher = self._ink_bot(self._sparse_chat_image())
attempts = []
bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2))
bot._copy_selection = lambda: ""
with patcher:
bot._select_visible_chat(100, 200, 600, 800, 20, 230)
# 起点仍被钳在 top+margin:那道边距是防止拖到聊天区外触发历史翻页。
# 气泡跨 210~259,钳到 220 依然落在字上,锚得住
self.assertEqual(attempts[0], (200 + 20, 200 + 57))
def test_a_full_chat_keeps_the_proven_path_first(self):
"""消息占满面板时不改动既有顺序,墨迹路径只当兜底。"""
image = np.full((800, 600, 4), 246, dtype=np.uint8)
image[10:790, 40:560, :3] = 40
bot, patcher = self._ink_bot(image)
attempts = []
bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2))
bot._copy_selection = lambda: ""
with patcher:
bot._select_visible_chat(100, 200, 600, 800, 20, 230)
self.assertEqual(attempts[0], (200 + 800 - 20, 230))
self.assertEqual(len(attempts), 3)
def test_a_working_first_drag_never_costs_a_second_one(self):
bot, patcher = self._ink_bot(self._sparse_chat_image())
attempts = []
bot._drag_select = lambda x1, y1, x2, y2, steps=12: attempts.append((y1, y2))
bot._copy_selection = lambda: "高瑞@微信 7/31 11:51:00\n在不在"
with patcher:
lines = bot._select_visible_chat(100, 200, 600, 800, 20, 230)
self.assertEqual(len(attempts), 1)
self.assertEqual(lines, ["高瑞@微信 7/31 11:51:00", "在不在"])
def test_a_recovered_copy_rebuilds_the_missing_send_anchor(self):
"""带表头的复制文本必须能解析出锚点——这正是发送被卡住的那一步。"""
bot = WeChatBot.__new__(WeChatBot)
self.assertEqual(bot._normalized_copied_blocks("在不在"), [])
blocks = bot._normalized_copied_blocks(
"高瑞@微信@微信联系人 7/31 11:51:00\n在不在"
)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0][1], "在不在")
def test_a_composed_reply_waits_out_the_last_seconds_of_spacing(self):
"""回复已经生成就不能因为还差几秒发送间隔被丢掉,下一轮要从头再调模型。"""
bot = WeChatBot.__new__(WeChatBot)
remaining = [1.0, 1.0, 0.0]
bot._send_gate_open = lambda: False
bot._send_gate_remaining = lambda: remaining.pop(0) if remaining else 0.0
self.assertTrue(bot._await_send_gate())
def test_a_long_rate_limit_still_leaves_the_task_for_later(self):
"""分钟/小时配额用尽时不能原地干等,必须放行让轮询继续跑。"""
bot = WeChatBot.__new__(WeChatBot)
bot._send_gate_open = lambda: False
bot._send_gate_remaining = lambda: bot._SEND_GATE_WAIT_BUDGET_SECONDS + 30.0
self.assertFalse(bot._await_send_gate())
def _hold_bot(self, active_fp: bytes, pending: dict):
bot = WeChatBot.__new__(WeChatBot)
bot._active_session_fp = active_fp
bot._pending_reply_sessions = pending
bot._active_hold_key = ""
bot._active_hold_since = 0.0
bot._active_hold_expired = False
return bot
def test_an_unfinished_session_keeps_the_round_to_itself(self):
"""当前会话没回完,本轮不能掉进未读扫描去点开别人。"""
fp = bytes.fromhex("aa" * 40)
bot = self._hold_bot(fp, {fp.hex(): {"confirmed_unread": True}})
self.assertTrue(bot._hold_for_unfinished_active_session())
self.assertTrue(bot._hold_for_unfinished_active_session())
def test_a_finished_session_releases_the_round_at_once(self):
"""回完就立刻放行,排队的会话不该多等一轮。"""
fp = bytes.fromhex("aa" * 40)
pending = {fp.hex(): {"confirmed_unread": True}}
bot = self._hold_bot(fp, pending)
self.assertTrue(bot._hold_for_unfinished_active_session())
pending.clear()
self.assertFalse(bot._hold_for_unfinished_active_session())
def test_a_stuck_session_stops_blocking_everyone_else(self):
"""真卡住的会话必须到点放行,否则后面排队的客户一起陪葬。"""
fp = bytes.fromhex("aa" * 40)
bot = self._hold_bot(fp, {fp.hex(): {"confirmed_unread": True}})
self.assertTrue(bot._hold_for_unfinished_active_session())
bot._active_hold_since -= bot._ACTIVE_SESSION_HOLD_SECONDS + 1.0
self.assertFalse(bot._hold_for_unfinished_active_session())
# 放行之后不能再反复压住:同一个会话不该每 60 秒又独占一次
self.assertFalse(bot._hold_for_unfinished_active_session())
def test_switching_sessions_starts_a_fresh_hold(self):
"""换到下一个会话时重新计时,不继承上一个会话烧掉的额度。"""
first = bytes.fromhex("aa" * 40)
second = bytes.fromhex("bb" * 40)
pending = {first.hex(): {}, second.hex(): {}}
bot = self._hold_bot(first, pending)
self.assertTrue(bot._hold_for_unfinished_active_session())
bot._active_hold_since -= bot._ACTIVE_SESSION_HOLD_SECONDS + 1.0
self.assertFalse(bot._hold_for_unfinished_active_session())
bot._active_session_fp = second
self.assertTrue(bot._hold_for_unfinished_active_session())
# ── 订阅号/系统号没有输入框,不能一直占着回复队列 ──────────────────────
# 现场(2026-07-31 11:04):“行业资讯”订阅号的聊天页没有输入框,机器人每轮
# 都把它重新打开、调一次视觉模型,再卡在“无法确认消息区与输入区分隔线”上。
# 队列里 7 条待回复有 3 条是这种永远完不成的任务,真客户被挤到后面。
def _composer_bot(self):
bot = WeChatBot.__new__(WeChatBot)
handle, path = tempfile.mkstemp(suffix=".json")
os.close(handle)
os.unlink(path)
self.addCleanup(
lambda: os.path.exists(path) and os.unlink(path)
)
bot._unrepliable_path = path
bot._unrepliable_sessions = {}
bot._composerless_strikes = {}
bot._pending_reply_path = ""
bot._pending_reply_sessions = {}
bot._pending_scan_progress = {}
bot._pending_exchanges = {}
return bot
def test_a_page_without_a_composer_leaves_the_reply_queue(self):
bot = self._composer_bot()
fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
bot._pending_reply_sessions[fp.hex()] = {"confirmed_unread": True}
bot._composer_geometry_valid = False
for _ in range(bot._COMPOSERLESS_STRIKES):
self.assertFalse(bot._session_accepts_replies(fp))
# 判定之后:任务出队、后续轮次直接跳过,不再重复调用模型
self.assertNotIn(fp.hex(), bot._pending_reply_sessions)
self.assertTrue(bot._session_is_unrepliable(fp))
self.assertFalse(bot._session_accepts_replies(fp))
def test_one_bad_frame_never_silences_a_real_contact(self):
"""单帧没渲染完不能把真客户静默移出队列。"""
bot = self._composer_bot()
fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
bot._composer_geometry_valid = False
self.assertFalse(bot._session_accepts_replies(fp))
bot._composer_geometry_valid = True
self.assertTrue(bot._session_accepts_replies(fp))
# 计数已清零:再连续失败仍需攒满整轮才判定
bot._composer_geometry_valid = False
for _ in range(bot._COMPOSERLESS_STRIKES - 1):
self.assertFalse(bot._session_accepts_replies(fp))
self.assertFalse(bot._session_is_unrepliable(fp))
def test_the_filter_rechecks_itself_after_the_cooldown(self):
"""企微改版或误判时必须能自己走回来。"""
bot = self._composer_bot()
fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
bot._unrepliable_sessions[fp.hex()] = (
time.time() - bot._UNREPLIABLE_RECHECK_SECONDS - 1.0
)
self.assertFalse(bot._session_is_unrepliable(fp))
bot._composer_geometry_valid = True
self.assertTrue(bot._session_accepts_replies(fp))
def test_cross_monitor_window_is_detected_even_when_its_center_monitor_is_valid(self):
"""The field failure spans a 200% laptop and a 100% external panel."""
monitors = [
(0, 0, 1536, 960),
(3072, 0, 4992, 1080),
]
# Exact geometry from the 2026-08-18 field log. Looking only at the
# centre/nearest monitor missed this split and left the composer offset.
self.assertTrue(
window_spans_multiple_displays(
(1387, 474, 4359, 1774),
monitors,
)
)
# In a per-monitor-aware Python process the same physical arrangement
# exposes doubled monitor coordinates, while virtualized WeCom keeps
# reporting the rectangle above. Its external-screen half therefore
# lands in a numeric gap and must still be treated as unsafe.
self.assertTrue(
window_spans_multiple_displays(
(1387, 474, 4359, 1774),
[(0, 0, 3072, 1920), (6144, 0, 9984, 2160)],
)
)
self.assertFalse(
window_spans_multiple_displays(
(40, 20, 1496, 912),
monitors,
)
)
def test_cross_monitor_window_is_resized_entirely_inside_primary_work_area(self):
x, y, width, height = fit_window_inside_work_area(
(1387, 474, 4359, 1774),
(0, 0, 1536, 912),
)
self.assertGreaterEqual(x, 0)
self.assertGreaterEqual(y, 0)
self.assertLessEqual(x + width, 1536)
self.assertLessEqual(y + height, 912)
self.assertGreaterEqual(width, 640)
self.assertGreaterEqual(height, 480)
def test_open_customer_recaptures_geometry_before_composer_filter(self):
bot = self._composer_bot()
bot.hwnd = 1
fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
bot._composer_geometry_valid = False
bot._input_geometry_valid = False
bot.report_operation = mock.Mock()
bot._capture_full_window = mock.Mock(
return_value=np.zeros((720, 1280, 4), dtype=np.uint8)
)
def recover(_full):
bot._composer_geometry_valid = True
bot._input_geometry_valid = True
return True
bot._refresh_message_geometry = mock.Mock(side_effect=recover)
bot._maximize_window_for_composer = mock.Mock(return_value=False)
bot._run_ai_layout_guard = mock.Mock(return_value=False)
self.assertTrue(bot._session_accepts_replies(fp))
bot._capture_full_window.assert_called_once()
bot._refresh_message_geometry.assert_called_once()
bot._run_ai_layout_guard.assert_not_called()
self.assertNotIn(fp.hex(), bot._composerless_strikes)
def test_visual_model_link_error_preserves_real_customer_task(self):
bot = self._composer_bot()
bot.hwnd = 1
fp = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
bot._pending_reply_sessions[fp.hex()] = {"confirmed_unread": True}
bot._composer_geometry_valid = False
bot._input_geometry_valid = False
bot.report_operation = mock.Mock()
bot._capture_full_window = mock.Mock(
return_value=np.zeros((720, 1280, 4), dtype=np.uint8)
)
bot._refresh_message_geometry = mock.Mock(return_value=False)
bot._maximize_window_for_composer = mock.Mock(return_value=False)
def link_error(*_args, **_kwargs):
bot._last_layout_guard_outcome = "link_error"
return False
bot._run_ai_layout_guard = mock.Mock(side_effect=link_error)
for _ in range(bot._COMPOSERLESS_STRIKES + 2):
self.assertFalse(bot._session_accepts_replies(fp))
self.assertIn(fp.hex(), bot._pending_reply_sessions)
self.assertNotIn(fp.hex(), bot._composerless_strikes)
self.assertFalse(bot._session_is_unrepliable(fp))
def test_a_far_avatar_under_the_same_name_is_still_a_different_row(self):
"""名称相同也不能无条件放行;头像彻底不同仍必须拒绝。"""
bot = self._fp_bot()
near = self._DRIFT_AVATAR_BEFORE + self._DRIFT_NAME
far = bytes.fromhex("ffffffffffffffff") + self._DRIFT_NAME
self.assertFalse(bot._session_fp_matches(near, far))
def test_close_avatars_with_different_names_never_match(self):
"""现场最接近的一对不同联系人(头像仅差 10 位)必须继续被分开。"""
bot = self._fp_bot()
left = bytes.fromhex("18181810151d1f1f") + bytes.fromhex("017b0163") + b"\x5a" * 28
right = self._DRIFT_AVATAR_AFTER + self._DRIFT_NAME
avatar_distance = (
int.from_bytes(left[:8], "big") ^ int.from_bytes(right[:8], "big")
).bit_count()
self.assertLessEqual(avatar_distance, bot._FP_HAMMING_TOL_NAMED)
self.assertFalse(bot._session_fp_matches(left, right))
def test_a_blank_name_hash_falls_back_to_the_strict_avatar_tolerance(self):
"""名称采不到字时它什么都证明不了,头像必须回到严格容差。"""
bot = self._fp_bot()
blank = b"\x00" * 32
left = self._DRIFT_AVATAR_BEFORE + blank
right = self._DRIFT_AVATAR_AFTER + blank
self.assertFalse(bot._name_fp_is_substantive(blank))
self.assertFalse(bot._session_fp_matches(left, right))
# 同样一片空白的名称下,头像足够接近时仍然照旧放行。
near = bytes(
self._DRIFT_AVATAR_BEFORE[:7] + bytes([self._DRIFT_AVATAR_BEFORE[7] ^ 0b11])
) + blank
self.assertTrue(bot._session_fp_matches(left, near))
def test_calibration_gate_keeps_warning_instead_of_going_silent(self):
"""闸门关闭 = 自动回复完全停摆,绝不能只吭一声就静默。"""
bot = WeChatBot.__new__(WeChatBot)
bot._nav_width_confirmed = False
bot._nav_width_gate_logged = 0.0
bot._nav_width_gate_since = 0.0
def gate_output(now):
buffer = io.StringIO()
with mock.patch("wechat_bot.time.monotonic", return_value=now):
with redirect_stdout(buffer):
self.assertFalse(bot._session_identity_trustworthy())
return buffer.getvalue()
self.assertIn("自动回复已暂停", gate_output(1000.0))
# 紧接着的轮次不刷屏。
self.assertEqual(gate_output(1005.0), "")
# 但停摆持续下去必须继续告警,并报出已经停了多久。
later = gate_output(1040.0)
self.assertIn("自动回复已暂停 40 秒", later)
bot._nav_width_confirmed = True
self.assertTrue(bot._session_identity_trustworthy())
# 恢复后重新计时,下次停摆立刻重新告警。
bot._nav_width_confirmed = False
self.assertIn("自动回复已暂停 0 秒", gate_output(2000.0))
def test_low_confidence_frame_never_revokes_a_confirmed_navigation_width(self):
"""置信度不足时沿用已生效宽度,因此不能撤销既有的确认结论。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.L = bot.T = 0
bot.R, bot.B = 1200, 800
bot._list_x = 320
bot._composer_rel_top = 600
bot._composer_geometry_valid = True
bot._nav_width_confirmed = True
full = np.zeros((800, 1200, 4), dtype=np.uint8)
with mock.patch(
"wechat_bot.infer_navigation_width",
return_value=(68, 0.0),
), mock.patch(
"wechat_bot.infer_composer_top",
return_value=600,
):
bot._refresh_message_geometry(full)
self.assertEqual(bot._list_x, 320)
self.assertTrue(bot._nav_width_confirmed)
def test_navigation_geometry_is_committed_before_a_chat_composer_exists(self):
"""消息首页没有选中会话时也必须先校准列表,才能找到并打开未读客户。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L = bot.T = 0
bot.R, bot.B = 2200, 1300
bot._list_x = 136
bot._nav_width_confirmed = False
bot._selected_tracking_initialized = True
bot._active_session_fp = b"old"
bot._active_chat_signature = b"old"
bot._active_identity_signature = b"old"
full = np.zeros((1300, 2200, 4), dtype=np.uint8)
with mock.patch(
"wechat_bot.infer_navigation_width",
return_value=(320, 0.9),
), mock.patch(
"wechat_bot.infer_composer_top",
return_value=None,
):
self.assertTrue(bot._refresh_message_geometry(full))
self.assertEqual(bot._list_x, 320)
self.assertEqual(bot.list_region["left"], 320)
self.assertTrue(bot._nav_width_confirmed)
self.assertTrue(bot._session_geometry_valid)
self.assertFalse(bot._composer_geometry_valid)
self.assertFalse(bot._selected_tracking_initialized)
def test_semantic_layout_hint_still_requires_local_pixel_boundaries(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L = bot.T = 0
bot.R, bot.B = 2200, 1300
bot._list_x = 136
bot._list_w = 460
bot._nav_width_confirmed = False
bot._selected_tracking_initialized = False
bot._active_session_fp = None
bot._active_chat_signature = None
bot._active_identity_signature = None
full = np.full((1300, 2200, 4), 245, dtype=np.uint8)
full[:, :, 3] = 255
full[:, :320, :3] = 225
full[:, 320:820, :3] = 248
full[:, 820:, :3] = 238
hint = {
"navigation_right_ratio": 320 / 2200,
"session_list_right_ratio": 820 / 2200,
"composer_top_ratio": 1000 / 1300,
}
with (
mock.patch("wechat_bot.infer_navigation_width", return_value=(136, 0.0)),
mock.patch("wechat_bot.infer_session_list_width", return_value=(460, 0.0)),
mock.patch("wechat_bot.infer_composer_top", return_value=None),
mock.patch("wechat_bot.composer_divider_matches", return_value=(True, 1000)),
):
self.assertTrue(bot._refresh_message_geometry(full, semantic_hint=hint))
self.assertEqual(bot._list_x, 320)
self.assertEqual(bot._list_w, 500)
self.assertEqual(bot._composer_rel_top, 1000)
self.assertTrue(bot._nav_width_confirmed)
self.assertTrue(bot._composer_geometry_valid)
def test_avatar_anchor_keeps_the_caller_estimate_without_a_lattice(self):
"""看不出行距时宁可沿用调用方的估算,不能凭单个色块乱吸附。"""
bot = self._fingerprint_bot()
blank = np.full((320, 460, 4), 245, dtype=np.uint8)
blank[:, :, 3] = 255
self.assertEqual(bot._avatar_anchor(blank, 137), 137)
single = blank.copy()
for y in range(24, 104):
for x in range(20, 88):
single[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255)
self.assertEqual(bot._avatar_anchor(single, 137), 137)
def test_unread_badge_disappearing_does_not_change_flat_contact_key(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
base = np.full((160, 460, 4), 245, dtype=np.uint8)
base[:, :, 3] = 255
# 纯色文字头像 + 两个深色名称字形块。
base[20:108, 22:108, :3] = (220, 120, 30)
base[42:82, 56:70, :3] = 250
base[26:56, 122:150, :3] = 25
base[30:52, 164:194, :3] = 25
unread = base.copy()
yy, xx = np.ogrid[:unread.shape[0], :unread.shape[1]]
# 多位数未读徽章会越过头像右边缘,覆盖名称第一字左上/下缘。
badge = (xx - 116) ** 2 + (yy - 32) ** 2 <= 20 ** 2
unread[badge, :3] = (55, 55, 245)
unread[26:39, 107:125, :3] = 250
selected = base.copy()
selected[:, -24:, :3] = (238, 126, 36)
before = bot._session_fingerprint(unread, 32)
after = bot._session_fingerprint(selected, 64, row_center=True)
self.assertEqual(before, after)
def test_same_avatar_with_different_row_names_gets_distinct_session_keys(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
first = np.full((160, 460, 4), 245, dtype=np.uint8)
second = first.copy()
first[:, :, 3] = 255
second[:, :, 3] = 255
# 完全相同的头像。
for image in (first, second):
for y in range(40, 88):
for x in range(20, 84):
image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255)
# 名称首行字形位置不同,模拟两个使用相同头像的联系人。
first[26:56, 122:158, :3] = 25
first[32:50, 174:210, :3] = 25
second[26:56, 226:262, :3] = 25
second[32:50, 278:314, :3] = 25
first_fp = bot._session_fingerprint(first, 64, row_center=True)
second_fp = bot._session_fingerprint(second, 64, row_center=True)
self.assertEqual(len(first_fp), 40)
self.assertEqual(first_fp[:8], second_fp[:8])
self.assertNotEqual(first_fp, second_fp)
def test_same_avatar_short_names_with_different_first_character_stay_distinct(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
first = np.full((160, 460, 4), 245, dtype=np.uint8)
second = first.copy()
first[:, :, 3] = 255
second[:, :, 3] = 255
for image in (first, second):
image[40:88, 20:84, :3] = 90
# 相同的第二个字。
image[28:55, 174:210, :3] = 20
# 仅首字底部字形不同,覆盖名称前缀安全采样条。
first[48:55, 126:142, :3] = 20
first[51:58, 146:158, :3] = 20
second[51:58, 126:142, :3] = 20
second[48:55, 146:158, :3] = 20
first_fp = bot._session_fingerprint(first, 64, row_center=True)
second_fp = bot._session_fingerprint(second, 64, row_center=True)
self.assertNotEqual(first_fp, second_fp)
self.assertFalse(bot._remember_live_render_alias(first_fp, second_fp))
def test_same_name_is_stable_between_normal_and_selected_row_colors(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
normal = np.full((160, 460, 4), 245, dtype=np.uint8)
selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8)
normal[:, :, 3] = 255
for image in (normal, selected):
for y in range(40, 88):
for x in range(20, 84):
image[y, x, :3] = ((x * 3) % 255, (y * 5) % 255, (x + y) % 255)
normal[28:54, 124:154, :3] = 20
normal[30:56, 170:204, :3] = 20
selected[28:54, 124:154, :3] = 250
selected[30:56, 170:204, :3] = 250
normal_fp = bot._session_fingerprint(normal, 64, row_center=True)
selected_fp = bot._session_fingerprint(selected, 64, row_center=True)
self.assertEqual(normal_fp, selected_fp)
def test_persisted_name_hash_keeps_established_40_byte_key_layout(self):
"""Do not silently reinterpret existing conversations.json keys."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
image = np.full((160, 460, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
image[28:54, 124:154, :3] = 20
image[30:56, 170:204, :3] = 20
self.assertEqual(
bot._session_name_fingerprint(image, 64, row_center=True).hex(),
"7ff07ff0000000000000007c00007c00007c00007c00007c00007c00007c0000",
)
def test_unread_bold_name_and_selected_regular_name_keep_same_identity(self):
"""WeCom removes unread bold weight when the row becomes selected."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
unread = np.full((160, 460, 4), 245, dtype=np.uint8)
selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8)
unread[:, :, 3] = 255
for image in (unread, selected):
for y in range(40, 88):
for x in range(20, 84):
image[y, x, :3] = (
(x * 3) % 255,
(y * 5) % 255,
(x + y) % 255,
)
# Same glyph centre-lines. The unread rendering is five pixels wider;
# the selected rendering uses the normal three-pixel font weight.
unread[27:56, 178:187, :3] = 20
unread[34:44, 168:204, :3] = 20
unread[28:56, 224:233, :3] = 20
unread[45:55, 216:246, :3] = 20
selected[29:54, 181:184, :3] = 250
selected[37:40, 171:201, :3] = 250
selected[30:54, 227:230, :3] = 250
selected[48:51, 219:243, :3] = 250
unread_fp = bot._session_fingerprint(unread, 64, row_center=True)
selected_fp = bot._session_fingerprint(selected, 64, row_center=True)
distance = (
int.from_bytes(unread_fp[8:], "big")
^ int.from_bytes(selected_fp[8:], "big")
).bit_count()
# The established 40-byte archive key must not be redefined merely to
# absorb a font-weight change. The immediately observed unread and
# selected rows are linked by a process-local render proof instead.
self.assertTrue(
bot._remember_live_render_alias(unread_fp, selected_fp),
(
f"name distance={distance}, "
f"unread_render={bot._live_render_ids_for(unread_fp)}, "
f"selected_render={bot._live_render_ids_for(selected_fp)}"
),
)
self.assertTrue(bot._session_fp_matches(unread_fp, selected_fp))
def test_unread_badge_and_dynamic_time_do_not_change_session_identity(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
unread = np.full((160, 460, 4), 245, dtype=np.uint8)
selected = np.full((160, 460, 4), (210, 130, 45, 255), dtype=np.uint8)
unread[:, :, 3] = 255
for image in (unread, selected):
image[40:88, 20:84, :3] = 90
# 名称位于固定名称带;未读红点在头像右上,右侧时间每天都会变化。
unread[28:55, 170:220, :3] = 20
selected[28:55, 170:220, :3] = 250
unread[18:48, 70:146, :3] = (35, 45, 235)
unread[30:50, 350:420, :3] = 80
selected[30:50, 340:430, :3] = 250
self.assertEqual(
bot._session_fingerprint(unread, 64, row_center=True),
bot._session_fingerprint(selected, 64, row_center=True),
)
def test_typing_status_below_title_does_not_change_chat_identity(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.scale = 2.0
bot.L, bot.R = 0, 1800
bot._list_x = 136
bot._list_w = 460
idle = np.full((112, 1000, 4), 245, dtype=np.uint8)
typing = idle.copy()
idle[:, :, 3] = 255
typing[:, :, 3] = 255
idle[22:54, 50:210, :3] = 30
typing[22:54, 50:210, :3] = 30
typing[76:100, 50:190, :3] = 80
with mock.patch(
"wechat_bot.capture_window_region",
side_effect=[idle, typing],
):
self.assertEqual(
bot._chat_identity_signature(),
bot._chat_identity_signature(),
)
def test_same_avatar_long_common_name_prefix_keeps_distinct_suffixes(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
first = np.full((160, 460, 4), 245, dtype=np.uint8)
second = first.copy()
first[:, :, 3] = 255
second[:, :, 3] = 255
for image in (first, second):
image[40:88, 20:84, :3] = 90
# 相同的长公共前缀。
image[28:54, 122:282, :3] = 25
# 名称末尾不同,且都位于动态时间左侧。
first[26:38, 296:326, :3] = 25
first[44:56, 330:360, :3] = 25
second[44:56, 296:326, :3] = 25
second[26:38, 330:360, :3] = 25
first_fp = bot._session_fingerprint(first, 64, row_center=True)
second_fp = bot._session_fingerprint(second, 64, row_center=True)
self.assertNotEqual(first_fp, second_fp)
def test_same_avatar_names_differing_at_last_visible_character_stay_distinct(self):
"""The final name glyph before the time column is part of session identity."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._known_fps = set()
bot._known_session_fps = set()
first = np.full((160, 460, 4), 245, dtype=np.uint8)
second = first.copy()
first[:, :, 3] = 255
second[:, :, 3] = 255
for image in (first, second):
image[40:88, 20:84, :3] = 90
# Identical long visible prefix through logical x=160.
image[28:54, 122:320, :3] = 25
# The last visible glyph lives before the x=170 time-column boundary.
first[28:38, 322:338, :3] = 25
second[44:54, 322:338, :3] = 25
first_fp = bot._session_fingerprint(first, 64, row_center=True)
second_fp = bot._session_fingerprint(second, 64, row_center=True)
self.assertNotEqual(first_fp, second_fp)
self.assertFalse(bot._remember_live_render_alias(first_fp, second_fp))
def test_mixed_legacy_and_composite_keys_never_match_by_avatar_only(self):
bot = WeChatBot.__new__(WeChatBot)
self.assertFalse(
bot._session_fp_matches(b"a" * 8, b"a" * 8 + b"b" * 32)
)
def test_legacy_archive_is_moved_once_after_composite_identity_is_known(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.entry_snapshot.side_effect = [
None,
{
"last_lines": [
"高兴亮 7/28 08:59:00",
"已回复",
"客户甲 7/28 09:00:00",
"旧问题",
],
"history": [
{"role": "assistant", "content": "已回复"},
{
"role": "user",
"content": (
"高兴亮 7/28 08:59:00\n已回复\n"
"客户甲 7/28 09:00:00\n旧问题"
),
},
],
},
]
bot.store.migrate_key.return_value = True
bot._legacy_migration_checked = set()
fp = b"avatar01" + b"n" * 32
chat_text = (
"高兴亮 7/29 09:59:00\n已回复\n"
"客户甲 7/29 10:00:00\n新问题"
)
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
bot._ensure_session_archive_key(fp, chat_text)
bot._ensure_session_archive_key(fp, chat_text)
bot.store.migrate_key.assert_called_once_with(
b"avatar01".hex(),
fp.hex(),
)
def test_previous_16_byte_archive_is_preferred_over_avatar_only_archive(self):
bot = WeChatBot.__new__(WeChatBot)
avatar = b"avatar01"
old16 = avatar + b"oldname1"
current = avatar + b"N" * 32
legacy_entry = {
"last_lines": [
"客户甲 7/28 09:00:00",
"旧问题",
"高兴亮 7/28 09:01:00",
"已回复",
],
"history": [
{"role": "user", "content": "旧问题"},
{"role": "assistant", "content": "已回复"},
],
}
bot.store = mock.Mock()
bot.store.keys.return_value = [avatar.hex(), old16.hex()]
bot.store.entry_snapshot.side_effect = [
None,
legacy_entry,
legacy_entry,
]
bot.store.migrate_key.return_value = True
bot._legacy_fp_for_current = {current.hex(): old16}
bot._legacy_migration_checked = set()
bot._known_session_fps = set()
bot._pending_reply_sessions = {}
bot._chat_identity_signature = mock.Mock(return_value=b"")
chat_text = (
"高兴亮 7/29 09:59:00\n已回复\n"
"客户甲 7/29 10:00:00\n新问题"
)
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
bot._ensure_session_archive_key(current, chat_text)
bot.store.migrate_key.assert_called_once_with(old16.hex(), current.hex())
def test_previous_16_byte_pending_is_atomically_rekeyed_after_title_proof(self):
bot = WeChatBot.__new__(WeChatBot)
avatar = b"avatar01"
old16 = avatar + b"oldname1"
current = avatar + b"C" * 32
state = {
"batch_ready": True,
"confirmed_unread": True,
"chat_text": "客户甲 10:00:00\n待回复内容",
"identity_signature": b"stable-title",
}
bot.store = mock.Mock()
bot.store.keys.return_value = []
bot.store.entry_snapshot.return_value = None
bot._legacy_fp_for_current = {current.hex(): old16}
bot._legacy_migration_checked = set()
bot._known_session_fps = set()
bot._pending_reply_sessions = {old16.hex(): state}
bot._pending_reply_path = ""
bot._chat_identity_signature = mock.Mock(return_value=b"stable-title")
bot._ensure_session_archive_key(
current,
"客户甲 10:00:00\n待回复内容",
)
self.assertNotIn(old16.hex(), bot._pending_reply_sessions)
self.assertIs(bot._pending_reply_sessions[current.hex()], state)
self.assertTrue(state["confirmed_unread"])
def test_legacy_archive_is_not_migrated_when_customer_name_does_not_match(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.entry_snapshot.side_effect = [
None,
{"last_lines": ["客户甲 7/28 09:00:00", "旧问题"]},
]
bot._legacy_migration_checked = set()
fp = b"avatar01" + b"m" * 32
bot._ensure_session_archive_key(
fp,
"客户乙 7/29 10:00:00\n新问题",
)
bot.store.migrate_key.assert_not_called()
def test_legacy_archive_with_another_customer_in_history_is_never_migrated(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.entry_snapshot.side_effect = [
None,
{
"last_lines": ["客户乙 7/28 09:00:00", "乙的问题"],
"history": [
{
"role": "user",
"content": "客户甲 7/27 09:00:00\n甲的问题",
}
],
},
]
bot._legacy_migration_checked = set()
fp = b"avatar01" + b"q" * 32
bot._ensure_session_archive_key(fp, "客户乙 7/29 10:00:00\n新问题")
bot.store.migrate_key.assert_not_called()
def test_date_bearing_agent_header_is_not_treated_as_customer_message(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.history.return_value = [
{"role": "assistant", "content": "更早的一条回复"}
]
text = (
"客户甲 7/29 10:52:00\n上一条问题\n"
"高兴亮 7/29 10:53:23\n这是我方刚发出的回复"
)
with mock.patch("ai_config.AI_AGENT_NAME", "高兴亮"):
self.assertFalse(bot._has_pending_customer_message(text, b"session"))
def test_actual_staff_name_is_learned_when_configured_nickname_differs(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.history.return_value = [
{"role": "assistant", "content": "历史我方回复"}
]
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
text = (
"高兴亮 7/29 10:50:00\n历史我方回复\n"
"高兴亮 7/29 10:53:23\n这是人工刚发出的新回复"
)
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertFalse(bot._has_pending_customer_message(text, b"session"))
def test_customer_echoing_an_old_assistant_phrase_is_still_pending(self):
bot = WeChatBot.__new__(WeChatBot)
bot.store = mock.Mock()
bot.store.history.return_value = [
{"role": "assistant", "content": "好的"}
]
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
text = "客户甲 10:00:00\n好的"
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertTrue(bot._has_pending_customer_message(text, b"session"))
self.assertNotIn(
"客户甲",
getattr(bot, "_known_agent_speakers", set()),
)
def test_right_side_unselectable_media_does_not_relabel_last_copied_customer(self):
bot = WeChatBot.__new__(WeChatBot)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
text = "客户甲 10:00:00\n请看上一条,后面还有一张图片"
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertFalse(bot._has_pending_customer_message(text, b"session"))
self.assertNotIn(
"客户甲",
getattr(bot, "_known_agent_speakers", set()),
)
def test_message_page_refreshes_geometry_after_wide_sidebar_opens(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350
bot._list_x = 136
bot._selected_tracking_initialized = True
bot._active_session_fp = b"old"
bot._active_chat_signature = b"old"
bot._active_identity_signature = b"old"
image = np.full((1300, 2200, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
image[130:210, :320, :3] = (250, 232, 215)
image[1000:1002, :, :3] = 230
image[1002:, :, :3] = 255
self.assertTrue(bot._refresh_message_geometry(image))
self.assertEqual(bot._list_x, 320)
self.assertEqual(bot.list_region["left"], 420)
self.assertEqual(bot.list_click_x, 650)
# 视觉/指纹裁剪从会话列表右边缘附近开始,必须覆盖客户左侧气泡;
# 不能再额外右移 300×DPI。
self.assertLessEqual(bot._chat_rel_x, bot._list_x + bot._list_w + 20 * bot.scale)
self.assertGreater(bot._chat_rel_w, 1000)
self.assertTrue(bot._composer_geometry_valid)
self.assertLessEqual(
bot._chat_region["top"] + bot._chat_region["height"],
bot.T + 1000 - int(2 * bot.scale),
)
self.assertGreater(bot.input_y, bot.T + 1000)
self.assertFalse(bot._selected_tracking_initialized)
@staticmethod
def _geometry_surface():
image = np.full((1300, 2200, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
image[130:210, :320, :3] = (250, 232, 215)
image[1000:1002, :, :3] = 230
image[1002:, :, :3] = 255
return image
def test_auto_hiding_chat_scrollbar_never_counts_as_a_new_message(self):
"""消息区右缘的滚动条随鼠标进出淡入淡出,绝不能改写聊天画面指纹。
机器人自己把鼠标从消息区移到输入框就会让这条滚动条消失。若它落在指纹
裁剪区内,按 Enter 前的校验会把这次重绘当成“又收到新消息”,清掉刚写好
的草稿,自动回复永远发不出去。
"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350
bot._list_x = 136
bot.detect_selected_row = mock.Mock(return_value=-1)
bot._active_session_fp = None
base = self._geometry_surface()
self.assertTrue(bot._refresh_message_geometry(base))
chat_x1 = bot._chat_rel_x
chat_x2 = chat_x1 + bot._chat_rel_w
with_bubble = base.copy()
with_bubble[400:460, chat_x1 + 40:chat_x1 + 300, :3] = 90
bot._capture_full_window = mock.Mock(return_value=with_bubble)
baseline = bot._chat_surface_signature()
self.assertTrue(baseline)
# 滚动条紧贴消息区右缘,出现或淡出都不得改变指纹。
with_scrollbar = with_bubble.copy()
with_scrollbar[500:900, chat_x2 - 6:chat_x2, :3] = 150
bot._capture_full_window.return_value = with_scrollbar
self.assertEqual(bot._chat_surface_signature(), baseline)
# 同样的判据仍须能发现消息区内部真正的新增内容。
with_new_message = with_bubble.copy()
with_new_message[700:760, chat_x1 + 40:chat_x1 + 300, :3] = 90
bot._capture_full_window.return_value = with_new_message
self.assertNotEqual(bot._chat_surface_signature(), baseline)
def test_chat_capture_still_covers_the_full_outgoing_bubble(self):
"""自己的气泡右缘紧贴滚动条,截图裁剪区不能为了避开滚动条把它切掉。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 100, 50, 2300, 1350
bot._list_x = 136
self.assertTrue(bot._refresh_message_geometry(self._geometry_surface()))
window_width = bot.R - bot.L
# 只允许留出视觉边距本身,不得额外内缩。
self.assertGreaterEqual(
bot._chat_rel_x + bot._chat_rel_w,
window_width - max(4, int(8 * bot.scale)),
)
def test_text_selection_region_is_clamped_inside_narrow_window(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 100, 50, 800, 500
bot._list_x = 136
bot._selected_tracking_initialized = True
bot._active_session_fp = b"old"
bot._active_chat_signature = b"old"
bot._active_identity_signature = b"old"
full = np.zeros((450, 700, 4), dtype=np.uint8)
with (
mock.patch("wechat_bot.infer_navigation_width", return_value=(320, 0.9)),
mock.patch("wechat_bot.infer_composer_top", return_value=280),
):
self.assertTrue(bot._refresh_message_geometry(full))
region = bot._chat_region
self.assertGreaterEqual(region["left"], bot.L)
self.assertGreaterEqual(region["top"], bot.T)
self.assertLessEqual(region["left"] + region["width"], bot.R)
self.assertLessEqual(region["top"] + region["height"], bot.B)
self.assertGreaterEqual(bot.input_x, bot.L)
self.assertLess(bot.input_x, bot.R)
self.assertGreaterEqual(bot.input_y, bot.T)
self.assertLess(bot.input_y, bot.B)
self.assertFalse(bot._session_geometry_valid)
self.assertFalse(bot._input_geometry_valid)
def test_tiny_chat_region_never_starts_mouse_drag(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot._chat_region = {"left": 799, "top": 200, "width": 1, "height": 120}
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._drag_select = mock.Mock()
self.assertEqual(bot.extract_chat_text(screens=1), "")
bot.wait_for_mouse_idle.assert_not_called()
bot._drag_select.assert_not_called()
def test_failed_composer_redetection_invalidates_every_chat_reader(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.L, bot.T, bot.R, bot.B = 0, 0, 1200, 800
bot._list_x = 68
bot._list_w = 230
bot._composer_rel_top = 620
bot._composer_geometry_valid = True
bot._chat_geometry_valid = True
bot._input_geometry_valid = True
bot._chat_region = {"left": 310, "top": 100, "width": 850, "height": 500}
bot._drag_select = mock.Mock()
frame = np.full((800, 1200, 4), 245, dtype=np.uint8)
with (
mock.patch("wechat_bot.infer_navigation_width", return_value=(68, 0.9)),
mock.patch("wechat_bot.infer_composer_top", return_value=None),
mock.patch("wechat_bot.pyautogui.click") as click,
):
self.assertFalse(bot._refresh_message_geometry(frame))
self.assertFalse(bot._composer_geometry_valid)
self.assertFalse(bot._chat_geometry_valid)
self.assertFalse(bot._input_geometry_valid)
self.assertEqual(bot.extract_chat_text(screens=1), "")
self.assertIsNone(bot._last_visible_bubble_is_outgoing())
self.assertEqual(bot._chat_surface_signature(), b"")
with self.assertRaises(RuntimeError):
bot.capture_chat_area()
click.assert_not_called()
bot._drag_select.assert_not_called()
def test_invalid_session_geometry_never_scrolls_mouse(self):
bot = WeChatBot.__new__(WeChatBot)
bot._session_geometry_valid = False
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.pyautogui.moveTo") as move,
mock.patch("wechat_bot.pyautogui.scroll") as scroll,
):
bot._scroll_session_list_top()
self.assertIsNone(
bot._scroll_session_list_page(np.zeros((20, 20, 4), dtype=np.uint8))
)
bot.wait_for_mouse_idle.assert_not_called()
move.assert_not_called()
scroll.assert_not_called()
def test_selected_mail_is_not_mistaken_for_messages(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
self.assertFalse(bot._message_nav_selected(self._mail_nav_surface()))
def test_ui_guard_rejects_model_action_that_does_not_match_state(self):
decision = _parse_ui_guard_decision(
'说明文字 {"state":"chat_ready","action":"open_messages",'
'"confidence":0.99,"reason":"误判动作"}'
)
self.assertEqual(decision["state"], "chat_ready")
self.assertEqual(decision["action"], "none")
def test_ui_guard_skips_unrelated_json_before_protocol_object(self):
decision = _parse_ui_guard_decision(
'{"trace_id":"abc","tokens":42}\n'
'{"state":"non_message_page","action":"open_messages",'
'"confidence":0.96,"reason":"当前是邮件页"}'
)
self.assertEqual(decision["state"], "non_message_page")
self.assertEqual(decision["action"], "open_messages")
self.assertEqual(decision["confidence"], 0.96)
def test_ui_guard_conflicting_protocol_objects_fail_closed(self):
decision = _parse_ui_guard_decision(
'{"state":"blocking_modal","action":"escape","confidence":0.95}\n'
'{"state":"non_message_page","action":"open_messages","confidence":0.99}'
)
self.assertEqual(decision["state"], "unknown")
self.assertEqual(decision["action"], "none")
self.assertEqual(decision["confidence"], 0.0)
def test_ui_guard_missing_protocol_field_fails_closed(self):
decision = _parse_ui_guard_decision(
'{"state":"non_message_page","confidence":0.99}\n'
'{"action":"open_messages","confidence":0.99}'
)
self.assertEqual(decision["state"], "unknown")
self.assertEqual(decision["action"], "none")
def test_layout_guard_accepts_one_normalized_semantic_layout(self):
decision = _parse_wecom_layout_decision(
'{"state":"chat_ready","navigation_right_ratio":0.10,'
'"session_list_right_ratio":0.30,"composer_top_ratio":0.78,'
'"chat_right_ratio":1.0,"confidence":0.93,"reason":"消息页"}'
)
self.assertEqual(decision["state"], "chat_ready")
self.assertEqual(decision["confidence"], 0.93)
def test_layout_guard_rejects_impossible_or_ambiguous_geometry(self):
impossible = _parse_wecom_layout_decision(
'{"state":"chat_ready","navigation_right_ratio":0.40,'
'"session_list_right_ratio":0.20,"composer_top_ratio":0.10,'
'"chat_right_ratio":1.0,"confidence":0.99}'
)
self.assertEqual(impossible["confidence"], 0.0)
ambiguous = _parse_wecom_layout_decision(
'{"state":"chat_ready","navigation_right_ratio":0.10,'
'"session_list_right_ratio":0.30,"confidence":0.9}\n'
'{"state":"chat_ready","navigation_right_ratio":0.12,'
'"session_list_right_ratio":0.32,"confidence":0.9}'
)
self.assertEqual(ambiguous["confidence"], 0.0)
def test_dify_ui_guard_uploads_image_before_chat_message(self):
upload = mock.Mock(ok=True, status_code=201, text="")
upload.json.return_value = {"id": "file-123"}
chat = mock.Mock(ok=True, status_code=200, text="")
chat.json.return_value = {
"answer": (
'{"state":"blocking_modal","action":"close_modal",'
'"confidence":0.96,"reason":"文档弹窗遮挡"}'
)
}
with (
mock.patch("ai_chat.ai_config.AI_PROVIDER_TYPE", "dify"),
mock.patch("ai_chat.ai_config.AI_API_BASE", "https://dify.example/v1"),
mock.patch("ai_chat.ai_config.AI_API_KEY", "app-secret"),
mock.patch("ai_chat.requests.post", side_effect=[upload, chat]) as post,
):
decision = classify_wecom_ui(b"png-data", trigger="测试")
self.assertEqual(decision["action"], "close_modal")
self.assertEqual(post.call_args_list[0].args[0], "https://dify.example/v1/files/upload")
self.assertIn("file", post.call_args_list[0].kwargs["files"])
second_payload = post.call_args_list[1].kwargs["json"]
self.assertEqual(second_payload["files"][0]["upload_file_id"], "file-123")
self.assertEqual(second_payload["files"][0]["transfer_method"], "local_file")
def test_ai_page_guard_can_only_open_fixed_message_entry(self):
bot = WeChatBot.__new__(WeChatBot)
bot._last_ui_guard_ts = 0.0
bot._last_ui_guard_signature = ""
bot._open_messages_page = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._escape_proven_blocker = mock.Mock(return_value=False)
surface = np.full((300, 500, 4), 220, dtype=np.uint8)
surface[:, :, 3] = 255
with (
mock.patch("wechat_bot.time.monotonic", return_value=100.0),
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_UI_GUARD_ENABLED", True),
mock.patch(
"ai_chat.classify_wecom_ui",
return_value={
"state": "non_message_page",
"action": "open_messages",
"confidence": 0.95,
"reason": "当前是微盘",
},
),
):
self.assertTrue(bot._run_ai_page_guard("测试", full=surface))
bot._open_messages_page.assert_called_once()
bot._dismiss_internal_blocker.assert_not_called()
bot._escape_proven_blocker.assert_not_called()
def test_ai_page_guard_never_closes_security_verification(self):
bot = WeChatBot.__new__(WeChatBot)
bot._last_ui_guard_ts = 0.0
bot._last_ui_guard_signature = ""
bot.security_verification_required = False
bot._window_ready = True
bot._open_messages_page = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=True)
bot._escape_proven_blocker = mock.Mock(return_value=True)
surface = np.full((300, 500, 4), 220, dtype=np.uint8)
surface[:, :, 3] = 255
with (
mock.patch("wechat_bot.time.monotonic", return_value=100.0),
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_UI_GUARD_ENABLED", True),
mock.patch(
"ai_chat.classify_wecom_ui",
return_value={
"state": "security_verification",
"action": "none",
"confidence": 0.93,
"reason": "需要扫码",
},
),
):
self.assertFalse(bot._run_ai_page_guard("测试", full=surface))
self.assertTrue(bot.security_verification_required)
self.assertFalse(bot._window_ready)
bot._open_messages_page.assert_not_called()
bot._dismiss_internal_blocker.assert_not_called()
bot._escape_proven_blocker.assert_not_called()
def test_minimum_send_interval_is_enforced(self):
bot = WeChatBot.__new__(WeChatBot)
bot._send_timestamps = deque([95.0])
bot._last_send_ts = 95.0
with mock.patch("wechat_bot.time.monotonic", return_value=100.0):
self.assertAlmostEqual(bot._send_gate_remaining(), 3.0)
def test_send_stops_immediately_after_owned_blocker_handling(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=True)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(bot.send_reply("这次不能发送"))
bot._ensure_visible.assert_not_called()
bot._dismiss_internal_blocker.assert_not_called()
click.assert_not_called()
def test_send_stops_immediately_after_internal_modal_handling(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = 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._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock()
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(bot.send_reply("这次不能发送"))
bot._capture_full_window.assert_not_called()
click.assert_not_called()
def test_send_is_cancelled_before_click_when_chat_identity_changed(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=b"target")
bot._chat_identity_signature = mock.Mock(return_value=b"changed")
bot._active_identity_signature = b"original"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(
bot.send_reply(
"不会发送",
session_id=b"target".hex(),
expected_fp=b"target",
)
)
click.assert_not_called()
def test_send_is_cancelled_when_pending_title_does_not_match(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"target02"
bot._pending_reply_sessions = {
fp.hex(): {"identity_signature": b"original-title"}
}
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"other-title")
bot._active_identity_signature = b"other-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(
bot.send_reply(
"不会串发",
session_id=fp.hex(),
expected_fp=fp,
)
)
click.assert_not_called()
def test_send_is_cancelled_when_new_message_arrives_after_generation(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"target04"
bot._pending_reply_sessions = {
fp.hex(): {
"identity_signature": b"same-title",
"generation_surface_signature": b"before-model",
}
}
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"new-message")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(
bot.send_reply(
"已经过时的回复",
session_id=fp.hex(),
expected_fp=fp,
)
)
click.assert_not_called()
bot._run_ai_page_guard.assert_not_called()
def test_send_is_cancelled_when_message_arrives_after_paste_before_enter(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"z" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
}
}
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(
side_effect=[b"stable-chat", b"new-message"]
)
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(
side_effect=["", "已经过时的草稿"]
)
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(
bot.send_reply(
"已经过时的草稿",
session_id=fp.hex(),
expected_fp=fp,
)
)
self.assertIn(mock.call("ctrl", "v"), hotkey.call_args_list)
self.assertIn(mock.call("ctrl", "a"), hotkey.call_args_list)
self.assertIn(mock.call("backspace"), press.call_args_list)
self.assertFalse(bot._pending_reply_state(fp)["batch_ready"])
def test_send_is_cancelled_when_input_geometry_is_invalid(self):
bot = WeChatBot.__new__(WeChatBot)
bot._input_geometry_valid = False
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = 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._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(bot.send_reply("不会发送"))
click.assert_not_called()
def test_send_is_cancelled_when_selected_session_fingerprint_is_missing(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"target03"
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(
bot.send_reply(
"不会发送",
session_id=fp.hex(),
expected_fp=fp,
)
)
click.assert_not_called()
def test_successful_send_keeps_current_chat_open(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=b"target")
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._active_identity_signature = b"identity"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "正常发送"])
bot._record_send = mock.Mock()
bot._commit_staged_exchange = mock.Mock()
bot._remember_active_surface = mock.Mock()
bot._deselect_session = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.send_reply(
"正常发送",
session_id=b"target".hex(),
expected_fp=b"target",
)
)
bot._deselect_session.assert_not_called()
def test_final_enter_gate_uses_detected_chat_pane_right(self):
"""Regression: an expanded customer panel used to fake a resize forever."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
"last_lines": ["客户甲 10:00:00", "在不在"],
}
}
bot._pending_reply_path = ""
bot._set_task_stage = mock.Mock()
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot._composer_geometry_valid = True
bot.scale = 2.0
bot._list_x = 320
bot._list_w = 460
bot._chat_pane_right_rel = 1550
bot._composer_rel_top = 916
bot._refresh_message_geometry = mock.Mock(return_value=False)
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "会回复的"])
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._record_send = mock.Mock()
bot._same_chat_is_open = mock.Mock(return_value=True)
bot._send_receipt_matches = mock.Mock(return_value=True)
bot._last_send_receipt_followup = False
bot._visible_reply_has_customer_followup = mock.Mock(return_value=False)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
with (
mock.patch(
"wechat_bot.composer_divider_matches",
return_value=(True, 916),
) as divider,
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.send_reply("会回复的", session_id=fp.hex(), expected_fp=fp)
)
self.assertEqual(divider.call_args.kwargs["chat_right"], 1550)
def test_layout_change_after_paste_keeps_draft_and_staged_reply(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"l" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
"staged_reply_text": "保留这条回复",
"last_lines": ["客户甲 10:00:00", "在不在"],
}
}
bot._pending_reply_path = ""
bot._set_task_stage = mock.Mock()
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot._composer_geometry_valid = True
bot.scale = 2.0
bot._list_x = 320
bot._list_w = 460
bot._chat_pane_right_rel = 1550
bot._composer_rel_top = 916
bot._refresh_message_geometry = mock.Mock(return_value=False)
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "保留这条回复"])
bot._persist_pending_replies = mock.Mock(return_value=True)
with (
mock.patch(
"wechat_bot.composer_divider_matches",
return_value=(False, 1008),
),
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(
bot.send_reply(
"保留这条回复", session_id=fp.hex(), expected_fp=fp
)
)
state = bot._pending_reply_state(fp)
self.assertTrue(state["layout_retry_pending"])
self.assertTrue(state["batch_ready"])
self.assertEqual(state["staged_reply_text"], "保留这条回复")
self.assertEqual(state["last_pasted_draft"], "保留这条回复")
self.assertNotIn(mock.call("ctrl", "a"), hotkey.call_args_list)
self.assertNotIn(mock.call("backspace"), press.call_args_list)
def test_layout_rebase_distinguishes_resize_from_appended_message(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"b" * 40
baseline = ["客户甲 10:00:00", "在不在"]
state = {
"identity_signature": b"same-title",
"generation_surface_signature": b"old-size",
"last_lines": list(baseline),
"staged_reply_text": "我在呢",
"layout_retry_pending": True,
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_target_matches = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(
return_value="客户甲 10:00:00\n在不在"
)
bot._chat_surface_signature = mock.Mock(return_value=b"new-size")
bot._persist_pending_replies = mock.Mock(return_value=True)
self.assertEqual(
bot._rebase_pending_after_layout_change(fp, state),
"unchanged",
)
self.assertEqual(state["generation_surface_signature"], b"new-size")
self.assertEqual(state["staged_reply_text"], "我在呢")
self.assertNotIn("layout_retry_pending", state)
changed_state = {
"identity_signature": b"same-title",
"last_lines": list(baseline),
}
bot.extract_chat_text.return_value = (
"客户甲 10:00:00\n在不在\n客户甲 10:01:00\n还有一件事"
)
self.assertEqual(
bot._rebase_pending_after_layout_change(fp, changed_state),
"changed",
)
def test_unchanged_surface_reuses_staged_reply_without_model_call(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"u" * 40
state = {
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
"staged_reply_text": "已经生成过的回复",
"layout_retry_pending": True,
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_target_matches = mock.Mock(return_value=True)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._set_task_stage = mock.Mock()
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch(
"ai_chat.get_ai_reply",
side_effect=AssertionError("unchanged retry must not call the model"),
),
):
self.assertEqual(
bot._generate_ai_reply(fp),
"已经生成过的回复",
)
self.assertNotIn("layout_retry_pending", state)
bot._set_task_stage.assert_called_with(
fp,
"ready_to_send",
detail="聊天内容未变化,复用已生成回复",
)
def test_repeated_unknown_layout_escalates_to_maximize_then_multimodal_guard(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"e" * 40
state = {
"identity_signature": b"same-title",
"generation_surface_signature": b"old-layout",
"staged_reply_text": "保留回复",
"layout_retry_pending": True,
"layout_retry_count": 2,
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_target_matches = mock.Mock(return_value=True)
bot._chat_surface_signature = mock.Mock(return_value=b"new-layout")
bot._rebase_pending_after_layout_change = mock.Mock(return_value="unknown")
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._set_task_stage = mock.Mock()
bot._maximize_window_for_composer = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(
return_value=np.zeros((300, 500, 4), dtype=np.uint8)
)
bot._run_ai_page_guard = mock.Mock(return_value=False)
with mock.patch("ai_config.AI_ENABLED", True):
self.assertIsNone(bot._generate_ai_reply(fp))
bot._maximize_window_for_composer.assert_called_once_with(force=True)
bot._run_ai_page_guard.assert_called_once_with(
"连续布局重定位失败",
full=bot._capture_full_window.return_value,
)
def test_tiny_window_is_maximized_before_any_input_click(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 0, 0, 1200, 800
bot.hwnd = 123
bot.auto_activate_window = True
bot._window_ready = True
bot.connect = mock.Mock(return_value=True)
with (
mock.patch(
"wechat_bot.win32gui.GetWindowPlacement",
return_value=(0, wechat_bot.win32con.SW_SHOWNORMAL, (0, 0), (0, 0), (0, 0, 1200, 800)),
),
mock.patch("wechat_bot.win32gui.ShowWindow") as show,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._maximize_window_for_composer())
show.assert_called_once_with(123, wechat_bot.win32con.SW_MAXIMIZE)
bot.connect.assert_called_once_with(activate=False)
def test_reply_without_generation_surface_still_records_pre_enter_baseline(self):
"""AI-disabled/fixed-reply mode must not become permanently uncertain."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"f" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"last_lines": ["客户甲 10:00:00", "固定回复前的问题"],
}
}
bot._pending_reply_path = ""
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"pre-enter")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "固定回复"])
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._record_send = mock.Mock()
bot._same_chat_is_open = mock.Mock(return_value=True)
bot._send_receipt_matches = mock.Mock(return_value=True)
bot._last_send_receipt_followup = False
bot._visible_reply_has_customer_followup = mock.Mock(return_value=False)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.send_reply("固定回复", session_id=fp.hex(), expected_fp=fp)
)
state = bot._pending_reply_state(fp)
self.assertEqual(state["send_surface_signature"], b"pre-enter")
bot._send_receipt_matches.assert_called_once()
def test_human_draft_is_preserved_and_enter_is_never_pressed(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = 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._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(return_value="人工正在编辑的内容")
with (
mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"),
mock.patch("wechat_bot.pyperclip.copy") as copy,
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.pyautogui.press") as press,
):
self.assertFalse(bot.send_reply("自动回复"))
self.assertNotIn(mock.call("ctrl", "v"), hotkey.call_args_list)
self.assertNotIn(mock.call("enter"), press.call_args_list)
copy.assert_called_once_with("原剪贴板")
def test_incomplete_paste_is_never_sent(self):
bot = WeChatBot.__new__(WeChatBot)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = 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._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "应该完整写入"])
with (
mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(bot.send_reply("应该完整写入的自动回复"))
self.assertIn(mock.call("ctrl", "v"), hotkey.call_args_list)
self.assertNotIn(mock.call("enter"), press.call_args_list)
self.assertIn(mock.call("backspace"), press.call_args_list)
def test_delayed_copy_marker_never_claims_human_draft_is_empty(self):
bot = WeChatBot.__new__(WeChatBot)
bot.input_x = 800
bot.input_y = 700
bot._input_editor_looks_blank = mock.Mock(return_value=False)
marker = "__wecom_empty_123__"
with (
mock.patch("wechat_bot.time.time_ns", return_value=123),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyperclip.paste", return_value=marker),
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.time.sleep"),
):
self.assertIsNone(bot._read_input_draft())
press.assert_called_with("end")
def _draft_read(self, clipboard, blank):
"""跑一次 _read_input_draft,剪贴板与输入框像素判定都由调用方给定。"""
bot = WeChatBot.__new__(WeChatBot)
bot.input_x = 800
bot.input_y = 700
bot._input_editor_looks_blank = mock.Mock(return_value=blank)
with (
mock.patch("wechat_bot.time.time_ns", return_value=123),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyperclip.paste", return_value=clipboard),
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.time.sleep"),
):
return bot._read_input_draft()
def test_a_lingering_chat_selection_is_not_a_human_draft(self):
"""机器人刚框选复制过聊天记录,那块高亮不会因为点一下空白就消失。
现场(2026-07-31 12:26~12:30):Ctrl+C 拿回来的是聊天区里还亮着的
“在不在”,被当成人工草稿,于是每轮调一次模型、又每轮让路,连着十几轮
一条都没发出去。输入框像素上是空的,就不能算草稿。
"""
self.assertEqual(self._draft_read("在不在", blank=True), "")
def test_a_real_draft_in_a_non_blank_editor_still_wins(self):
self.assertEqual(
self._draft_read("我自己在打字", blank=False),
"我自己在打字",
)
def test_an_uncertain_editor_never_gets_overwritten(self):
"""像素判定拿不准时保持原样保守:宁可不发,也不能覆盖人工输入。"""
self.assertEqual(
self._draft_read("说不清是什么", blank=None),
"说不清是什么",
)
def test_empty_editor_requires_visual_blank_evidence(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot._list_x = 0
bot._list_w = 100
bot._composer_geometry_valid = True
bot._composer_rel_top = 180
blank = np.full((300, 500, 4), 255, dtype=np.uint8)
bot._capture_full_window = mock.Mock(return_value=blank)
self.assertTrue(bot._input_editor_looks_blank())
with_text = blank.copy()
with_text[240:260, 130:200, :3] = (100, 130, 240)
bot._capture_full_window.return_value = with_text
self.assertFalse(bot._input_editor_looks_blank())
def test_expanded_right_toolbar_is_excluded_from_empty_editor_vision(self):
"""200% DPI field evidence must stop at the detected chat divider."""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot._list_x = 0
bot._list_w = 100
bot._composer_geometry_valid = True
bot._composer_rel_top = 180
bot._chat_pane_right_rel = 700
frame = np.full((400, 1100, 4), 255, dtype=np.uint8)
frame[:, :, 3] = 255
# Simulate a dense customer-info toolbar to the right of x=700.
frame[180:390, 720:1080, :3] = 40
bot._capture_full_window = mock.Mock(return_value=frame)
self.assertTrue(bot._input_editor_looks_blank())
with_draft = frame.copy()
with_draft[290:320, 150:250, :3] = (70, 100, 220)
bot._capture_full_window.return_value = with_draft
self.assertFalse(bot._input_editor_looks_blank())
def test_input_focus_retries_a_second_visual_editor_point(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 0
bot.scale = 2.0
bot.L, bot.T, bot.R, bot.B = 60, 70, 2324, 1774
bot._list_x = 320
bot._list_w = 460
bot._chat_pane_right_rel = 2200
bot._composer_rel_top = 1270
bot._editor_rel_top = 1350
bot.input_x = 1760
bot.input_y = 1654
bot._input_editor_looks_blank = mock.Mock(side_effect=[None, True])
marker = "__wecom_empty_123__"
with (
mock.patch("wechat_bot.time.time_ns", return_value=123),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyperclip.paste", return_value=marker),
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertEqual(bot._read_input_draft(), "")
self.assertEqual(click.call_count, 2)
self.assertNotEqual(click.call_args_list[0], click.call_args_list[1])
@staticmethod
def _composer_panel_surface(with_draft=False):
"""带圆角边框的输入面板,面板下方还有窗口底色。
采样框必然盖到面板边框和面板外底色;若不先收缩到编辑区内部,空输入
框的几乎每一列都会被算成“有内容”。
"""
image = np.full((300, 500, 4), 235, dtype=np.uint8)
image[:, :, 3] = 255
image[200:273, 118:339, :3] = 255
image[269:273, 118:339, :3] = 215
image[200:273, 118:122, :3] = 215
image[200:273, 334:339, :3] = 215
# 空输入框里只有一条细光标。
image[226:244, 126, :3] = 60
if with_draft:
image[230:250, 150:260, :3] = (90, 120, 235)
return image
def test_composer_border_and_window_backdrop_never_fake_a_draft(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot._list_x = 0
bot._list_w = 100
bot._composer_geometry_valid = True
bot._composer_rel_top = 180
bot._capture_full_window = mock.Mock(
return_value=self._composer_panel_surface()
)
self.assertTrue(bot._input_editor_looks_blank())
bot._capture_full_window.return_value = self._composer_panel_surface(True)
self.assertFalse(bot._input_editor_looks_blank())
def test_ctrl_enter_accepts_verified_square_avatar_raw_fingerprint_once(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"q" * 40
state = {}
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._active_identity_signature = b"same-title"
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(
side_effect=["兼容发送", "兼容发送"]
)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_timestamps = deque([1.0])
with (
mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot._try_ctrl_enter_for_retained_draft(
"兼容发送",
state,
fp,
b"same-title",
)
)
self.assertFalse(
bot._try_ctrl_enter_for_retained_draft(
"兼容发送",
state,
fp,
b"same-title",
)
)
self.assertTrue(state["ctrl_enter_attempted"])
self.assertGreater(bot._send_timestamps[-1], 1.0)
self.assertGreater(state["send_dispatched_at"], 0.0)
self.assertEqual(
hotkey.call_args_list.count(mock.call("ctrl", "enter")),
1,
)
bot._selected_session_fingerprint.assert_not_called()
def test_ctrl_enter_persist_failure_does_not_lock_future_recovery(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"p" * 40
state = {"send_state": "sent_uncommitted"}
bot._same_chat_is_open = mock.Mock(return_value=True)
bot._read_input_draft = mock.Mock(
side_effect=["兼容发送", "兼容发送"]
)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._persist_pending_replies = mock.Mock(return_value=False)
with (
mock.patch("wechat_bot.pyperclip.paste", return_value="原剪贴板"),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey,
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(
bot._try_ctrl_enter_for_retained_draft(
"兼容发送",
state,
fp,
)
)
self.assertEqual(state["send_state"], "sent_uncommitted")
self.assertNotIn("ctrl_enter_attempted", state)
self.assertNotIn(mock.call("ctrl", "enter"), hotkey.call_args_list)
def test_ctrl_enter_stops_immediately_when_human_has_taken_over(self):
bot = WeChatBot.__new__(WeChatBot)
state = {"send_state": "sent_uncommitted"}
bot._mouse_is_idle_now = mock.Mock(return_value=False)
bot._same_chat_is_open = mock.Mock(return_value=True)
bot._begin_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock()
bot._persist_pending_replies = mock.Mock()
bot._refresh_send_reservation = mock.Mock()
with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey:
self.assertFalse(
bot._try_ctrl_enter_for_retained_draft(
"兼容发送",
state,
b"target",
)
)
bot._same_chat_is_open.assert_not_called()
bot._begin_bot_mouse.assert_not_called()
bot._read_input_draft.assert_not_called()
bot._persist_pending_replies.assert_not_called()
bot._refresh_send_reservation.assert_not_called()
hotkey.assert_not_called()
self.assertNotIn("ctrl_enter_attempted", state)
self.assertNotIn("send_dispatched_at", state)
def test_send_receipt_allows_bounded_delayed_render(self):
bot = WeChatBot.__new__(WeChatBot)
bot._send_receipt_matches = mock.Mock(side_effect=[False, True])
with mock.patch("wechat_bot.time.sleep") as sleep:
self.assertTrue(
bot._wait_for_visible_reply(
"已发送",
"客户甲",
b"before",
)
)
self.assertEqual(bot._send_receipt_matches.call_count, 2)
self.assertEqual(sleep.call_count, 1)
def test_old_same_text_bubble_cannot_confirm_unchanged_transaction(self):
bot = WeChatBot.__new__(WeChatBot)
bot._visible_last_message_matches = mock.Mock(return_value=True)
bot._chat_surface_signature = mock.Mock(return_value=b"before-send")
self.assertFalse(
bot._send_receipt_matches(
"好的",
"客户甲",
b"before-send",
)
)
def test_old_same_text_plus_new_customer_message_cannot_confirm_send(self):
bot = WeChatBot.__new__(WeChatBot)
visible = (
"贴心管家 10:00:00\n好的\n"
"客户甲 10:00:05\n我再问一句"
)
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"customer-followup")
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertFalse(
bot._send_receipt_matches(
reply_text="好的",
customer_speaker="客户甲",
before_surface=b"before-send",
before_blocks=[["贴心管家 10:00:00", "好的"]],
reply_was_visible=True,
)
)
def test_new_same_text_outgoing_block_confirms_send(self):
bot = WeChatBot.__new__(WeChatBot)
visible = (
"贴心管家 10:00:00\n好的\n"
"客户甲 10:00:05\n我再问一句\n"
"贴心管家 10:00:10\n好的"
)
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
bot._chat_surface_signature = mock.Mock(return_value=b"after-send")
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertTrue(
bot._send_receipt_matches(
reply_text="好的",
customer_speaker="客户甲",
before_surface=b"before-send",
before_blocks=[
["贴心管家 10:00:00", "好的"],
["客户甲 10:00:05", "我再问一句"],
],
reply_was_visible=True,
)
)
def test_actual_staff_name_can_be_proved_by_outgoing_bubble_side(self):
bot = WeChatBot.__new__(WeChatBot)
visible = (
"客户甲 10:00:05\n我再问一句\n"
"高兴亮 10:00:10\n好的"
)
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
bot._chat_surface_signature = mock.Mock(return_value=b"after-send")
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertTrue(
bot._send_receipt_matches(
reply_text="好的",
customer_speaker="客户甲",
before_surface=b"before-send",
before_blocks=[["客户甲 10:00:05", "我再问一句"]],
)
)
self.assertIn("高兴亮", bot._known_agent_speakers)
def test_group_member_echo_cannot_be_mistaken_for_our_reply(self):
bot = WeChatBot.__new__(WeChatBot)
visible = (
"客户甲 10:00:00\n有人在吗\n"
"客户乙 10:00:03\n我在呢"
)
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"changed")
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertFalse(
bot._send_receipt_matches(
reply_text="我在呢",
customer_speaker="客户甲",
before_surface=b"before",
before_blocks=[["客户甲 10:00:00", "有人在吗"]],
)
)
def test_proven_real_staff_name_confirms_reply_before_fast_followup(self):
bot = WeChatBot.__new__(WeChatBot)
bot._known_agent_speakers = {"高兴亮"}
visible = (
"客户甲 10:00:00\n原问题\n"
"高兴亮 10:00:05\n我在呢\n"
"客户甲 10:00:06\n还有一个问题"
)
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"after")
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertTrue(
bot._send_receipt_matches(
reply_text="我在呢",
customer_speaker="客户甲",
before_surface=b"before",
before_blocks=[["客户甲 10:00:00", "原问题"]],
)
)
self.assertTrue(bot._last_send_receipt_followup)
self.assertTrue(
bot._visible_reply_has_customer_followup("我在呢", "客户甲")
)
def test_receipt_and_fast_followup_are_committed_from_one_snapshot(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"j" * 40
state = {
"send_state": "sent_uncommitted",
"reply_text": "我在呢",
"customer_speaker": "客户甲",
"send_surface_signature": b"before",
"send_baseline_blocks": [["客户甲 10:00:00", "原问题"]],
"send_known_outgoing_speakers": ["高兴亮"],
}
visible = (
"客户甲 10:00:00\n原问题\n"
"高兴亮 10:00:05\n我在呢\n"
"客户甲 10:00:06\n还有一个问题"
)
bot._known_agent_speakers = set()
bot.extract_chat_text = mock.Mock(return_value=visible)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"after")
bot._visible_reply_has_customer_followup = mock.Mock(return_value=False)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._queue_followup_after_confirmed_send = mock.Mock()
bot._remember_active_surface = mock.Mock()
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent")
bot.extract_chat_text.assert_called_once()
bot._visible_reply_has_customer_followup.assert_not_called()
bot._queue_followup_after_confirmed_send.assert_called_once_with(fp)
bot._remember_active_surface.assert_not_called()
def test_unknown_followup_direction_keeps_a_pending_probe(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"v" * 40
state = {
"send_state": "sent_uncommitted",
"reply_text": "已发送",
"send_surface_signature": b"before",
"send_baseline_blocks": [["客户甲 10:00:00", "原问题"]],
}
bot._chat_surface_signature = mock.Mock(return_value=b"after")
bot._send_receipt_matches = mock.Mock(return_value=True)
bot._last_send_receipt_followup = None
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._queue_followup_after_confirmed_send = mock.Mock()
bot._remember_active_surface = mock.Mock()
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent")
bot._queue_followup_after_confirmed_send.assert_called_once_with(fp)
bot._remember_active_surface.assert_not_called()
def test_block_anchor_tolerates_old_blocks_scrolling_off_screen(self):
bot = WeChatBot.__new__(WeChatBot)
baseline = [
["客户甲 09:59:00", "更早的问题"],
["贴心管家 09:59:10", "更早的回复"],
["客户甲 10:00:00", "新的问题"],
]
current = baseline[1:] + [["贴心管家 10:00:10", "新的回答"]]
self.assertEqual(
bot._blocks_after_baseline(baseline, current),
[["贴心管家 10:00:10", "新的回答"]],
)
def test_missing_or_malformed_legacy_anchor_never_confirms_old_reply(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"g" * 40
state = {
"send_state": "sent_uncommitted",
"reply_text": "好的",
"send_surface_signature": b"before",
"send_reply_match_count": 0,
}
bot._chat_surface_signature = mock.Mock(return_value=b"after")
bot._send_receipt_matches = mock.Mock(return_value=True)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
bot._send_receipt_matches.assert_not_called()
bot._commit_staged_exchange.assert_not_called()
bot._clear_reply_pending.assert_not_called()
state["send_state"] = "sent_uncommitted"
state["send_baseline_blocks"] = []
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
bot._send_receipt_matches.assert_not_called()
def test_legacy_uncertain_task_yields_to_a_proven_new_customer_turn(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"y" * 40
state = {
"send_state": "uncertain",
"reply_text": "旧草稿绝不能重发",
"send_surface_signature": b"before",
"uncertain_since": 1.0,
"customer_speaker": "客户甲",
"last_lines": ["客户甲 10:00:00", "原问题"],
"confirmed_unread": True,
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {fp.hex(): b"before"}
bot._chat_surface_signature = mock.Mock(return_value=b"after")
bot.extract_chat_text = mock.Mock(
return_value=(
"客户甲 10:00:00\n原问题\n"
"客户甲 10:00:30\n这是后续新消息"
)
)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
with mock.patch("wechat_bot.pyautogui.hotkey") as hotkey:
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "unsent")
refreshed = bot._pending_reply_state(fp)
self.assertNotIn("send_state", refreshed)
self.assertNotIn("reply_text", refreshed)
self.assertTrue(refreshed["confirmed_unread"])
self.assertEqual(refreshed["last_lines"], ["客户甲 10:00:00", "原问题"])
bot._commit_staged_exchange.assert_not_called()
bot._clear_reply_pending.assert_not_called()
hotkey.assert_not_called()
def test_same_chat_receipt_uses_strong_chat_fallback_when_nav_colour_misses(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"h" * 40
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(False))
bot._message_nav_selected = mock.Mock(return_value=False)
bot._target_chat_ready = mock.Mock(return_value=fp)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._active_identity_signature = b"same-title"
self.assertTrue(bot._same_chat_is_open(fp, b"same-title"))
def test_same_chat_receipt_accepts_title_render_drift_with_full_selected_fp(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"i" * 40
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"new-render")
bot._active_identity_signature = b"old-render"
self.assertTrue(bot._same_chat_is_open(fp, b"old-render"))
def test_send_receipt_deadline_stops_more_expensive_checks(self):
bot = WeChatBot.__new__(WeChatBot)
bot._send_receipt_matches = mock.Mock(return_value=False)
with (
mock.patch("wechat_bot.time.monotonic", side_effect=[0.0, 9.0]),
mock.patch("wechat_bot.time.sleep") as sleep,
):
self.assertFalse(
bot._wait_for_visible_reply(
"已发送",
"客户甲",
b"before",
)
)
bot._send_receipt_matches.assert_called_once()
sleep.assert_not_called()
def test_restart_reconciles_visible_sent_reply_without_resending(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
state = {
"send_state": "sent_uncommitted",
"reply_text": "已经发送的回复",
"send_surface_signature": b"before-send",
"send_baseline_blocks": [["客户甲 10:00:00", "原问题"]],
}
def visible_sent(*_args, **_kwargs):
bot._last_send_receipt_followup = False
return True
bot._visible_last_message_matches = mock.Mock(side_effect=visible_sent)
bot._chat_surface_signature = mock.Mock(return_value=b"after-send")
bot._visible_reply_has_customer_followup = mock.Mock(return_value=False)
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "sent")
bot._commit_staged_exchange.assert_called_once_with(fp.hex())
bot._clear_reply_pending.assert_called_once_with(fp)
def test_followup_detection_anchors_latest_same_text_reply(self):
bot = WeChatBot.__new__(WeChatBot)
no_followup = (
"贴心管家 10:00:00\n好的\n"
"客户甲 10:00:01\n谢谢\n"
"贴心管家 10:00:02\n好的"
)
bot.extract_chat_text = mock.Mock(return_value=no_followup)
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertFalse(
bot._visible_reply_has_customer_followup("好的", "客户甲")
)
with_followup = no_followup + "\n客户甲 10:00:03\n还有一个问题"
bot.extract_chat_text.return_value = with_followup
with mock.patch("ai_config.AI_AGENT_NAME", "贴心管家"):
self.assertTrue(
bot._visible_reply_has_customer_followup("好的", "客户甲")
)
def test_restart_only_retries_when_pre_send_surface_proves_enter_did_not_happen(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"u" * 40
state = {
"send_state": "sending",
"reply_text": "尚未发送的回复",
"send_surface_signature": b"same-before-enter",
}
bot._visible_last_message_matches = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"same-before-enter")
bot._persist_pending_replies = mock.Mock(return_value=True)
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
self.assertEqual(state["send_state"], "uncertain")
self.assertEqual(state["reply_text"], "尚未发送的回复")
def test_uncertain_receipt_is_rechecked_at_low_frequency(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"l" * 40
state = {
"send_state": "sent_uncommitted",
"reply_text": "等待回执",
"send_baseline_blocks": [["客户甲 10:00:00", "原问题"]],
}
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {}
bot._visible_last_message_matches = mock.Mock(return_value=False)
bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=False)
bot._chat_surface_signature = mock.Mock(return_value=b"stable")
bot._persist_pending_replies = mock.Mock(return_value=True)
with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 101.0]):
self.assertEqual(
bot._reconcile_uncertain_send(fp, state),
"uncertain",
)
self.assertEqual(
bot._reconcile_uncertain_send(fp, state),
"uncertain",
)
bot._visible_last_message_matches.assert_called_once()
bot._try_ctrl_enter_for_retained_draft.assert_not_called()
def test_uncertain_state_is_strictly_read_only_even_after_surface_change(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"x" * 40
state = {
"send_state": "uncertain",
"reply_text": "不允许再发",
"send_surface_signature": b"before",
"send_baseline_blocks": [["客户甲 10:00:00", "原问题"]],
"uncertain_since": 1.0,
}
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {fp.hex(): b"before"}
bot._chat_surface_signature = mock.Mock(return_value=b"changed")
bot._send_receipt_matches = mock.Mock(return_value=False)
bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=True)
bot._persist_pending_replies = mock.Mock(return_value=True)
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
bot._send_receipt_matches.assert_called_once()
bot._try_ctrl_enter_for_retained_draft.assert_not_called()
def test_unknown_send_state_fails_closed_with_real_uncertain_timestamp(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"w" * 40
state = {
"send_state": "future-v99",
"reply_text": "未知事务",
"uncertain_since": 0.0,
}
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._send_receipt_matches = mock.Mock(return_value=False)
bot._persist_pending_replies = mock.Mock(return_value=True)
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
self.assertEqual(state["send_state"], "uncertain")
self.assertGreater(state["uncertain_since"], 0.0)
bot._persist_pending_replies.assert_called_once()
def test_an_untouched_chat_proves_the_send_never_landed(self):
"""按回车前后整片聊天区一模一样 → 那一下什么都没发生,可以安全重来。
这条路以前不存在:没有表头就没有回执比对,任务永远停在"发送待核对",
而 `_check_selected_session` 每轮都在这个状态上 return False,那位客户
之后再发多少条都不会有人理。这里也不许重按回车,只是把任务放回队列。
"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"d" * 40
state = {
"send_state": "uncertain",
"reply_text": "等待确认",
"send_surface_signature": b"same",
"uncertain_since": 123.0,
}
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {}
bot._chat_surface_signature = mock.Mock(return_value=b"same")
bot._send_receipt_matches = mock.Mock(return_value=False)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=None)
bot._reset_pending_batch = mock.Mock()
bot._pending_reply_sessions = {}
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._log_queue_event = mock.Mock()
with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 106.0]):
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "unsent")
# 回执比对要表头,这条路正是为"没有表头"准备的,不该被调用
bot._send_receipt_matches.assert_not_called()
bot._reset_pending_batch.assert_called_once_with(fp)
def test_an_undecidable_screen_is_rechecked_instead_of_rewritten(self):
"""判不出结果时不反复写盘,也不反复框选,就是安静地等下一轮。"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"d" * 40
state = {
"send_state": "uncertain",
"reply_text": "等待确认",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 1.0,
}
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {}
bot._chat_surface_signature = mock.Mock(return_value=b"after")
bot._send_receipt_matches = mock.Mock(return_value=False)
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=None)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._log_queue_event = mock.Mock()
with mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 106.0]):
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
self.assertEqual(bot._reconcile_uncertain_send(fp, state), "uncertain")
bot._persist_pending_replies.assert_not_called()
@staticmethod
def _stalled_bot(fp: bytes, state: dict, bubble=None, visible: str = ""):
"""一个卡在「发送待核对」上的机器人,屏幕由参数决定。"""
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_sessions = {fp.hex(): state}
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {}
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=bubble)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._log_queue_event = mock.Mock()
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
bot._reset_pending_batch = mock.Mock()
bot.extract_chat_text = mock.Mock(return_value=visible)
return bot
def test_a_waiting_customer_breaks_the_reconciliation_deadlock(self):
"""待核对期间客户还在等 → 放弃旧草稿去回答他,而不是把他晾在那儿。
这正是"当前这个会话来了新消息却不回复"的根子:任务卡在待核对上,
`_check_selected_session` 每轮都在这个状态上 return False,新消息
连读都不会被读到。
"""
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "旧草稿",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 10.0,
}
bot = self._stalled_bot(fp, state, bubble=False)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"before", current_surface=b"after",
reply_text="旧草稿",
),
"unsent",
)
bot._reset_pending_batch.assert_called_once_with(fp)
# 绝不重发那条旧草稿,只是让正常流程重新读一遍最新的话
bot._commit_staged_exchange.assert_not_called()
def test_our_own_last_bubble_settles_the_task_as_sent(self):
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "在的",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 10.0,
}
bot = self._stalled_bot(fp, state, bubble=True)
bot._nothing_left_to_answer = mock.Mock(return_value=True)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"before", current_surface=b"after",
reply_text="在的",
),
"sent",
)
def test_a_task_is_never_judged_before_the_screen_can_catch_up(self):
"""刚按下回车就断言"屏幕没变=没发出去",会冤枉刚刚发成功的那一条。"""
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "在的",
"send_surface_signature": b"same",
"send_dispatched_at": time.time(),
}
bot = self._stalled_bot(fp, state, bubble=None)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"same", current_surface=b"same",
reply_text="在的",
),
"",
)
bot._reset_pending_batch.assert_not_called()
def test_a_task_with_no_dispatch_time_is_left_alone(self):
fp = b"q" * 40
state = {"send_state": "uncertain", "reply_text": "在的"}
bot = self._stalled_bot(fp, state, bubble=None)
self.assertEqual(
bot._resolve_stalled_send(fp, state, reply_text="在的"), ""
)
def test_an_unreadable_screen_never_becomes_a_second_reply(self):
"""一个字都读不到时不许推出"没发出去"——那正是连回三条的老路。"""
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "在的",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 600.0,
}
bot = self._stalled_bot(fp, state, bubble=None, visible="")
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"before", current_surface=b"after",
reply_text="在的",
),
"",
)
bot._reset_pending_batch.assert_not_called()
def test_a_reply_found_on_screen_after_the_deadline_counts_as_sent(self):
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "您好,这边帮您看",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 600.0,
}
bot = self._stalled_bot(
fp, state, bubble=None, visible="客户甲 10:00\n在不在\n我 10:01\n您好,这边帮您看",
)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"before", current_surface=b"after",
reply_text="您好,这边帮您看",
),
"sent",
)
bot._commit_staged_exchange.assert_called_once()
def test_a_readable_screen_without_our_reply_frees_the_customer(self):
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "您好,这边帮您看",
"send_surface_signature": b"before",
"uncertain_since": time.time() - 600.0,
}
bot = self._stalled_bot(
fp, state, bubble=None, visible="客户甲 10:00\n在不在",
)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"before", current_surface=b"after",
reply_text="您好,这边帮您看",
),
"unsent",
)
def test_an_unchanged_screen_with_our_bubble_last_is_not_called_unsent(self):
"""画面没变却说最后一条是我方发的,两件事对不上,宁可不下"没发出去"。"""
fp = b"q" * 40
state = {
"send_state": "uncertain",
"reply_text": "在的",
"send_surface_signature": b"same",
"uncertain_since": time.time() - 10.0,
}
bot = self._stalled_bot(fp, state, bubble=True)
bot._nothing_left_to_answer = mock.Mock(return_value=True)
self.assertEqual(
bot._resolve_stalled_send(
fp, state, before_surface=b"same", current_surface=b"same",
reply_text="在的",
),
"sent",
)
bot._reset_pending_batch.assert_not_called()
def test_reset_pending_clears_uncertain_tracking_maps(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
key = fp.hex()
bot._pending_reply_sessions = {key: {"send_state": "uncertain"}}
bot._uncertain_send_last_check = {key: 1.0}
bot._uncertain_send_last_log = {key: 1.0}
bot._uncertain_send_last_surface = {key: b"surface"}
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._reset_pending_batch(fp)
self.assertNotIn(key, bot._uncertain_send_last_check)
self.assertNotIn(key, bot._uncertain_send_last_log)
self.assertNotIn(key, bot._uncertain_send_last_surface)
def test_reset_pending_batch_drops_the_cached_question_text(self):
"""放弃旧草稿必须连缓存的旧问题一起丢,下一轮才不会拿它再答一遍。
2026-07-31 现场:回执没确认 → 对账「放弃旧草稿改答最新消息」→ 重新
生成时提取恰好失败(人在动鼠标)→ 生成流程复用了缓存里上一轮的
「你多大了」,同一句回复被原样再发一遍,客户刚问的新问题没进模型。
"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
key = fp.hex()
bot._pending_reply_sessions = {key: {
"send_state": "uncertain",
"chat_text": "高瑞@微信@微信联系人 7/31 18:13\n你多大了",
"reply_text": "四十来岁啦,怎么突然问这个?",
}}
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._reset_pending_batch(fp)
self.assertNotIn("chat_text", bot._pending_reply_sessions[key])
def test_uncertain_active_task_does_not_block_next_pending_session(self):
bot = WeChatBot.__new__(WeChatBot)
uncertain_fp = b"a" * 40
next_fp = b"b" * 40
bot._pending_reply_sessions = {
uncertain_fp.hex(): {
"send_state": "uncertain",
"identity_signature": b"active-title",
},
next_fp.hex(): {
"confirmed_unread": True,
"identity_signature": b"other-title",
},
}
bot._active_session_fp = uncertain_fp
bot._active_identity_signature = b"active-title"
bot._chat_identity_signature = mock.Mock(return_value=b"active-title")
bot._reconcile_uncertain_send = mock.Mock(return_value="uncertain")
bot._pending_scan_progress = {}
bot._pending_scan_incomplete = False
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._find_pending_session = mock.Mock(return_value=None)
self.assertFalse(bot._resume_orphaned_pending_reply())
bot._find_pending_session.assert_called_once_with(next_fp)
def test_offscreen_uncertain_head_never_starts_deep_scan(self):
bot = WeChatBot.__new__(WeChatBot)
uncertain_fp = b"o" * 40
next_fp = b"n" * 40
bot._pending_reply_sessions = {
uncertain_fp.hex(): {
"send_state": "uncertain",
"identity_signature": b"old-title",
},
next_fp.hex(): {
"confirmed_unread": True,
"identity_signature": b"next-title",
},
}
bot._active_session_fp = None
bot._active_identity_signature = None
bot._chat_identity_signature = mock.Mock(return_value=b"")
bot._pending_scan_progress = {}
bot._pending_scan_incomplete = False
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._find_pending_session = mock.Mock(return_value=None)
self.assertFalse(bot._resume_orphaned_pending_reply())
bot._find_pending_session.assert_called_once_with(next_fp)
def test_offscreen_dispatched_head_never_blocks_normal_pending(self):
bot = WeChatBot.__new__(WeChatBot)
dispatched_fp = b"s" * 40
next_fp = b"p" * 40
bot._pending_reply_sessions = {
dispatched_fp.hex(): {
"send_state": "sent_uncommitted",
"identity_signature": b"old-title",
},
next_fp.hex(): {
"confirmed_unread": True,
"identity_signature": b"next-title",
},
}
bot._active_session_fp = None
bot._active_identity_signature = None
bot._chat_identity_signature = mock.Mock(return_value=b"")
bot._pending_scan_progress = {}
bot._pending_scan_incomplete = False
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._find_pending_session = mock.Mock(return_value=None)
self.assertFalse(bot._resume_orphaned_pending_reply())
bot._find_pending_session.assert_called_once_with(next_fp)
def test_active_sent_uncommitted_is_reconciled_before_model_call(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"a" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"send_state": "sent_uncommitted",
"reply_text": "已发出的回复",
"identity_signature": b"same-title",
}
}
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=-1)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._ensure_session_archive_key = mock.Mock()
bot._selected_tracking_initialized = True
bot._active_session_fp = fp
bot._active_identity_signature = b"same-title"
bot._active_chat_signature = b"surface"
bot._reconcile_uncertain_send = mock.Mock(return_value="sent")
bot._generate_ai_reply = mock.Mock()
bot.send_reply = mock.Mock()
self.assertTrue(bot._check_selected_session(np.zeros((40, 40, 4), dtype=np.uint8)))
bot._reconcile_uncertain_send.assert_called_once()
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
def test_selected_pending_title_render_drift_still_reaches_reply_flow(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"d" * 40
state = {
"batch_ready": True,
"confirmed_unread": True,
"identity_signature": b"old-title-render",
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=24)
bot._session_fingerprint = mock.Mock(return_value=fp)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"new-title-render")
bot._chat_surface_signature = mock.Mock(return_value=b"new-chat-surface")
bot._selected_tracking_initialized = True
bot._active_session_fp = fp
bot._active_identity_signature = b"old-title-render"
bot._active_chat_signature = b"old-chat-surface"
bot._ensure_session_archive_key = mock.Mock()
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._reconcile_uncertain_send = mock.Mock(return_value="unsent")
bot._activate_wx = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(
return_value="客户甲 10:00:00\n请回复这条新消息"
)
bot._has_pending_customer_message = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="已经正常回复")
bot.send_reply = mock.Mock(return_value=True)
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(
bot._check_selected_session(
np.zeros((80, 80, 4), dtype=np.uint8)
)
)
self.assertEqual(state["identity_signature"], b"new-title-render")
bot._generate_ai_reply.assert_called_once()
bot.send_reply.assert_called_once_with(
"已经正常回复",
session_id=fp.hex(),
expected_fp=fp,
)
def test_enter_without_visible_receipt_never_commits_or_clears(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"n" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
"last_lines": ["客户甲 10:00:00", "没有真正发出前的问题"],
}
}
bot._pending_reply_path = ""
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "没有真正发出"])
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._visible_last_message_matches = mock.Mock(return_value=False)
bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=False)
bot._record_send = mock.Mock()
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(
bot.send_reply("没有真正发出", session_id=fp.hex(), expected_fp=fp)
)
self.assertEqual(bot._pending_reply_state(fp)["send_state"], "uncertain")
bot._record_send.assert_called_once_with(fp.hex())
bot._commit_staged_exchange.assert_not_called()
bot._clear_reply_pending.assert_not_called()
def test_ctrl_enter_is_used_only_after_retained_draft_is_proven(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"c" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"identity_signature": b"same-title",
"generation_surface_signature": b"stable-chat",
"last_lines": ["客户甲 10:00:00", "兼容发送前的问题"],
}
}
bot._pending_reply_path = ""
bot._security_gate_visible = mock.Mock(return_value=False)
bot._send_gate_open = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._chat_surface_signature = mock.Mock(return_value=b"stable-chat")
bot._active_identity_signature = b"same-title"
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._input_geometry_valid = True
bot.input_x = 800
bot.input_y = 700
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._read_input_draft = mock.Mock(side_effect=["", "兼容发送"])
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_receipt_matches = mock.Mock(side_effect=[False, True])
bot._last_send_receipt_followup = False
bot._try_ctrl_enter_for_retained_draft = mock.Mock(return_value=True)
bot._visible_reply_has_customer_followup = mock.Mock(return_value=False)
bot._record_send = mock.Mock()
bot._commit_staged_exchange = mock.Mock()
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.pyautogui.hotkey"),
mock.patch("wechat_bot.pyautogui.press"),
mock.patch("wechat_bot.pyperclip.paste", return_value=""),
mock.patch("wechat_bot.pyperclip.copy"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.send_reply("兼容发送", session_id=fp.hex(), expected_fp=fp)
)
bot._try_ctrl_enter_for_retained_draft.assert_called_once()
bot._commit_staged_exchange.assert_called_once_with(fp.hex())
def test_internal_modal_is_closed_with_escape_before_clicking_x(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.L = 20
bot.T = 30
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._capture_full_window = mock.Mock(
side_effect=[self._modal_surface(), self._modal_surface(False)]
)
bot._message_nav_selected = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.safe_set_foreground"),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=bot.hwnd),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._dismiss_internal_blocker("测试"))
press.assert_called_once_with("esc")
click.assert_not_called()
def test_non_message_page_is_never_treated_as_internal_modal(self):
bot = WeChatBot.__new__(WeChatBot)
bot._capture_full_window = mock.Mock(return_value=self._modal_surface())
bot._message_nav_selected = mock.Mock(return_value=False)
with mock.patch("wechat_bot.find_blocking_modal_close") as detector:
self.assertFalse(bot._dismiss_internal_blocker("测试"))
detector.assert_not_called()
def test_independent_wecom_document_window_is_not_force_closed(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200),
mock.patch(
"wechat_bot.win32process.GetWindowThreadProcessId",
return_value=(0, 77),
),
mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True),
mock.patch("wechat_bot.win32gui.GetWindow", return_value=0),
mock.patch("wechat_bot.win32gui.GetClassName", return_value="WeWorkWindow"),
mock.patch("wechat_bot.win32gui.GetWindowText", return_value="智能文档"),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.win32gui.PostMessage") as close,
):
self.assertFalse(bot._dismiss_owned_blocking_window())
press.assert_not_called()
close.assert_not_called()
def test_login_verification_child_window_is_never_closed(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200),
mock.patch(
"wechat_bot.win32process.GetWindowThreadProcessId",
return_value=(0, 77),
),
mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True),
mock.patch("wechat_bot.win32gui.GetWindow", return_value=100),
mock.patch("wechat_bot.win32gui.GetClassName", return_value="#32770"),
mock.patch("wechat_bot.win32gui.GetWindowText", return_value="企业微信登录验证"),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.win32gui.PostMessage") as close,
):
self.assertFalse(bot._dismiss_owned_blocking_window())
press.assert_not_called()
close.assert_not_called()
def test_stubborn_owned_dialog_is_blocked_but_not_reclosed_every_poll(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 105.0]),
mock.patch("wechat_bot.time.sleep"),
mock.patch("wechat_bot.safe_set_foreground", return_value=True),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=200),
mock.patch(
"wechat_bot.win32process.GetWindowThreadProcessId",
return_value=(0, 77),
),
mock.patch("wechat_bot.win32gui.IsWindowVisible", return_value=True),
mock.patch("wechat_bot.win32gui.IsWindow", return_value=True),
mock.patch("wechat_bot.win32gui.GetWindow", return_value=100),
mock.patch("wechat_bot.win32gui.GetClassName", return_value="#32770"),
mock.patch("wechat_bot.win32gui.GetWindowText", return_value="发送文档"),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.win32gui.PostMessage") as close,
):
self.assertTrue(bot._dismiss_owned_blocking_window())
self.assertTrue(bot._dismiss_owned_blocking_window())
press.assert_called_once_with("esc")
close.assert_called_once_with(200, mock.ANY, 0, 0)
def test_stubborn_internal_modal_freezes_poll_without_repeated_escape(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.L = 10
bot.T = 20
bot._capture_full_window = mock.Mock(return_value=self._modal_surface())
bot._message_nav_selected = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.time.monotonic", side_effect=[100.0, 105.0]),
mock.patch("wechat_bot.time.sleep"),
mock.patch("wechat_bot.safe_set_foreground", return_value=True),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100),
mock.patch(
"wechat_bot.find_blocking_modal_close",
return_value=(870, 175),
),
mock.patch("wechat_bot.pyautogui.press") as press,
mock.patch("wechat_bot.pyautogui.click") as click,
):
self.assertTrue(bot._dismiss_internal_blocker("测试"))
self.assertTrue(bot._dismiss_internal_blocker("测试"))
press.assert_called_once_with("esc")
click.assert_called_once_with(880, 195)
def test_page_restore_keeps_pending_and_next_poll_reopens_it(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"p" * 40
state = {
"batch_ready": True,
"confirmed_unread": True,
"chat_text": "客户甲 10:00:00\n还没有回复的问题",
}
bot.scale = 1.0
bot._pending_reply_sessions = {fp.hex(): state}
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(
side_effect=[self._nav_surface(False), self._nav_surface(True)]
)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=np.zeros((64, 80, 4), dtype=np.uint8))
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=True)
bot.send_reply = mock.Mock()
bot._poll_once() # 邮件/文档/加载页:只恢复消息入口。
self.assertIs(bot._pending_reply_sessions[fp.hex()], state)
bot._resume_orphaned_pending_reply.assert_not_called()
bot._poll_once() # 下一轮已回消息页:恢复原已读未回复任务。
bot._resume_orphaned_pending_reply.assert_called_once()
self.assertIs(bot._pending_reply_sessions[fp.hex()], state)
bot.send_reply.assert_not_called()
def test_pending_persist_failure_prevents_unread_click(self):
"""Never consume the unread badge when its recovery task is not durable."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"d" * 40
message_page = self._nav_surface(True)
session_page = np.zeros((180, 460, 4), dtype=np.uint8)
bot.scale = 1.0
bot.session_item_h = 64
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = set()
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=session_page)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
return_value=(session_page, 16, fp)
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._mark_reply_pending = mock.Mock(return_value=False)
bot._activate_wx = mock.Mock(return_value=True)
bot.click_session = mock.Mock(return_value=True)
bot._poll_once()
bot._mark_reply_pending.assert_called_once_with(
fp,
confirmed_unread=True,
requires_visual_proof=False,
bind_identity=False,
)
bot._activate_wx.assert_not_called()
bot.click_session.assert_not_called()
def test_selected_mismatch_keeps_preclick_pending_on_disk(self):
"""A post-click render mismatch must not lose the now-read message."""
with tempfile.TemporaryDirectory() as directory:
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
message_page = self._nav_surface(True)
session_page = np.zeros((180, 460, 4), dtype=np.uint8)
bot.scale = 1.0
bot.session_item_h = 64
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = set()
bot._pending_reply_path = os.path.join(
directory,
"pending_replies.json",
)
bot._pending_reply_sessions = {}
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=session_page)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
return_value=(session_page, 16, fp)
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
bot._last_click_failure_reason = "selected_fingerprint_mismatch"
bot.click_session = mock.Mock(return_value=False)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._poll_once()
state = bot._pending_reply_state(fp)
self.assertIsNotNone(state)
self.assertTrue(state["confirmed_unread"])
self.assertNotIn("identity_signature", state)
# 指纹对不上多半是列表在识别和点击之间重排了,等一下重定位再点一次;
# 两次都不行才跳过它去服务其他未读会话(这里的桩固定返回同一个目标,
# 真实扫描会因为 processed_fp 而换人)
self.assertEqual(
bot.click_session.call_args_list[:2],
[mock.call(16, expected_fp=fp)] * 2,
)
with open(bot._pending_reply_path, encoding="utf-8") as handle:
persisted = json.load(handle)
self.assertTrue(persisted[fp.hex()]["confirmed_unread"])
def test_confirmed_unread_pending_older_than_24_hours_is_restored(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
fp = b"o" * 40
with open(path, "w", encoding="utf-8") as handle:
json.dump(
{
fp.hex(): {
"confirmed_unread": True,
"batch_ready": False,
"updated_at": 1.0,
}
},
handle,
)
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = path
with mock.patch("wechat_bot.time.time", return_value=200000.0):
restored = bot._load_pending_replies()
self.assertIn(fp.hex(), restored)
self.assertTrue(restored[fp.hex()]["confirmed_unread"])
def test_startup_archive_delta_creates_pending_and_enters_retry(self):
"""A selected read message newer than its archive snapshot is recoverable."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"a" * 40
title = b"customer-title"
session_page = np.zeros((180, 460, 4), dtype=np.uint8)
visible_text = (
"Agent 09:00:00\nold reply\n"
"Customer 09:01:00\nnew question"
)
bot.scale = 1.0
bot.session_item_h = 64
bot._selected_tracking_initialized = False
bot._active_session_fp = None
bot._active_identity_signature = None
bot._active_chat_signature = None
bot._pending_reply_path = ""
bot._pending_reply_sessions = {}
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=32)
bot._chat_identity_signature = mock.Mock(return_value=title)
bot._session_fingerprint = mock.Mock(return_value=fp)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._ensure_session_archive_key = mock.Mock()
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._activate_wx = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(return_value=visible_text)
bot.store = mock.Mock()
bot.store.has_record.return_value = True
bot.store.last_lines.return_value = [
"Agent 09:00:00",
"old reply",
]
bot.store.history.return_value = []
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=False)
bot._wait_for_message_batch = mock.Mock(return_value=False)
bot._generate_ai_reply = mock.Mock(return_value="must not run")
bot.send_reply = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(bot._check_selected_session(session_page))
state = bot._pending_reply_state(fp)
self.assertIsNotNone(state)
self.assertEqual(state["identity_signature"], title)
bot._wait_for_message_batch.assert_called_once_with(fp)
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
click.assert_not_called()
def test_restart_binds_bold_unread_pending_to_selected_regular_render(self):
"""Persisted render metadata recovers one task without changing its key."""
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "pending_replies.json")
unread = np.full((160, 460, 4), 245, dtype=np.uint8)
selected = np.full(
(160, 460, 4),
(210, 130, 45, 255),
dtype=np.uint8,
)
unread[:, :, 3] = 255
for image in (unread, selected):
for y in range(40, 88):
for x in range(20, 84):
image[y, x, :3] = (
(x * 3) % 255,
(y * 5) % 255,
(x + y) % 255,
)
# The same two glyph centre-lines use WeCom's unread-bold and
# selected-regular weights respectively.
unread[27:56, 178:187, :3] = 20
unread[34:44, 168:204, :3] = 20
unread[28:56, 224:233, :3] = 20
unread[45:55, 216:246, :3] = 20
selected[29:54, 181:184, :3] = 250
selected[37:40, 171:201, :3] = 250
selected[30:54, 227:230, :3] = 250
selected[48:51, 219:243, :3] = 250
# Same avatar, different first name glyph. It must never inherit
# the original task even though its avatar hash is identical.
distractor = selected.copy()
distractor[48:55, 126:142, :3] = 250
distractor[51:58, 146:158, :3] = 250
producer = WeChatBot.__new__(WeChatBot)
producer.scale = 2.0
producer.session_item_h = 128
producer._known_fps = set()
producer._known_session_fps = set()
producer._pending_reply_path = path
producer._pending_reply_sessions = {}
unread_fp = producer._session_fingerprint(
unread,
64,
row_center=True,
)
distractor_fp = producer._session_fingerprint(
distractor,
64,
row_center=True,
)
self.assertEqual(unread_fp[:8], distractor_fp[:8])
self.assertNotEqual(unread_fp, distractor_fp)
self.assertFalse(
producer._live_render_transition_match(
unread_fp,
distractor_fp,
)
)
self.assertTrue(
producer._mark_reply_pending(
unread_fp,
confirmed_unread=True,
bind_identity=False,
)
)
with open(path, encoding="utf-8") as handle:
persisted = json.load(handle)
self.assertTrue(
persisted[unread_fp.hex()]["render_identities"]
)
# Simulate a fresh process: only the pending task and its render
# metadata survive; live alias pairs deliberately do not.
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
bot._pending_reply_path = path
bot._pending_reply_sessions = bot._load_pending_replies()
bot._session_render_ids = {
key: set(state.get("render_identities") or [])
for key, state in bot._pending_reply_sessions.items()
}
bot._known_fps = {unread_fp[:8]}
bot._known_session_fps = {unread_fp}
bot._live_session_fp_aliases = set()
bot._selected_tracking_initialized = False
bot._active_session_fp = None
bot._active_identity_signature = None
bot._active_chat_signature = None
bot._capture_full_window = mock.Mock(
return_value=self._nav_surface(True)
)
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=64)
bot._chat_identity_signature = mock.Mock(
return_value=b"original-customer-title"
)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._ensure_session_archive_key = mock.Mock()
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._activate_wx = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(
return_value="Customer 09:01:00\nnew question"
)
bot._has_pending_customer_message = mock.Mock(return_value=True)
bot.store = mock.Mock()
bot._wait_for_message_batch = mock.Mock(return_value=False)
bot._generate_ai_reply = mock.Mock(return_value="must not run")
bot.send_reply = mock.Mock(return_value=True)
with mock.patch("wechat_bot.pyautogui.click") as click:
self.assertFalse(bot._check_selected_session(selected))
selected_fp = bot._session_fingerprint(
selected,
64,
row_center=True,
)
restarted_distractor_fp = bot._session_fingerprint(
distractor,
64,
row_center=True,
)
self.assertNotEqual(selected_fp, unread_fp)
self.assertTrue(bot._session_fp_matches(selected_fp, unread_fp))
self.assertFalse(
bot._session_fp_matches(restarted_distractor_fp, unread_fp)
)
self.assertEqual(
set(bot._pending_reply_sessions),
{unread_fp.hex()},
)
state = bot._pending_reply_sessions[unread_fp.hex()]
self.assertEqual(
state["identity_signature"],
b"original-customer-title",
)
bot._wait_for_message_batch.assert_called_once_with(unread_fp)
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
click.assert_not_called()
def test_flat_system_entry_clears_the_preclick_pending_reservation(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"f" * 40
message_page = self._nav_surface(True)
non_message_page = self._nav_surface(False)
session_page = np.zeros((180, 460, 4), dtype=np.uint8)
bot.scale = 1.0
bot.session_item_h = 64
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_rejected_session_fps = set()
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(
side_effect=[message_page, message_page, non_message_page, message_page]
)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=session_page)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
side_effect=[(session_page, 32, fp), None]
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
# click_session now proves a navigation exit with two post-click frames
# and reports the reason to the poller. This mock bypasses that method,
# so provide the proved outcome explicitly.
bot._last_click_failure_reason = "left_message_workspace"
bot.click_session = mock.Mock(return_value=False)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._mark_reply_pending = mock.Mock()
bot.send_reply = mock.Mock()
bot._poll_once()
self.assertIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertNotIn(fp.hex(), bot._flat_visual_proof_fps)
bot._ensure_message_workspace.assert_called_once_with("系统入口识别")
bot._mark_reply_pending.assert_called_once_with(
fp,
confirmed_unread=True,
requires_visual_proof=True,
bind_identity=False,
)
bot.send_reply.assert_not_called()
def test_real_wechat_external_avatar_survives_transient_nav_false(self):
"""The green @微信 default avatar is a contact, not a system entry."""
# Programmatic copy of the 200% DPI field geometry. Never use
# debug_skipped_row0.png as a fixture: production deliberately
# overwrites that diagnostic file whenever another row is skipped.
sample = np.full((100, 116, 4), 245, dtype=np.uint8)
sample[:, :, 3] = 255
selected_blue = np.array((242, 131, 48), dtype=np.uint8)
sample[:, 8:, :3] = selected_blue
for y in range(4, 96):
for x in range(20, 112):
sample[y, x, :3] = (
35 + ((x + y) % 55),
125 + ((2 * x + y) % 105),
25 + ((x + 3 * y) % 65),
)
sample[0:10, 16:28, :3] = selected_blue
sample[0:10, 104:116, :3] = selected_blue
sample[90:100, 16:28, :3] = selected_blue
sample[90:100, 104:116, :3] = selected_blue
probe = WeChatBot.__new__(WeChatBot)
probe.scale = 2.0
probe.session_item_h = 128
# This field sample is WeCom's green default avatar for an external
# ``@微信`` contact. It is gradient-rich and is accepted as a real row
# even though it resembles some built-in application icons.
self.assertFalse(probe._is_flat_icon(sample, sample.shape[0] // 2))
self.assertTrue(
probe._is_real_conversation(
sample,
sample.shape[0] // 2,
quiet=True,
row_center=True,
)
)
bot = WeChatBot.__new__(WeChatBot)
customer_fp = b"w" * 40
message_page = self._nav_surface(True)
unread_page = np.full((180, 460, 4), 245, dtype=np.uint8)
unread_page[:, :, 3] = 255
unread_page[: sample.shape[0], : sample.shape[1]] = sample
selected_page = unread_page.copy()
# After clicking, the badge disappears and the row/preview becomes
# selected. These changes are independent of the navigation highlight.
selected_page[8:30, 94:122, :3] = 245
selected_page[0:128, 130:350, :3] = (238, 224, 210)
bot.scale = 2.0
bot.session_item_h = 128
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot._session_geometry_valid = True
bot.L, bot.T, bot.R, bot.B = 0, 0, 1000, 800
bot.input_x, bot.input_y = 760, 700
bot._input_geometry_valid = True
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = set()
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot._active_session_fp = None
title_signature = "一个小迷糊@微信".encode("utf-8")
bot._active_identity_signature = title_signature
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
# Poll precheck and click precheck are correct. The first post-click
# navigation read is a false negative during repaint; the confirming
# read succeeds while the selected conversation and input remain valid.
bot._message_nav_selected = mock.Mock(
side_effect=[True, True, False, True]
)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(
side_effect=[unread_page, unread_page, selected_page]
)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
side_effect=[(unread_page, 16, customer_fp), None]
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
bot.detect_badge_rows = mock.Mock(return_value=[16])
bot._session_fingerprint = mock.Mock(return_value=customer_fp)
bot.detect_selected_row = mock.Mock(return_value=64)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._chat_identity_signature = mock.Mock(
return_value=title_signature
)
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._escape_proven_blocker = mock.Mock(return_value=False)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._mark_reply_pending = mock.Mock()
bot._wait_for_message_batch = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="已回复外部联系人")
bot.send_reply = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
bot._poll_once()
self.assertNotIn(customer_fp.hex(), bot._flat_rejected_session_fps)
bot._ensure_message_workspace.assert_not_called()
bot._mark_reply_pending.assert_any_call(
customer_fp,
confirmed_unread=True,
requires_visual_proof=False,
)
bot._generate_ai_reply.assert_called_once_with(
customer_fp,
confirmed_unread=True,
)
bot.send_reply.assert_called_once_with(
"已回复外部联系人",
session_id=customer_fp.hex(),
expected_fp=customer_fp,
)
def test_flat_fingerprint_mismatch_is_not_reclassified_as_system_entry(self):
"""A row-render mismatch must retry, never permanently blacklist a contact."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
message_page = self._nav_surface(True)
session_page = np.zeros((180, 460, 4), dtype=np.uint8)
bot.scale = 1.0
bot.session_item_h = 64
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_rejected_session_fps = set()
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(return_value=session_page)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
return_value=(session_page, 16, fp)
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
bot._last_click_failure_reason = "selected_fingerprint_mismatch"
bot.click_session = mock.Mock(return_value=False)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._mark_reply_pending = mock.Mock()
bot._poll_once()
self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertIn(fp.hex(), bot._flat_visual_proof_fps)
bot._ensure_message_workspace.assert_not_called()
# 关键是这个联系人没被拉黑、任务照常落盘;至于本轮重试几次由排队策略决定
self.assertEqual(
bot._mark_reply_pending.call_args,
mock.call(
fp,
confirmed_unread=True,
requires_visual_proof=True,
bind_identity=False,
),
)
def test_flat_real_row0_same_open_title_enters_visual_reply_flow(self):
"""A selected real chat can keep the same title when its unread row is clicked."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
message_page = self._nav_surface(True)
unread_page = np.zeros((180, 460, 4), dtype=np.uint8)
selected_page = unread_page.copy()
# Model the two visible changes from the real log: the unread badge is
# gone and row0 now has selected/updated preview pixels. The chat title
# legitimately remains unchanged because this conversation was already
# open when its new message arrived.
unread_page[12:24, 105:117, :3] = (70, 70, 245)
selected_page[0:64, 120:300, :3] = (238, 224, 210)
bot.scale = 1.0
bot.session_item_h = 64
bot.list_region = {"top": 100}
bot.list_click_x = 200
bot._session_geometry_valid = True
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot._flat_visual_proof_fps = {fp.hex()}
bot._flat_verified_session_fps = set()
bot._flat_rejected_session_fps = set()
bot._active_session_fp = None
bot._active_identity_signature = b"same-title"
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
bot._message_nav_selected = mock.Mock(return_value=True)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(
side_effect=[unread_page, unread_page, selected_page]
)
bot._check_selected_session = mock.Mock(return_value=False)
bot._resume_orphaned_pending_reply = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(
side_effect=[(unread_page, 16, fp), None]
)
bot._send_gate_open = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
bot.detect_badge_rows = mock.Mock(return_value=[16])
bot._session_fingerprint = mock.Mock(return_value=fp)
bot.detect_selected_row = mock.Mock(return_value=32)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._chat_identity_signature = mock.Mock(return_value=b"same-title")
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._escape_proven_blocker = mock.Mock(return_value=False)
bot._ensure_message_workspace = mock.Mock(return_value=True)
bot._mark_reply_pending = mock.Mock()
bot._wait_for_message_batch = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="视觉确认后的回复")
bot.send_reply = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
bot._poll_once()
self.assertNotIn(fp.hex(), bot._flat_rejected_session_fps)
self.assertIn(fp.hex(), bot._flat_visual_proof_fps)
bot._ensure_message_workspace.assert_not_called()
bot._mark_reply_pending.assert_any_call(
fp,
confirmed_unread=True,
requires_visual_proof=True,
)
bot._generate_ai_reply.assert_called_once_with(
fp,
confirmed_unread=True,
)
bot.send_reply.assert_called_once_with(
"视觉确认后的回复",
session_id=fp.hex(),
expected_fp=fp,
)
def test_full_business_page_returns_to_messages_without_blind_escape(self):
bot = WeChatBot.__new__(WeChatBot)
surface = self._nav_surface(False)
bot._message_nav_selected = mock.Mock(return_value=False)
bot._security_gate_visible = mock.Mock(return_value=False)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._open_messages_page = mock.Mock(return_value=True)
bot._run_ai_page_guard = mock.Mock(return_value=False)
with mock.patch("wechat_bot.pyautogui.press") as press:
self.assertTrue(bot._ensure_message_workspace("轮询前", full=surface))
press.assert_not_called()
bot._open_messages_page.assert_called_once_with(
"轮询前检测到当前不在消息页,"
)
bot._run_ai_page_guard.assert_not_called()
def test_open_messages_uses_safe_message_row_in_wide_navigation(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.scale = 2.0
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=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._message_page_misses = 3
bot._selected_tracking_initialized = True
bot._active_session_fp = b"old"
bot._active_chat_signature = b"old"
bot._active_identity_signature = b"old"
with (
mock.patch("wechat_bot.safe_set_foreground"),
mock.patch(
"wechat_bot.win32gui.GetWindowRect",
return_value=(400, 300, 2600, 1600),
),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100),
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._open_messages_page("测试:"))
# 回退布局下“消息”行为逻辑 y 65~105,点击行中心 85(物理 170)。
click.assert_called_once_with(468, 470)
@staticmethod
def _wide_nav_surface_with_profile_block():
"""宽文字导航,且“消息”不在第一位:上方还有个人资料块。"""
image = np.full((700, 900, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
# 导航列浅蓝灰底色(BGR),宽 300 物理像素;右侧是白色会话列表。
image[:, :300, :3] = (250, 232, 215)
# 个人资料块:头像 + 名字文字(深色内容)。
image[40:104, 20:84, :3] = (90, 90, 90)
image[56:88, 100:220, :3] = (60, 60, 60)
# “消息”行:图标 + 文字。
image[264:300, 40:76, :3] = (238, 126, 36)
image[268:296, 100:170, :3] = (60, 60, 60)
# “邮件”行。
image[340:376, 40:76, :3] = (120, 120, 120)
image[344:372, 100:170, :3] = (60, 60, 60)
return image
def test_message_nav_band_located_by_text_when_not_first(self):
"""“消息”上方有个人资料块时,靠 OCR 文字定位它的行。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
reader = mock.Mock()
reader.available = True
reader.read_raw = mock.Mock(side_effect=[("高兴亮", 0.9), ("消息1", 0.95)])
bot._name_reader_instance = reader
surface = self._wide_nav_surface_with_profile_block()
y1, y2, located = bot._message_nav_band(surface)
self.assertTrue(located)
self.assertLessEqual(y1, 268)
self.assertGreaterEqual(y2, 296)
# 缓存生效:同尺寸画面不再触发 OCR。
bot._message_nav_band(surface)
self.assertEqual(reader.read_raw.call_count, 2)
def test_message_nav_band_falls_back_without_wide_text_column(self):
"""窄图标栏 / 找不到导航分界时回退旧的固定位置,不加载 OCR。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
image = np.full((700, 900, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
y1, y2, located = bot._message_nav_band(image)
self.assertFalse(located)
self.assertEqual((y1, y2), (130, 210))
# 回退路径绝不能触碰 OCR 模型加载。
self.assertIsNone(getattr(bot, "_name_reader_instance", None))
def test_message_nav_selected_uses_located_band(self):
"""选中态校验跟随动态定位的“消息”行,而不是固定 y 65~105。"""
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
image = np.full((700, 900, 4), 242, dtype=np.uint8)
image[:, :, 3] = 255
bot._msg_nav_band_cache = ((700, 900), (264, 304, True))
# 高亮画在定位到的行上 → 判定为消息页。
selected = image.copy()
selected[264:304, 4:120, :3] = (238, 126, 36)
self.assertTrue(bot._message_nav_selected(selected))
# 高亮画在旧的固定位置(65~105 逻辑)→ 不是消息被选中。
other = image.copy()
other[130:210, 4:120, :3] = (238, 126, 36)
self.assertFalse(bot._message_nav_selected(other))
def test_open_messages_clicks_located_band_center(self):
bot = WeChatBot.__new__(WeChatBot)
bot.hwnd = 100
bot.scale = 2.0
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()
surface = np.full((700, 900, 4), 242, dtype=np.uint8)
surface[:, :, 3] = 255
bot._capture_full_window = mock.Mock(return_value=surface)
bot._msg_nav_band_cache = ((700, 900), (400, 480, True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot._message_page_misses = 0
bot._selected_tracking_initialized = False
bot._active_session_fp = None
bot._active_chat_signature = None
bot._active_identity_signature = None
with (
mock.patch("wechat_bot.safe_set_foreground"),
mock.patch(
"wechat_bot.win32gui.GetWindowRect",
return_value=(400, 300, 2600, 1600),
),
mock.patch("wechat_bot.win32gui.GetForegroundWindow", return_value=100),
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._open_messages_page("测试:"))
click.assert_called_once_with(468, 740)
def test_global_unread_signature_follows_located_band(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot._list_x = 300
image = np.full((700, 900, 4), 245, dtype=np.uint8)
image[:, :, 3] = 255
bot._msg_nav_band_cache = ((700, 900), (264, 304, True))
# 红点画在定位到的“消息”行内 → 有全局未读。
with_badge = image.copy()
with_badge[270:290, 240:270, :3] = (81, 81, 250)
self.assertTrue(bot._global_unread_signature(with_badge))
# 红点画在旧固定位置(该布局下属于别的入口)→ 不算消息未读。
wrong_row = image.copy()
wrong_row[140:160, 240:270, :3] = (81, 81, 250)
self.assertFalse(bot._global_unread_signature(wrong_row))
def test_unread_group_band_requires_unique_exact_ocr_label(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot._list_x = 300
bot._msg_nav_band_cache = ((700, 900), (264, 304, True))
bot._nav_column_width_px = mock.Mock(return_value=300)
reader = mock.Mock()
reader.available = True
reader.read_layout.return_value = [
{"text": "分组", "x1": 40, "y1": 430, "y2": 458, "score": 0.99},
{"text": "未读", "x1": 90, "y1": 500, "y2": 528, "score": 0.98},
{"text": "5", "x1": 250, "y1": 502, "y2": 526, "score": 0.99},
]
bot._name_reader_instance = reader
surface = np.full((700, 900, 4), 245, dtype=np.uint8)
surface[:, :, 3] = 255
self.assertEqual(
bot._locate_unread_group_band_by_text(surface),
(480, 548),
)
reader.read_layout.return_value.append(
{"text": "未读", "x1": 100, "y1": 610, "y2": 638, "score": 0.97}
)
self.assertIsNone(bot._locate_unread_group_band_by_text(surface))
def test_open_unread_group_requires_two_post_click_frames(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.hwnd = 100
bot._list_x = 300
bot._unread_filter_active = False
bot._unread_scan_resume = False
bot._unread_filtered_center_fps = set()
bot._unread_candidate_names = {}
bot._last_unread_discovery_log = ("", 0.0)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._unread_group_band = mock.Mock(return_value=(500, 540))
bot._locate_unread_group_band_by_text = mock.Mock(return_value=(500, 540))
bot._nav_column_width_px = mock.Mock(return_value=300)
bot._capture_full_window = mock.Mock(
return_value=np.zeros((700, 900, 4), dtype=np.uint8)
)
before = np.zeros((120, 100, 4), dtype=np.uint8)
filtered = np.ones((120, 100, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(
side_effect=[before, filtered, filtered]
)
bot._session_page_signature = mock.Mock(
side_effect=[b"ordinary", b"filtered", b"filtered"]
)
bot._looks_like_message_list = mock.Mock(return_value=True)
bot._unread_group_selected = mock.Mock(return_value=False)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
bot.report_progress = mock.Mock()
bot._log_unread_discovery = 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, 900, 700)),
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._open_unread_group())
self.assertTrue(bot._unread_filter_active)
click.assert_called_once_with(102, 520)
self.assertEqual(bot.capture_session_list.call_count, 3)
def test_completed_ordinary_scan_switches_to_hidden_unread_group(self):
bot = WeChatBot.__new__(WeChatBot)
ordinary = np.zeros((80, 100, 4), dtype=np.uint8)
filtered = np.ones((80, 100, 4), dtype=np.uint8)
target = (filtered, 40, b"t" * 40)
bot._unread_filter_active = False
bot._unread_scan_resume = False
bot._strict_visual_actions = True
bot.capture_session_list = mock.Mock(
side_effect=[ordinary, ordinary, filtered]
)
bot._target_from_session_image = mock.Mock(return_value=None)
bot._deep_unread_scan_allowed = mock.Mock(return_value=True)
bot._scroll_session_list_top = mock.Mock()
bot._scroll_session_list_page = mock.Mock(return_value=None)
bot._session_page_signature = mock.Mock(return_value=b"ordinary")
bot._capture_full_window = mock.Mock(return_value=ordinary)
bot._global_unread_signature = mock.Mock(return_value=b"five")
bot._log_unread_discovery = mock.Mock()
def open_filter(_full):
bot._unread_filter_active = True
return True
bot._open_unread_group = mock.Mock(side_effect=open_filter)
bot._scan_unread_filter = mock.Mock(return_value=target)
self.assertEqual(
bot._find_next_unread_session(set(), set(), full=ordinary),
target,
)
bot._open_unread_group.assert_called_once_with(ordinary)
bot._scan_unread_filter.assert_called_once_with(filtered, set(), set())
def test_filtered_unread_list_uses_avatar_row_and_name_without_red_dot(self):
bot = WeChatBot.__new__(WeChatBot)
bot._unread_filter_active = True
bot.identity_by_name = True
bot._strict_visual_actions = True
bot._flat_visual_proof_fps = set()
image = np.zeros((120, 180, 4), dtype=np.uint8)
target = WeChatBot._fp_from_name("客户甲@微信")
bot._avatar_row_centers = mock.Mock(return_value=[52])
bot._row_display_name = mock.Mock(return_value="客户甲@微信")
bot._is_system_entry_name = mock.Mock(return_value=False)
bot._session_fingerprint = mock.Mock(return_value=target)
bot._flat_session_rejected = mock.Mock(return_value=False)
bot._is_real_conversation = mock.Mock(return_value=True)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
self.assertEqual(
bot._target_from_filtered_unread_image(image, set(), set()),
(52, target, "客户甲@微信"),
)
def test_session_vision_model_link_error_is_not_cached(self):
bot = WeChatBot.__new__(WeChatBot)
bot._strict_visual_actions = True
bot._session_row_ai_cache = {}
bot.session_item_h = 64
bot._ui_guard_image_bytes = mock.Mock(return_value=b"row")
bot._row_preview_text = mock.Mock(return_value="预览")
bot.report_progress = mock.Mock()
image = np.zeros((128, 180, 4), dtype=np.uint8)
with mock.patch(
"ai_chat.classify_wecom_session_row",
side_effect=RuntimeError("temporary link error"),
):
decision = bot._classify_ambiguous_session_row(
image,
64,
b"x" * 40,
"客户甲",
row_center=True,
)
self.assertEqual(decision["kind"], "unknown")
self.assertEqual(bot._session_row_ai_cache, {})
bot.report_progress.assert_called_once_with(
"视觉模型链路失败,未读消息已保留等待重试"
)
def test_filtered_unread_row_may_disappear_only_after_exact_title_proof(self):
bot = WeChatBot.__new__(WeChatBot)
name = "客户甲@微信"
target = WeChatBot._fp_from_name(name)
bot.scale = 1.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot._session_geometry_valid = True
bot._active_session_fp = None
bot._unread_filtered_center_fps = {target.hex()}
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(
return_value=np.zeros((300, 600, 4), dtype=np.uint8)
)
bot._message_workspace_selected = mock.Mock(return_value=True)
latest = np.zeros((160, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot._pending_reply_state = mock.Mock(
return_value={"requires_visual_proof": True}
)
bot._is_real_conversation = mock.Mock(return_value=True)
bot._session_fingerprint = mock.Mock(return_value=target)
bot._chat_identity_signature = mock.Mock(return_value=b"title-render")
bot._raw_selected_session_fingerprint = mock.Mock(return_value=None)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._open_chat_display_name = mock.Mock(return_value=name)
bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready")
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.click_session(64, expected_fp=target, row_center=True)
)
bot._open_chat_display_name.assert_called_once_with()
bot._remember_active_surface.assert_called_once_with(target)
def test_navigation_width_inference_accepts_custom_band(self):
image = np.full((700, 900, 4), 248, dtype=np.uint8)
image[:, :, 3] = 255
# “消息”行在 y 264~304(不在第一位),浅蓝底色只画在这一行。
image[264:304, :300, :3] = (250, 232, 215)
image[264:304, 300:, :3] = (245, 245, 245)
self.assertEqual(
infer_navigation_width(image, 2.0, band=(264, 304))[0],
300,
)
# 不传 band 时按旧固定行采样,该布局下识别不出宽度。
self.assertLess(infer_navigation_width(image, 2.0)[1], 0.12)
def test_unchanged_selected_chat_does_not_activate_or_move_mouse(self):
bot = WeChatBot.__new__(WeChatBot)
bot.detect_selected_row = mock.Mock(return_value=-1)
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._active_session_fp = b"target"
bot._active_identity_signature = b"identity"
bot._active_chat_signature = b"surface"
bot._selected_tracking_initialized = True
bot._activate_wx = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
image = np.zeros((64, 80, 4), dtype=np.uint8)
self.assertFalse(bot._check_selected_session(image))
bot._activate_wx.assert_not_called()
def test_message_batch_collects_updates_then_allows_one_request(self):
bot = WeChatBot.__new__(WeChatBot)
bot._stop_check = None
bot._active_identity_signature = b"identity"
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(
side_effect=[b"start", b"start", b"second", b"third"] + [b"third"] * 20
)
bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
self.assertTrue(
bot._wait_for_message_batch(
b"target",
window_seconds=0.02,
poll_seconds=0.005,
)
)
bot.wait_for_mouse_idle.assert_called_once()
def test_message_batch_aborts_when_chat_identity_changes(self):
bot = WeChatBot.__new__(WeChatBot)
bot._stop_check = None
bot._active_identity_signature = b"identity"
bot._chat_identity_signature = mock.Mock(side_effect=[b"identity", b"other"])
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._capture_full_window = mock.Mock(return_value=np.zeros((20, 20, 4), dtype=np.uint8))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
self.assertFalse(
bot._wait_for_message_batch(
b"target",
window_seconds=0.05,
poll_seconds=0.005,
)
)
bot.wait_for_mouse_idle.assert_not_called()
def test_message_batch_aborts_when_same_title_switches_to_another_session(self):
"""A same-title manual switch must not contaminate the original pending batch."""
bot = WeChatBot.__new__(WeChatBot)
target = b"target-session"
other = b"other-session!"
bot._stop_check = None
bot._active_session_fp = target
bot._active_identity_signature = b"same-visible-title"
bot._chat_identity_signature = mock.Mock(return_value=b"same-visible-title")
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._selected_session_fingerprint = mock.Mock(
side_effect=[target, other]
)
bot._capture_full_window = mock.Mock(
return_value=np.zeros((20, 20, 4), dtype=np.uint8)
)
bot._message_nav_selected = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
self.assertFalse(
bot._wait_for_message_batch(
target,
window_seconds=0.05,
poll_seconds=0.005,
)
)
bot.wait_for_mouse_idle.assert_not_called()
def test_message_batch_accepts_title_render_drift_for_same_full_session(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"m" * 40
state = {
"batch_ready": False,
"identity_signature": b"old-render",
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._stop_check = None
bot._active_session_fp = fp
bot._active_identity_signature = b"old-render"
bot._chat_identity_signature = mock.Mock(
side_effect=[b"first-new-render", b"second-new-render"]
)
bot._chat_surface_signature = mock.Mock(return_value=b"stable-surface")
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._activate_wx = mock.Mock(return_value=True)
self.assertTrue(
bot._wait_for_message_batch(
fp,
window_seconds=0.0,
poll_seconds=0.05,
)
)
self.assertEqual(state["identity_signature"], b"second-new-render")
def test_message_batch_retry_keeps_original_persisted_deadline(self):
"""A transient retry must wait only the original window's remainder."""
bot = WeChatBot.__new__(WeChatBot)
target = b"t" * 40
bot._pending_reply_path = ""
bot._pending_reply_sessions = {
target.hex(): {"batch_ready": False},
}
bot._active_session_fp = target
bot._active_identity_signature = b"identity"
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._raw_selected_session_fingerprint = mock.Mock(return_value=target)
bot._selected_session_fingerprint = mock.Mock(return_value=target)
bot._stop_check = mock.Mock()
bot._stop_check.wait.return_value = True
with (
mock.patch("wechat_bot.time.time", return_value=100.0),
mock.patch("wechat_bot.time.monotonic", return_value=0.0),
):
self.assertFalse(
bot._wait_for_message_batch(
target,
window_seconds=7.0,
poll_seconds=0.5,
)
)
original_deadline = bot._pending_reply_state(target)["batch_deadline_at"]
self.assertEqual(original_deadline, 107.0)
bot._stop_check.wait.reset_mock()
with (
mock.patch("wechat_bot.time.time", return_value=106.8),
mock.patch("wechat_bot.time.monotonic", return_value=0.0),
):
self.assertFalse(
bot._wait_for_message_batch(
target,
window_seconds=7.0,
poll_seconds=0.5,
)
)
self.assertEqual(
bot._pending_reply_state(target)["batch_deadline_at"],
original_deadline,
)
self.assertAlmostEqual(
bot._stop_check.wait.call_args.args[0],
0.2,
places=5,
)
def test_marking_batch_ready_clears_persisted_merge_deadline(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"r" * 40
bot._pending_reply_path = ""
bot._pending_reply_sessions = {
target.hex(): {
"batch_ready": False,
"batch_started_at": 100.0,
"batch_deadline_at": 120.0,
"batch_window_seconds": 20.0,
},
}
bot._live_render_ids_for = mock.Mock(return_value=set())
self.assertTrue(bot._mark_reply_pending(target, batch_ready=True))
state = bot._pending_reply_state(target)
self.assertTrue(state["batch_ready"])
self.assertNotIn("batch_started_at", state)
self.assertNotIn("batch_deadline_at", state)
self.assertNotIn("batch_window_seconds", state)
def test_hidden_unread_is_found_on_third_scanned_page(self):
bot = WeChatBot.__new__(WeChatBot)
pages = [
np.full((40, 40, 4), value, dtype=np.uint8)
for value in (10, 20, 30, 40)
]
bot.capture_session_list = mock.Mock(side_effect=[pages[0], pages[1]])
bot._target_from_session_image = mock.Mock(
side_effect=[None, None, None, (12, b"customer")]
)
bot._deep_unread_scan_allowed = mock.Mock(return_value=True)
bot._scroll_session_list_top = mock.Mock()
bot._scroll_session_list_page = mock.Mock(side_effect=[pages[2], pages[3]])
found = bot._find_next_unread_session(set(), set(), full=pages[0])
self.assertEqual(found[1:], (12, b"customer"))
self.assertIs(found[0], pages[3])
self.assertEqual(bot._scroll_session_list_page.call_count, 2)
def test_deep_unread_scan_resumes_after_time_budget_instead_of_restarting(self):
bot = WeChatBot.__new__(WeChatBot)
pages = [
np.full((40, 40, 4), value, dtype=np.uint8)
for value in (10, 20)
]
bot._unread_scan_resume = False
bot.capture_session_list = mock.Mock(
side_effect=[pages[0], pages[0], pages[1]]
)
bot._target_from_session_image = mock.Mock(return_value=None)
bot._deep_unread_scan_allowed = mock.Mock(return_value=True)
bot._scroll_session_list_top = mock.Mock()
bot._scroll_session_list_page = mock.Mock(
side_effect=[pages[1], None]
)
with (
mock.patch("wechat_bot.SESSION_SCAN_MAX_SECONDS", 0.0),
mock.patch("wechat_bot.time.monotonic", return_value=100.0),
):
self.assertIsNone(
bot._find_next_unread_session(set(), set(), full=pages[0])
)
self.assertTrue(bot._unread_scan_resume)
# First pass went to the top once; it deliberately did not jump back.
self.assertEqual(bot._scroll_session_list_top.call_count, 1)
bot._scroll_session_list_top.reset_mock()
with mock.patch("wechat_bot.time.monotonic", return_value=101.0):
self.assertIsNone(
bot._find_next_unread_session(set(), set(), full=pages[1])
)
self.assertFalse(bot._unread_scan_resume)
# The resumed pass started at page[1] and only returned to top at bottom.
bot._scroll_session_list_top.assert_called_once()
def test_deep_unread_resume_is_not_cancelled_by_scan_cooldown(self):
"""A paused forward scan must continue while the global unread still exists."""
bot = WeChatBot.__new__(WeChatBot)
page = np.full((40, 40, 4), 20, dtype=np.uint8)
bot._unread_scan_resume = True
bot.capture_session_list = mock.Mock(return_value=page)
bot._target_from_session_image = mock.Mock(return_value=None)
bot._deep_unread_scan_allowed = mock.Mock(return_value=False)
bot._global_unread_signature = mock.Mock(return_value=b"still-unread")
bot._scroll_session_list_top = mock.Mock()
bot._scroll_session_list_page = mock.Mock(return_value=None)
self.assertIsNone(
bot._find_next_unread_session(set(), set(), full=page)
)
bot._scroll_session_list_page.assert_called_once_with(page)
self.assertFalse(bot._unread_scan_resume)
def test_read_pending_conversation_is_reopened_by_fingerprint(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"p" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"chat_text": "客户甲 10:00:00\n待回复内容",
"identity_signature": b"same-title-old-render",
}
}
bot._pending_reply_path = ""
bot._active_session_fp = b"a" * 40
bot._active_identity_signature = b"another-title"
bot._send_gate_open = mock.Mock(return_value=True)
page = np.zeros((200, 100, 4), dtype=np.uint8)
bot._find_pending_session = mock.Mock(return_value=(page, 64))
def open_target(*_args, **_kwargs):
bot._active_session_fp = fp
bot._active_identity_signature = b"same-title-new-render"
return True
bot.click_session = mock.Mock(side_effect=open_target)
bot._chat_identity_signature = mock.Mock(
side_effect=[b"another-title", b"same-title-new-render"]
)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="继续回复")
bot._activate_wx = mock.Mock(return_value=True)
bot.send_reply = mock.Mock(return_value=True)
with (
mock.patch("wechat_bot.time.monotonic", return_value=100.0),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot._resume_orphaned_pending_reply())
bot.click_session.assert_called_once_with(
64,
expected_fp=fp,
row_center=True,
)
bot._generate_ai_reply.assert_called_once_with(
fp,
confirmed_unread=True,
)
bot.send_reply.assert_called_once_with(
"继续回复",
session_id=fp.hex(),
expected_fp=fp,
)
self.assertEqual(
bot._pending_reply_state(fp)["identity_signature"],
b"same-title-new-render",
)
def test_pending_search_rejects_same_identity_on_different_pages(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"p" * 40
pages = [
np.full((80, 80, 4), value, dtype=np.uint8)
for value in (10, 20, 30)
]
bot._scroll_session_list_top = mock.Mock()
bot.capture_session_list = mock.Mock(return_value=pages[0])
bot._pending_rows_on_page = mock.Mock(
side_effect=[[12], [], [44]]
)
bot._scroll_session_list_page = mock.Mock(
side_effect=[pages[1], pages[2], None]
)
self.assertIsNone(bot._find_pending_session(fp))
self.assertGreaterEqual(bot._scroll_session_list_top.call_count, 2)
def test_pending_search_deduplicates_same_row_on_overlapping_pages(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 1.0
bot.session_item_h = 32
fp = b"o" * 40
first = np.full((120, 100, 4), 245, dtype=np.uint8)
first[:, :, 3] = 255
first[18:34, 8:28, :3] = (30, 80, 150)
first[50:58, 35:82, :3] = 20
first[84:104, 12:30, :3] = (90, 40, 180)
second = np.full_like(first, 245)
second[:, :, 3] = 255
shift = 20
second[: first.shape[0] - shift] = first[shift:]
second[first.shape[0] - shift :, 40:70, :3] = 60
bot._scroll_session_list_top = mock.Mock()
bot.capture_session_list = mock.Mock(side_effect=[first, first])
bot._pending_rows_on_page = mock.Mock(
side_effect=[[72], [52], [72]]
)
bot._scroll_session_list_page = mock.Mock(
side_effect=[second, None]
)
found = bot._find_pending_session(fp)
self.assertIs(found[0], first)
self.assertEqual(found[1], 72)
def test_pending_row_scan_covers_unknown_scrolled_phase(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.session_item_h = 128
target = b"a" * 8 + b"n" * 32
page = np.zeros((300, 460, 4), dtype=np.uint8)
true_center = 23
bot.detect_selected_row = mock.Mock(return_value=-1)
bot.detect_badge_rows = mock.Mock(return_value=[])
bot._session_fingerprint = mock.Mock(return_value=b"x" * 40)
bot._raw_session_fingerprint = mock.Mock(
side_effect=lambda _img, y, row_center=True: (
target[:8] if y == true_center else b"z" * 8
)
)
bot._session_name_fingerprint = mock.Mock(
side_effect=lambda _img, y, row_center=True: (
target[8:] if y == true_center else b"q" * 32
)
)
bot._is_real_conversation = mock.Mock(
side_effect=lambda _img, y, **_kwargs: y == true_center
)
self.assertEqual(
bot._pending_rows_on_page(page, target),
[true_center],
)
self.assertTrue(
any(
call.kwargs.get("allow_flat") is True
for call in bot._is_real_conversation.call_args_list
)
)
def test_pending_search_scans_all_pages_then_replays_unique_page(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"q" * 40
pages = [
np.full((80, 80, 4), value, dtype=np.uint8)
for value in (10, 20, 30)
]
bot._scroll_session_list_top = mock.Mock()
bot.capture_session_list = mock.Mock(
side_effect=[pages[0], pages[0]]
)
bot._pending_rows_on_page = mock.Mock(
side_effect=[[], [], [44], [46]]
)
bot._scroll_session_list_page = mock.Mock(
side_effect=[
pages[1], pages[2], None,
pages[1], pages[2],
]
)
found = bot._find_pending_session(fp)
self.assertIs(found[0], pages[2])
self.assertEqual(found[1], 46)
def test_pending_search_resumes_after_budget_without_restarting_from_top(self):
"""Long lists must make forward progress across polling turns."""
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
pages = [
np.full((80, 80, 4), value, dtype=np.uint8)
for value in (10, 20, 30)
]
bot._scroll_session_list_top = mock.Mock()
bot.capture_session_list = mock.Mock(
# First pass starts at page 0. The resumed pass captures page 1;
# replay after a complete scan starts at page 0 again.
side_effect=[pages[0], pages[1], pages[0]]
)
bot._pending_rows_on_page = mock.Mock(
side_effect=[[], [], [44], [46]]
)
bot._scroll_session_list_page = mock.Mock(
side_effect=[pages[1], pages[2], None, pages[1], pages[2]]
)
# Process page 0, scroll to page 1, then exhaust this turn's budget.
with mock.patch(
"wechat_bot.time.monotonic",
side_effect=[0.0, 46.0],
):
self.assertIsNone(bot._find_pending_session(fp))
self.assertTrue(bot._pending_scan_incomplete)
bot._scroll_session_list_top.assert_called_once()
bot._scroll_session_list_top.reset_mock()
with mock.patch("wechat_bot.time.monotonic", return_value=100.0):
found = bot._find_pending_session(fp)
self.assertIs(found[0], pages[2])
self.assertEqual(found[1], 46)
# The resumed scan does not jump to the top before continuing. It only
# returns to the top once the global uniqueness scan is complete.
bot._scroll_session_list_top.assert_called_once()
def test_same_avatar_with_different_title_never_resumes_cached_reply(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"p" * 40
other_fp = b"q" * 40
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"chat_text": "客户甲 10:00:00\n待回复内容",
"identity_signature": b"customer-a-title",
}
}
bot._active_session_fp = b"another2"
bot._active_identity_signature = b"another-title"
bot._send_gate_open = mock.Mock(return_value=True)
page = np.zeros((200, 100, 4), dtype=np.uint8)
bot._find_pending_session = mock.Mock(return_value=(page, 64))
bot.click_session = mock.Mock(return_value=True)
bot._chat_identity_signature = mock.Mock(
side_effect=[b"another-title", b"customer-b-title"]
)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=other_fp)
bot._selected_session_fingerprint = mock.Mock(return_value=other_fp)
bot._generate_ai_reply = mock.Mock(return_value="绝不能发送")
bot._activate_wx = mock.Mock(return_value=True)
bot.send_reply = mock.Mock(return_value=True)
with mock.patch("wechat_bot.time.monotonic", return_value=100.0):
self.assertFalse(bot._resume_orphaned_pending_reply())
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
def test_full_selected_fingerprint_overrides_title_render_hash_drift(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"t" * 40
state = {"identity_signature": b"old-render"}
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._active_session_fp = fp
bot._active_identity_signature = b"old-render"
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._persist_pending_replies = mock.Mock(return_value=True)
self.assertTrue(
bot._chat_target_matches(
fp,
b"old-render",
current_identity=b"new-render",
)
)
self.assertTrue(
bot._accept_selected_title_render(
fp,
state,
b"new-render",
)
)
self.assertEqual(state["identity_signature"], b"new-render")
self.assertEqual(bot._active_identity_signature, b"new-render")
def test_same_title_never_overrides_a_different_full_selected_fingerprint(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"t" * 40
other = b"o" * 40
bot._raw_selected_session_fingerprint = mock.Mock(return_value=other)
bot._selected_session_fingerprint = mock.Mock(return_value=other)
self.assertFalse(
bot._chat_target_matches(
target,
b"same-title",
current_identity=b"same-title",
)
)
def test_title_mismatch_without_selected_row_proof_remains_blocked(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"t" * 40
bot._raw_selected_session_fingerprint = mock.Mock(return_value=None)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
self.assertFalse(
bot._chat_target_matches(
target,
b"old-render",
current_identity=b"new-render",
)
)
def test_legacy_pending_identity_cannot_rebind_from_a_full_selected_row(self):
bot = WeChatBot.__new__(WeChatBot)
legacy_fp = b"l" * 16
current_fp = b"c" * 40
state = {"identity_signature": b"legacy-title"}
bot._raw_selected_session_fingerprint = mock.Mock(
return_value=current_fp
)
bot._selected_session_fingerprint = mock.Mock(return_value=current_fp)
self.assertFalse(
bot._chat_target_matches(
legacy_fp,
b"legacy-title",
current_identity=b"new-title-render",
)
)
self.assertFalse(
bot._accept_selected_title_render(
legacy_fp,
state,
b"new-title-render",
)
)
self.assertEqual(state["identity_signature"], b"legacy-title")
def test_title_render_rebind_failure_rolls_back_and_blocks(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
state = {
"identity_signature": b"old-render",
"updated_at": 123.0,
}
bot._pending_reply_sessions = {fp.hex(): state}
bot._active_session_fp = fp
bot._active_identity_signature = b"old-render"
bot._raw_selected_session_fingerprint = mock.Mock(return_value=fp)
bot._selected_session_fingerprint = mock.Mock(return_value=fp)
bot._persist_pending_replies = mock.Mock(return_value=False)
self.assertFalse(
bot._accept_selected_title_render(
fp,
state,
b"new-render",
)
)
self.assertEqual(state["identity_signature"], b"old-render")
self.assertEqual(state["updated_at"], 123.0)
self.assertEqual(bot._active_identity_signature, b"old-render")
def test_pending_task_freezes_original_chat_title_signature(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"pending3"
bot._pending_reply_sessions = {}
bot._active_identity_signature = b"customer-a-title"
bot._mark_reply_pending(fp, confirmed_unread=True)
state = bot._pending_reply_state(fp)
self.assertEqual(state["identity_signature"], b"customer-a-title")
self.assertTrue(state["confirmed_unread"])
def test_no_global_unread_does_not_scroll_the_list(self):
bot = WeChatBot.__new__(WeChatBot)
page = np.zeros((40, 40, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=page)
bot._target_from_session_image = mock.Mock(return_value=None)
bot._deep_unread_scan_allowed = mock.Mock(return_value=False)
bot._scroll_session_list_top = mock.Mock()
self.assertIsNone(bot._find_next_unread_session(set(), set(), full=page))
bot._scroll_session_list_top.assert_not_called()
def test_click_relocates_unread_by_fingerprint_before_acting(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
latest = np.zeros((500, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot.detect_badge_rows = mock.Mock(return_value=[300, 80])
bot._session_fingerprint = mock.Mock(
side_effect=lambda _img, y: b"target" if y == 80 else b"other"
)
bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"])
bot._selected_session_fingerprint = mock.Mock(return_value=b"target")
bot._remember_active_surface = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._escape_proven_blocker = mock.Mock(return_value=False)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot.click_session(300, expected_fp=b"target"))
# badge y=80 + 16*2 的头像中心,再加列表 top=200。
click.assert_called_once_with(600, 312)
def test_click_two_nav_misses_accepts_raw_target_with_chat_evidence(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"t" * 40
message_page = self._nav_surface(True)
other_page = self._nav_surface(False)
bot.scale = 1.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(
side_effect=[message_page, other_page, other_page]
)
latest = np.zeros((200, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot.detect_badge_rows = mock.Mock(return_value=[32])
bot._session_fingerprint = mock.Mock(return_value=target)
bot._chat_identity_signature = mock.Mock(
side_effect=[b"same-title", b"same-title"]
)
bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready")
bot._active_session_fp = None
bot._raw_selected_session_fingerprint = mock.Mock(return_value=target)
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._pending_reply_state = mock.Mock(return_value=None)
bot._flat_visual_proof_fps = {target.hex()}
bot._flat_verified_session_fps = set()
# Reproduce an earlier false rejection from the same listener process.
# Strong raw-row/title/chat evidence must heal it immediately.
bot._flat_rejected_session_fps = {target.hex()}
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot.click_session(32, expected_fp=target))
self.assertEqual(bot._last_click_failure_reason, "")
self.assertNotIn(target.hex(), bot._flat_rejected_session_fps)
self.assertIn(target.hex(), bot._flat_verified_session_fps)
self.assertNotIn(target.hex(), bot._flat_visual_proof_fps)
bot._raw_selected_session_fingerprint.assert_called_once_with()
bot._selected_session_fingerprint.assert_not_called()
bot._chat_surface_signature.assert_called_once_with()
bot._remember_active_surface.assert_called_once_with(target)
def test_click_two_nav_misses_without_raw_selected_row_proves_page_exit(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"target"
message_page = self._nav_surface(True)
other_page = self._nav_surface(False)
bot.scale = 1.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(
side_effect=[message_page, other_page, other_page]
)
latest = np.zeros((200, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot.detect_badge_rows = mock.Mock(return_value=[32])
bot._session_fingerprint = mock.Mock(return_value=target)
bot._chat_identity_signature = mock.Mock(return_value=b"before")
bot._active_session_fp = None
bot._raw_selected_session_fingerprint = mock.Mock(return_value=None)
bot._selected_session_fingerprint = mock.Mock(return_value=target)
bot._chat_surface_signature = mock.Mock(return_value=b"chat-ready")
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(bot.click_session(32, expected_fp=target))
self.assertEqual(bot._last_click_failure_reason, "left_message_workspace")
self.assertEqual(bot._capture_full_window.call_count, 3)
bot._raw_selected_session_fingerprint.assert_called_once_with()
bot._selected_session_fingerprint.assert_not_called()
bot._chat_surface_signature.assert_not_called()
bot._remember_active_surface.assert_not_called()
def test_click_ignores_one_transient_post_click_navigation_frame(self):
bot = WeChatBot.__new__(WeChatBot)
target = b"target"
message_page = self._nav_surface(True)
transient_page = self._nav_surface(False)
bot.scale = 1.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(
side_effect=[message_page, transient_page, message_page]
)
latest = np.zeros((200, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot.detect_badge_rows = mock.Mock(return_value=[32])
bot._session_fingerprint = mock.Mock(return_value=target)
bot._chat_identity_signature = mock.Mock(
side_effect=[b"before", b"after"]
)
bot._active_session_fp = None
bot._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("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(bot.click_session(32, expected_fp=target))
self.assertEqual(bot._last_click_failure_reason, "")
self.assertEqual(bot._capture_full_window.call_count, 3)
bot._remember_active_surface.assert_called_once_with(target)
def test_click_can_reopen_flat_avatar_pending_for_visual_confirmation(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot._session_geometry_valid = True
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
latest = np.zeros((500, 460, 4), dtype=np.uint8)
expected = b"f" * 40
bot.capture_session_list = mock.Mock(return_value=latest)
bot._session_fingerprint = mock.Mock(return_value=expected)
bot._pending_reply_sessions = {
expected.hex(): {"requires_visual_proof": True}
}
bot.store = mock.Mock()
bot.store.has_record.return_value = False
bot._is_real_conversation = mock.Mock(
side_effect=lambda *_args, **kwargs: bool(kwargs.get("allow_flat"))
)
bot._chat_identity_signature = mock.Mock(
side_effect=[b"before-title", b"after-title"]
)
bot._active_session_fp = None
bot._selected_session_fingerprint = mock.Mock(return_value=expected)
bot._remember_active_surface = mock.Mock()
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click") as click,
mock.patch("wechat_bot.time.sleep"),
):
self.assertTrue(
bot.click_session(64, expected_fp=expected, row_center=True)
)
self.assertTrue(
bot._is_real_conversation.call_args.kwargs["allow_flat"]
)
click.assert_called_once_with(600, 264)
def test_click_fails_closed_when_selected_fingerprint_cannot_be_read(self):
bot = WeChatBot.__new__(WeChatBot)
bot.scale = 2.0
bot.hwnd = 100
bot.list_region = {"top": 200}
bot.list_click_x = 600
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
latest = np.zeros((500, 180, 4), dtype=np.uint8)
bot.capture_session_list = mock.Mock(return_value=latest)
bot.detect_badge_rows = mock.Mock(return_value=[80])
bot._session_fingerprint = mock.Mock(return_value=b"target")
bot._is_real_conversation = mock.Mock(return_value=True)
bot._chat_identity_signature = mock.Mock(side_effect=[b"before", b"after"])
bot._selected_session_fingerprint = mock.Mock(return_value=None)
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot._run_ai_page_guard = mock.Mock(return_value=False)
bot._escape_proven_blocker = mock.Mock(return_value=False)
bot._begin_bot_mouse = mock.Mock()
bot._end_bot_mouse = mock.Mock()
with (
mock.patch("wechat_bot.pyautogui.click"),
mock.patch("wechat_bot.time.sleep"),
):
self.assertFalse(bot.click_session(80, expected_fp=b"target"))
def test_generate_reply_passes_archive_history_and_stages_whole_batch(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"12345678"
history = [
{"role": "user", "content": "上次的问题"},
{"role": "assistant", "content": "上次的回复"},
]
batch = (
"客户甲 7/29 10:00:01\n第一段\n"
"客户甲 7/29 10:00:08\n第二段\n"
"客户甲 7/29 10:00:18\n第三段"
)
bot.get_session_history = mock.Mock(return_value=history)
bot.extract_context_for = mock.Mock(return_value=batch)
bot._stage_exchange = mock.Mock()
bot._run_ai_page_guard = mock.Mock(return_value=False)
with (
mock.patch("ai_config.AI_ENABLED", True),
mock.patch("ai_config.AI_USE_VISION", False),
mock.patch("ai_config.AI_CONTEXT_ENABLED", True),
mock.patch("ai_chat.call_ai_text", return_value="统一回复") as call_ai,
mock.patch(
"registration_store.process_registration_reply",
return_value=("统一回复", None),
),
mock.patch("wechat_bot.time.sleep"),
):
self.assertEqual(bot._generate_ai_reply(fp), "统一回复")
call_ai.assert_called_once_with(batch, history=history)
staged_user_text = bot._stage_exchange.call_args.args[1]
self.assertEqual(staged_user_text, "第一段\n第二段\n第三段")
# ── 一个卡住的待回复任务不能饿死其它会话 ─────────────────────────────────
# `_poll_once` 见到 `_resume_orphaned_pending_reply()` 返回 True 就整轮 return。
# 只要「没做成任何事」也返回 True,一个永远认不回来的任务就会把未读扫描永久
# 挡掉,表现就是「回复几次之后再也不回了」。
def _resume_bot(self, **state_fields):
"""搭一个只有一个已读未回复任务的机器人,恢复链路全部打桩。"""
bot = WeChatBot.__new__(WeChatBot)
fp = b"r" * 40
state = {"confirmed_unread": True, "identity_signature": b"title"}
state.update(state_fields)
bot._pending_reply_sessions = {fp.hex(): state}
bot._pending_reply_path = ""
bot._pending_scan_progress = {}
bot._pending_scan_incomplete = False
bot._live_session_fp_aliases = set()
bot._active_session_fp = None
bot._active_identity_signature = None
bot._chat_identity_signature = mock.Mock(return_value=b"title")
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._find_pending_session = mock.Mock(return_value=None)
bot.click_session = mock.Mock(return_value=True)
bot._chat_target_matches = mock.Mock(return_value=True)
bot._accept_selected_title_render = mock.Mock(return_value=True)
bot._reconcile_uncertain_send = mock.Mock(return_value="unsent")
bot._mark_reply_pending = mock.Mock(return_value=True)
bot._wait_for_message_batch = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="回复")
bot._activate_wx = mock.Mock(return_value=True)
bot.send_reply = mock.Mock(return_value=True)
bot._pending_reply_state = (
lambda key: bot._pending_reply_sessions.get(bytes(key).hex())
)
return bot, fp, state
def test_unlocatable_pending_task_leaves_the_round_free_for_new_unread(self):
"""扫描完整个列表也认不回来时,绝不能吃掉本轮的未读处理。"""
bot, _fp, state = self._resume_bot()
self.assertFalse(bot._resume_orphaned_pending_reply())
self.assertEqual(state["resume_failures"], 1)
def test_an_unfinished_scan_keeps_its_progress_without_blocking_unread(self):
"""翻页扫描没走完要保留进度,但同样不能挡住未读扫描。"""
bot, _fp, state = self._resume_bot()
def scan(_target):
bot._pending_scan_incomplete = True
return None
bot._find_pending_session = mock.Mock(side_effect=scan)
self.assertFalse(bot._resume_orphaned_pending_reply())
# 分页扫描本身是在推进,不该被记成失败而提前退避。
self.assertNotIn("resume_failures", state)
def test_failed_reopen_does_not_consume_the_polling_round(self):
bot, _fp, state = self._resume_bot()
bot._find_pending_session = mock.Mock(
return_value=(np.zeros((8, 8, 4), np.uint8), 40)
)
bot.click_session = mock.Mock(return_value=False)
self.assertFalse(bot._resume_orphaned_pending_reply())
self.assertEqual(state["resume_failures"], 1)
def test_repeated_resume_failures_back_off_instead_of_retrying_every_round(self):
bot, _fp, _state = self._resume_bot()
base = wechat_bot.SESSION_SCAN_COOLDOWN_SECONDS
self.assertEqual(bot._resume_cooldown_seconds({}), base)
self.assertEqual(bot._resume_cooldown_seconds({"resume_failures": 1}), base * 2)
self.assertEqual(bot._resume_cooldown_seconds({"resume_failures": 3}), base * 8)
# 退避有上限,任务不会被无限期搁置。
self.assertEqual(
bot._resume_cooldown_seconds({"resume_failures": 99}),
bot._RESUME_BACKOFF_MAX_SECONDS,
)
def test_a_successful_resume_clears_the_backoff(self):
bot, fp, state = self._resume_bot(resume_failures=4)
bot._find_pending_session = mock.Mock(
return_value=(np.zeros((8, 8, 4), np.uint8), 40)
)
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(bot._resume_orphaned_pending_reply())
bot.send_reply.assert_called_once_with(
"回复",
session_id=fp.hex(),
expected_fp=fp,
)
self.assertNotIn("resume_failures", state)
def test_stuck_pending_task_no_longer_starves_the_unread_scan(self):
"""端到端:卡住的任务存在时,本轮仍然必须去扫描新的未读会话。"""
bot, _fp, _state = self._resume_bot()
message_page = self._nav_surface(True)
bot.scale = 1.0
bot.false_pos_rows = set()
bot.safe_window_mode = True
bot._did_initial_cleanup = True
bot.wait_for_mouse_idle = mock.Mock(return_value=True)
bot._dismiss_owned_blocking_window = mock.Mock(return_value=False)
bot._ensure_visible = mock.Mock(return_value=True)
bot._security_gate_visible = mock.Mock(return_value=False)
bot._capture_full_window = mock.Mock(return_value=message_page)
bot._refresh_message_geometry = mock.Mock()
bot._dismiss_internal_blocker = mock.Mock(return_value=False)
bot.capture_session_list = mock.Mock(
return_value=np.zeros((64, 80, 4), dtype=np.uint8)
)
bot._check_selected_session = mock.Mock(return_value=False)
bot._find_next_unread_session = mock.Mock(return_value=None)
# 真正会卡死的形态:列表很长,翻页扫描一轮走不完,于是每一轮都重扫。
def scan(_target):
bot._pending_scan_incomplete = True
return None
bot._find_pending_session = mock.Mock(side_effect=scan)
bot._poll_once()
bot._find_next_unread_session.assert_called_once()
# ── 每个会话的第一条消息必须回复 ─────────────────────────────────────────
# 判断“是否还在跟同一个会话”一旦用裸 `!=`,同一个联系人只要头像哈希漂几位
# (或只是未读粗体换成选中常规体),就会被当成刚切过来的新会话,本轮到达的
# 消息被并进基线吞掉,客户要再发一条才会得到回复。
_NAME_FP = b"n" * 32
# 实测同一联系人漂移:78781818… → 78700818…(相差 2 位,容差 6 位)
_AVATAR_FP = bytes([0x78, 0x78, 0x18, 0x18, 0x00, 0x00, 0x00, 0x00])
_AVATAR_FP_DRIFTED = bytes([0x78, 0x70, 0x08, 0x18, 0x00, 0x00, 0x00, 0x00])
def _selected_session_bot(self, current_fp, tracked_fp, *, customer_waiting):
"""搭一个“当前会话画面已变化”的机器人,回复链路全部打桩。"""
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_sessions = {}
bot._pending_reply_path = ""
bot._live_session_fp_aliases = set()
bot._capture_full_window = mock.Mock(return_value=self._nav_surface(True))
bot._message_nav_selected = mock.Mock(return_value=True)
bot.detect_selected_row = mock.Mock(return_value=24)
bot._session_fingerprint = mock.Mock(return_value=current_fp)
bot._flat_row_requires_visual_proof = mock.Mock(return_value=False)
bot._is_tool_selected = mock.Mock(return_value=False)
bot._raw_selected_session_fingerprint = mock.Mock(return_value=current_fp)
bot._selected_session_fingerprint = mock.Mock(return_value=current_fp)
bot._chat_identity_signature = mock.Mock(return_value=b"title")
bot._chat_surface_signature = mock.Mock(return_value=b"new-surface")
bot._ensure_session_archive_key = mock.Mock()
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._reconcile_uncertain_send = mock.Mock(return_value="unsent")
bot._selected_tracking_initialized = tracked_fp is not None
bot._active_session_fp = tracked_fp
bot._active_identity_signature = b"title"
bot._active_chat_signature = b"old-surface"
bot._activate_wx = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(
return_value="客户甲 10:00:00\n第一条消息"
)
bot._has_pending_customer_message = mock.Mock(return_value=customer_waiting)
bot._wait_for_message_batch = mock.Mock(return_value=True)
bot._send_gate_open = mock.Mock(return_value=True)
bot._generate_ai_reply = mock.Mock(return_value="这是回复")
bot.send_reply = mock.Mock(return_value=True)
states = {}
bot._pending_reply_state = lambda key: states.get(bytes(key))
def mark(target, **kwargs):
states.setdefault(bytes(target), {}).update(kwargs)
return True
bot._mark_reply_pending = mock.Mock(side_effect=mark)
return bot
def test_avatar_hash_drift_no_longer_swallows_the_first_new_message(self):
"""头像哈希漂 2 位仍是同一个人,第一条消息就必须回复。"""
current = self._AVATAR_FP_DRIFTED + self._NAME_FP
tracked = self._AVATAR_FP + self._NAME_FP
# 文字判定故意给 False:跟踪中的会话只要画面变化就应进入回复链路,
# 不该退回“建基线”把消息吞掉。
bot = self._selected_session_bot(current, tracked, customer_waiting=False)
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8)))
bot._generate_ai_reply.assert_called_once()
bot.send_reply.assert_called_once_with(
"这是回复",
session_id=current.hex(),
expected_fp=current,
)
def test_proven_render_alias_no_longer_swallows_the_first_new_message(self):
"""未读粗体与选中常规体已被证明是同一人,同样不能吞掉第一条消息。"""
current = bytes([0x11] * 8) + self._NAME_FP
tracked = bytes([0xEE] * 8) + self._NAME_FP
bot = self._selected_session_bot(current, tracked, customer_waiting=False)
bot._live_session_fp_aliases = {
tuple(sorted((current.hex(), tracked.hex())))
}
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8)))
bot._generate_ai_reply.assert_called_once()
def test_a_different_contact_still_only_establishes_a_baseline(self):
"""名称哈希不同就是另一个人,必须停在建基线,绝不能串聊天。"""
current = self._AVATAR_FP + b"n" * 32
tracked = self._AVATAR_FP + b"m" * 32
bot = self._selected_session_bot(current, tracked, customer_waiting=False)
bot.store = mock.Mock()
bot.store.has_record = mock.Mock(return_value=False)
with mock.patch("wechat_bot.time.sleep"):
self.assertFalse(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8)))
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
self.assertEqual(bot._active_session_fp, current)
def test_session_without_an_archive_recovers_its_first_unanswered_message(self):
"""还没建档的会话,第一条没人回过的客户消息不能被基线吞掉。"""
current = self._AVATAR_FP + self._NAME_FP
bot = self._selected_session_bot(current, None, customer_waiting=True)
bot.store = mock.Mock()
bot.store.has_record = mock.Mock(return_value=False)
bot.store.last_lines = mock.Mock(
side_effect=AssertionError("无档案时不得调用 last_lines,会建出空档案")
)
with mock.patch("wechat_bot.time.sleep"):
self.assertTrue(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8)))
bot._generate_ai_reply.assert_called_once()
bot.send_reply.assert_called_once()
def test_session_without_an_archive_baselines_when_the_agent_spoke_last(self):
"""屏幕上最后一条是我方发的,说明没人在等回复,仍然只建基线。"""
current = self._AVATAR_FP + self._NAME_FP
bot = self._selected_session_bot(current, None, customer_waiting=False)
bot.store = mock.Mock()
bot.store.has_record = mock.Mock(return_value=False)
with mock.patch("wechat_bot.time.sleep"):
self.assertFalse(bot._check_selected_session(np.zeros((80, 80, 4), np.uint8)))
bot._generate_ai_reply.assert_not_called()
bot.send_reply.assert_not_called()
# ── 挂载哪个窗口 ─────────────────────────────────────────────────────────
# 企业微信是多进程架构,子进程会创建 ClassName 和标题都一样的顶层窗口,
# 它平时隐藏、且完全不渲染内容。挂到它上面会既读不到界面,又被 _ensure_visible
# 强行显示成一整块黑屏。
@staticmethod
def _black_window(width: int, height: int) -> np.ndarray:
return np.zeros((height, width, 4), dtype=np.uint8)
@staticmethod
def _rendered_window(width: int, height: int) -> np.ndarray:
"""深色主题主界面:整体偏暗,但有大量明暗结构。"""
img = np.full((height, width, 4), 45, dtype=np.uint8)
img[:, ::7] = 210
img[::5, :] = 120
return img
def _patched_windows(self, windows, capture):
"""windows: [(hwnd, rect, visible, 进程启动时间)],按 Z 序排列。"""
rects = {hwnd: rect for hwnd, rect, _v, _s in windows}
visibles = {hwnd: vis for hwnd, _r, vis, _s in windows}
starts = {hwnd: start for hwnd, _r, _v, start in windows}
def enum_windows(callback, extra):
for hwnd, _rect, _vis, _start in windows:
callback(hwnd, extra)
return True
patchers = (
mock.patch("wechat_bot.win32gui.EnumWindows", side_effect=enum_windows),
mock.patch("wechat_bot.win32gui.GetClassName", return_value="WeWorkWindow"),
mock.patch(
"wechat_bot.win32gui.GetWindowRect",
side_effect=lambda hwnd: rects[hwnd],
),
mock.patch(
"wechat_bot.win32gui.IsWindowVisible",
side_effect=lambda hwnd: visibles[hwnd],
),
mock.patch(
"wechat_bot._window_process_start_time",
side_effect=lambda hwnd: starts[hwnd],
),
mock.patch("wechat_bot.capture_window_region", side_effect=capture),
)
with ExitStack() as stack:
for patcher in patchers:
stack.enter_context(patcher)
return stack.pop_all()
def test_blank_child_window_never_wins_over_the_rendered_main_window(self):
shell, main_window = 0x00480B1A, 0x000308E0
windows = [
# 空壳窗口排在 Z 序最前,FindWindow 会先返回它。
(shell, (0, 0, 2000, 1300), True, 1000.0),
(main_window, (342, 428, 2598, 1746), True, 900.0),
]
def capture(hwnd, _x, _y, width, height):
if hwnd == shell:
return self._black_window(width, height)
return self._rendered_window(width, height)
with self._patched_windows(windows, capture) as _patches:
self.assertEqual(find_wx_hwnd(), main_window)
def test_lone_hidden_window_is_kept_without_probing_pixels(self):
"""主面板收进托盘时只有一个候选,必须原样返回,不能因为“截图是黑的”被否掉。"""
tray = 0x000308E0
windows = [(tray, (342, 428, 2598, 1746), False, 900.0)]
capture = mock.Mock(side_effect=AssertionError("单个候选不应触发截图探测"))
with self._patched_windows(windows, capture) as _patches:
self.assertEqual(find_wx_hwnd(), tray)
capture.assert_not_called()
def test_all_blank_candidates_prefer_the_visible_earlier_process(self):
"""两个窗口都没渲染时(主面板也在托盘里),可见且进程更早的才是主界面。"""
shell, main_window = 0x00480B1A, 0x000308E0
windows = [
(shell, (0, 0, 2000, 1300), False, 1000.0),
(main_window, (342, 428, 2598, 1746), True, 900.0),
]
def capture(_hwnd, _x, _y, width, height):
return self._black_window(width, height)
with self._patched_windows(windows, capture) as _patches:
self.assertEqual(find_wx_hwnd(), main_window)
def test_render_probe_separates_dark_theme_from_an_empty_shell(self):
rect = (342, 428, 2598, 1746)
width, height = rect[2] - rect[0], rect[3] - rect[1]
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect),
mock.patch(
"wechat_bot.capture_window_region",
return_value=self._rendered_window(width, height),
),
):
self.assertTrue(window_looks_rendered(0x1))
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect),
mock.patch(
"wechat_bot.capture_window_region",
return_value=self._black_window(width, height),
),
):
self.assertFalse(window_looks_rendered(0x1))
# 纯色填充同样不是主界面(没有任何界面结构)。
with (
mock.patch("wechat_bot.win32gui.GetWindowRect", return_value=rect),
mock.patch(
"wechat_bot.capture_window_region",
return_value=np.full((height, width, 4), 200, dtype=np.uint8),
),
):
self.assertFalse(window_looks_rendered(0x1))
def test_render_probe_survives_a_window_that_dies_mid_capture(self):
with (
mock.patch(
"wechat_bot.win32gui.GetWindowRect",
return_value=(342, 428, 2598, 1746),
),
mock.patch(
"wechat_bot.capture_window_region",
side_effect=RuntimeError("窗口尺寸异常"),
),
):
self.assertFalse(window_looks_rendered(0x1))
class NothingLeftToAnswerTextProofTest(TestCase):
"""气泡方向看错边时,复制文字必须能把客户的新消息救回来(2026-07-31 复盘)。
现场时间线:客户刚收到我方一条长回复、马上又追了一句很短的话;底部取样把
两个气泡圈在一起,长回复的墨量让整段被判成「右侧收尾」,任务被清掉、红点
又已被点掉,客户从此收不到回复。
"""
CUSTOMER_TAIL = (
"高兴亮 7/31 17:12:11\n"
"在这儿呢,刚看到你的消息,有什么事你说\n"
"高瑞@微信@微信联系人 7/31 17:19:30\n"
"哈哈哈"
)
AGENT_TAIL = (
"高瑞@微信@微信联系人 7/31 17:11:49\n"
"你在干嘛\n"
"高兴亮 7/31 17:12:11\n"
"在这儿呢,刚看到你的消息,有什么事你说"
)
def _bot(self, fp, state, extracted_text):
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_sessions = {fp.hex(): state}
bot._known_outgoing_speakers = mock.Mock(return_value={"高兴亮"})
bot._last_visible_bubble_is_outgoing = mock.Mock(return_value=True)
bot.extract_chat_text = mock.Mock(return_value=extracted_text)
bot._commit_staged_exchange = mock.Mock()
bot._reset_pending_batch = mock.Mock()
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._clear_reply_pending = mock.Mock()
bot._remember_active_surface = mock.Mock()
bot._log_queue_event = mock.Mock()
return bot
def test_customer_tail_overrides_the_outgoing_bubble_and_keeps_the_task(self):
fp = b"g" * 40
state = {"confirmed_unread": True}
bot = self._bot(fp, state, self.CUSTOMER_TAIL)
with redirect_stdout(io.StringIO()):
self.assertFalse(bot._nothing_left_to_answer(fp))
# 没传文字时必须自己取证,而不是只信像素
bot.extract_chat_text.assert_called_once_with(screens=1)
bot._clear_reply_pending.assert_not_called()
def test_explicit_chat_text_skips_the_extra_extraction(self):
fp = b"g" * 40
bot = self._bot(fp, {"confirmed_unread": True}, "")
with redirect_stdout(io.StringIO()):
self.assertFalse(bot._nothing_left_to_answer(fp, self.CUSTOMER_TAIL))
bot.extract_chat_text.assert_not_called()
bot._clear_reply_pending.assert_not_called()
def test_agent_tail_still_settles_and_clears_the_task(self):
fp = b"g" * 40
bot = self._bot(fp, {"confirmed_unread": True}, self.AGENT_TAIL)
with redirect_stdout(io.StringIO()):
self.assertTrue(bot._nothing_left_to_answer(fp))
bot._clear_reply_pending.assert_called_once_with(fp)
def test_delivered_reply_is_archived_before_answering_the_followup(self):
fp = b"g" * 40
state = {
"confirmed_unread": False,
"staged_reply_text": "在这儿呢,刚看到你的消息,有什么事你说",
}
bot = self._bot(fp, state, self.CUSTOMER_TAIL)
with redirect_stdout(io.StringIO()):
self.assertFalse(bot._nothing_left_to_answer(fp))
# 我方那句已经在屏幕上:先落档,再把任务转回「客户又有新消息」
bot._commit_staged_exchange.assert_called_once_with(fp.hex())
bot._reset_pending_batch.assert_called_once_with(fp)
self.assertTrue(state.get("confirmed_unread"))
bot._clear_reply_pending.assert_not_called()
class PendingDisplayNameBindingTest(TestCase):
"""点击前标题还停在上一个会话时,任务不得错绑上一个客户的名字。"""
def test_display_name_waits_until_the_title_matches_the_target(self):
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_sessions = {}
bot.identity_by_name = True
bot._persist_pending_replies = mock.Mock(return_value=True)
bot._log_queue_event = mock.Mock()
bot._chat_identity_signature = mock.Mock(return_value=b"")
fp = WeChatBot._fp_from_name("一个小迷糊@微信")
# 面板还停在上一个会话「高瑞@微信」时入队:名字对不上号,不绑
bot._session_label = mock.Mock(return_value="高瑞@微信")
bot._mark_reply_pending(fp, confirmed_unread=True, bind_identity=False)
state = bot._pending_reply_sessions[fp.hex()]
self.assertNotIn("display_name", state)
# 面板切换完成后,下一次调用补绑正确的名字
bot._session_label = mock.Mock(return_value="一个小迷糊@微信")
bot._mark_reply_pending(fp, batch_ready=True)
self.assertEqual(state.get("display_name"), "一个小迷糊@微信")
class ManualPendingCancellationTest(TestCase):
def _bot(self, state):
bot = WeChatBot.__new__(WeChatBot)
fp = b"c" * 40
key = fp.hex()
bot._pending_lock = threading.RLock()
bot._pending_reply_sessions = {key: state}
bot._pending_scan_progress = {key: {"page": 1}}
bot._pending_exchanges = {key: ("问题", "回复")}
bot._cancelled_reply_sessions = set()
bot._persist_pending_replies_unlocked = mock.Mock(return_value=True)
bot._queue_log_instance = mock.Mock()
bot._active_session_fp = fp
bot._active_hold_key = key
bot._active_hold_since = 1.0
bot._active_hold_expired = True
bot._uncertain_send_last_check = {}
bot._uncertain_send_last_log = {}
bot._uncertain_send_last_surface = {}
return bot, fp, key
def test_live_manual_delete_removes_state_and_blocks_inflight_recreation(self):
bot, fp, key = self._bot({
"display_name": "一个小迷糊@微信",
"generation_surface_signature": b"surface",
})
result = bot.cancel_pending_replies([key])
self.assertEqual(result["deleted"], [key])
self.assertNotIn(key, bot._pending_reply_sessions)
self.assertNotIn(key, bot._pending_exchanges)
self.assertTrue(bot._reply_task_cancelled(fp))
self.assertFalse(bot._mark_reply_pending(fp, batch_ready=True))
self.assertEqual(bot._active_chat_signature, b"surface")
def test_manual_delete_protects_task_after_send_dispatch_reservation(self):
bot, _fp, key = self._bot({
"display_name": "甲",
"send_state": "sending",
"send_dispatched_at": time.time(),
})
result = bot.cancel_pending_replies([key])
self.assertEqual(result["protected"], [key])
self.assertIn(key, bot._pending_reply_sessions)
self.assertFalse(bot._cancelled_reply_sessions)
def test_cancelled_inflight_task_is_rejected_before_any_send_action(self):
bot, fp, key = self._bot({"display_name": "甲"})
bot._cancelled_reply_sessions.add(key)
self.assertFalse(bot.send_reply("不会发送", expected_fp=fp))
self.assertEqual(bot.last_send_failure_reason(), "任务已被手动删除")
def setUpModule():
queue_log_redirect.start()
def tearDownModule():
queue_log_redirect.stop()
if __name__ == "__main__":
main()