Files
kefu/wechat_rpa/wechat_bot.py
T
2026-07-31 11:48:16 +08:00

8596 lines
382 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
企业微信 PC 端 GUI 自动化机器人 v3.1
=====================================
【技术路线】
由于企业微信使用完全自定义的 GPU 渲染引擎(UIA 节点=0Win32子窗口=0),
所有 UIAutomation / win32gui 内部控件查询均无效。
本脚本采用:
1. win32gui → 查找窗口 HWND、强制还原窗口、获取真实坐标
2. mss.MSS → 高速截图
3. numpy → 红点色彩识别
4. pyautogui → 鼠标点击 + 键盘输入
5. pyperclip → 剪贴板粘贴(防止中文字符乱码)
【安装依赖】
pip install mss Pillow numpy pyautogui pyperclip pywin32
【运行方式】
python wechat_bot.py # 正式监听模式
python wechat_bot.py --calibrate # 标定模式(验证区域划分是否正确)
python wechat_bot.py --test-input # 测试输入框点击
"""
import sys
import time
import json
import os
import re
import hashlib
import ctypes
import io
import unicodedata
from collections import deque
import win32gui
import win32con
import win32ui
import win32process
import win32api
import pyautogui
import pyperclip
import numpy as np
from PIL import Image
from conversation_store import ConversationStore
from runtime_paths import application_data_dir
# ──────────────────────────────────────────────────────────────────────────────
# 全局安全设置
# ──────────────────────────────────────────────────────────────────────────────
pyautogui.FAILSAFE = True # 鼠标移到屏幕左上角 (0,0) 时强制停止,防止失控
pyautogui.PAUSE = 0.05 # 每次 pyautogui 操作后的基础延时(秒)
# ──────────────────────────────────────────────────────────────────────────────
# 常量配置(如界面布局变化,只修改这里)
# ──────────────────────────────────────────────────────────────────────────────
WX_WINDOW_CLASS = 'WeWorkWindow' # 企业微信主窗口类名(已通过 inspect_tree.py 确认)
AUTO_REPLY_TEXT = "在的,您慢慢说,我这边看着呢。" # AI 不可用时的简短兜底回
POLL_INTERVAL = 2.0 # 轮询间隔(秒)
# 连续消息聚合:发现待回复消息后先收集 20 秒,把客户在这段时间内连续发送的
# 多个气泡合并为一次模型请求,避免每个短句各回一遍
MESSAGE_BATCH_WINDOW_SECONDS = 20.0
MESSAGE_BATCH_WINDOW_MIN_SECONDS = 1.0
MESSAGE_BATCH_WINDOW_MAX_SECONDS = 120.0
MESSAGE_BATCH_POLL_SECONDS = 0.5
# 合规发送保护:限制自动回复的连续操作频率,避免短时间内集中发送。
# 这些限制不是为了绕过平台检测,而是为了在业务高峰时主动降载。
MIN_SEND_INTERVAL_SECONDS = 8.0
MAX_SENDS_PER_MINUTE = 4
MAX_SENDS_PER_HOUR = 60
MAX_REPLIES_PER_ROUND = 5
# 发送结果不确定时只做低频只读对账,避免每轮框选并重复刷“冻结”日志
UNCERTAIN_SEND_RECHECK_SECONDS = 5.0
UNCERTAIN_SEND_LOG_SECONDS = 30.0
SEND_RECEIPT_MAX_CHECKS = 2
SEND_RECEIPT_TIMEOUT_SECONDS = 8.0
SEND_RECEIPT_CHECK_INTERVAL_SECONDS = 0.5
# 未读会话可能不会自动排到列表顶部。只有检测到“消息”入口仍有全局未读时,
# 才分页扫描会话列表;页面签名重复即认为已到底
# 每次只发送一个滚轮格。Windows 可配置为“一格滚三行”或“一格翻一页”;
# 若一次发 6 格,在“按页滚动”的机器上会直接跨过 5 页未读会话
SESSION_SCAN_SCROLL_CLICKS = 1
SESSION_SCAN_MAX_PAGES = 240
SESSION_SCAN_MAX_SECONDS = 45.0
SESSION_SCAN_COOLDOWN_SECONDS = 15.0
# 会话身份由 64 位头像感知哈希 + 256 位名称字形哈希组成。名称使用更
# 高分辨率是为了避免相同默认头像、相似显示名被压成同一 8x8 指纹
_AVATAR_FP_BYTES = 8
_NAME_FP_BYTES = 32
_SESSION_FP_BYTES = _AVATAR_FP_BYTES + _NAME_FP_BYTES
# 早期复合键曾使用“8 字节头像 + 8 字节名称”。升级后仍读取并在能证明
# 联系人归属时迁移,不能把已读未回复任务或会话上下文静默丢掉
_LEGACY_SESSION_FP_BYTES = 16
_CHAT_SPEAKER_HEADER_RE = re.compile(
r"^(?P<speaker>.+?)\s+"
r"(?:(?:(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}|"
r"今天|昨天|前天|星期[一二三四五六日天])\s+)?"
r"\d{1,2}:\d{2}(?::\d{2})?$"
)
# AI 页面守护只在异常画面触发,并限制调用频率;正常聊天轮询不调用视觉模型
AI_UI_GUARD_COOLDOWN_SECONDS = 8.0
AI_UI_GUARD_SAME_PAGE_SECONDS = 45.0
AI_UI_GUARD_MIN_CONFIDENCE = 0.82
# 同一个顽固弹窗如果没有被 Esc/关闭按钮移除,不得每轮轮询都重复操作
# 在保持回复流程冻结的同时降低重试频率,避免用户日志中 7 秒按一 Esc
BLOCKER_RETRY_SECONDS = 12.0
# 人机共存:人工移动鼠标后,机器人暂停;鼠标静止满此秒数才继续操作
MOUSE_IDLE_ENABLED = True
MOUSE_IDLE_SECONDS = 20
MOUSE_MOVE_THRESHOLD = 8 # 位移超过此像素才视为人工移动(过滤抖动)
# 企业微信 UI 布局参数(相对于窗口左上角的像素偏移,适用于大多数 PC 版本)
NAV_BAR_W = 68 # 最左侧导航图标栏宽度
SESSION_LIST_W = 230 # 会话列表区域宽度
HEADER_H = 56 # 顶部标题栏高度
SESSION_ITEM_H = 64 # 每个会话条目的高度(用于行号计算)
# 输入框位置(从窗口底部量)
# 企业微信新版的编辑器正文位于底部工具栏下方。旧 95 200% DPI
# 下会落到工具栏空白处 0 会稳定落在正文内部,真正发送前仍会回读草稿校验
INPUT_Y_FROM_BOTTOM = 60 # 输入框正文取样点距窗口底部的逻辑像素
INPUT_X_RATIO = 0.62 # 输入框中心在聊天区域的水平比例
CHAT_BOTTOM_FROM_BOTTOM = 125 # 消息区底边到窗口底部(独立于输入点击点)
RIGHT_SIDEBAR_W = 350 # 右侧工具栏宽度(客户转账/问诊单/会话管理等)
# 消息区右缘那条滚动条会随鼠标进出自动淡入淡出。它一变化就会改写聊天画面
# 指纹,把机器人自己移动鼠标造成的重绘当成“又收到新消息”,进而在 Enter
# 前清掉刚写好的草稿,自动回复永远发不出去。发给模型的截图仍需保留完整气泡
# (自己的气泡右缘紧贴滚动条),所以只在计算指纹墨迹网格时排除这条槽位
CHAT_SCROLLBAR_GUTTER = 14 # 计算画面指纹时忽略的消息区右缘逻辑像素
# 聊天上下文提取参数(框选 + 剪贴板复制)
CHAT_SELECT_TOP_MARGIN = 30 # 框选终点距聊天区域顶部的安全距离(逻辑像素,防止触发翻页加载历史)
CHAT_CONTEXT_MAX_LINES = 150 # 提取聊天记录的最大行数
CHAT_CONTEXT_SCREENS = 3 # 向上翻屏复制的屏数(1 = 只复制当前可见一屏)
CHAT_SCROLL_CLICKS = 8 # 每向上翻一屏滚动的滚轮格数(不够一屏可调大)
CHAT_FULL_SCREEN_LINES = 12 # 一屏少于此行数视为「消息不满一屏」,不再翻屏采集更早的历史
# 红点色彩阈值(企业微信未读徽章颜色 #FA5151 = R250 G81 B81
# mss 截图格式为 BGRA,通道顺序:B=0, G=1, R=2, A=3
# ⚠ 阈值收紧:避免误把会话头像里的红色内容当红点
BADGE_R_MIN = 235 # 红通道最小值(#FA5151 的 R=250,留15点容差)
BADGE_R_MAX = 255 # 红通道最大值
BADGE_G_MAX = 90 # 绿通道最大值(#FA5151 的 G=81
BADGE_B_MAX = 90 # 蓝通道最大值(#FA5151 的 B=81
MIN_RED_PIXELS = 15 # 判定为一个红点所需的最少红色像素数(增大,减少噪声)
BADGE_MERGE_GAP = 12 # 像素行间距 ≤ 此值视为同一个红点
# ⚠ 空间过滤:企业微信未读数字红点悬浮于头像右上角
# 头像在左侧(x: 0~60),红点徽章在此区域右上角(x: 35~80),只扫描此区间避免头像和右侧混淆误判
BADGE_SCAN_X_START = 35 # 扫描起点
BADGE_SCAN_X_END = 80 # 扫描终点
def infer_navigation_width(full: np.ndarray, scale: float = 1.0) -> tuple[int, float]:
"""
从左侧导航在“消息”行的浅蓝背景推断会话列表起点。
企业微信同时存在约 68 逻辑像素的窄图标栏和约 145~160 逻辑像素的
宽文字栏。返回 ``(物理像素宽度, 置信度)``;识别失败时回退旧窄栏宽度。
"""
fallback = max(1, int(NAV_BAR_W * max(0.75, float(scale or 1.0))))
if full is None or getattr(full, "ndim", 0) != 3:
return fallback, 0.0
scale = max(0.75, float(scale or 1.0))
height, width = full.shape[:2]
y1 = max(0, int(65 * scale))
y2 = min(height, max(y1 + 1, int(105 * scale)))
max_x = min(width, max(fallback + 1, int(210 * scale)))
roi = full[y1:y2, :max_x, :3].astype(np.int16)
if roi.size == 0:
return fallback, 0.0
blue, green, red = roi[:, :, 0], roi[:, :, 1], roi[:, :, 2]
# 导航背景是很浅的蓝灰色;消息被选中时还会包含更明显的蓝色
nav_tint = (
(blue > 175)
& (green > 155)
& (red > 125)
& (blue - red > 8)
& (blue - green > 2)
)
coverage = nav_tint.mean(axis=0)
active = coverage >= 0.25
# 从窗口左边开始追踪连续背景,容忍图标/文字造成的小缺口
gap_limit = max(3, int(6 * scale))
last_active = -1
gap = 0
started = False
for x, is_active in enumerate(active):
if is_active:
started = True
last_active = x
gap = 0
elif started:
gap += 1
if gap > gap_limit:
break
candidate = last_active + 1
min_width = int(50 * scale)
max_width = int(205 * scale)
if candidate < min_width or candidate > max_width:
return fallback, 0.0
inside_start = max(0, candidate - max(2, int(5 * scale)))
outside_end = min(len(coverage), candidate + max(3, int(8 * scale)))
inside = float(np.mean(coverage[inside_start:candidate])) if candidate > inside_start else 0.0
outside = float(np.mean(coverage[candidate:outside_end])) if outside_end > candidate else 0.0
confidence = max(0.0, min(1.0, inside - outside))
if confidence < 0.12:
return fallback, confidence
return candidate, confidence
def infer_composer_top(
full: np.ndarray,
chat_left: int,
scale: float = 1.0,
) -> int | None:
"""Return the window-relative y of the message/editor divider.
WeCom lets the user drag the composer to different heights, so a fixed
distance from the window bottom is not a valid message viewport boundary.
The divider itself is much more stable: across the chat pane it is a
two-edge horizontal rule (message background -> rule -> white editor).
"""
if full is None or getattr(full, "ndim", 0) != 3:
return None
height, width = full.shape[:2]
scale = max(0.75, float(scale or 1.0))
input_center = int(
chat_left + max(0, width - chat_left) * INPUT_X_RATIO
)
x1 = max(
0,
int(chat_left + 10 * scale),
input_center - int(220 * scale),
)
x2 = min(
width,
int(width - 80 * scale),
input_center + int(220 * scale),
)
if x2 - x1 < max(80, int(120 * scale)):
return None
# A normal composer is roughly 70-360 logical pixels tall. Keeping the
# search near the bottom avoids confusing the title/header separator with
# the draggable composer divider.
search_start = max(
int(height * 0.35),
height - int(360 * scale),
)
search_stop = min(
height - max(2, int(70 * scale)),
height - 2,
)
if search_stop <= search_start:
return None
try:
pane = full[:, x1:x2, :3].astype(np.int16)
gray = pane.mean(axis=2)
row_delta = np.abs(np.diff(gray, axis=0))
changed_fraction = (row_delta > 4.0).mean(axis=1)
mean_delta = row_delta.mean(axis=1)
indices = np.arange(search_start - 1, search_stop - 1)
valid = indices[
(changed_fraction[indices] >= 0.80)
& (mean_delta[indices] >= 8.0)
]
if not len(valid):
return None
cluster_gap = max(3, int(round(2 * scale)))
clusters = [[int(valid[0])]]
for index in valid[1:]:
index = int(index)
if index - clusters[-1][-1] <= cluster_gap:
clusters[-1].append(index)
else:
clusters.append([index])
if len(clusters) != 1:
return None
cluster = np.asarray(clusters[0], dtype=int)
best_fraction = float(changed_fraction[cluster].max())
# The rule has two strong edges. Prefer the first near-equal edge so
# the message crop ends before the rule rather than inside the editor.
near_best = cluster[
changed_fraction[cluster] >= max(0.80, best_fraction - 0.02)
]
return int(near_best[0] + 1)
except Exception:
return None
# ──────────────────────────────────────────────────────────────────────────────
# 全局路径
# ──────────────────────────────────────────────────────────────────────────────
_SCRIPT_DIR = str(application_data_dir())
# ──────────────────────────────────────────────────────────────────────────────
# 内存临时黑名单配置
# ──────────────────────────────────────────────────────────────────────────────
# 假阳性行号黑名单不再进行文件持久化,而是在每次轮询开始前动态清空,
# 配合头像类型检测过滤系统工具。
# ──────────────────────────────────────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────────────────────────────────────
def safe_set_foreground(hwnd: int) -> bool:
"""
安全地将指定窗口设置为前台焦点窗口。
解决 (0, 'SetForegroundWindow', 'No error message is available') 权限限制。
"""
try:
# 如果当前已经是前台窗口,直接返回成功
if win32gui.GetForegroundWindow() == hwnd:
return True
# 尝试直接设置
win32gui.SetForegroundWindow(hwnd)
return True
except Exception:
pass
# 尝试模拟 ALT 键按下和释放,绕过 Windows 前台限制
try:
# VK_MENU = 0x12
win32api.keybd_event(0x12, 0, 0, 0) # ALT 按下
win32gui.SetForegroundWindow(hwnd)
win32api.keybd_event(0x12, 0, win32con.KEYEVENTF_KEYUP, 0) # ALT 释放
return True
except Exception:
pass
# 尝试 AttachThreadInput 挂接线程输入
try:
fore_hwnd = win32gui.GetForegroundWindow()
fore_thread, _ = win32process.GetWindowThreadProcessId(fore_hwnd)
curr_thread = win32api.GetCurrentThreadId()
if fore_thread != curr_thread:
win32process.AttachThreadInput(curr_thread, fore_thread, True)
win32gui.ShowWindow(hwnd, win32con.SW_SHOW)
win32gui.SetForegroundWindow(hwnd)
win32process.AttachThreadInput(curr_thread, fore_thread, False)
return True
except Exception as e:
print(f" [~] safe_set_foreground 彻底失败: {e}")
return False
def window_looks_rendered(hwnd: int) -> bool:
"""窗口是否真的画出了内容,用于在多个同类窗口之间取舍。
企业微信子进程的空壳窗口整幅都是纯黑;深色主题的真实主界面是深灰并且带有
大量明暗结构,因此“几乎全是纯黑 + 几乎没有明暗差异”才判为未渲染。
"""
try:
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
except Exception:
return False
width, height = right - left, bottom - top
if width < 64 or height < 64:
return False
try:
shot = capture_window_region(hwnd, 0, 0, width, height)
except Exception:
return False
if shot is None or getattr(shot, "size", 0) == 0:
return False
sample = shot[::8, ::8, :3]
if not sample.size:
return False
if float((sample.max(axis=2) < 16).mean()) >= 0.98:
return False
return float(sample.std()) >= 3.0
def _window_process_start_time(hwnd: int) -> float:
"""窗口所属进程的创建时间;主进程总比它派生的子进程更早。"""
handle = None
try:
_thread_id, pid = win32process.GetWindowThreadProcessId(hwnd)
handle = win32api.OpenProcess(
win32con.PROCESS_QUERY_INFORMATION,
False,
pid,
)
created = win32process.GetProcessTimes(handle).get("CreationTime")
try:
return float(created.timestamp())
except AttributeError:
return float(int(created))
except Exception:
return float("inf")
finally:
if handle:
try:
win32api.CloseHandle(handle)
except Exception:
pass
def find_wx_hwnd() -> int:
"""
用 win32gui 查找企业微信主窗口句柄。
企业微信是多进程架构:除了真正的主界面,它的子进程还会创建 ClassName 和标题
都完全相同的顶层窗口(实测 2000×1300、位于 (0,0)、完全没有渲染内容)。
FindWindow 只按 Z 序返回第一个,挂到这种空壳窗口上会同时造成两个后果:监听
读不到任何内容,而 _ensure_visible 又会把它强行显示并切到前台,用户眼前就
出现一整块黑屏。
因此多个候选时按「有渲染内容 > 可见 > 进程启动更早 > 面积更大」打分挑选;
只有一个候选时直接返回,保持“主面板收进托盘、窗口暂时不可见”的还原流程不变。
"""
candidates = []
def collect(hwnd, _extra):
try:
if WX_WINDOW_CLASS in win32gui.GetClassName(hwnd):
candidates.append(hwnd)
except Exception:
pass
return True
try:
win32gui.EnumWindows(collect, None)
except Exception:
candidates = []
if not candidates:
try:
return win32gui.FindWindow(WX_WINDOW_CLASS, None) or 0
except Exception:
return 0
if len(candidates) == 1:
return candidates[0]
scored = []
for hwnd in candidates:
try:
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
area = max(0, right - left) * max(0, bottom - top)
except Exception:
area = 0
try:
visible = bool(win32gui.IsWindowVisible(hwnd))
except Exception:
visible = False
scored.append((
1 if window_looks_rendered(hwnd) else 0,
1 if visible else 0,
-_window_process_start_time(hwnd),
area,
hwnd,
))
scored.sort(reverse=True)
if scored[0][0] and not all(item[0] for item in scored):
print(
f"[*] 企业微信存在 {len(scored)} 个同类顶层窗口,"
"已挑选真正渲染了主界面的那一个(其余为子进程空壳窗口)。"
)
return scored[0][4]
def find_wx_process_path() -> str:
"""主窗口被关闭到托盘时,查找仍在运行的企业微信程序路径。"""
process_names = {"wxwork.exe", "wework.exe"}
try:
process_ids = win32process.EnumProcesses()
except Exception:
return ""
for process_id in process_ids:
if not process_id:
continue
handle = None
try:
handle = win32api.OpenProcess(
win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ,
False,
process_id,
)
path = win32process.GetModuleFileNameEx(handle, 0)
if os.path.basename(path).lower() in process_names:
return path
except Exception:
continue
finally:
if handle:
try:
win32api.CloseHandle(handle)
except Exception:
pass
return ""
def is_wx_process_running() -> bool:
return bool(find_wx_process_path())
def restore_window(hwnd: int):
"""显示或还原窗口并切到前台,但不设置系统级置顶。"""
placement = win32gui.GetWindowPlacement(hwnd)
if placement[1] == win32con.SW_SHOWMINIMIZED:
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
time.sleep(0.4)
elif not win32gui.IsWindowVisible(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_SHOW)
time.sleep(0.3)
safe_set_foreground(hwnd)
def capture_window_region(hwnd: int, x: int, y: int, w: int, h: int) -> np.ndarray:
"""
使用 PrintWindow(PW_RENDERFULLCONTENT=2) 直接从窗口显存截图。
✅ 不依赖窗口是否可见、是否在前台、是否被其他窗口遮挡。
✅ 适用于 GPU 渲染(DirectX/OpenGL)的自定义框架应用(如企业微信)。
参数 x,y,w,h 为相对于窗口客户区左上角的偏移。
返回 BGRA numpy 数组,shape = (h, w, 4)。
"""
# 获取完整窗口尺寸
rect = win32gui.GetWindowRect(hwnd)
win_w = rect[2] - rect[0]
win_h = rect[3] - rect[1]
if win_w <= 0 or win_h <= 0:
raise RuntimeError(f"窗口尺寸异常: {win_w}x{win_h}")
# 建立内存 DC 和兼容位图。窗口探测会对任意候选窗口截图,其中可能有正在销
# 或尺寸异常的窗口;任何一步抛错都必须归还 GDI 资源,否则高频截图会累积泄漏
# 直到窗口画不出内容
hwnd_dc = mfc_dc = mem_dc = bmp = None
try:
hwnd_dc = win32gui.GetWindowDC(hwnd)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
mem_dc = mfc_dc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(mfc_dc, win_w, win_h)
mem_dc.SelectObject(bmp)
# PW_RENDERFULLCONTENT = 2Windows 8.1+),专门捕获 GPU 渲染内容
ctypes.windll.user32.PrintWindow(hwnd, mem_dc.GetSafeHdc(), 2)
# 读取像素数据(BGRA 32位)
raw = bmp.GetBitmapBits(True)
full = np.frombuffer(raw, dtype=np.uint8).reshape(win_h, win_w, 4)
# 裁剪到目标区域并返回副本
return full[y: y + h, x: x + w].copy()
finally:
for release in (
lambda: win32gui.DeleteObject(bmp.GetHandle()),
lambda: mem_dc.DeleteDC(),
lambda: mfc_dc.DeleteDC(),
lambda: win32gui.ReleaseDC(hwnd, hwnd_dc),
):
try:
release()
except Exception:
pass
def save_debug_screenshot(img_np, filename="debug_list.png"):
"""将 BGRA numpy 数组保存为 PNG"""
import os
path = os.path.join(_SCRIPT_DIR, filename)
Image.fromarray(img_np[:, :, :3][:, :, ::-1]).save(path) # BGRA→RGB
return path
def looks_like_security_verification(img_np: np.ndarray) -> bool:
"""保守识别登录/安全验证二维码页面;命中后禁止任何自动点击和发送。"""
if img_np is None or getattr(img_np, "ndim", 0) != 3:
return False
height, width = img_np.shape[:2]
if height < 300 or width < 500:
return False
# 验证页没有正常主界面的深色左侧导航,同时中央有高密度黑白二维码。
rgb = img_np[:, :, :3].astype(np.float32)
gray = rgb.mean(axis=2)
nav_width = max(16, int(width * 0.06))
nav = gray[int(height * 0.08):int(height * 0.92), :nav_width]
center = gray[
int(height * 0.32):int(height * 0.70),
int(width * 0.32):int(width * 0.68),
]
if not nav.size or not center.size:
return False
nav_dark_ratio = float((nav < 120).mean())
dark = center < 75
light_ratio = float((center > 220).mean())
transition_ratio = float(
(dark[:, 1:] != dark[:, :-1]).mean()
+ (dark[1:, :] != dark[:-1, :]).mean()
)
qr_like = float(dark.mean()) >= 0.025 and light_ratio >= 0.45 and transition_ratio >= 0.035
return nav_dark_ratio < 0.12 and qr_like
def _longest_true_run(mask: np.ndarray) -> int:
"""返回布尔序列中最长的连续 True 段长度。"""
flat = np.asarray(mask).reshape(-1)
if not flat.size or not flat.any():
return 0
padded = np.concatenate(([False], flat.astype(bool), [False]))
edges = np.flatnonzero(padded[1:] != padded[:-1])
return int((edges[1::2] - edges[0::2]).max())
def _largest_true_span(mask: np.ndarray) -> tuple[int, int] | None:
"""返回布尔序列中最长连续 True 段的 [起点, 终点) 下标。"""
flat = np.asarray(mask).reshape(-1)
if not flat.size or not flat.any():
return None
padded = np.concatenate(([False], flat.astype(bool), [False]))
edges = np.flatnonzero(padded[1:] != padded[:-1])
starts, stops = edges[0::2], edges[1::2]
best = int(np.argmax(stops - starts))
return int(starts[best]), int(stops[best])
def _panel_border_column_run(
gray: np.ndarray,
center_x: int,
width: int,
top: int,
bottom: int,
) -> int:
"""X 右侧最像弹窗竖直边框的那一列,其最长连续边缘长度。
弹窗边框是一条不间断的直线;聊天气泡叠在一起时,同一列只会得到一串
被气泡间隙打断的短边缘,凑不出连续数百像素的边线。
"""
best = 0
start = center_x + max(14, int(18 * width / 1600))
stop = min(width - 1, center_x + max(70, int(95 * width / 1600)))
for edge_x in range(max(1, start), stop):
column = gray[top:bottom, edge_x]
previous = gray[top:bottom, edge_x - 1]
if not column.size or not previous.size:
continue
mask = np.abs(column - previous) > 5
run = _longest_true_run(mask)
if run <= best:
continue
# 边框自身灰度基本恒定;汉字笔画沿纵向会剧烈起伏
if float(column[mask].std()) > 40.0:
continue
best = run
return best
def _panel_top_border_run(
gray: np.ndarray,
center_y: int,
height: int,
) -> int:
"""X 上方最长的横向面板上边线长度。"""
best = 0
top = max(1, center_y - int(height * 0.30))
bottom = max(top + 1, center_y - 6)
for edge_y in range(top, bottom):
mask = np.abs(gray[edge_y] - gray[edge_y - 1]) > 5
run = _longest_true_run(mask)
if run > best:
best = run
return best
def looks_like_modal_scrim(img_np: np.ndarray) -> bool:
"""真正的内部弹窗会给主界面盖一层半透明遮罩,四周同时变暗。"""
if img_np is None or getattr(img_np, "ndim", 0) != 3:
return False
height, width = img_np.shape[:2]
if height < 300 or width < 600:
return False
gray = img_np[:, :, :3].astype(np.float32).mean(axis=2)
bands = [
gray[:, : int(width * 0.30)],
gray[:, int(width * 0.90):],
gray[: int(height * 0.05), :],
gray[int(height * 0.95):, :],
]
values = [band.reshape(-1) for band in bands if band.size]
if not values:
return False
merged = np.concatenate(values)
dim_ratio = float(((merged >= 60) & (merged <= 175)).mean())
return dim_ratio >= 0.75 and float(merged.mean()) < 190.0
def find_blocking_modal_close(img_np: np.ndarray) -> tuple[int, int] | None:
"""识别企业微信内部居中弹窗右上角的 X,返回窗口内坐标。"""
if img_np is None or getattr(img_np, "ndim", 0) != 3:
return None
height, width = img_np.shape[:2]
if height < 300 or width < 600:
return None
gray = img_np[:, :, :3].astype(np.float32).mean(axis=2)
scrim_present = looks_like_modal_scrim(img_np)
x1, x2 = int(width * 0.40), int(width * 0.88)
y1, y2 = int(height * 0.08), int(height * 0.58)
roi = gray[y1:y2, x1:x2]
dark = roi < 145
visited = np.zeros(dark.shape, dtype=bool)
candidates = []
# X 是一个小型八连通深色组件。逐组件检查比模板绑定某个企业微信版本更稳健
for sy, sx in np.argwhere(dark):
sy, sx = int(sy), int(sx)
if visited[sy, sx]:
continue
stack = [(sy, sx)]
visited[sy, sx] = True
points = []
while stack:
cy, cx = stack.pop()
points.append((cy, cx))
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if not dx and not dy:
continue
ny, nx = cy + dy, cx + dx
if (
0 <= ny < dark.shape[0]
and 0 <= nx < dark.shape[1]
and dark[ny, nx]
and not visited[ny, nx]
):
visited[ny, nx] = True
stack.append((ny, nx))
if not 10 <= len(points) <= 180:
continue
ys = np.array([p[0] for p in points], dtype=np.float32)
xs = np.array([p[1] for p in points], dtype=np.float32)
bw = int(xs.max() - xs.min() + 1)
bh = int(ys.max() - ys.min() + 1)
if not (7 <= bw <= 30 and 7 <= bh <= 30 and 0.55 <= bw / bh <= 1.8):
continue
nx = (xs - xs.min()) / max(1.0, bw - 1)
ny = (ys - ys.min()) / max(1.0, bh - 1)
diag_a = float((np.abs(nx - ny) <= 0.18).mean())
diag_b = float((np.abs(nx + ny - 1.0) <= 0.18).mean())
if diag_a < 0.28 or diag_b < 0.28:
continue
center_x = x1 + int(round((xs.min() + xs.max()) / 2))
center_y = y1 + int(round((ys.min() + ys.max()) / 2))
radius = max(20, int(24 * max(1.0, width / 1600)))
px1, px2 = max(0, center_x - radius), min(width, center_x + radius + 1)
py1, py2 = max(0, center_y - radius), min(height, center_y + radius + 1)
surround = gray[py1:py2, px1:px2]
if not surround.size:
continue
# 关闭按钮通常位于留白充分的标题栏;过滤正文中的汉字、列表图标等
if float((surround > 185).mean()) < 0.78 or float((surround < 145).mean()) > 0.12:
continue
# 弹窗必须有闭合边框:右侧一条不间断的竖直面板边线, X 上方存在
# 一条足够长的面板上边线(或整个主界面已被弹窗遮罩压暗)。聊天气
# 里的汉字即使形似 ×,右侧只有一叠被间隙打断的气泡边缘
vertical_top = max(0, center_y - int(80 * max(1.0, height / 900)))
vertical_bottom = min(height - 1, center_y + int(height * 0.48))
vertical_span = max(1, vertical_bottom - vertical_top)
border_run = _panel_border_column_run(
gray,
center_x,
width,
vertical_top,
vertical_bottom,
)
if border_run < max(120, int(vertical_span * 0.5)):
continue
if (
not scrim_present
and _panel_top_border_run(gray, center_y, height) < int(width * 0.25)
):
continue
candidates.append((center_x, center_y, float(border_run)))
if not candidates:
return None
# 弹窗关闭按钮通常是标题栏最靠右 X
# 同一弹窗正文里可能仍有形 X 的图形;真正关闭按钮位于最上方标题栏
best = min(candidates, key=lambda point: (point[1], -point[0], -point[2]))
return best[0], best[1]
# ──────────────────────────────────────────────────────────────────────────────
# 主类
# ──────────────────────────────────────────────────────────────────────────────
class WeChatBot:
def __init__(self):
self.scale = 1.0
self.hwnd = 0
self.gui_hwnd = 0
self.replied = set()
# 内存行号黑名单(每次轮询开始前清空,避免位置改变导致误跳过)
self.false_pos_rows = set()
# 会话档案:按会话指纹持久化每个会话的完整对话记录 + 上次画面快照。
# 重启不丢失;靠它提供 AI 上下文,每次只需增量提取最新消息。
self.store = ConversationStore(os.path.join(_SCRIPT_DIR, "conversations.json"))
# 已知头像/复合会话指纹集合(用于感知指纹的汉明距离归一化)
# 从档案键值预热,重启后同一客户仍映射到原档案。
self._known_fps = set()
# 复合会话指纹 = 头像感知哈希 + 会话名称区域哈希。头像仍单独归一化,
# 名称部分用于区分使用相同头像(尤其是默认头像)的不同联系人
self._known_session_fps = set()
# Solid-colour WeCom text avatars look very similar to built-in app
# icons. Unknown flat rows are allowed through, but must be confirmed
# by the vision page guard before any reply can be sent.
self._flat_visual_proof_fps = set()
self._flat_verified_session_fps = set()
self._flat_rejected_session_fps = set()
# click_session records a machine-readable failure category so the
# outer loop never infers "system entry" from an unrelated fingerprint
# mismatch. In particular, a flat text avatar is a valid contact and
# must not be permanently blacklisted after one stale/transient frame.
self._last_click_failure_reason = ""
# 当前 40 字节 -> 同一行按上一版算法算出的 16 字节键,仅用于安全升级
self._legacy_fp_for_current = {}
self._legacy_migration_checked = set()
try:
for k in list(self.store._data.keys()):
stored_fp = bytes.fromhex(k)
if len(stored_fp) == _AVATAR_FP_BYTES: # 最早的纯头像键
self._known_fps.add(stored_fp)
elif len(stored_fp) == _LEGACY_SESSION_FP_BYTES:
# 只预热头像部分; 64 位名称哈希不能与 256 位名称哈
# 直接比较,必须等复制到说话人名称后再做安全迁移
self._known_fps.add(stored_fp[:_AVATAR_FP_BYTES])
elif len(stored_fp) == _SESSION_FP_BYTES:
self._known_fps.add(stored_fp[:_AVATAR_FP_BYTES])
self._known_session_fps.add(stored_fp)
except Exception:
pass
# 置顶状态(自动重连后需要恢复)
self._topmost = False
# 重连失败计数(用于限流日志,避免每 2s 刷一条)
self._reconnect_fails = 0
# 首次轮询时执行一次界面清理(关闭遗留的搜索弹层/取消遗留选中)
self._did_initial_cleanup = False
# 人机共存:人工操作鼠标时暂停自动回复
self.mouse_idle_enabled = MOUSE_IDLE_ENABLED
self.mouse_idle_seconds = MOUSE_IDLE_SECONDS
# 每次开始新的消息合并窗口时读取实例值。GUI 可在监听期间更新它,
# 已经开始的窗口保持原截止时间,下一轮回复使用新设置
self.message_batch_window_seconds = MESSAGE_BATCH_WINDOW_SECONDS
self._bot_controlling = False # 机器人正在操控鼠标时为 True
self._last_mouse_pos = None
self._last_user_move_ts = 0.0 # 0 = 启动时视为已空闲,可立即开始
self._idle_log_ts = 0.0
self._stop_check = None # 可选 threading.Event,停止时打断等待
# 安全模式不使用系统级置顶;可单独启用自动还原和前台激活。
self.safe_window_mode = False
self.auto_activate_window = False
self._safe_wait_log_ts = 0.0
self._window_ready = False
self._last_window_launch_ts = 0.0
# 企业微信出现登录/安全验证页时锁死自动操作,必须由人工扫码后重新启动监听。
self.security_verification_required = False
self._security_log_emitted = False
# 固定频率限制,防止业务高峰时形成连续发送突发。
self._send_timestamps = deque()
self._last_send_ts = 0.0
self._last_rate_limit_log_ts = 0.0
self._uncertain_send_last_check = {}
self._uncertain_send_last_log = {}
self._uncertain_send_last_surface = {}
self.reply_count = 0
# 当前打开会话采用只读画面指纹跟踪。回复后不再点击系统工具页“取消选中”,
# 只有聊天消息区域发生变化时才读取内容,避免空闲轮询反复移动鼠标
self._selected_tracking_initialized = False
self._active_session_fp = None
self._active_chat_signature = None
self._active_identity_signature = None
self._row_activity_cache = {}
self._unread_scan_resume = False
self._pending_scan_progress = {}
self._pending_scan_incomplete = False
self._session_geometry_valid = False
self._input_geometry_valid = False
# 导航栏宽度只有经过画面识别才可信。未确认时会话列表的裁剪起点可能
# 整体偏移(宽/窄侧栏实测差 184px),据此算出的会话指纹会把同一
# 联系人铸造成一个全新的档案键,因此这段时间内禁止生成会话身份
self._nav_width_confirmed = False
self._nav_width_gate_logged = 0.0
self._nav_width_gate_since = 0.0
# AI 生成后先暂存上下文,确认真正发送成功后再写入档案,防止校验失败
# 档案误以为消息已经回复
self._pending_exchanges = {}
# 打开未读会话后红点会立刻消失;若模型暂时失败,必须保留待回复状态,
# 让下一轮在当前聊天页重试,不能因为“已读但未回”而永久丢失
self._pending_reply_path = os.path.join(_SCRIPT_DIR, "pending_replies.json")
self._pending_reply_sessions = self._load_pending_replies()
# 订阅号/系统号(行业资讯、客户联系、企小码、打卡、微盘…)的聊天页
# 根本没有输入框,回复永远发不出去。它们照样会有未读红点,于是每一轮
# 都被重新打开、调一次模型、再卡在发送保护上,把真正的客户挤到后面。
# 连续证实若干次“这一页没有输入框”后就把它挪出回复队列
self._unrepliable_path = os.path.join(_SCRIPT_DIR, "unrepliable_sessions.json")
self._composerless_strikes = {}
self._unrepliable_sessions = self._load_unrepliable_sessions()
# 当前会话没回完时压住轮询,不让未读扫描把页面切走
self._active_hold_key = ""
self._active_hold_since = 0.0
self._active_hold_expired = False
# Rebuild conservative send-rate reservations from unfinished
# transactions. Receipt confirmation is deliberately separate from
# dispatch accounting, so a restart cannot forget a just-sent message.
wall_now = time.time()
mono_now = time.monotonic()
restored_dispatches = []
for pending_state in self._pending_reply_sessions.values():
dispatched_at = float(
pending_state.get("send_dispatched_at", 0.0)
or pending_state.get("uncertain_since", 0.0)
or 0.0
)
age = wall_now - dispatched_at if dispatched_at else 0.0
if dispatched_at and 0 <= age < 3600:
restored_dispatches.append(mono_now - age)
for dispatched in sorted(restored_dispatches):
self._send_timestamps.append(dispatched)
if restored_dispatches:
self._last_send_ts = max(restored_dispatches)
# Render identities are not archive keys. They are persisted only with
# an unfinished task so a restart can prove the single unread-bold ->
# selected-regular transition after WeCom has already cleared the dot.
self._session_render_ids = {
str(key): set(state.get("render_identities") or [])
for key, state in self._pending_reply_sessions.items()
if isinstance(state, dict) and state.get("render_identities")
}
for pending_key in self._pending_reply_sessions:
try:
pending_fp = bytes.fromhex(pending_key)
except (TypeError, ValueError):
continue
if len(pending_fp) in (_LEGACY_SESSION_FP_BYTES, _SESSION_FP_BYTES):
self._known_fps.add(pending_fp[:_AVATAR_FP_BYTES])
if len(pending_fp) == _SESSION_FP_BYTES:
self._known_session_fps.add(pending_fp)
self._message_page_misses = 0
self._last_message_restore_ts = 0.0
self._last_ui_guard_ts = 0.0
self._last_ui_guard_signature = ""
self._last_internal_blocker_ts = 0.0
self._last_internal_blocker_signature = ""
self._last_owned_blocker_ts = 0.0
self._last_owned_blocker_key = ""
self._last_session_scan_ts = 0.0
self._last_global_unread_signature = b""
self.L = self.T = self.R = self.B = 0
self._list_x = 0
self._list_y = 0
self._list_w = 0
self._list_h = 0
self.list_click_x = 0
self.input_x = 0
self.input_y = 0
self.list_region = {}
# 聊天区域坐标(用于 AI 截图)
self._chat_region = {} # mss 截图区域
# 动态几何参数(将在 connect() 中根据 DPI 自适应更新)
self.session_item_h = SESSION_ITEM_H
self.badge_scan_x_start = BADGE_SCAN_X_START
self.badge_scan_x_end = BADGE_SCAN_X_END
# ── 1. 窗口挂载层 ─────────────────────────────────────────────────────────
def connect(self, activate: bool = True, wait_if_missing: bool = False) -> bool:
"""查找并挂载企业微信主窗口,计算所有关键区域坐标"""
print("[*] 正在查找企业微信主窗口...")
self.hwnd = find_wx_hwnd()
if not self.hwnd:
self._window_ready = False
process_path = find_wx_process_path() if not activate and wait_if_missing else ""
if process_path and self.auto_activate_window:
now = time.time()
if now - self._last_window_launch_ts >= 10:
self._last_window_launch_ts = now
try:
os.startfile(process_path)
print("[*] 正在请求企业微信显示主界面...")
except Exception as e:
print(f"[~] 无法请求企业微信显示主界面: {e}")
for _ in range(10):
if self._stop_check is not None and self._stop_check.is_set():
return False
time.sleep(0.2)
self.hwnd = find_wx_hwnd()
if self.hwnd:
break
if not self.hwnd and process_path:
print(
"[+] 企业微信仍在托盘运行,主窗口句柄暂不可用;"
"已进入等待并将继续尝试切到前台。"
)
return True
if not self.hwnd:
print("[-] 未找到企业微信!请确认客户端已经登录并正在运行。")
return False
# 获取 DPI 缩放比例
try:
dpi = ctypes.windll.user32.GetDpiForWindow(self.hwnd)
self.scale = dpi / 96.0
except Exception:
try:
hdc = win32gui.GetDC(0)
dpi_x = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88) # 88 = LOGPIXELSX
self.scale = dpi_x / 96.0
win32gui.ReleaseDC(0, hdc)
except Exception:
self.scale = 1.0
if self.scale != 1.0:
print(f"[+] 检测到系统 DPI 缩放比例: {self.scale * 100:.1f}%,启用自适应几何缩放。")
placement = None
if activate:
# 校准和命令行模式沿用原行为;GUI 安全模式不会执行这里。
restore_window(self.hwnd)
time.sleep(0.3)
self._window_ready = True
else:
try:
placement = win32gui.GetWindowPlacement(self.hwnd)
self._window_ready = (
placement[1] != win32con.SW_SHOWMINIMIZED
and bool(win32gui.IsWindowVisible(self.hwnd))
)
if not self._window_ready:
if self.auto_activate_window:
print(
"[+] 已找到企业微信窗口;主界面当前隐藏或最小化,"
"软件将自动还原并切到前台。"
)
else:
print(
"[+] 已找到企业微信窗口;主界面当前隐藏或最小化,"
"监听将在用户手动恢复窗口后自动开始。"
)
except Exception as e:
print(f"[-] 无法检查企业微信窗口状态: {e}")
return False
# 最小化时 GetWindowRect 可能返回 -32000 附近的占位坐标;
# 此时使用 WINDOWPLACEMENT 中最后一次正常显示的矩形完成被动挂载。
if not self._window_ready and placement and len(placement) > 4:
left, top, right, bottom = placement[4]
else:
left, top, right, bottom = win32gui.GetWindowRect(self.hwnd)
self.L, self.T, self.R, self.B = left, top, right, bottom
W = self.R - self.L
H = self.B - self.T
if W <= 0 or H <= 0:
if not self._window_ready:
W = int(1200 * self.scale)
H = int(800 * self.scale)
self.L = self.T = 0
self.R, self.B = W, H
print(
"[!] 暂时无法读取企业微信正常窗口尺寸,"
"已进入等待;窗口恢复后会自动重新校准。"
)
else:
print(f"[-] 窗口尺寸异常 ({W}×{H}),请手动将企业微信拖到屏幕上。")
return False
cls = win32gui.GetClassName(self.hwnd)
title = win32gui.GetWindowText(self.hwnd)
state_text = "可监听" if self._window_ready else "等待主界面恢复"
print(
f"[+] 挂载成功: HWND=0x{self.hwnd:08X}, "
f"ClassName='{cls}', Title='{title}', State='{state_text}'"
)
print(f" 窗口坐标: ({self.L},{self.T}) → ({self.R},{self.B}),尺寸: {W}×{H}")
# ── 计算各区域坐标 ──
# 几何在此处整体重算,静态兜底宽度未经识别,先撤销上一次的确认结论
self._nav_width_confirmed = False
nav_bar_w = int(NAV_BAR_W * self.scale)
full_for_geometry = None
if self._window_ready:
try:
full_for_geometry = capture_window_region(self.hwnd, 0, 0, W, H)
inferred_w, confidence = infer_navigation_width(
full_for_geometry,
self.scale,
)
if confidence >= 0.12:
nav_bar_w = inferred_w
self._nav_width_confirmed = True
print(
f" 动态导航宽度: {nav_bar_w}px "
f"(置信度 {confidence:.2f}"
)
else:
print(
f" [~] 宽/窄侧栏识别置信度不足({confidence:.2f}),"
f"暂用 {nav_bar_w}px;本轮不会凭未知坐标点击导航正文。"
)
except Exception as e:
print(f" [~] 动态侧栏识别失败,使用兼容宽度: {e}")
session_list_w = int(SESSION_LIST_W * self.scale)
header_h = int(HEADER_H * self.scale)
list_left = self.L + nav_bar_w
list_top = self.T + header_h
list_height = self.B - list_top
self.list_region = {
"left": list_left,
"top": list_top,
"width": session_list_w,
"height": list_height,
}
action_pad = max(2, int(4 * self.scale))
raw_list_click_x = list_left + session_list_w // 2
self.list_click_x = min(
max(self.L + action_pad, raw_list_click_x),
max(self.L + action_pad, self.R - action_pad),
)
self._session_geometry_valid = (
list_left >= self.L
and list_top >= self.T
and list_left + session_list_w <= self.R
and list_top < self.B
)
# 输入框:聊天区域(会话列表右侧)的水平中间 + 距窗口底部固定偏移
chat_left = list_left + session_list_w
chat_right = self.R
detected_composer_top = infer_composer_top(
full_for_geometry,
nav_bar_w + session_list_w,
self.scale,
)
self._composer_geometry_valid = detected_composer_top is not None
composer_rel_top = detected_composer_top
if composer_rel_top is None:
composer_rel_top = max(
header_h + 1,
H - int(CHAT_BOTTOM_FROM_BOTTOM * self.scale),
)
self._composer_rel_top = int(composer_rel_top)
self._editor_rel_top = min(
H - 1,
self._composer_rel_top + int(38 * self.scale),
)
raw_input_x = int(chat_left + (chat_right - chat_left) * INPUT_X_RATIO)
preferred_input_y = self.B - int(INPUT_Y_FROM_BOTTOM * self.scale)
editor_min_y = self.T + self._editor_rel_top + int(6 * self.scale)
editor_max_y = self.B - int(10 * self.scale)
input_room_ok = bool(
self._composer_geometry_valid
and editor_max_y > editor_min_y
)
raw_input_y = (
min(max(preferred_input_y, editor_min_y), editor_max_y)
if input_room_ok
else preferred_input_y
)
self.input_x = min(
max(self.L + action_pad, raw_input_x),
max(self.L + action_pad, self.R - action_pad),
)
self.input_y = min(
max(self.T + action_pad, raw_input_y),
max(self.T + action_pad, self.B - action_pad),
)
self._input_geometry_valid = (
input_room_ok
and
chat_right - chat_left >= max(120, int(160 * self.scale))
and self.L <= raw_input_x < self.R
and self.T <= raw_input_y < self.B
)
# 记录窗口内相对偏移(供 PrintWindow 裁剪使用)
self._list_x = nav_bar_w
self._list_y = header_h
self._list_h = list_height
# ★ 截图宽度必须使用 DPI 缩放后的值,否则高分屏下只能截到会话列表左半边,
# 导致头像四角采样错位、真实会话被误判为系统工具。
self._list_w = session_list_w
# 兼容 calibrate_mode 中对 list_region 的引用
self.list_region = {
"left": list_left,
"top": list_top,
"width": session_list_w,
"height": list_height,
}
print(f" 会话列表区域: left={list_left}, top={list_top}, "
f"{session_list_w}×{list_height}px")
print(f" 输入框估算坐标: ({self.input_x}, {self.input_y})")
# 框选必须覆盖客户左侧气泡和我方右侧气泡。chat_left 已经位于会话列表
# 右边缘,旧版再右 300×DPI 会直接漏掉客户消息;这里只保留小安全边距
chat_left_extra = max(4, int(12 * self.scale))
right_sidebar_w = max(4, int(16 * self.scale))
# 极窄窗口或异 DPI 下,旧算法的最小宽高会把拖拽终点推到企业微信窗口外
# 所有绝对坐标都夹在当前窗口内;空间不足时宁可得 1px 的无效选区并安全失败
chat_msg_left = min(
max(self.L, chat_left + chat_left_extra),
max(self.L, self.R - 1),
)
chat_msg_top = min(
max(self.T, self.T + header_h + int(60 * self.scale)),
max(self.T, self.B - 1),
)
# The message viewport ends at the toolbar divider. It must not be
# derived from the editor click point: changing that point previously
# cropped the newest 70 50 physical pixels at 200% DPI and hid the
# just-sent bubble from receipt verification.
chat_msg_bottom = min(
max(
chat_msg_top + 1,
self.T
+ self._composer_rel_top
- max(2, int(2 * self.scale)),
),
self.B,
)
desired_chat_width = max(
chat_right - chat_msg_left - right_sidebar_w,
int(300 * self.scale),
)
chat_msg_width = max(
1,
min(desired_chat_width, self.R - chat_msg_left),
)
available_chat_height = max(1, chat_msg_bottom - chat_msg_top)
self._chat_geometry_valid = (
available_chat_height >= max(50, int(80 * self.scale))
)
self._input_geometry_valid = bool(
self._input_geometry_valid and self._chat_geometry_valid
)
chat_msg_height = max(
1,
min(
available_chat_height,
self.B - chat_msg_top,
),
)
self._chat_region = {
"left": chat_msg_left,
"top": chat_msg_top,
"width": chat_msg_width,
"height": chat_msg_height,
}
# 视觉识别和画面指纹必须覆盖完整消息面板。旧逻辑在会话列表右侧又
# 偏移 300×DPI,恰好会裁掉客户左侧的图片、贴纸和语音气泡
visual_margin = max(4, int(8 * self.scale))
window_width = max(1, self.R - self.L)
window_height = max(1, self.B - self.T)
self._chat_rel_x = min(
max(0, nav_bar_w + session_list_w + visual_margin),
window_width - 1,
)
self._chat_rel_y = min(
max(0, header_h + int(60 * self.scale)),
window_height - 1,
)
self._chat_rel_w = max(
window_width - self._chat_rel_x - visual_margin,
1,
)
self._chat_rel_h = max(
1,
min(
chat_msg_height,
window_height - self._chat_rel_y,
),
)
# 更新自适应缩放几何参数
self.session_item_h = int(SESSION_ITEM_H * self.scale)
self.badge_scan_x_start = int(BADGE_SCAN_X_START * self.scale)
self.badge_scan_x_end = int(BADGE_SCAN_X_END * self.scale)
print(f" 聊天区域: {self._chat_region['width']}×{self._chat_region['height']}px")
return True
# ── 2. 截图层 ─────────────────────────────────────────────────────────────
def capture_session_list(self) -> np.ndarray:
"""
使用 PrintWindow 直接从窗口显存截取会话列表区域。
不依赖屏幕绝对坐标,完全规避多显示器/高分屏下 mss 截屏为黑色的问题。
"""
return capture_window_region(
self.hwnd,
self._list_x,
self._list_y,
self._list_w,
self._list_h
)
def _refresh_message_geometry(self, full: np.ndarray) -> bool:
"""消息页展开/收起侧栏后,按当前画面重新计算列表、标题和输入区域。"""
inferred_w, confidence = infer_navigation_width(full, self.scale)
old_width = int(self._list_x)
confident = confidence >= 0.12
nav_bar_w = int(inferred_w) if confident else old_width
# 置信度不足时沿用已生效的宽度,因此绝不撤销既有的确认结论;只在识别
# 结果确实成为当前裁剪宽度时才置信
if confident and nav_bar_w == old_width:
self._nav_width_confirmed = True
session_list_w = int(SESSION_LIST_W * self.scale)
header_h = int(HEADER_H * self.scale)
detected_composer_top = infer_composer_top(
full,
nav_bar_w + session_list_w,
self.scale,
)
if detected_composer_top is None:
# A stale fixed crop may include the editor and make the pasted
# draft look like a new customer message. Fail closed until a
# fresh frame proves the draggable divider again.
self._composer_geometry_valid = False
self._chat_geometry_valid = False
self._input_geometry_valid = False
return False
old_composer_top = int(
getattr(self, "_composer_rel_top", detected_composer_top)
)
nav_changed = abs(nav_bar_w - old_width) > max(
2,
int(2 * self.scale),
)
composer_changed = abs(
int(detected_composer_top) - old_composer_top
) > max(2, int(2 * self.scale))
was_composer_valid = bool(
getattr(self, "_composer_geometry_valid", False)
)
self._composer_geometry_valid = True
if not nav_changed and not composer_changed and was_composer_valid:
return False
self._composer_rel_top = int(detected_composer_top)
window_height = max(1, self.B - self.T)
self._editor_rel_top = min(
window_height - 1,
self._composer_rel_top + int(38 * self.scale),
)
list_left = self.L + nav_bar_w
list_top = self.T + header_h
list_height = self.B - list_top
self._list_x = nav_bar_w
if confident:
self._nav_width_confirmed = True
self._list_y = header_h
self._list_w = session_list_w
self._list_h = list_height
self.list_region = {
"left": list_left,
"top": list_top,
"width": session_list_w,
"height": list_height,
}
action_pad = max(2, int(4 * self.scale))
raw_list_click_x = list_left + session_list_w // 2
self.list_click_x = min(
max(self.L + action_pad, raw_list_click_x),
max(self.L + action_pad, self.R - action_pad),
)
self._session_geometry_valid = (
list_left >= self.L
and list_top >= self.T
and list_left + session_list_w <= self.R
and list_top < self.B
)
chat_left = list_left + session_list_w
chat_right = self.R
raw_input_x = int(chat_left + (chat_right - chat_left) * INPUT_X_RATIO)
preferred_input_y = self.B - int(INPUT_Y_FROM_BOTTOM * self.scale)
editor_min_y = self.T + self._editor_rel_top + int(6 * self.scale)
editor_max_y = self.B - int(10 * self.scale)
input_room_ok = editor_max_y > editor_min_y
raw_input_y = (
min(max(preferred_input_y, editor_min_y), editor_max_y)
if input_room_ok
else preferred_input_y
)
self.input_x = min(
max(self.L + action_pad, raw_input_x),
max(self.L + action_pad, self.R - action_pad),
)
self.input_y = min(
max(self.T + action_pad, raw_input_y),
max(self.T + action_pad, self.B - action_pad),
)
self._input_geometry_valid = (
input_room_ok
and
chat_right - chat_left >= max(120, int(160 * self.scale))
and self.L <= raw_input_x < self.R
and self.T <= raw_input_y < self.B
)
chat_left_extra = max(4, int(12 * self.scale))
right_sidebar_w = max(4, int(16 * self.scale))
chat_msg_left = min(
max(self.L, chat_left + chat_left_extra),
max(self.L, self.R - 1),
)
chat_msg_top = min(
max(self.T, self.T + header_h + int(60 * self.scale)),
max(self.T, self.B - 1),
)
chat_msg_bottom = min(
max(
chat_msg_top + 1,
self.T
+ self._composer_rel_top
- max(2, int(2 * self.scale)),
),
self.B,
)
desired_chat_width = max(
chat_right - chat_msg_left - right_sidebar_w,
int(300 * self.scale),
)
chat_msg_width = max(
1,
min(desired_chat_width, self.R - chat_msg_left),
)
available_chat_height = max(1, chat_msg_bottom - chat_msg_top)
self._chat_geometry_valid = (
available_chat_height >= max(50, int(80 * self.scale))
)
self._input_geometry_valid = bool(
self._input_geometry_valid and self._chat_geometry_valid
)
chat_msg_height = max(
1,
min(
available_chat_height,
self.B - chat_msg_top,
),
)
self._chat_region = {
"left": chat_msg_left,
"top": chat_msg_top,
"width": chat_msg_width,
"height": chat_msg_height,
}
visual_margin = max(4, int(8 * self.scale))
window_width = max(1, self.R - self.L)
self._chat_rel_x = min(
max(0, nav_bar_w + session_list_w + visual_margin),
window_width - 1,
)
self._chat_rel_y = min(
max(0, header_h + int(60 * self.scale)),
window_height - 1,
)
self._chat_rel_w = max(
window_width - self._chat_rel_x - visual_margin,
1,
)
self._chat_rel_h = max(
1,
min(chat_msg_height, window_height - self._chat_rel_y),
)
# 所有画面指纹都依赖裁剪区域;几何变化后必须重新建基线
self._selected_tracking_initialized = False
self._active_session_fp = None
self._active_chat_signature = None
self._active_identity_signature = None
print(
f" [页面校准] 消息侧栏 {old_width}px→{nav_bar_w}px"
f"输入面板顶边 {old_composer_top}px→{self._composer_rel_top}px"
"会话列表、消息区与输入区域已同步重算。"
)
return True
def _security_gate_visible(self) -> bool:
"""检测验证二维码;一旦命中,本次监听周期永久停机,等待人工处理。"""
if self.security_verification_required:
return True
try:
left, top, right, bottom = win32gui.GetWindowRect(self.hwnd)
width, height = right - left, bottom - top
full = capture_window_region(self.hwnd, 0, 0, width, height)
except Exception:
return False
if not looks_like_security_verification(full):
return False
self.security_verification_required = True
self._window_ready = False
if not self._security_log_emitted:
self._security_log_emitted = True
print(
"[安全验证] 检测到企业微信登录/安全验证二维码,已停止全部自动点击和发送。"
"请用手机企业微信扫码完成验证,再重新点击“开始监听”。"
)
return True
def _capture_full_window(self) -> np.ndarray:
left, top, right, bottom = win32gui.GetWindowRect(self.hwnd)
return capture_window_region(
self.hwnd,
0,
0,
max(1, right - left),
max(1, bottom - top),
)
def _message_nav_selected(self, full: np.ndarray) -> bool:
"""确认左侧第一项“消息”被选中,并排除紧邻的“邮件”选中态。"""
if full is None or getattr(full, "ndim", 0) != 3:
return False
scale = max(0.75, float(getattr(self, "scale", 1.0) or 1.0))
height, width = full.shape[:2]
x1 = max(0, int(2 * scale))
x2 = min(width, max(x1 + 1, int(60 * scale)))
def selected_blue_score(logical_y1: float, logical_y2: float) -> float:
y1 = max(0, int(logical_y1 * scale))
y2 = min(height, max(y1 + 1, int(logical_y2 * scale)))
roi = full[y1:y2, x1:x2, :3].astype(np.int16)
if roi.size == 0:
return 0.0
blue, green, red = roi[:, :, 0], roi[:, :, 1], roi[:, :, 2]
selected_blue = (
(blue > 150)
& (green > 60)
& (blue - red > 50)
& (blue - green > 25)
)
return float(selected_blue.mean())
# 企业微信存在窄图标栏和宽文字栏两种布局,但两者的消息行都位于
# 逻辑 y 5~105,邮件行紧随其后。旧实现取到 y=125,会把邮件的
# 蓝色背景算进消息区域;邮件页实机得分 7.46%,因此被误判为消息页
message_score = selected_blue_score(65, 105)
mail_score = selected_blue_score(105, 145)
# At 200% DPI the rounded highlight plus icon/text cut-outs leave about
# 11.7% blue coverage in the real Messages ROI. The previous 12%
# cutoff therefore rejected an unquestionably selected Messages page.
# Mail false positives observed in the same band stay below 7.5%, and
# the adjacent-row margin remains required.
return message_score >= 0.10 and message_score >= mail_score + 0.06
@staticmethod
def _ui_guard_image_bytes(full: np.ndarray) -> bytes:
"""将窗口截图压缩为适合视觉判断的 PNG,避免上传原始超大图片。"""
if full is None or getattr(full, "ndim", 0) != 3:
return b""
pixels = full[:, :, :3]
# PrintWindow 返回 BGR/BGRAPillow 使用 RGB
image = Image.fromarray(pixels[:, :, ::-1].astype(np.uint8), mode="RGB")
image.thumbnail((1280, 960), getattr(Image, "Resampling", Image).LANCZOS)
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True)
return buffer.getvalue()
@staticmethod
def _ui_guard_surface_signature(full: np.ndarray) -> str:
if full is None or getattr(full, "ndim", 0) != 3:
return ""
y_step = max(1, full.shape[0] // 120)
x_step = max(1, full.shape[1] // 160)
sampled = np.ascontiguousarray(full[::y_step, ::x_step, :3])
return hashlib.sha256(sampled.tobytes()).hexdigest()
def _open_messages_page(self, reason: str) -> bool:
"""只点击固定的“消息”导航入口,并验证选中态,不接受任意坐标。"""
if not self.wait_for_mouse_idle() or self._security_gate_visible():
return False
# 新旧版企业微信分别存在窄图标栏、宽文字栏;消息入口的安全区
# 位于逻辑 y 0。按从左到右的三个点重试,全部都限制在消息入口内
# 不会使用 AI 返回的任意坐标,也不会落到会话列表或正文区域
candidates = ((34, 80), (52, 80), (34, 88))
selected = False
last_error = None
for logical_x, logical_y in candidates:
self._begin_bot_mouse()
try:
if not safe_set_foreground(self.hwnd):
raise RuntimeError("企业微信未能取得前台焦点,已取消本次点击")
if win32gui.GetForegroundWindow() != self.hwnd:
raise RuntimeError("前台窗口不是企业微信,已取消本次点击")
left, top, _, _ = win32gui.GetWindowRect(self.hwnd)
pyautogui.click(
left + int(logical_x * self.scale),
top + int(logical_y * self.scale),
)
except Exception as exc:
last_error = exc
finally:
self._end_bot_mouse()
# GPU 页面切换 PrintWindow 偶尔会短暂返回旧帧;轮询验证而不
# 固定睡眠一次,避免页面其实已打开却被判定失败
deadline = time.monotonic() + 1.8
while time.monotonic() < deadline:
time.sleep(0.2)
try:
if self._message_nav_selected(self._capture_full_window()):
selected = True
break
except Exception as exc:
last_error = exc
break
if selected:
break
if not selected:
if last_error is not None:
print(f" [页面清理] 返回消息页失败: {last_error}")
print(f" [页面清理] {reason}已点击“消息”,但页面尚未确认切换成功。")
return False
try:
self._refresh_message_geometry(self._capture_full_window())
except Exception:
pass
self._message_page_misses = 0
self._selected_tracking_initialized = False
self._active_session_fp = None
self._active_chat_signature = None
self._active_identity_signature = None
print(f" [页面清理] {reason}已返回“消息”页面并通过选中态校验。")
return True
def _ensure_message_workspace(self, reason: str, full: np.ndarray = None) -> bool:
"""托管恢复:非消息页直接回到消息入口;失败才交给 AI。"""
try:
full = full if full is not None else self._capture_full_window()
except Exception:
full = None
if self._message_nav_selected(full):
return True
if self._security_gate_visible() or not self.wait_for_mouse_idle():
return False
# 智能文档、邮件、日程等是完整业务页面,不是弹窗。这里不能盲 Esc
# 企业微信部分页面收到 Esc 后会短暂重建/切换焦点,紧接着的“消息”点击会被吞掉
# 先直接点击固定的消息入口;只有后续明确识别出弹窗时才允许执行 Esc
if self._open_messages_page(f"{reason}检测到当前不在消息页,"):
return True
# 固定恢复失败时才 AI 识别未知页面;AI 仍只能使用动作白名单
return self._run_ai_page_guard(f"{reason}无法返回消息页", full=full)
def _run_ai_page_guard(self, trigger: str, full: np.ndarray = None) -> bool:
"""异常时请视觉模型分类,再由本地白名单执行安全恢复动作。"""
try:
import ai_config
if not getattr(ai_config, "AI_ENABLED", False):
return False
if not getattr(ai_config, "AI_UI_GUARD_ENABLED", True):
return False
except Exception:
return False
try:
full = full if full is not None else self._capture_full_window()
except Exception:
return False
if looks_like_security_verification(full):
return False
signature = self._ui_guard_surface_signature(full)
now = time.monotonic()
last_ts = float(getattr(self, "_last_ui_guard_ts", 0.0) or 0.0)
last_signature = str(getattr(self, "_last_ui_guard_signature", "") or "")
if signature and signature == last_signature and now - last_ts < AI_UI_GUARD_SAME_PAGE_SECONDS:
return False
if now - last_ts < AI_UI_GUARD_COOLDOWN_SECONDS:
return False
self._last_ui_guard_ts = now
self._last_ui_guard_signature = signature
image_bytes = self._ui_guard_image_bytes(full)
if not image_bytes:
return False
try:
from ai_chat import classify_wecom_ui
decision = classify_wecom_ui(image_bytes, trigger=trigger)
except Exception as exc:
print(f" [AI页面守护] 判断失败,继续使用本地安全规则: {exc}")
return False
state = str(decision.get("state") or "unknown")
action = str(decision.get("action") or "none")
confidence = float(decision.get("confidence") or 0.0)
reason = str(decision.get("reason") or "")
print(
f" [AI页面守护] {trigger}: {state} / {action} / "
f"{confidence:.0%}{f'{reason}' if reason else ''}"
)
if state == "security_verification" and confidence >= 0.70:
self.security_verification_required = True
self._window_ready = False
print(" [安全验证] AI 判断为登录或安全验证页,已禁止自动关闭和发送。")
return False
if confidence < AI_UI_GUARD_MIN_CONFIDENCE:
return False
if action == "close_modal":
if self._dismiss_internal_blocker(f"AI识别·{trigger}"):
return True
return self._escape_proven_blocker(f"AI识别·{trigger}")
if action == "escape":
return self._escape_proven_blocker(f"AI识别·{trigger}")
if action == "open_messages":
return self._open_messages_page(f"AI识别到非消息页面({trigger}),")
return False
def _dismiss_owned_blocking_window(self) -> bool:
"""关闭企业微信进程弹出的文件选择/预览等独立阻塞子窗口。"""
try:
foreground = win32gui.GetForegroundWindow()
if not foreground or foreground == self.hwnd:
return False
_, main_pid = win32process.GetWindowThreadProcessId(self.hwnd)
_, foreground_pid = win32process.GetWindowThreadProcessId(foreground)
if main_pid != foreground_pid or not win32gui.IsWindowVisible(foreground):
return False
owner = win32gui.GetWindow(foreground, win32con.GW_OWNER)
class_name = win32gui.GetClassName(foreground)
title = win32gui.GetWindowText(foreground)
# 只关闭能由窗口关系证明属于主界面的模态子窗或系统文件对话框
# “智能文档”“邮件”等也可能是用户主动打开的独立顶层窗口;仅凭标题
# 发 WM_CLOSE 会造成未保存内容丢失,也不是恢复主消息页所必需的
if owner != self.hwnd and class_name != "#32770":
return False
if any(
word in str(title or "")
for word in ("登录", "验证", "二维码", "扫码", "安全校验")
):
return False
except Exception:
return False
blocker_key = f"{foreground}:{class_name}:{title}"
now = time.monotonic()
if (
blocker_key == str(getattr(self, "_last_owned_blocker_key", "") or "")
and now - float(getattr(self, "_last_owned_blocker_ts", 0.0) or 0.0)
< BLOCKER_RETRY_SECONDS
):
# 仍然返回 True:页面确有阻塞,本轮必须冻结会话点击和发送;只是
# 不再对同一个顽固窗口连续注入键盘关闭消息
return True
self._begin_bot_mouse()
try:
if not safe_set_foreground(foreground):
return False
if win32gui.GetForegroundWindow() != foreground:
return False
self._last_owned_blocker_key = blocker_key
self._last_owned_blocker_ts = now
pyautogui.press("esc")
time.sleep(0.45)
if win32gui.IsWindow(foreground) and win32gui.IsWindowVisible(foreground):
win32gui.PostMessage(foreground, win32con.WM_CLOSE, 0, 0)
time.sleep(0.35)
except Exception as exc:
print(f" [页面清理] 关闭企业微信子窗口失败: {exc}")
return False
finally:
self._end_bot_mouse()
if win32gui.IsWindow(foreground) and win32gui.IsWindowVisible(foreground):
print(
f" [页面清理] 阻塞子窗口暂未关闭,将限频重试: "
f"{title or class_name}"
)
return True
print(f" [页面清理] 已关闭妨碍回复的企业微信子窗口: {title or class_name}")
return True
@staticmethod
def _blocker_area_changed(
before: np.ndarray,
after: np.ndarray,
candidate: tuple[int, int],
) -> bool:
"""判断候选 X 附近的画面在 Esc 前后是否真的变化过。"""
if before is None or after is None:
return True
if getattr(before, "ndim", 0) != 3 or getattr(after, "ndim", 0) != 3:
return True
if before.shape != after.shape:
return True
height, width = before.shape[:2]
center_x, center_y = int(candidate[0]), int(candidate[1])
radius = max(20, int(24 * max(1.0, width / 1600)))
x1, x2 = max(0, center_x - radius), min(width, center_x + radius + 1)
y1, y2 = max(0, center_y - radius), min(height, center_y + radius + 1)
if x2 <= x1 or y2 <= y1:
return True
patch_before = before[y1:y2, x1:x2, :3].astype(np.int16)
patch_after = after[y1:y2, x1:x2, :3].astype(np.int16)
changed = np.abs(patch_before - patch_after).max(axis=2) > 12
return float(changed.mean()) > 0.005
def _remember_internal_blocker_false_positive(self, signature: str) -> None:
"""记住被证伪的弹窗画面,避免每 12 秒重复按一次无效的 Esc。"""
if not signature:
return
known = getattr(self, "_internal_blocker_false_positives", None)
if known is None:
known = self._internal_blocker_false_positives = {}
known[signature] = time.monotonic()
if len(known) > 32:
for stale, _ts in sorted(known.items(), key=lambda item: item[1])[:8]:
known.pop(stale, None)
def _dismiss_internal_blocker(self, reason: str = "轮询前") -> bool:
"""识别并关闭企业微信主窗口内的模态弹窗。"""
try:
full = self._capture_full_window()
except Exception:
return False
if looks_like_security_verification(full):
return False
# 邮件、文档、智能文档等完整业务页不是弹窗;它们应通过左侧消息入口
# 导航恢复。否则页面里的导航模板图标可能被误当成 X,形成反复按 Esc
if not self._message_nav_selected(full):
return False
candidate = find_blocking_modal_close(full)
if candidate is None:
return False
blocker_signature = self._ui_guard_surface_signature(full)
if blocker_signature and blocker_signature in getattr(
self,
"_internal_blocker_false_positives",
{},
):
# 这张画面已被 Esc 证明只是聊天内容里形 × 的图形或汉字
# 再冻结轮询会让整个自动回复永久停摆
return False
if not self.wait_for_mouse_idle():
return False
now = time.monotonic()
if (
blocker_signature
and blocker_signature
== str(getattr(self, "_last_internal_blocker_signature", "") or "")
and now - float(getattr(self, "_last_internal_blocker_ts", 0.0) or 0.0)
< BLOCKER_RETRY_SECONDS
):
# 弹窗仍在,因此必须阻止本轮继续点击聊天区;但不重复按 Esc
return True
self._begin_bot_mouse()
try:
if not safe_set_foreground(self.hwnd):
return False
if win32gui.GetForegroundWindow() != self.hwnd:
return False
self._last_internal_blocker_signature = blocker_signature
self._last_internal_blocker_ts = now
pyautogui.press("esc")
time.sleep(0.45)
finally:
self._end_bot_mouse()
try:
after_escape = self._capture_full_window()
remaining = find_blocking_modal_close(after_escape)
except Exception:
print(f" [页面清理] {reason}执行 Esc 后无法验证弹窗状态,本轮保持冻结。")
return True
if remaining is None:
if not self._blocker_area_changed(full, after_escape, candidate):
# 真弹窗被关掉时,X 所在区域必然发生变化。画面纹丝不动说
# 命中的只是聊天内容,本轮必须继续正常回复
self._remember_internal_blocker_false_positive(blocker_signature)
print(
f" [页面清理] {reason}Esc 前后该区域画面完全一致,"
"判定为聊天内容误判,本轮继续正常回复。"
)
return False
print(f" [页面清理] {reason}检测到阻塞弹窗,已用 Esc 自动关闭。")
return True
# 少数弹窗不响 Esc,再点击它自身的关闭 X;坐标来自当前画面重新识别
self._begin_bot_mouse()
try:
if win32gui.GetForegroundWindow() != self.hwnd:
return False
pyautogui.click(self.L + remaining[0], self.T + remaining[1])
time.sleep(0.45)
finally:
self._end_bot_mouse()
try:
still_blocked = find_blocking_modal_close(self._capture_full_window())
except Exception:
still_blocked = remaining
if still_blocked is not None:
print(f" [页面清理] {reason}弹窗仍在,将限频重试且本轮不执行会话操作。")
return True
print(f" [页面清理] {reason}检测到阻塞弹窗,已点击弹窗关闭按钮。")
return True
def _escape_proven_blocker(self, reason: str) -> bool:
"""会话点击已被明确拦截时,用 Esc 关闭无 X 的下拉层或临时页面。"""
if not self.wait_for_mouse_idle() or self._security_gate_visible():
return False
self._begin_bot_mouse()
try:
if not safe_set_foreground(self.hwnd):
return False
if win32gui.GetForegroundWindow() != self.hwnd:
return False
pyautogui.press("esc")
time.sleep(0.4)
except Exception:
return False
finally:
self._end_bot_mouse()
print(f" [页面清理] {reason}确认页面阻塞,已用 Esc 执行安全恢复。")
return True
def _looks_like_message_list(self, img: np.ndarray) -> bool:
"""判断固定区域里是否仍是消息会话列表。"""
if img is None or getattr(img, "ndim", 0) != 3:
return False
rows = max(0, img.shape[0] // max(1, self.session_item_h))
if rows <= 0 or img.shape[1] < int(60 * self.scale):
return False
visible_rows = 0
for row in range(min(rows, 12)):
y_c = row * self.session_item_h + self.session_item_h // 2
half = max(8, int(17 * self.scale))
y1, y2 = max(0, y_c - half), min(img.shape[0], y_c + half)
x1, x2 = int(10 * self.scale), min(img.shape[1], int(56 * self.scale))
if y2 <= y1 or x2 <= x1:
continue
avatar = img[y1:y2, x1:x2, :3].astype(np.int16)
bg = self._patch_color(img, min(int(3 * self.scale), img.shape[1] - 1), y_c)
distance = np.abs(avatar - bg.reshape(1, 1, 3)).sum(axis=2)
if float((distance > 55).mean()) >= 0.10:
visible_rows += 1
return visible_rows >= 1
def _restore_message_page_if_needed(self, img: np.ndarray) -> bool:
"""连续确认当前不是消息列表后,点击左侧“消息”入口恢复工作页。"""
if self._looks_like_message_list(img):
self._message_page_misses = 0
return False
self._message_page_misses += 1
now = time.monotonic()
if self._message_page_misses < 2 or now - self._last_message_restore_ts < 12:
return False
if not self.wait_for_mouse_idle():
return False
self._last_message_restore_ts = now
self._message_page_misses = 0
return self._open_messages_page("当前页面不是消息列表,")
def _send_gate_remaining(self) -> float:
"""返回发送保护还需等待的秒数;0 表示当前可以发送。"""
now = time.monotonic()
while self._send_timestamps and now - self._send_timestamps[0] >= 3600:
self._send_timestamps.popleft()
waits = []
if self._last_send_ts:
waits.append(self._last_send_ts + MIN_SEND_INTERVAL_SECONDS - now)
minute_sends = [ts for ts in self._send_timestamps if now - ts < 60]
if len(minute_sends) >= MAX_SENDS_PER_MINUTE:
waits.append(minute_sends[0] + 60 - now)
if len(self._send_timestamps) >= MAX_SENDS_PER_HOUR:
waits.append(self._send_timestamps[0] + 3600 - now)
return max(0.0, max(waits, default=0.0))
def _send_gate_open(self) -> bool:
"""只读检查发送额度;被限流时不点开未读会话。"""
remaining = self._send_gate_remaining()
if remaining <= 0:
return True
now = time.monotonic()
if now - self._last_rate_limit_log_ts >= 10:
self._last_rate_limit_log_ts = now
print(f" [发送保护] 当前回复较集中,暂停自动发送约 {remaining:.0f} 秒。")
return False
# 回复已经生成、页面也停在正确的会话上时,还差几秒发送间隔就把回复丢掉,
# 下一轮只能从头再调一次模型、再撞上同一道闸门——客户永远等不到这句话。
# 短于这个上限就地等完再发,长于它(分钟/小时配额用尽)才留到后续轮询。
_SEND_GATE_WAIT_BUDGET_SECONDS = MIN_SEND_INTERVAL_SECONDS + 2.0
def _await_send_gate(self) -> bool:
"""Spend the last few seconds of send spacing here instead of dropping the reply."""
if self._send_gate_open():
return True
remaining = self._send_gate_remaining()
if remaining <= 0 or remaining > self._SEND_GATE_WAIT_BUDGET_SECONDS:
return False
print(f" [发送保护] 发送间隔还差 {remaining:.1f} 秒,等完这段再把本条发出去。")
deadline = time.monotonic() + remaining + 0.5
while time.monotonic() < deadline:
if self._send_gate_remaining() <= 0:
return True
time.sleep(0.2)
return self._send_gate_remaining() <= 0
def _record_send(self, session_id=None):
now = time.monotonic()
self._last_send_ts = now
self._send_timestamps.append(now)
self.reply_count += 1
self.replied.add(session_id or f"reply-{self.reply_count}")
def _refresh_send_reservation(self):
"""Move the current transaction's rate reservation to its real keypress."""
now = time.monotonic()
self._last_send_ts = now
timestamps = getattr(self, "_send_timestamps", None)
if timestamps is None:
timestamps = self._send_timestamps = deque()
if timestamps:
timestamps[-1] = now
else:
timestamps.append(now)
def capture_chat_area(self) -> bytes:
"""使用 PrintWindow 直接从窗口显存截取聊天消息显示区域,返回 PNG bytes。"""
if (
hasattr(self, "_composer_geometry_valid")
and not bool(self._composer_geometry_valid)
) or (
hasattr(self, "_chat_geometry_valid")
and not bool(self._chat_geometry_valid)
):
raise RuntimeError("尚未确认聊天消息区与输入编辑区的动态分隔线")
import io
img_np = capture_window_region(
self.hwnd,
self._chat_rel_x,
self._chat_rel_y,
self._chat_rel_w,
self._chat_rel_h
)
if (
img_np is None
or getattr(img_np, "ndim", 0) < 3
or not getattr(img_np, "size", 0)
or img_np.shape[0] < 1
or img_np.shape[1] < 1
):
raise RuntimeError("聊天消息区域截图为空")
# img_np 格式为 BGRA
img = Image.fromarray(img_np[:, :, :3][:, :, ::-1]) # BGRA -> RGB
buf = io.BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
@staticmethod
def _ink_grid(
img: np.ndarray,
rows: int,
cols: int,
*,
threshold: float = 18.0,
coverage_threshold: float = 0.055,
) -> np.ndarray:
"""Convert a rendered UI region into a background-independent ink grid."""
if img is None or getattr(img, "ndim", 0) != 3 or img.shape[2] < 3:
return np.zeros((rows, cols), dtype=bool)
pixels = img[:, :, :3].astype(np.int16)
h, w = pixels.shape[:2]
if h < 2 or w < 2:
return np.zeros((rows, cols), dtype=bool)
border = np.concatenate(
(
pixels[0, :, :],
pixels[-1, :, :],
pixels[:, 0, :],
pixels[:, -1, :],
),
axis=0,
)
background = np.median(border, axis=0)
foreground = np.max(np.abs(pixels - background), axis=2) >= threshold
ys = np.linspace(0, h, rows + 1).astype(int)
xs = np.linspace(0, w, cols + 1).astype(int)
grid = np.zeros((rows, cols), dtype=bool)
for row in range(rows):
for col in range(cols):
block = foreground[ys[row]:ys[row + 1], xs[col]:xs[col + 1]]
if block.size and float(block.mean()) >= coverage_threshold:
grid[row, col] = True
return grid
@staticmethod
def _filled_layout_grid(grid: np.ndarray) -> np.ndarray:
"""Keep coarse component bounds while ignoring animated pixels inside them."""
if grid is None or not getattr(grid, "size", 0):
return np.zeros((1, 1), dtype=bool)
source = np.asarray(grid, dtype=bool)
# 膨胀仅用于找到一个媒体画布的整体范围;最终仍保留 source 中位
# 媒体范围之外的精确文字锚点,避免把整行文字压成半屏色块
expanded = source.copy()
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if not (dy or dx):
continue
y1 = max(0, dy)
y2 = source.shape[0] + min(0, dy)
x1 = max(0, dx)
x2 = source.shape[1] + min(0, dx)
expanded[y1:y2, x1:x2] |= source[
y1 - dy:y2 - dy,
x1 - dx:x2 - dx,
]
result = source.copy()
media_slots = np.zeros_like(source)
visited = np.zeros_like(expanded)
height, width = expanded.shape
for start_y in range(height):
for start_x in range(width):
if visited[start_y, start_x] or not expanded[start_y, start_x]:
continue
stack = [(start_y, start_x)]
visited[start_y, start_x] = True
component = []
while stack:
y, x = stack.pop()
component.append((y, x))
for ny, nx in (
(y - 1, x - 1), (y - 1, x), (y - 1, x + 1),
(y, x - 1), (y, x + 1),
(y + 1, x - 1), (y + 1, x), (y + 1, x + 1),
):
if (
0 <= ny < height
and 0 <= nx < width
and expanded[ny, nx]
and not visited[ny, nx]
):
visited[ny, nx] = True
stack.append((ny, nx))
if len(component) < 2:
continue
rows = [point[0] for point in component]
cols = [point[1] for point in component]
min_y, max_y = min(rows), max(rows)
min_x, max_x = min(cols), max(cols)
box_h = max_y - min_y + 1
box_w = max_x - min_x + 1
if box_h >= 6 and box_w >= 6:
# 大块内容通常是图片或动画表情。其内部人物会移动、缩放,
# 但画布所在的左右侧与纵向带不变;将它归一为“侧带 + 纵向带”,
# 避免每帧重触发,同时新追加的下方气泡仍会改变布局
# 纵向只做小幅量化:过大的行带会把满屏滚动后的不同气泡压成
# 同一位置;6 行既能容忍动画抖动,又保留更多滚动锚点
# 先从精确锚点中删除整个媒体范围,GIF 的断开小部件不
# 作为独立文字锚点逐帧出现/消失
clear_y1 = max(0, min_y - 2)
clear_y2 = min(height - 1, max_y + 2)
clear_x1 = max(0, min_x - 2)
clear_x2 = min(width - 1, max_x + 2)
result[clear_y1:clear_y2 + 1, clear_x1:clear_x2 + 1] = False
# GIF 主体在透明画布内上下移动一两格很常见;6 行槽避免
# 把换帧当新消息。真实新增由行预览和外部文字锚点共同发现
band = 6
slot_min_y = (min_y // band) * band
slot_max_y = min(height - 1, ((max_y // band) + 1) * band - 1)
center_x = sum(cols) / max(1, len(cols))
if center_x < width / 2:
slot_min_x, slot_max_x = 0, max(0, width // 2 - 1)
else:
slot_min_x, slot_max_x = width // 2, width - 1
media_slots[
slot_min_y:slot_max_y + 1,
slot_min_x:slot_max_x + 1,
] = True
return result | media_slots
def _without_chat_scrollbar(self, img: np.ndarray) -> np.ndarray:
"""裁掉消息区右缘的滚动条槽位再做墨迹统计。
那条滚动条随鼠标进出自动淡入淡出,留在统计范围内会让画面指纹凭空变化
(被当成新消息),也会在最右侧凭空添墨(干扰气泡左右判定)。
"""
if img is None or getattr(img, "ndim", 0) != 3:
return img
scale = max(0.75, float(getattr(self, "scale", 1.0) or 1.0))
gutter = max(6, int(CHAT_SCROLLBAR_GUTTER * scale))
if img.shape[1] - gutter < max(20, gutter):
return img
return img[:, :-gutter]
@classmethod
def _chat_layout_signature(cls, img: np.ndarray) -> bytes:
"""Hash bubble/text layout, not raw pixels inside an animated sticker."""
grid = cls._ink_grid(img, 36, 48, threshold=20.0, coverage_threshold=0.045)
layout = cls._filled_layout_grid(grid)
packed = np.packbits(layout.reshape(-1)).tobytes()
return hashlib.blake2b(packed, digest_size=16).digest() if packed else b""
def _session_row_activity_signature(
self,
img: np.ndarray,
row_center: int,
) -> bytes:
"""Hash the stable message-preview column, excluding dynamic time and GIF frames."""
if img is None or getattr(img, "ndim", 0) != 3:
return b""
scale = max(0.75, float(self.scale or 1.0))
y_c = max(0, min(img.shape[0] - 1, int(row_center)))
def region_grid(x1, x2, y1, y2, rows, cols):
x1 = max(0, int(x1))
x2 = min(img.shape[1], int(x2))
y1 = max(0, int(y1))
y2 = min(img.shape[0], int(y2))
if x2 - x1 < 4 or y2 - y1 < 3:
return np.zeros((rows, cols), dtype=bool)
return self._ink_grid(
img[y1:y2, x1:x2],
rows,
cols,
threshold=16.0,
coverage_threshold=0.045,
)
# 只取第二行消息预览。第一行“刚 10:21”会随时间自行变化,不能
# 它当成新消息;背景通过 ink grid 消除,选中/悬停颜色也不会改变结果
preview = region_grid(
60 * scale,
img.shape[1] - 58 * scale,
y_c + 2 * scale,
y_c + 27 * scale,
6,
32,
)
payload = np.packbits(preview.reshape(-1)).tobytes()
return hashlib.blake2b(payload, digest_size=12).digest()
def _chat_surface_signature(self) -> bytes:
"""双信号指纹:聊天布局忽略 GIF 帧,会话预览负责识别同布局新媒体。"""
if (
hasattr(self, "_composer_geometry_valid")
and not bool(self._composer_geometry_valid)
) or (
hasattr(self, "_chat_geometry_valid")
and not bool(self._chat_geometry_valid)
):
return b""
try:
full = self._capture_full_window()
if full is None or getattr(full, "ndim", 0) != 3:
return b""
list_x1 = max(0, int(self._list_x))
list_y1 = max(0, int(self._list_y))
list_x2 = min(full.shape[1], list_x1 + int(self._list_w))
list_y2 = min(full.shape[0], list_y1 + int(self._list_h))
chat_x1 = max(0, int(self._chat_rel_x))
chat_y1 = max(0, int(self._chat_rel_y))
chat_x2 = min(full.shape[1], chat_x1 + int(self._chat_rel_w))
chat_y2 = min(full.shape[0], chat_y1 + int(self._chat_rel_h))
if (
list_x2 <= list_x1
or list_y2 <= list_y1
or chat_x2 <= chat_x1
or chat_y2 <= chat_y1
):
return b""
session_list = full[list_y1:list_y2, list_x1:list_x2]
img = full[chat_y1:chat_y2, chat_x1:chat_x2]
def selected_activity() -> bytes:
cache = getattr(self, "_row_activity_cache", None)
if cache is None:
cache = self._row_activity_cache = {}
try:
selected_y = self.detect_selected_row(session_list)
if selected_y >= 0:
activity = self._session_row_activity_signature(
session_list,
selected_y,
)
selected_fp = self._session_fingerprint(
session_list,
selected_y,
row_center=True,
)
active_fp = getattr(self, "_active_session_fp", None)
if (
active_fp
and (
not selected_fp
or not self._session_fp_matches(selected_fp, active_fp)
)
):
return bytes(cache.get(active_fp.hex()) or b"")
if selected_fp and activity:
cache[selected_fp.hex()] = activity
return activity
except Exception:
pass
active_fp = getattr(self, "_active_session_fp", None)
if active_fp:
return bytes(cache.get(active_fp.hex()) or b"")
return b""
if img is None or not getattr(img, "size", 0):
return b""
chat_signature = self._chat_layout_signature(
self._without_chat_scrollbar(img)
)
if not chat_signature:
return b""
# 聊天区、会话列表和选中行来自同一 PrintWindow 全窗口快照,
# 不会再产生“旧聊天 + 新预览”的撕裂基线
activity_signature = selected_activity()
# 某些主题无法读到蓝色选中行,仍保留聊天布局信号;能读到时,
# 重复图片/表情即便使聊天区滚动后布局相同,左侧最新预览也会变化
payload = b"chat:" + chat_signature + b"|row:" + activity_signature
return hashlib.blake2b(payload, digest_size=16).digest()
except Exception:
return b""
def _chat_identity_signature(self) -> bytes:
"""只读计算聊天标题区域指纹,用于发现 AI 等待期间的页面/对象切换。"""
try:
window_width = self.R - self.L
rel_x = self._list_x + self._list_w
width = min(max(0, window_width - rel_x), int(520 * self.scale))
height = max(int(HEADER_H * self.scale), int(40 * self.scale))
if width < int(120 * self.scale):
return b""
img = capture_window_region(self.hwnd, rel_x, 0, width, height)
if img is None or not getattr(img, "size", 0):
return b""
scale = max(0.75, float(self.scale or 1.0))
# 只读取联系人名称首行,避开“正在输入”、在线状态和右侧按钮
x1 = max(0, int(12 * scale))
x2 = min(img.shape[1], int(250 * scale))
y1 = max(0, int(7 * scale))
y2 = min(img.shape[0], int(34 * scale))
if x2 - x1 < 12 or y2 - y1 < 8:
return b""
grid = self._ink_grid(
img[y1:y2, x1:x2],
8,
48,
threshold=16.0,
coverage_threshold=0.05,
)
packed = np.packbits(grid.reshape(-1)).tobytes()
return hashlib.blake2b(packed, digest_size=16).digest() if packed else b""
except Exception:
return b""
def _selected_target_proof(
self,
expected_fp: bytes,
current_fp: bytes | None = None,
*,
selected_fp_checked: bool = False,
) -> bool | None:
"""Return True/False for a full selected-row proof, or None if unavailable.
Only the current 40-byte avatar+name identity is strong enough to
override a title-render hash. Legacy/partial identities deliberately
fall back to the exact title check so old tasks cannot be rebound from
an avatar alone.
"""
expected_fp = bytes(expected_fp or b"")
if current_fp is None and not selected_fp_checked:
try:
current_fp = (
self._raw_selected_session_fingerprint()
or self._selected_session_fingerprint()
)
except Exception:
current_fp = None
current_fp = bytes(current_fp or b"")
if (
len(expected_fp) != _SESSION_FP_BYTES
or len(current_fp) != _SESSION_FP_BYTES
):
return None
return self._session_fp_matches(current_fp, expected_fp)
def _chat_target_matches(
self,
expected_fp: bytes,
expected_identity: bytes = b"",
*,
current_fp: bytes | None = None,
current_identity: bytes | None = None,
selected_fp_checked: bool = False,
) -> bool:
"""Prefer selected avatar+name identity; use title only as fallback."""
if current_identity is None:
try:
current_identity = self._chat_identity_signature()
except Exception:
current_identity = b""
current_identity = bytes(current_identity or b"")
if not current_identity:
return False
selected_proof = self._selected_target_proof(
expected_fp,
current_fp,
selected_fp_checked=selected_fp_checked,
)
if selected_proof is not None:
return selected_proof
return bool(
expected_identity
and current_identity == bytes(expected_identity or b"")
)
def _accept_selected_title_render(
self,
expected_fp: bytes,
state: dict | None,
current_identity: bytes,
*,
current_fp: bytes | None = None,
selected_fp_checked: bool = False,
) -> bool:
"""Rebind a pending title hash only after a full selected-row proof."""
current_identity = bytes(current_identity or b"")
if not current_identity or self._selected_target_proof(
expected_fp,
current_fp,
selected_fp_checked=selected_fp_checked,
) is not True:
return False
old_identity = bytes((state or {}).get("identity_signature") or b"")
if state is not None and old_identity != current_identity:
had_updated_at = "updated_at" in state
old_updated_at = state.get("updated_at")
state["identity_signature"] = current_identity
state["updated_at"] = time.time()
try:
persisted = self._persist_pending_replies()
except Exception:
persisted = False
if not persisted:
if old_identity:
state["identity_signature"] = old_identity
else:
state.pop("identity_signature", None)
if had_updated_at:
state["updated_at"] = old_updated_at
else:
state.pop("updated_at", None)
return False
active_fp = bytes(getattr(self, "_active_session_fp", None) or b"")
if active_fp and self._session_fp_matches(active_fp, expected_fp):
self._active_identity_signature = current_identity
if old_identity and old_identity != current_identity:
notices = getattr(self, "_title_render_rebind_notices", None)
if notices is None:
notices = self._title_render_rebind_notices = set()
key = bytes(expected_fp or b"").hex()
if key not in notices:
print(" [会话校验] 标题发生渲染变化,已通过头像和名称复合指纹确认仍是原会话。")
notices.add(key)
return True
def _wait_for_message_batch(
self,
fp: bytes,
window_seconds: float | None = None,
poll_seconds: float = MESSAGE_BATCH_POLL_SECONDS,
) -> bool:
"""
在当前会话收集一段固定时间内的连续消息,再放行一次 AI 请求。
等待期间只读取聊天画面和标题指纹,不点击输入框。若用户切换了会话、
页面离开“消息”或监听被停止,立即取消,绝不把回复发到别的对象。
"""
if window_seconds is None:
raw_configured_seconds = getattr(
self,
"message_batch_window_seconds",
MESSAGE_BATCH_WINDOW_SECONDS,
)
try:
configured_seconds = (
MESSAGE_BATCH_WINDOW_SECONDS
if isinstance(raw_configured_seconds, bool)
else float(raw_configured_seconds)
)
except (TypeError, ValueError):
configured_seconds = MESSAGE_BATCH_WINDOW_SECONDS
if not (
MESSAGE_BATCH_WINDOW_MIN_SECONDS
<= configured_seconds
<= MESSAGE_BATCH_WINDOW_MAX_SECONDS
):
configured_seconds = MESSAGE_BATCH_WINDOW_SECONDS
# 只在窗口开始时取一次快照,运行中修改配置不会截断当前客户的等待
window_seconds = configured_seconds
pending_state = self._pending_reply_state(fp)
identity = self._chat_identity_signature()
signature = self._chat_surface_signature()
if not identity or not signature:
return False
active_identity = getattr(self, "_active_identity_signature", None)
expected_identity = bytes(
(pending_state or {}).get("identity_signature")
or active_identity
or identity
)
if not self._chat_target_matches(
fp,
expected_identity,
current_identity=identity,
):
print(" [消息合并] 当前聊天对象已变化,取消本次合并与回复。")
return False
if identity != expected_identity and not self._accept_selected_title_render(
fp,
pending_state,
identity,
):
print(" [消息合并] 无法证明标题变化仍属于原会话,取消本次合并与回复。")
return False
def selected_session_still_matches() -> bool:
"""Use the stronger list identity when the selected row is observable."""
active_fp = getattr(self, "_active_session_fp", None)
if active_fp and not self._session_fp_matches(active_fp, fp):
return False
try:
selected_fp = self._raw_selected_session_fingerprint()
if not selected_fp:
selected_fp = self._selected_session_fingerprint()
except Exception:
selected_fp = None
# Some themes do not expose a detectable selected-row colour. In
# that case the frozen title/active fingerprint checks remain the
# fallback. When a selected fingerprint is available, however,
# it must match: two contacts can legitimately have the same title.
return bool(
not selected_fp
or self._session_fp_matches(selected_fp, fp)
)
if not selected_session_still_matches():
print(" [消息合并] 当前选中会话指纹已变化,取消本次合并与回复。")
return False
window_seconds = max(0.0, float(window_seconds))
poll_seconds = max(0.05, float(poll_seconds))
wall_now = time.time()
remaining_seconds = window_seconds
resumed_window = False
if pending_state is not None and not pending_state.get("batch_ready", False):
try:
stored_deadline = float(
pending_state.get("batch_deadline_at", 0.0) or 0.0
)
except (TypeError, ValueError):
stored_deadline = 0.0
if stored_deadline > 0.0:
# Epoch time survives a process restart. A transient capture,
# title or focus failure therefore resumes the original window
# instead of silently opening another full 20-second wait.
try:
stored_window = float(
pending_state.get("batch_window_seconds", window_seconds)
or window_seconds
)
except (TypeError, ValueError):
stored_window = window_seconds
stored_window = max(
0.0,
min(MESSAGE_BATCH_WINDOW_MAX_SECONDS, stored_window),
)
# A wall-clock rollback must not turn a short merge window into
# an hours-long wait.
remaining_seconds = min(
stored_window,
max(0.0, stored_deadline - wall_now),
)
resumed_window = True
else:
stored_deadline = wall_now + window_seconds
pending_state["batch_started_at"] = wall_now
pending_state["batch_deadline_at"] = stored_deadline
pending_state["batch_window_seconds"] = window_seconds
pending_state["updated_at"] = wall_now
self._persist_pending_replies()
started = time.monotonic()
deadline = started + remaining_seconds
changes = 0
if remaining_seconds:
if resumed_window:
print(
f" [消息合并] 继续上次合并窗口,"
f"剩余约 {remaining_seconds:.0f} 秒…"
)
else:
print(f" [消息合并] 开始收集本会话 {window_seconds:.0f} 秒内的连续消息…")
elif resumed_window:
print(" [消息合并] 原合并窗口已到期,直接进入最终校验。")
while time.monotonic() < deadline:
remaining = max(0.0, deadline - time.monotonic())
delay = min(poll_seconds, remaining)
stop_check = getattr(self, "_stop_check", None)
if stop_check is not None:
if stop_check.wait(delay):
return False
else:
time.sleep(delay)
try:
full = self._capture_full_window()
except Exception:
return False
if not self._message_nav_selected(full):
ready_fp = self._target_chat_ready(fp)
if not ready_fp:
print(" [消息合并] 页面已离开“消息”,取消本次回复。")
return False
self._confirm_flat_session(
ready_fp,
self._pending_reply_state(fp),
)
print(" [消息合并] 导航取色异常,但目标聊天区仍可用,继续收集消息。")
current_identity = self._chat_identity_signature()
if not self._chat_target_matches(
fp,
identity,
current_identity=current_identity,
):
print(" [消息合并] 等待期间聊天对象发生变化,取消本次回复。")
return False
if current_identity != identity:
if not self._accept_selected_title_render(
fp,
pending_state,
current_identity,
):
print(" [消息合并] 等待期间标题变化无法安全确认,取消本次回复。")
return False
identity = current_identity
if not selected_session_still_matches():
print(" [消息合并] 等待期间切换到了同名会话,取消本次回复。")
return False
current_signature = self._chat_surface_signature()
if not current_signature:
return False
if current_signature != signature:
signature = current_signature
changes += 1
# 合并等待期间人工动过鼠标时,继续等到人手空闲后再框选;随后再校验一次对象
if not self.wait_for_mouse_idle():
return False
if not self._activate_wx():
print(" [消息合并] 收集结束时企业微信不在安全前台,取消本次回复。")
return False
final_identity = self._chat_identity_signature()
if not self._chat_target_matches(
fp,
identity,
current_identity=final_identity,
):
print(" [消息合并] 提取前聊天对象发生变化,取消本次回复。")
return False
if final_identity != identity:
if not self._accept_selected_title_render(
fp,
pending_state,
final_identity,
):
print(" [消息合并] 提取前标题变化无法安全确认,取消本次回复。")
return False
identity = final_identity
if not selected_session_still_matches():
print(" [消息合并] 提取前选中会话指纹已变化,取消本次回复。")
return False
print(
f" [消息合并] 收集完成"
f"(期间检测到 {changes} 次消息画面更新),将只发起 1 次模型请求。"
)
return True
# 剪贴板哨兵:复制前先写入该唯一标记,复制后若剪贴板仍是它,说明本次拖拽未选中任何文字。
_CLIP_SENTINEL = "__WX_RPA_CLIP_EMPTY__"
def _drag_select(self, x_start: int, y_start: int, x_end: int, y_end: int, steps: int = 10):
"""
手动模拟一次「按下 → 分段移动 → 抬起」的鼠标拖拽,比 pyautogui.dragTo 更易被
企业微信识别为文本框选(dragTo 的补间在自绘控件里经常选不中)。
★ 拖拽期间临时关闭 pyautogui.PAUSE:全局 PAUSE=0.05 会在补间循环的
每一步 moveTo 后强制延时,一次拖拽白白多耗 1 秒以上。
"""
old_pause = pyautogui.PAUSE
pyautogui.PAUSE = 0
try:
pyautogui.moveTo(x_start, y_start)
time.sleep(0.05)
pyautogui.mouseDown(button='left')
time.sleep(0.08)
for i in range(1, steps + 1):
ix = int(x_start + (x_end - x_start) * i / steps)
iy = int(y_start + (y_end - y_start) * i / steps)
pyautogui.moveTo(ix, iy)
time.sleep(0.01)
time.sleep(0.05)
pyautogui.mouseUp(button='left')
time.sleep(0.12)
finally:
pyautogui.PAUSE = old_pause
def _copy_selection(self) -> str:
"""
用哨兵法可靠判断 Ctrl+C 是否真的复制到了内容。
返回复制到的文本;若未选中任何文字则返回空串。
"""
try:
pyperclip.copy(self._CLIP_SENTINEL)
except Exception:
pass
time.sleep(0.05)
pyautogui.hotkey('ctrl', 'c')
time.sleep(0.2)
try:
data = pyperclip.paste()
except Exception:
data = ''
if not data or data == self._CLIP_SENTINEL:
return ''
return data
def _select_visible_chat(self, left, top, width, height, margin, safe_top) -> list:
"""
框选并复制【当前可见一屏】的聊天内容。
返回去掉空行后的文本行列表(空列表 = 本屏复制失败)。
依次尝试两种拖拽方向:
1. 右下 → 左上:适用于消息填满面板的情况(大多数老会话)。
2. 左上 → 右下:适用于【消息不满一屏】的情况——此时消息都靠在顶部,
底部是空白,鼠标在空白处按下无法锚定到任何文字,方向 1 必然选空;
从顶部消息处按下再往下拖就能正常选中。
"""
attempts = [
# (起点x, 起点y, 终点x, 终点y)
(left + width - margin, top + height - margin, left + margin, safe_top),
(left + margin, safe_top, left + width - margin, top + height - margin),
]
for x1, y1, x2, y2 in attempts:
self._drag_select(x1, y1, x2, y2, steps=12)
text = self._copy_selection()
if text:
return [l.strip() for l in text.splitlines() if l.strip()]
return []
@staticmethod
def _merge_overlap(older: list, newer: list) -> list:
"""
拼接两屏复制到的行列表,自动去掉重叠部分。
翻屏滚动不可能精确一屏,相邻两屏必然有重复消息:
找到 older 尾部与 newer 头部的最大公共子序列,去重后拼接。
"""
max_k = min(len(older), len(newer))
for k in range(max_k, 0, -1):
if older[-k:] == newer[:k]:
return older + newer[k:]
return older + newer
def extract_chat_text(
self,
screens: int = None,
wait_for_idle: bool = True,
) -> str:
"""
通过鼠标框选 + 剪贴板复制,提取当前打开会话的聊天记录。
企业微信为自绘控件,不支持 Ctrl+A 全选,只能靠拖拽框选。
策略:先复制当前可见一屏,再向上滚动翻屏、逐屏复制,
共采集 screens 屏(默认 CHAT_CONTEXT_SCREENS)后按重叠去重拼接,
最后滚回底部。返回最近 CHAT_CONTEXT_MAX_LINES 行文本。
★ 增量模式(会话已有档案)只需 screens=1,速度最快。
"""
if (
getattr(self, "_composer_geometry_valid", True) is False
or getattr(self, "_chat_geometry_valid", True) is False
):
print(" [剪贴板] 聊天区域边界尚未可靠识别,已取消提取。")
return ''
region = self._chat_region
left = region['left']
top = region['top']
width = region['width']
height = region['height']
min_width = max(40, int(80 * max(0.75, float(self.scale or 1.0))))
min_height = max(50, int(80 * max(0.75, float(self.scale or 1.0))))
if width < min_width or height < min_height:
print(" [剪贴板] 聊天框选区域空间不足,已禁止窗口外拖拽。")
return ''
if all(hasattr(self, name) for name in ("L", "T", "R", "B")) and not (
self.L <= left < left + width <= self.R
and self.T <= top < top + height <= self.B
):
print(" [剪贴板] 聊天框选区域超出企业微信窗口,已取消提取。")
return ''
margin = max(4, min(20, (width - 2) // 4, (height - 2) // 4))
center_x = left + width // 2
center_y = top + height // 2
safe_top = min(
top + height - margin,
max(top + margin, top + int(CHAT_SELECT_TOP_MARGIN * self.scale)),
)
if safe_top >= top + height - margin:
print(" [剪贴板] 聊天框选安全边距不足,已取消提取。")
return ''
screens = max(1, screens if screens is not None else CHAT_CONTEXT_SCREENS)
if wait_for_idle:
if not self.wait_for_mouse_idle():
return ''
elif self.mouse_idle_enabled:
# Receipt reconciliation must never sit in the 20-second human
# idle wait. If a person is using the mouse, defer the proof to a
# later poll without touching the UI.
self._sync_user_mouse_activity()
if time.time() - self._last_user_move_ts < self.mouse_idle_seconds:
return ''
self._begin_bot_mouse()
try:
return self._extract_chat_text_locked(screens, left, top, width, height,
margin, center_x, center_y, safe_top)
finally:
self._end_bot_mouse()
def _extract_chat_text_locked(self, screens, left, top, width, height,
margin, center_x, center_y, safe_top) -> str:
"""extract_chat_text 的实际实现(调用方已持有 bot_mouse 锁)。"""
try:
# 1. 备份当前剪贴板内容
old_clipboard = ''
try:
old_clipboard = pyperclip.paste()
except Exception:
pass
# 2. 确保企业微信仍在前台。安全模式下不会主动抢回焦点。
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,取消聊天内容提取。")
return ''
# 先在聊天区底部空白处点一下,确保焦点落在聊天面板而非别处
pyautogui.click(center_x, top + height - margin)
time.sleep(0.2)
# 3. 逐屏采集:blocks[0] = 最新一屏(底部),往后越来越旧
blocks = []
scrolled = 0 # 累计向上滚动的格数(用于最后滚回底部)
for i in range(screens):
lines = self._select_visible_chat(left, top, width, height, margin, safe_top)
# 第一屏就失败 → 区域可能不对,保存调试截图后放弃
if i == 0 and not lines:
print(" [剪贴板] 未能复制到聊天内容(两次框选均为空)")
try:
dbg = capture_window_region(
self.hwnd, self._chat_rel_x, self._chat_rel_y,
self._chat_rel_w, self._chat_rel_h)
p = save_debug_screenshot(dbg, "debug_chat_area.png")
print(f" [剪贴板] 已保存聊天区域截图: {p}")
except Exception:
pass
break
# 翻屏后内容和上一屏完全一样 → 已到聊天记录顶部,停止
if blocks and lines == blocks[-1]:
break
if lines:
blocks.append(lines)
# 本屏行数很少 → 消息不满一屏(整个历史已可见),翻屏是浪费时间
if len(lines) < CHAT_FULL_SCREEN_LINES:
break
# 还需要更早的消息 → 向上滚动一屏(鼠标须悬停在聊天区内)
if i < screens - 1:
pyautogui.moveTo(center_x, center_y)
pyautogui.scroll(CHAT_SCROLL_CLICKS * 120, center_x, center_y)
scrolled += CHAT_SCROLL_CLICKS
time.sleep(0.5) # 等待渲染 / 加载更早的历史消息
# 4. 滚回底部(多滚一些确保到底),并点空白处取消选中高亮
if scrolled:
pyautogui.moveTo(center_x, center_y)
pyautogui.scroll(-(scrolled + CHAT_SCROLL_CLICKS * 2) * 120, center_x, center_y)
time.sleep(0.4)
pyautogui.click(center_x, top + height - margin)
time.sleep(0.1)
# 5. 还原之前的剪贴板内容
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
if not blocks:
return ''
# 6. 从最旧一屏开始向新拼接,相邻屏按重叠去重
merged = blocks[-1]
for newer in reversed(blocks[:-1]):
merged = self._merge_overlap(merged, newer)
recent = merged[-CHAT_CONTEXT_MAX_LINES:]
result = '\n'.join(recent)
print(f" [剪贴板] 成功提取 {len(recent)} 行聊天记录"
f"(共采集 {len(blocks)} 屏 / 去重后 {len(merged)} 行)")
return result
except Exception as e:
print(f" [剪贴板] 提取失败: {e}")
return ''
# ── 会话档案(持久化上下文)──────────────────────────────────────────────
def get_session_history(self, fp: bytes) -> list:
"""获取指定会话的历史消息列表(来自持久化档案)。"""
return self.store.history(fp.hex())
def remember_exchange(self, fp: bytes, user_text: str, reply_text: str):
"""将本轮「客户新消息 + 我方回复」写入该会话的持久化档案。"""
fp_hex = fp.hex()
self.store.append(fp_hex, "user", user_text)
self.store.append(fp_hex, "assistant", reply_text)
self.store.save()
@staticmethod
def _delta_lines(old_lines: list, new_lines: list) -> list:
"""
增量比对:old_lines 是档案里上次提取的画面快照,new_lines 是本次画面。
找 old_lines 的尾部片段(最长 20 行)在 new_lines 中的【首次】出现位置,
返回其后的行 = 上次提取之后新增的消息。
(取首次出现而非最后一次:客户重复发相同内容时,取最后一次会把
新消息误判成旧内容而漏掉;取首次最多带上一两行旧内容,AI 可自行忽略。)
找不到重叠(消息刷得太快/首次提取)则整屏都算新增。
"""
if not old_lines:
return list(new_lines)
max_k = min(len(old_lines), len(new_lines), 20)
for k in range(max_k, 0, -1):
tail = old_lines[-k:]
for start in range(len(new_lines) - k + 1):
if new_lines[start:start + k] == tail:
return new_lines[start + k:]
return list(new_lines)
def extract_context_for(
self,
fp: bytes,
pre_text: str = None,
defer_snapshot: bool = False,
) -> str:
"""
智能提取当前打开会话需要发给 AI 的文本:
- 该会话无档案(首次遇到):翻屏提取完整可见历史做建档,全文发给 AI;
- 已有档案:只提取最新一屏(快),与档案中上次画面快照做增量比对,
仅返回【新增的消息】——完整上下文由档案 history 提供,不再重复复制。
pre_text 可传入已提取好的一屏文本,避免重复框选。
defer_snapshot=True 时先把画面快照暂存在 pending;只有发送成功才落盘,
避免模型或发送失败后把尚未回复的内容误判成旧消息。
"""
fp_hex = fp.hex()
def remember_snapshot(lines: list) -> None:
if defer_snapshot:
self._mark_reply_pending(fp)
state = self._pending_reply_state(fp)
if state is not None:
state["last_lines"] = list(lines)
state["updated_at"] = time.time()
self._persist_pending_replies()
return
self.store.set_last_lines(fp_hex, lines)
self.store.save()
# 首次遇到该会话:完整提取建档
if not self.store.has_record(fp_hex):
text = pre_text if pre_text is not None else self.extract_chat_text()
lines = [l for l in text.splitlines() if l.strip()] if text else []
if lines:
remember_snapshot(lines)
action = "暂存" if defer_snapshot else "建档"
print(f" [档案] 首次遇到该会话,已{action}{len(lines)} 行可见历史)")
return text or ''
# 增量模式:只取最新一屏
text = pre_text if pre_text is not None else self.extract_chat_text(screens=1)
if not text:
return ''
lines = [l for l in text.splitlines() if l.strip()]
delta = self._delta_lines(self.store.last_lines(fp_hex), lines)
remember_snapshot(lines)
if delta:
print(f" [档案] 增量提取到 {len(delta)} 行新消息(历史上下文由会话档案提供)")
return '\n'.join(delta)
# 剪贴板内容没变但聊天画面可能新增了图片/贴纸/语音。这里绝不能
# 退回整屏旧文字,否则会把上一轮客户问题再次当作新消息重复回复
# 调用方会把空增量交给媒体视觉兜底
print(" [档案] 剪贴板没有新增文字,将检查是否为非文字消息")
return ''
# ── 3. 红点识别层 ─────────────────────────────────────────────────────────
def detect_badge_rows(self, img: np.ndarray) -> list:
"""
在截图中查找未读红点,返回每个红点中心的 Y 行号列表(相对于截图顶部)。
核心策略:
1. 只扫描截图右侧列(BADGE_SCAN_X_START 之后),头像在左侧不干扰
2. 色彩阈值贴近 #FA5151,减少头像内容误判
3. 最小像素数过滤,排除偶发噪点
"""
# 只取截图中的头像右上角区域,过滤其他区域的干扰
scan_region = img[:, self.badge_scan_x_start:self.badge_scan_x_end, :] # shape: (H, W', 4)
R = scan_region[:, :, 2].astype(np.int16) # 红通道
G = scan_region[:, :, 1].astype(np.int16) # 绿通道
B = scan_region[:, :, 0].astype(np.int16) # 蓝通道
# 布尔掩码
mask = (
(R >= BADGE_R_MIN) & (R <= BADGE_R_MAX) &
(G <= BADGE_G_MAX) &
(B <= BADGE_B_MAX)
)
row_pixel_counts = mask.sum(axis=1)
red_rows = np.where(row_pixel_counts >= 1)[0]
if len(red_rows) == 0:
return []
# 合并相邻行,取各组中心行号
badge_centers = []
group = [red_rows[0]]
for row in red_rows[1:]:
if row - group[-1] <= BADGE_MERGE_GAP:
group.append(row)
else:
if row_pixel_counts[group].sum() >= MIN_RED_PIXELS:
badge_centers.append(int(np.mean(group)))
group = [row]
if row_pixel_counts[group].sum() >= MIN_RED_PIXELS:
badge_centers.append(int(np.mean(group)))
return badge_centers
@staticmethod
def _patch_color(img: np.ndarray, px: int, py: int, r: int = 2) -> np.ndarray:
"""
采样 (px, py) 周围 (2r+1)² 小块的中位数颜色(BGR),降低单像素噪声/抗锯齿边缘的影响。
返回 int16 的 [B, G, R]。
"""
x1 = max(0, px - r)
x2 = min(img.shape[1], px + r + 1)
y1 = max(0, py - r)
y2 = min(img.shape[0], py + r + 1)
patch = img[y1:y2, x1:x2, :3].reshape(-1, 3)
return np.median(patch, axis=0).astype(np.int16)
def _is_real_conversation(
self,
img: np.ndarray,
badge_y: int,
quiet: bool = False,
row_center: bool = False,
allow_flat: bool = False,
) -> bool:
"""
判断该行是否为真实人/群对话,而非系统工具(打卡、行业资讯等)。
quiet=True 时不打印日志、不导出调试截图(用于每轮都会执行的选中行检查)。
原理:
企业微信的真实头像(照片/字母头像/群聊九宫格)多为圆角方形,四角会露出
会话列表背景色;而系统工具图标通常填满整个方块、四角不露背景。
通过对头像四角做「小块中位数采样」并与动态背景色比对来区分,自适应深浅色主题。
⚠ 为避免「把真实会话误判成系统工具而漏回复」这种最坏情况,判定偏向宽松:
只要 ≥2 个角落露出背景(圆角头像的典型特征)就视为真实会话。
"""
avatar_x1 = int(8 * self.scale)
avatar_x2 = int(58 * self.scale)
avatar_radius = int(25 * self.scale) # 头像宽度从 x: 8~58,半径为 25
# 红点位于头像右上方,中心通常比头像中心高 16 个逻辑像素
# 直接从红点推头像中心,不再假设滚动后列表仍与固定行网格对齐
y_c = int(badge_y) if row_center else self._row_center_from_badge(img, badge_y)
y_c = max(0, min(img.shape[0] - 1, y_c))
row_idx = max(0, int(y_c // max(1, self.session_item_h)))
y1 = max(0, y_c - avatar_radius)
y2 = min(img.shape[0], y_c + avatar_radius)
# 越界保护
if y2 <= y1 or avatar_x2 >= img.shape[1]:
return True # 无法判断,默认当作真实对话
# 动态采样背景色。未选中行可用最左侧 x=3;但选中行的蓝色高亮
# 带有左侧圆角 00% DPI x=6 仍可能落在高亮外的白边,而头
# 四角已经位于蓝色高亮内。保留旧采样并增加头像左邻点,分别计
# 四角匹配数后取最大值,兼容未选中行、选中行和不同主题
edge_bg_x = int(3 * self.scale)
if edge_bg_x >= img.shape[1]:
edge_bg_x = 0
avatar_bg_x = max(
0,
avatar_x1 - max(1, int(round(max(0.75, float(self.scale or 1.0))))),
)
background_colors = [self._patch_color(img, edge_bg_x, y_c)]
if avatar_bg_x != edge_bg_x:
background_colors.append(self._patch_color(img, avatar_bg_x, y_c))
# 取头像区域四角的小块中位数颜色(BGR 通道,截图格式为 BGRA)
corner_pts = [
(avatar_x1, y1),
(avatar_x2-1, y1),
(avatar_x1, y2-1),
(avatar_x2-1, y2-1),
]
# 统计有多少个角落与任一可靠背景候选一致(三通道色差绝对值之
# 在容差范围内)。每个候选独立计数再取最大值,不能让不同背景各匹配
# 一个角后错误累加成真实会话
corner_colors = [self._patch_color(img, px, py) for px, py in corner_pts]
match_count = max(
sum(
1
for corner_color in corner_colors
if np.sum(np.abs(corner_color - bg_color)) < 45
)
for bg_color in background_colors
)
# 判定 1:≤ 1 个角落露出背景 → 图标填满方块 → 系统工具
# 判定 2:头像是「大面积高饱和纯色 + 白色图形」→ 系统应用图标
# (客户联系=绿、行业资讯=黄、企小码=蓝等,它们也是圆角方形,
# 四角同样露背景,仅靠判定 1 拦不住)
flat_icon = self._is_flat_icon(img, y_c)
is_real = match_count >= 2 and (allow_flat or not flat_icon)
if not is_real and not quiet:
reason = (f"四角匹配背景数={match_count}" if match_count < 2
else "纯色系统图标(大面积纯色+白色图形)")
print(f" [调试] 检测到系统工具图标,已跳过。判定依据: {reason}")
# 导出被跳过行的头像截图,便于人工核对/调参(覆盖写入,开销极小)
try:
crop = img[y1:y2, 0:avatar_x2]
save_debug_screenshot(crop, f"debug_skipped_row{row_idx}.png")
except Exception:
pass
return is_real
def _is_tool_selected(self, img: np.ndarray, sel_y: int) -> bool:
"""选中行是否为系统工具页(每轮静默检查用)。"""
if self._is_real_conversation(
img,
sel_y,
quiet=True,
row_center=True,
):
return False
# A rounded solid-colour row may be a real contact using WeCom's
# generated text avatar. Keep it observable until vision proves it is
# a tool page; this avoids permanently ignoring selected text-avatar
# conversations while still remembering confirmed tool rows.
if not self._is_real_conversation(
img,
sel_y,
quiet=True,
row_center=True,
allow_flat=True,
):
# 极少数外部联系人头像会铺满方块。只有该选中行与一个已由真
# 未读红点创建、要求视觉确认的 40 字节待回复任务唯一匹配时,
# 才允许继续做标题/聊天区校验;不能靠头像单独绕过工具过滤
fp = self._session_fingerprint(img, sel_y, row_center=True)
if self._flat_session_is_known(fp):
return False
return self._pending_visual_proof_target(fp) is None
fp = self._session_fingerprint(img, sel_y, row_center=True)
return self._flat_session_rejected(fp)
def _pending_visual_proof_target(self, fp: bytes) -> tuple[bytes, dict] | None:
"""Return one uniquely matching full-identity pending proof task."""
fp = bytes(fp or b"")
if len(fp) != _SESSION_FP_BYTES:
return None
matches = []
for key, state in getattr(self, "_pending_reply_sessions", {}).items():
if not isinstance(state, dict) or not state.get("requires_visual_proof"):
continue
try:
pending_fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if len(pending_fp) != _SESSION_FP_BYTES:
continue
if self._flat_session_rejected(pending_fp):
continue
if (
self._session_fp_matches(fp, pending_fp)
or self._live_render_transition_match(fp, pending_fp)
):
matches.append((pending_fp, state))
return matches[0] if len(matches) == 1 else None
def _fp_has_render_alias(self, fp: bytes, encoded_values) -> bool:
"""Match persisted/in-memory visual aliases across safe render drift."""
fp = bytes(fp or b"")
if not fp:
return False
for encoded in set(encoded_values or set()):
try:
candidate = bytes.fromhex(str(encoded))
except (TypeError, ValueError):
continue
if (
self._session_fp_matches(fp, candidate)
or self._live_render_match(fp, candidate)
):
return True
return False
def _flat_session_rejected(self, fp: bytes) -> bool:
"""True after the same flat-avatar/name row was proven to be a tool page."""
# Positive chat evidence must override an older colour/navigation guess.
# External WeChat contacts can use the same green rounded icon style as
# built-in applications, so a once-rejected row is not permanently
# poisoned after its selected row, title and chat surface are verified.
if self._fp_has_render_alias(
fp,
getattr(self, "_flat_verified_session_fps", set()),
):
return False
return self._fp_has_render_alias(
fp,
getattr(self, "_flat_rejected_session_fps", set()),
)
def _flat_session_is_known(self, fp: bytes) -> bool:
"""Return True only after a flat-avatar row has real conversation evidence."""
key = bytes(fp or b"").hex()
if not key:
return False
if self._fp_has_render_alias(
fp,
getattr(self, "_flat_verified_session_fps", set()),
):
return True
try:
return bool(self.store.has_record(key))
except Exception:
return False
def _confirm_flat_session(self, fp: bytes, pending_state: dict | None = None) -> None:
"""Remember deterministic/visual proof that a flat row is a real chat."""
key = bytes(fp or b"").hex()
if not key:
return
verified = getattr(self, "_flat_verified_session_fps", None)
if verified is None:
verified = self._flat_verified_session_fps = set()
verified.add(key)
rejected = getattr(self, "_flat_rejected_session_fps", set())
for encoded in list(rejected):
try:
candidate = bytes.fromhex(str(encoded))
except (TypeError, ValueError):
continue
if (
self._session_fp_matches(fp, candidate)
or self._live_render_match(fp, candidate)
):
rejected.discard(encoded)
proof_fps = getattr(self, "_flat_visual_proof_fps", set())
for encoded in list(proof_fps):
try:
candidate = bytes.fromhex(str(encoded))
except (TypeError, ValueError):
continue
if (
self._session_fp_matches(fp, candidate)
or self._live_render_match(fp, candidate)
):
proof_fps.discard(encoded)
if pending_state is not None:
pending_state["requires_visual_proof"] = False
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
def _flat_row_requires_visual_proof(
self,
img: np.ndarray,
row_y: int,
fp: bytes,
*,
row_center: bool,
) -> bool:
y_c = (
int(row_y)
if row_center
else self._row_center_from_badge(img, int(row_y))
)
return bool(
self._is_flat_icon(img, y_c)
and not self._flat_session_is_known(fp)
)
def _is_flat_icon(self, img: np.ndarray, y_c: int) -> bool:
"""
判断头像是否为「系统应用图标」:大面积高饱和纯色底 + 白色图形
(如 客户联系=绿底、行业资讯=黄底、企小码会话管理=蓝底、微盘/日程等)。
原理(用用户实际截图验证过,区分度非常大):
将头像中心区域颜色量化到 8 级/通道后,统计「覆盖 95% 像素所需的颜色种数」:
系统图标(纯色底+白色图形)只有 3~5 种有效颜色;
真人照片/群聊九宫格有 18~30 种有效颜色。
再要求主色中存在高饱和彩色(图标底色为亮绿/亮黄/亮蓝),
避免把低饱和的灰色默认头像误判成图标。
⚠ 已知取舍:企业微信「姓名文字头像」(纯蓝底+白字)会被误判为系统图标。
客户场景下几乎都是照片头像,此风险可接受;若真遇到,可在
debug_skipped_row*.png 中核对并调大 FLAT_MAX_COLORS 阈值。
"""
FLAT_MAX_COLORS = 8 # 有效颜色数 ≤ 此值视为纯色图标(实测:图标 3~5,照片 18+)
FLAT_SAT_MIN = 55 # 主色饱和度阈值(max通道-min通道)
x1 = int(12 * self.scale)
x2 = min(int(54 * self.scale), img.shape[1])
half = int(18 * self.scale)
y1 = max(0, y_c - half)
y2 = min(img.shape[0], y_c + half)
if y2 <= y1 or x2 <= x1:
return False # 无法判断时不拦截(宁可误点,不漏真实会话)
pix = img[y1:y2, x1:x2, :3].reshape(-1, 3).astype(np.int32) # BGR
if len(pix) < 50:
return False
# 颜色量化到 8 级/通道,统计覆盖 95% 像素所需的颜色种数
codes = (pix[:, 0] // 32) * 64 + (pix[:, 1] // 32) * 8 + (pix[:, 2] // 32)
counts = np.bincount(codes)
order = np.argsort(counts)[::-1]
cum = np.cumsum(counts[order]) / len(codes)
n_colors = int(np.searchsorted(cum, 0.95) + 1)
if n_colors > FLAT_MAX_COLORS:
return False # 颜色丰富 → 照片/九宫格头像
# 前几种主色中需存在高饱和彩色(图标底色);白色图形/背景饱和度低
for code in order[:min(3, len(order))]:
dom = pix[codes == code].mean(axis=0)
if dom.max() - dom.min() >= FLAT_SAT_MIN:
return True
return False
def _session_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""
用「头像 + 会话名称区域」生成复合指纹,唯一标识一个会话。
指纹跟着会话走,不随列表重排 / 行号变化而改变,因此可用于跨重排的去重
和会话档案的隔离;多个联系人使用同一个默认头像时,名称哈希仍能拆开档案。
★ 感知哈希而非原始像素哈希:
1. 采样区收窄到头像正中心(x 12~42, y ±12),避开圆角处会渗入
悬停/选中背景色的边缘像素;
2. 下采样到 8×8 网格取均值,再把颜色量化到 16 级——
悬停高亮、抗锯齿、字体渲染等微小差异不会改变指纹,
同一个客户在任何渲染状态下都稳定映射到同一份档案。
(采样头像中心也天然避开右上角的未读红点,红点数字变化不影响指纹。)
"""
if not self._session_identity_trustworthy():
return b""
avatar_fp = self._canonical_fp(
self._raw_session_fingerprint(img, rel_y, row_center=row_center)
)
name_fp = self._session_name_fingerprint(
img,
rel_y,
row_center=row_center,
)
if len(avatar_fp) != _AVATAR_FP_BYTES or len(name_fp) != _NAME_FP_BYTES:
return b""
session_fp = self._canonical_session_fp(avatar_fp + name_fp)
# Keep the persisted 40-byte key byte-for-byte compatible with the
# existing archive format. A second, render-normalized fingerprint is
# memory-only: it is used to prove that WeCom's unread-bold and
# selected-regular renderings belong to the row we just clicked, but is
# never written as a conversation or pending-reply key.
render_name_fp = self._session_render_fingerprint(
img,
rel_y,
row_center=row_center,
)
if len(render_name_fp) == _NAME_FP_BYTES:
aliases = getattr(self, "_session_render_ids", None)
if aliases is None:
aliases = self._session_render_ids = {}
aliases.setdefault(session_fp.hex(), set()).add(
(avatar_fp + render_name_fp).hex()
)
legacy_name_fp = self._legacy_session_name_fingerprint(
img,
rel_y,
row_center=row_center,
)
if len(legacy_name_fp) == _AVATAR_FP_BYTES:
mapping = getattr(self, "_legacy_fp_for_current", None)
if mapping is None:
mapping = self._legacy_fp_for_current = {}
mapping[session_fp.hex()] = avatar_fp + legacy_name_fp
return session_fp
def _session_identity_trustworthy(self) -> bool:
"""导航宽度未经识别确认时,绝不铸造会话身份。
宽/窄侧栏的会话列表裁剪起点实测相差 184px。窗口处于隐藏/最小化时
`connect()` 只能退回静态兜底宽度,此时采到的“头像”其实是导航栏或行内
别处的像素,算出的键与同一个联系人的真实键相差 25 位(容差 6 位)。
这种键一旦落进 conversations.json / pending_replies.json,就会永久多出
一个认不回来的会话:档案查不到 → 每轮都算“首次遇到” → 复制到的聊天
文字被丢弃、只靠截图回复,历史也注入不了。
返回空指纹即可,调用方本就把空值当作“这一行无法识别”处理。
缺省视为可信:真实实例由 `__init__` 明确置 False,闸门在识别成功前
始终关闭;此处的宽松缺省只用于不走几何初始化的隔离测试。
"""
if getattr(self, "_nav_width_confirmed", True):
self._nav_width_gate_logged = 0.0
self._nav_width_gate_since = 0.0
return True
# 闸门关闭期间一条会话身份都铸不出来,等于整个自动回复完全停摆。这
# 全量停摆绝不能只在第一轮吭一声就转入静默,否则看上去就是「机器人
# 缘无故不回消息了」。持续告警并报出已经停摆多久
now = time.monotonic()
since = float(getattr(self, "_nav_width_gate_since", 0.0) or 0.0)
if not since:
since = self._nav_width_gate_since = now
last_logged = float(getattr(self, "_nav_width_gate_logged", 0.0) or 0.0)
if not last_logged or now - last_logged >= 30.0:
self._nav_width_gate_logged = now
print(
" [会话身份] 导航栏宽度尚未识别确认,无法生成会话身份,"
f"自动回复已暂停 {now - since:.0f} 秒。"
"请确认企业微信主界面已还原、且停在“消息”页。"
)
return False
def _avatar_row_centers(self, img: np.ndarray) -> list[int]:
"""在会话列表里框出各行头像方块,返回它们的垂直中心。
头像方块的灰度横向方差在选中高亮和普通底色下都显著高于空白行,因此可
以直接定位方块本身,而不必依赖行中心估算。
"""
if img is None or getattr(img, "ndim", 0) != 3:
return []
scale = max(0.75, float(getattr(self, "scale", 1.0) or 1.0))
x1 = max(0, int(10 * scale))
x2 = min(img.shape[1], int(46 * scale))
if x2 - x1 < 8:
return []
band = img[:, x1:x2, :3].astype(np.float32).mean(axis=2)
detailed = band.std(axis=1) >= 8.0
blocks = []
start = None
for y in range(detailed.shape[0]):
if detailed[y]:
if start is None:
start = y
elif start is not None:
blocks.append((start, y))
start = None
if start is not None:
blocks.append((start, detailed.shape[0]))
blocks = [(a, b) for a, b in blocks if b - a >= int(20 * scale)]
if not blocks:
return []
heights = sorted(b - a for a, b in blocks)
median_height = heights[len(heights) // 2]
# 列表首尾的头像常被裁掉一部分,方块内画面平坦时下沿也会缺一截;
# 高度异常的块中心不可信,不能参与定位
return [
(a + b) // 2
for a, b in blocks
if abs((b - a) - median_height) <= max(2, int(0.2 * median_height))
]
def _avatar_anchor(self, img: np.ndarray, y_hint: int) -> int:
"""把粗略的行锚点吸附到头像方块的真实中心。
未读徽章按固定偏移推算的行中心,与选中行检测得到的行中心可能相差几十
像素,而头像感知哈希对锚点极敏感(实测偏 6px 即改写 11 位以上,偏 32px
改写 25 位,容差只有 6 位)。同一个联系人因此会算出不同的会话键,
`has_record()` 精确查表随即失配,于是每轮都被当成“首次遇到该会话”——
复制到的聊天文字被丢弃,只靠截图回复,档案历史也注入不了。
会话行的行距非常规整,用多行头像拟合出行距后按整数倍外推,即可把任何
粗锚点收敛到同一个中心。
"""
y_hint = int(y_hint)
centers = self._avatar_row_centers(img)
if len(centers) < 2:
return y_hint
gaps = sorted(
centers[index + 1] - centers[index]
for index in range(len(centers) - 1)
)
pitch = gaps[len(gaps) // 2]
if pitch <= 0:
return y_hint
nearest = min(centers, key=lambda value: abs(value - y_hint))
# 被裁切的首尾行不 centers 里,按行距整数倍补回它们的中心
anchor = nearest + int(round((y_hint - nearest) / pitch)) * pitch
if abs(anchor - y_hint) > pitch // 2:
return y_hint
return max(0, min(img.shape[0] - 1, anchor))
def _raw_session_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""Calculate an avatar perceptual hash without mutating the known-fp set."""
row_idx = rel_y // max(1, self.session_item_h)
y_c = int(rel_y) if row_center else self._row_center_from_badge(img, rel_y)
y_c = self._avatar_anchor(img, y_c)
y_c = max(0, min(img.shape[0] - 1, y_c))
x1 = int(12 * self.scale)
x2 = min(int(42 * self.scale), img.shape[1])
half = int(12 * self.scale)
y1 = max(0, y_c - half)
y2 = min(img.shape[0], y_c + half)
if y2 <= y1 or x2 <= x1:
return f"row{row_idx}".encode() # 越界兜底
# 灰度块均值 → 与中位数比较得到 64 位二值指纹(经典 pHash 思路)
region = img[y1:y2, x1:x2, :3].astype(np.float32)
# 未读徽章覆盖头像右上角,点击后又会消失。两种状态都固定屏蔽该象限,
# 防止同一个联系人仅因红点消失就变成另一个会话键
mask_y = max(1, int(round(region.shape[0] * 0.50)))
mask_x = max(1, int(round(region.shape[1] * 0.55)))
fill = np.median(region.reshape(-1, 3), axis=0)
region[:mask_y, mask_x:, :] = fill
gray = region.mean(axis=2)
gh = gw = 8
h, w = gray.shape
ys = np.linspace(0, h, gh + 1).astype(int)
xs = np.linspace(0, w, gw + 1).astype(int)
means = np.zeros((gh, gw), dtype=np.float32)
for i in range(gh):
for j in range(gw):
block = gray[ys[i]:ys[i + 1], xs[j]:xs[j + 1]]
if block.size:
means[i, j] = block.mean()
bits = (means > np.median(means)).flatten()
raw = np.packbits(bits).tobytes() # 8 字节
return raw
def _session_name_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""Return the established 256-bit display-name archive-key hash.
This layout is persisted in ``conversations.json`` and
``pending_replies.json``. Do not silently repurpose any of its bytes:
render-tolerant matching belongs in ``_session_render_fingerprint``.
"""
if img is None or getattr(img, "ndim", 0) != 3:
return b""
y_c = int(rel_y) if row_center else self._row_center_from_badge(img, rel_y)
y_c = self._avatar_anchor(img, y_c)
y_c = max(0, min(img.shape[0] - 1, y_c))
scale = max(0.75, float(self.scale or 1.0))
def packed_region(x1, x2, y1, y2, gh, gw) -> bytes:
x1 = max(0, int(x1))
x2 = min(img.shape[1], int(x2))
y1 = max(0, int(y1))
y2 = min(img.shape[0], int(y2))
if x2 - x1 < 4 or y2 - y1 < 3:
return bytes((gh * gw + 7) // 8)
gray = img[y1:y2, x1:x2, :3].astype(np.float32).mean(axis=2)
background = float(np.median(gray))
ink = (np.abs(gray - background) >= 16.0).astype(np.float32)
h, w = ink.shape
ys = np.linspace(0, h, gh + 1).astype(int)
xs = np.linspace(0, w, gw + 1).astype(int)
coverage = np.zeros((gh, gw), dtype=np.float32)
for row in range(gh):
for col in range(gw):
block = ink[ys[row]:ys[row + 1], xs[col]:xs[col + 1]]
if block.size:
coverage[row, col] = block.mean()
return np.packbits((coverage >= 0.08).reshape(-1)).tobytes()
# Badge-safe lower strip of the first glyph (64 bits), followed by the
# full visible title band (192 bits). This is the established on-disk
# 32-byte name layout used before render aliases were introduced.
prefix = packed_region(
60 * scale,
84 * scale,
y_c - 8 * scale,
y_c - 2 * scale,
4,
16,
)
main = packed_region(
80 * scale,
img.shape[1] - 60 * scale,
y_c - 21 * scale,
y_c - 2 * scale,
8,
24,
)
fingerprint = prefix + main
return fingerprint if len(fingerprint) == _NAME_FP_BYTES else b""
def _session_render_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""Return a memory-only name hash tolerant of font-weight changes."""
if img is None or getattr(img, "ndim", 0) != 3:
return b""
y_c = int(rel_y) if row_center else self._row_center_from_badge(img, rel_y)
y_c = self._avatar_anchor(img, y_c)
y_c = max(0, min(img.shape[0] - 1, y_c))
scale = max(0.75, float(self.scale or 1.0))
def centered_strokes(mask: np.ndarray) -> np.ndarray:
"""Reduce bold/regular glyphs to a shared one-pixel stroke centre."""
source = np.asarray(mask, dtype=bool)
result = np.zeros_like(source, dtype=bool)
def mark_run_centres(values: np.ndarray, setter) -> None:
padded = np.pad(values.astype(np.int8), (1, 1))
changes = np.diff(padded)
starts = np.where(changes == 1)[0]
ends = np.where(changes == -1)[0]
for start, end in zip(starts, ends):
# Mark both middle pixels for even-width strokes. This is
# stable across sub-pixel antialiasing and avoids a one-pixel
# left/right bias between selected and unread font renders.
middle_left = (int(start) + int(end) - 1) // 2
middle_right = (int(start) + int(end)) // 2
setter(middle_left)
setter(middle_right)
for row in range(source.shape[0]):
mark_run_centres(
source[row],
lambda col, row=row: result.__setitem__((row, col), True),
)
for col in range(source.shape[1]):
mark_run_centres(
source[:, col],
lambda row, col=col: result.__setitem__((row, col), True),
)
# Expand the centre-line by one logical pixel before the coarse
# grid. This absorbs the 1-2px endpoint/antialiasing drift that
# accompanies WeCom's unread bold -> selected regular transition.
radius = max(1, int(round(scale)))
padded = np.pad(result, radius)
stable = np.zeros_like(result)
height, width = result.shape
for dy in range(2 * radius + 1):
for dx in range(2 * radius + 1):
stable |= padded[dy:dy + height, dx:dx + width]
return stable
def packed_region(x1, x2, y1, y2, gh, gw) -> bytes:
x1 = max(0, int(x1))
x2 = min(img.shape[1], int(x2))
y1 = max(0, int(y1))
y2 = min(img.shape[0], int(y2))
if x2 - x1 < 4 or y2 - y1 < 3:
return bytes((gh * gw + 7) // 8)
gray = img[y1:y2, x1:x2, :3].astype(np.float32).mean(axis=2)
background = float(np.median(gray))
ink = centered_strokes(
np.abs(gray - background) >= 16.0
).astype(np.float32)
h, w = ink.shape
ys = np.linspace(0, h, gh + 1).astype(int)
xs = np.linspace(0, w, gw + 1).astype(int)
coverage = np.zeros((gh, gw), dtype=np.float32)
for row in range(gh):
for col in range(gw):
block = ink[ys[row]:ys[row + 1], xs[col]:xs[col + 1]]
if block.size:
coverage[row, col] = block.mean()
return np.packbits((coverage >= 0.12).reshape(-1)).tobytes()
def normalized_region(x1, x2, y1, y2, gh, gw) -> bytes:
"""Hash glyph topology after removing font-weight bounding drift."""
x1 = max(0, int(x1))
x2 = min(img.shape[1], int(x2))
y1 = max(0, int(y1))
y2 = min(img.shape[0], int(y2))
empty = bytes((gh * gw + 7) // 8)
if x2 - x1 < 4 or y2 - y1 < 3:
return empty
gray = img[y1:y2, x1:x2, :3].astype(np.float32).mean(axis=2)
background = float(np.median(gray))
strokes = centered_strokes(np.abs(gray - background) >= 16.0)
rows = np.where(strokes.any(axis=1))[0]
cols = np.where(strokes.any(axis=0))[0]
if not len(rows) or not len(cols):
return empty
strokes = strokes[
int(rows[0]):int(rows[-1]) + 1,
int(cols[0]):int(cols[-1]) + 1,
].astype(np.float32)
h, w = strokes.shape
ys = np.linspace(0, h, gh + 1).astype(int)
xs = np.linspace(0, w, gw + 1).astype(int)
coverage = np.zeros((gh, gw), dtype=np.float32)
for row in range(gh):
for col in range(gw):
block = strokes[ys[row]:ys[row + 1], xs[col]:xs[col + 1]]
if block.size:
coverage[row, col] = block.mean()
return np.packbits((coverage >= 0.12).reshape(-1)).tobytes()
# 红点覆盖名称左上区域;取第一字的底部小条保留“张三/李三”的首字差异
# 再拼接避开红点及右侧动态时间的主体字形带
# First 64 bits describe normalized topology in three badge-safe bands:
# 16 bits preserve the first glyph's lower strokes, 32 cover the middle,
# and 16 isolate the last visible glyph before the dynamic time column.
# The remaining 192 bits retain absolute glyph positions.
robust_prefix = normalized_region(
60 * scale,
84 * scale,
y_c - 8 * scale,
y_c - 2 * scale,
4,
4,
)
name_right = img.shape[1] - 60 * scale
suffix_left = name_right - 24 * scale
robust_main = normalized_region(
80 * scale,
suffix_left,
y_c - 21 * scale,
y_c - 2 * scale,
4,
8,
)
robust_suffix = normalized_region(
suffix_left,
name_right,
y_c - 21 * scale,
y_c - 2 * scale,
4,
4,
)
prefix = robust_prefix + robust_main + robust_suffix
main = packed_region(
80 * scale,
# The message-time column starts about 60 logical pixels from the
# right edge.
# Capping at x=160 dropped the final visible glyph of long names,
# causing contacts with a shared prefix and avatar to collide.
img.shape[1] - 60 * scale,
y_c - 21 * scale,
y_c - 2 * scale,
8,
24,
)
fingerprint = prefix + main
return fingerprint if len(fingerprint) == _NAME_FP_BYTES else b""
def _legacy_session_name_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""Reproduce the previous 64-bit display-name hash for one-time upgrades."""
if img is None or getattr(img, "ndim", 0) != 3:
return b""
y_c = int(rel_y) if row_center else self._row_center_from_badge(img, rel_y)
y_c = max(0, min(img.shape[0] - 1, y_c))
scale = max(0.75, float(self.scale or 1.0))
x1 = max(0, int(60 * scale))
x2 = min(img.shape[1], int(190 * scale), int(img.shape[1] - 70 * scale))
y1 = max(0, int(y_c - 21 * scale))
y2 = min(img.shape[0], int(y_c - 2 * scale))
if x2 - x1 < 8 or y2 - y1 < 6:
return b""
grid = self._ink_grid(
img[y1:y2, x1:x2],
8,
8,
threshold=16.0,
coverage_threshold=0.05,
)
packed = np.packbits(grid.reshape(-1)).tobytes()
return packed if len(packed) == _AVATAR_FP_BYTES else b""
def _legacy_session_fingerprint(
self,
img: np.ndarray,
rel_y: int,
row_center: bool = False,
) -> bytes:
"""Return the exact 16-byte key used by the immediately previous version."""
avatar = self._canonical_fp(
self._raw_session_fingerprint(img, rel_y, row_center=row_center)
)
name = self._legacy_session_name_fingerprint(
img,
rel_y,
row_center=row_center,
)
if len(avatar) != _AVATAR_FP_BYTES or len(name) != _AVATAR_FP_BYTES:
return b""
return avatar + name
def _row_center_from_badge(self, img: np.ndarray, badge_y: int) -> int:
"""把头像右上方的未读徽章 Y 映射到当前滚动相位下的真实行中心。"""
offset = max(4, int(16 * max(0.75, float(self.scale or 1.0))))
height = int(getattr(img, "shape", (1,))[0] or 1)
return max(0, min(height - 1, int(badge_y) + offset))
# 感知指纹的汉明距离容差:≤ 此值视为同一头像(64 位中容 6 位差异)
_FP_HAMMING_TOL = 6
# 名称哈希必须逐字节相等,不设汉明容差。实测过按位放宽的方案并否掉了:
# 名字只差最后一个字的两个联系人,名称哈希只差 2 位,比同一个人在未读/
# 选中之间的 4 位漂移还小——两种现象在汉明距离上真实重叠,任何容差不是
# 把两个人并进同一份档案,就是把同一个人再拆开。未读粗体与选中常规体的
# 栅格差异由 `_session_render_fingerprint` 的内存态别名消化。返回的距离
# 只供 `_canonical_session_fp` 在多个候选键之间排最近邻
# 名称字形哈希采不到字时会退化成全零;这种“空名称”不能证明任何身份,
# 实测两个毫无关系的联系人都会拿到全零名称哈希
_NAME_FP_MIN_BITS = 16
# 名称已逐位相等时头像允许的漂移上限。头像 pHash 把 8x8 块均值与中位数比
# 大小,纯色底加一个图标的头像(企微的“纯色文字头像”)块均值全挤在中位数
# 附近,属于刀刃比较,一点抗锯齿变化就能同时翻掉十几位:实测同一联系人
# 点开前后漂 15 位。另一头,两个不同联系人若名称渲染完全一致,头像可以差
# 到 30 位。取 20 位落在这两个实测值中间
_FP_HAMMING_TOL_NAMED = 20
def _name_fp_is_substantive(self, name_fp: bytes) -> bool:
"""名称字形哈希是否真的采到了字,而不是一片空白。"""
raw = bytes(name_fp or b"")
if len(raw) != _NAME_FP_BYTES:
return False
return int.from_bytes(raw, "big").bit_count() >= self._NAME_FP_MIN_BITS
def _name_fp_matches(self, left: bytes, right: bytes) -> tuple[bool, int]:
"""Compare established on-disk name hashes without reinterpreting bytes."""
left = bytes(left or b"")
right = bytes(right or b"")
if len(left) != _NAME_FP_BYTES or len(right) != _NAME_FP_BYTES:
return False, 999
distance = (
int.from_bytes(left, "big") ^ int.from_bytes(right, "big")
).bit_count()
return left == right, distance
def _live_render_ids_for(self, fp: bytes) -> set[str]:
"""Return memory-only render IDs observed for this persisted key."""
key = bytes(fp or b"").hex()
if not key:
return set()
values = getattr(self, "_session_render_ids", {}).get(key, set())
return {str(value) for value in values if value}
def _live_render_match(self, left: bytes, right: bytes) -> bool:
"""Compare two rows only through aliases observed in this process."""
if not left or not right:
return False
left_ids = self._live_render_ids_for(left)
right_ids = self._live_render_ids_for(right)
return bool(left_ids and right_ids and left_ids.intersection(right_ids))
def _live_render_transition_match(self, left: bytes, right: bytes) -> bool:
"""Prove one unread-bold -> selected-regular transition in memory only.
The persisted 40-byte archive key remains exact. This relaxed comparison
is used only immediately after clicking a row. Its normalized name ID
keeps the first and last glyph bands exact, while allowing limited drift
in the middle/detail bands caused by WeCom changing font weight.
"""
left_ids = self._live_render_ids_for(left)
right_ids = self._live_render_ids_for(right)
for left_hex in left_ids:
for right_hex in right_ids:
try:
left_id = bytes.fromhex(left_hex)
right_id = bytes.fromhex(right_hex)
except (TypeError, ValueError):
continue
if (
len(left_id) != _SESSION_FP_BYTES
or len(right_id) != _SESSION_FP_BYTES
):
continue
avatar_distance = (
int.from_bytes(left_id[:_AVATAR_FP_BYTES], "big")
^ int.from_bytes(right_id[:_AVATAR_FP_BYTES], "big")
).bit_count()
if avatar_distance > self._FP_HAMMING_TOL:
continue
left_name = left_id[_AVATAR_FP_BYTES:]
right_name = right_id[_AVATAR_FP_BYTES:]
def distance(a: bytes, b: bytes) -> int:
return (
int.from_bytes(a, "big") ^ int.from_bytes(b, "big")
).bit_count()
# Render-name layout: first glyph 2B, middle 4B, last glyph 2B,
# then the 24B absolute detail grid.
if left_name[:2] != right_name[:2]:
continue
if distance(left_name[2:6], right_name[2:6]) > 7:
continue
if left_name[6:8] != right_name[6:8]:
continue
if distance(left_name[8:], right_name[8:]) > 12:
continue
return True
return False
def _remember_live_render_alias(self, left: bytes, right: bytes) -> bool:
"""Authorize a transient key pair after a live selected-row proof."""
left = bytes(left or b"")
right = bytes(right or b"")
if (
len(left) != _SESSION_FP_BYTES
or len(right) != _SESSION_FP_BYTES
or not self._live_render_transition_match(left, right)
):
return False
aliases = getattr(self, "_live_session_fp_aliases", None)
if aliases is None:
aliases = self._live_session_fp_aliases = set()
aliases.add(tuple(sorted((left.hex(), right.hex()))))
return True
def _canonical_fp(self, raw: bytes) -> bytes:
"""
指纹归一化:感知哈希对渲染噪声只能做到「几乎不变」,个别位仍可能翻转。
在已知指纹集合中找汉明距离 ≤ _FP_HAMMING_TOL 的最近邻:
找到 → 归一化为已知指纹(同一客户永远映射到同一份档案);
找不到 → 登记为新会话指纹。
"""
if len(raw) != _AVATAR_FP_BYTES:
return raw
raw_int = int.from_bytes(raw, 'big')
best, best_d = None, 999
for known in self._known_fps:
d = bin(int.from_bytes(known, 'big') ^ raw_int).count('1')
if d < best_d:
best, best_d = known, d
if best is not None and best_d <= self._FP_HAMMING_TOL:
return best
self._known_fps.add(raw)
return raw
def _canonical_session_fp(self, raw: bytes) -> bytes:
"""Normalize small rendering differences in an avatar+name composite key."""
if len(raw) != _SESSION_FP_BYTES:
return raw
known_fps = getattr(self, "_known_session_fps", None)
if known_fps is None:
known_fps = self._known_session_fps = set()
avatar_value = int.from_bytes(raw[:_AVATAR_FP_BYTES], "big")
candidates = []
for known in known_fps:
if len(known) != _SESSION_FP_BYTES:
continue
avatar_distance = (
int.from_bytes(known[:_AVATAR_FP_BYTES], "big") ^ avatar_value
).bit_count()
# 头像的筛选上限跟着 `_session_fp_matches` 一起放宽:同一联系人的
# 头像实测能漂到 26 位,按原来的 6 位过滤会把真正的候选键直接剔掉,
# 于是同一个人又被铸成一把新键。名称退化成空白时它证明不了身份,
# 这一行就只剩头像可依据,必须退回严格容差
limit = (
self._FP_HAMMING_TOL_NAMED
if self._name_fp_is_substantive(raw[_AVATAR_FP_BYTES:])
else self._FP_HAMMING_TOL
)
if avatar_distance > limit:
continue
name_matches, name_distance = self._name_fp_matches(
known[_AVATAR_FP_BYTES:],
raw[_AVATAR_FP_BYTES:],
)
if not name_matches:
continue
candidates.append((avatar_distance + name_distance, known))
candidates.sort(key=lambda item: (item[0], item[1]))
if candidates:
best_score, best = candidates[0]
# 多个联系人同分时绝不依赖 set 遍历顺序任取一个;只有唯一最近邻
# (或与次近邻有明确分差)才归一化
if len(candidates) == 1 or candidates[1][0] >= best_score + 3:
return best
known_fps.add(raw)
return raw
def _session_fp_matches(self, left: bytes, right: bytes) -> bool:
"""Compare archive keys plus explicitly proved, process-local aliases."""
left = bytes(left or b"")
right = bytes(right or b"")
if left == right:
return bool(left)
pair = tuple(sorted((left.hex(), right.hex())))
if pair in getattr(self, "_live_session_fp_aliases", set()):
return True
if len(left) == _SESSION_FP_BYTES and len(right) == _SESSION_FP_BYTES:
avatar_distance = (
int.from_bytes(left[:_AVATAR_FP_BYTES], "big")
^ int.from_bytes(right[:_AVATAR_FP_BYTES], "big")
).bit_count()
name_matches, _name_distance = self._name_fp_matches(
left[_AVATAR_FP_BYTES:],
right[_AVATAR_FP_BYTES:],
)
if not name_matches:
return False
# 实测(2026-07-31 现场):同一个联系人被拆进 pending_replies.json 的
# 多条任务里,名称哈希只差 4 位,头像 pHash 却差到 26 位;而两个不同
# 联系人的头像最近只差 16 位。头像的同人/异人分布完全重叠,分不开人
# 却足以把同一个人否掉——每个会话回两次之后指纹一漂,档案和待回复
# 任务就都认不回来,这个客户从此再也收不到回复
# 因此名称一旦认定同一人,头像不再有否决权
if self._name_fp_is_substantive(left[_AVATAR_FP_BYTES:]):
return avatar_distance <= self._FP_HAMMING_TOL_NAMED
# 名称退化成空白时它什么都证明不了(实测两个不同联系人都会得到全零
# 名称哈希),只能退回原来的严格头像容差
return avatar_distance <= self._FP_HAMMING_TOL
# 混合长度绝不能退化成头像单独匹配;旧档案兼容由存储迁移完成
return False
# ── 4. 交互动作层 ─────────────────────────────────────────────────────────
def set_topmost(self, enable: bool):
"""
设置企业微信窗口为「系统级置顶」(HWND_TOPMOST) 或取消置顶。
置顶后窗口永远显示在所有普通窗口之上,鼠标点击也必然落在企业微信上。
"""
self._topmost = enable
try:
flag = win32con.HWND_TOPMOST if enable else win32con.HWND_NOTOPMOST
win32gui.SetWindowPos(
self.hwnd, flag, 0, 0, 0, 0,
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE
)
if enable:
# 置顶后顺手激活,确保用户看到微信
safe_set_foreground(self.hwnd)
except Exception as e:
print(f" [~] set_topmost({enable}): {e}")
def is_window_alive(self) -> bool:
"""检查当前持有的窗口句柄是否仍然有效(主面板被关闭后会失效)。"""
try:
return bool(self.hwnd) and bool(win32gui.IsWindow(self.hwnd))
except Exception:
return False
def _reconnect(self) -> bool:
"""
窗口句柄失效时自动重新挂载企业微信主窗口。
典型场景:主面板被 Esc/X 关闭后重新打开,窗口被销毁重建,旧 HWND 报 1400 错误。
"""
if self._reconnect_fails == 0:
print("[!] 企业微信窗口句柄已失效(主面板可能被关闭),尝试自动重新挂载...")
self.hwnd = 0
try:
ok = self.connect(
activate=not self.safe_window_mode,
wait_if_missing=self.auto_activate_window,
)
except Exception as e:
print(f"[-] 重连异常: {e}")
ok = False
if not ok:
self._reconnect_fails += 1
# 限流:首次失败和之后每 15 次(约 30s)提示一次
if self._reconnect_fails == 1 or self._reconnect_fails % 15 == 0:
print("[-] 自动重连失败:未找到企业微信主窗口,请重新打开主面板(双击托盘图标),将自动恢复监听。")
return False
self._reconnect_fails = 0
if self._topmost and not self.safe_window_mode:
self.set_topmost(True)
if not self.hwnd:
print("[+] 企业微信进程仍在,等待主窗口创建后自动切到前台。")
elif self._window_ready:
print("[+] 自动重连成功,恢复监听。")
else:
print("[+] 已重新挂载企业微信,等待主界面恢复后继续监听。")
return True
# ── 人机共存:鼠标空闲检测 ────────────────────────────────────────────────
def _mouse_pos(self):
p = pyautogui.position()
return (int(p[0]), int(p[1]))
def _sync_user_mouse_activity(self):
"""非机器人操控期间,若鼠标位置变化则记为人工操作。"""
if self._bot_controlling:
return
try:
pos = self._mouse_pos()
except Exception:
return
if self._last_mouse_pos is None:
self._last_mouse_pos = pos
return
dx = abs(pos[0] - self._last_mouse_pos[0])
dy = abs(pos[1] - self._last_mouse_pos[1])
if dx > MOUSE_MOVE_THRESHOLD or dy > MOUSE_MOVE_THRESHOLD:
self._last_user_move_ts = time.time()
self._last_mouse_pos = pos
def _begin_bot_mouse(self):
self._bot_controlling = True
def _end_bot_mouse(self):
try:
self._last_mouse_pos = self._mouse_pos()
except Exception:
pass
self._bot_controlling = False
def wait_for_mouse_idle(self) -> bool:
"""
等待鼠标静止 mouse_idle_seconds 秒后再允许机器人操作。
返回 False 表示监听已停止(被中断),调用方应立即退出本轮。
"""
if not self.mouse_idle_enabled:
return True
while True:
if self._stop_check is not None and self._stop_check.is_set():
return False
self._sync_user_mouse_activity()
idle = time.time() - self._last_user_move_ts
if idle >= self.mouse_idle_seconds:
return True
remain = self.mouse_idle_seconds - idle
now = time.time()
if now - self._idle_log_ts >= 5:
print(f" [人手] 检测到鼠标操作,暂停自动回复,"
f"还需静止 {remain:.0f}s…")
self._idle_log_ts = now
# 短睡并响应停止信号
if self._stop_check is not None:
if self._stop_check.wait(0.4):
return False
else:
time.sleep(0.4)
def _mouse_is_idle_now(self) -> bool:
"""Check human activity without blocking the receipt/recovery queue."""
if not getattr(self, "mouse_idle_enabled", False):
return True
try:
self._sync_user_mouse_activity()
last_move = float(getattr(self, "_last_user_move_ts", 0.0) or 0.0)
required = max(
0.0,
float(getattr(self, "mouse_idle_seconds", 0.0) or 0.0),
)
return time.time() - last_move >= required
except Exception:
return False
def _ensure_visible(self) -> bool:
"""
确保企业微信窗口可见且置顶。
- 句柄失效(主面板被关闭重建)→ 自动重连
- 用户手动最小化或切走了 → 自动还原 + 重新置顶
每次轮询截图前必须调用;返回 False 表示窗口当前不可用,应跳过本次轮询。
"""
# 句柄失效时先尝试自动重连,避免后续 win32 调用全部报 1400
if not self.is_window_alive():
self._window_ready = False
if not self._reconnect():
return False
try:
hwnd = self.hwnd
placement = win32gui.GetWindowPlacement(hwnd)
if self.safe_window_mode:
if self.auto_activate_window:
needs_activation = (
placement[1] == win32con.SW_SHOWMINIMIZED
or not win32gui.IsWindowVisible(hwnd)
or win32gui.GetForegroundWindow() != hwnd
)
if needs_activation:
restore_window(hwnd)
time.sleep(0.2)
placement = win32gui.GetWindowPlacement(hwnd)
ready = (
placement[1] != win32con.SW_SHOWMINIMIZED
and win32gui.IsWindowVisible(hwnd)
and win32gui.GetForegroundWindow() == hwnd
)
if not ready:
self._window_ready = False
now = time.time()
if now - self._safe_wait_log_ts >= 10:
print(
" [窗口激活] 企业微信主界面未能切到前台,"
"本轮暂停,下一轮将继续尝试。"
)
self._safe_wait_log_ts = now
return False
rect = tuple(win32gui.GetWindowRect(hwnd))
if not self._window_ready or rect != (self.L, self.T, self.R, self.B):
if not self.connect(activate=False):
return False
self._window_ready = True
return True
if placement[1] == win32con.SW_SHOWMINIMIZED:
# 窗口被最小化了,先还原
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
time.sleep(0.3)
# 重新设置置顶(最小化后 TOPMOST 标记会丢失)
self.set_topmost(True)
elif not win32gui.IsWindowVisible(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_SHOW)
time.sleep(0.2)
self.set_topmost(True)
self._window_ready = True
except Exception as e:
print(f" [~] _ensure_visible: {e}")
return self.is_window_alive()
return True
def _activate_wx(self):
"""将企业微信激活到前台并确保可见。"""
if not self._ensure_visible():
return False
if self.safe_window_mode:
return True
try:
safe_set_foreground(self.hwnd)
time.sleep(0.15)
return win32gui.GetForegroundWindow() == self.hwnd
except Exception as e:
print(f" [~] _activate_wx: {e}")
return False
def _raw_selected_session_fingerprint(self) -> bytes | None:
"""Return the selected row identity without icon-style filtering."""
try:
img = self.capture_session_list()
selected_y = self.detect_selected_row(img)
if selected_y < 0:
return None
return self._session_fingerprint(img, selected_y, row_center=True)
except Exception:
return None
def _selected_session_fingerprint(self) -> bytes | None:
"""只读确认当前打开的是一个真实会话,并返回头像+名称复合指纹。"""
try:
img = self.capture_session_list()
selected_y = self.detect_selected_row(img)
if selected_y < 0 or self._is_tool_selected(img, selected_y):
return None
return self._session_fingerprint(img, selected_y, row_center=True)
except Exception:
return None
def _target_chat_ready(self, expected_fp: bytes) -> bytes | None:
"""Prove a target chat despite an ambiguous navigation colour sample.
The Messages navigation highlight is a useful weak signal, but at high
DPI WeCom can render it outside the old sampling band. A selected row
matching the exact unread target, a readable chat title and a readable
chat surface are stronger evidence. The raw selected fingerprint is
intentional: icon colour alone must not hide real ``@微信`` contacts.
"""
expected_fp = bytes(expected_fp or b"")
if not expected_fp:
return None
opened_fp = self._raw_selected_session_fingerprint()
if not opened_fp:
return None
matched = self._session_fp_matches(opened_fp, expected_fp)
if (
not matched
and len(expected_fp) == _SESSION_FP_BYTES
and self._remember_live_render_alias(opened_fp, expected_fp)
):
matched = True
if not matched:
return None
try:
identity = self._chat_identity_signature()
surface = self._chat_surface_signature()
except Exception:
return None
if not identity or not surface:
return None
return opened_fp
def _ensure_session_archive_key(
self,
session_fp: bytes | None,
chat_text: str = "",
) -> None:
"""Migrate old 8/16-byte keys only when speaker/title evidence proves ownership."""
session_fp = bytes(session_fp or b"")
if len(session_fp) != _SESSION_FP_BYTES or not str(chat_text or "").strip():
return
new_key = session_fp.hex()
checked = getattr(self, "_legacy_migration_checked", None)
if checked is None:
checked = self._legacy_migration_checked = set()
if new_key in checked:
return
try:
try:
from ai_config import AI_AGENT_NAME
agent_name = str(AI_AGENT_NAME or "").strip()
except ImportError:
agent_name = ""
def speaker_blocks(text: str) -> list[tuple[str, str]]:
blocks = []
speaker = ""
body = []
for raw_line in str(text or "").splitlines():
line = raw_line.strip()
match = self._speaker_header_match(line)
if match:
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
speaker = match.group("speaker").strip()
body = []
elif speaker and line:
body.append(line)
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
return blocks
def normalized(value: str) -> str:
return " ".join(str(value or "").split())
def customer_speakers(text: str, inferred_agents=()) -> set[str]:
return {
speaker
for speaker, _body in speaker_blocks(text)
if speaker
and speaker not in inferred_agents
and not (agent_name and agent_name in speaker)
}
def entry_speaker_evidence(entry: dict) -> tuple[set[str], set[str]]:
history_items = list(entry.get("history") or [])
assistant_bodies = {
normalized(item.get("content"))
for item in history_items
if isinstance(item, dict)
and item.get("role") == "assistant"
and normalized(item.get("content"))
}
sources = ["\n".join(entry.get("last_lines") or [])]
sources.extend(
str(item.get("content") or "")
for item in history_items
if isinstance(item, dict)
)
blocks = [
block for source in sources for block in speaker_blocks(source)
]
inferred_agents = {
speaker
for speaker, body in blocks
if normalized(body) in assistant_bodies
}
if agent_name:
inferred_agents.update(
speaker
for speaker, _body in blocks
if agent_name in speaker
)
customer_names = {
speaker
for speaker, _body in blocks
if speaker
and speaker not in inferred_agents
and not (agent_name and agent_name in speaker)
}
return customer_names, inferred_agents
avatar = session_fp[:_AVATAR_FP_BYTES]
new_entry = self.store.entry_snapshot(new_key)
if not isinstance(new_entry, dict):
new_entry = None
expected_legacy_fp = getattr(
self,
"_legacy_fp_for_current",
{},
).get(new_key)
try:
stored_keys = list(self.store.keys())
except Exception:
# 兼容旧存储实现和隔离测试;纯头像键可直接由当前头像推导
stored_keys = [avatar.hex()]
safe_legacy16 = []
safe_avatar8 = []
for key in stored_keys:
try:
old_fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if len(old_fp) not in (_AVATAR_FP_BYTES, _LEGACY_SESSION_FP_BYTES):
continue
if old_fp[:_AVATAR_FP_BYTES] != avatar:
continue
if (
len(old_fp) == _LEGACY_SESSION_FP_BYTES
and (
not expected_legacy_fp
or old_fp != expected_legacy_fp
)
):
continue
entry = self.store.entry_snapshot(str(key))
if not isinstance(entry, dict):
continue
legacy_names, inferred_agents = entry_speaker_evidence(entry or {})
current_names = customer_speakers(chat_text, inferred_agents)
if (
len(current_names) == 1
and len(legacy_names) == 1
and current_names == legacy_names
):
if len(old_fp) == _LEGACY_SESSION_FP_BYTES:
safe_legacy16.append(str(key))
else:
safe_avatar8.append(str(key))
migrated = False
new_has_record = bool(
new_entry
and (new_entry.get("history") or new_entry.get("last_lines"))
)
if not new_has_record:
# 16 字节键包含旧名称证据,优先级高于只有头像 8 字节键
preferred = safe_legacy16 if safe_legacy16 else safe_avatar8
if len(preferred) == 1:
migrated = self.store.migrate_key(preferred[0], new_key)
elif len(preferred) > 1:
print(" [档案] 多个旧会话键都可能属于当前联系人,已拒绝自动合并。")
elif any(
len(str(key)) in (
_AVATAR_FP_BYTES * 2,
_LEGACY_SESSION_FP_BYTES * 2,
)
for key in stored_keys
):
print(" [档案] 旧会话档案归属无法唯一证明,已保留但不会自动用于当前会话。")
# 上一版已持久化的 16 字节待回复键也要保留。它无法直接与新
# 256 位名称哈希比较,只有标题指纹一致,或复制说话人名称唯一一致时才迁移
pending = getattr(self, "_pending_reply_sessions", {})
current_identity = self._chat_identity_signature()
pending_candidates = []
for key, state in list(pending.items()):
try:
old_fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if (
len(old_fp) != _LEGACY_SESSION_FP_BYTES
or old_fp[:_AVATAR_FP_BYTES] != avatar
or not isinstance(state, dict)
):
continue
if not expected_legacy_fp or old_fp != expected_legacy_fp:
continue
expected_identity = bytes(state.get("identity_signature") or b"")
identity_proven = bool(
expected_identity
and current_identity
and expected_identity == current_identity
)
pending_text = str(state.get("chat_text") or "")
if not pending_text:
pending_text = "\n".join(state.get("last_lines") or [])
# pending 没有独立历史可反推账号名;标题指纹是首选证据
# 仅在复制内容本身恰好只含一个非客服说话人时,才允许名称兜底
pending_names = customer_speakers(pending_text)
current_names = customer_speakers(chat_text)
names_proven = bool(
len(current_names) == 1
and len(pending_names) == 1
and current_names == pending_names
)
if identity_proven or names_proven:
pending_candidates.append(str(key))
if new_key not in pending and len(pending_candidates) == 1:
pending[new_key] = pending.pop(pending_candidates[0])
pending[new_key]["updated_at"] = time.time()
self._persist_pending_replies()
print(" [待回复恢复] 已将上一版待回复任务迁移到新版会话指纹。")
elif len(pending_candidates) > 1:
print(" [待回复恢复] 多个旧任务都可能属于当前联系人,已拒绝自动合并。")
except Exception as exc:
print(f" [档案] 旧会话键迁移失败,将保留原档案: {exc}")
return
checked.add(new_key)
getattr(self, "_known_session_fps", set()).add(session_fp)
if migrated:
print(" [档案] 已将旧版会话档案一次性迁移到新版复合会话键。")
def _remember_active_surface(self, session_fp: bytes | None) -> None:
if session_fp is None:
return
self._ensure_session_archive_key(session_fp)
self._selected_tracking_initialized = True
self._active_session_fp = session_fp
self._active_identity_signature = self._chat_identity_signature()
self._active_chat_signature = self._chat_surface_signature()
def _stage_exchange(
self,
fp: bytes,
user_text: str,
reply_text: str,
customer_speaker: str = "",
archive_enabled: bool = True,
registration_lead: dict | None = None,
) -> None:
pending = getattr(self, "_pending_exchanges", None)
if pending is None:
pending = self._pending_exchanges = {}
pending[fp.hex()] = (user_text, reply_text)
# 同时放进原子持久化的待回复状态。若 Enter 后进程退出,重启可以
# 不重复发送的前提下补交档案,而不是丢掉这轮上下文
state = self._pending_reply_state(fp)
if state is not None:
state["staged_user_text"] = str(user_text or "")
state["staged_reply_text"] = str(reply_text or "")
state["archive_enabled"] = bool(archive_enabled)
if not state.get("exchange_id"):
seed = (
f"{fp.hex()}|{state.get('created_at', 0)}|"
f"{user_text}|{reply_text}"
).encode("utf-8", errors="replace")
state["exchange_id"] = hashlib.blake2b(
seed,
digest_size=16,
).hexdigest()
if str(customer_speaker or "").strip():
state["customer_speaker"] = str(customer_speaker).strip()
if isinstance(registration_lead, dict) and registration_lead:
state["registration_lead"] = dict(registration_lead)
state["updated_at"] = time.time()
self._persist_pending_replies()
def _load_pending_replies(self) -> dict:
"""Restore read-but-unreplied work after a crash without restoring stale tasks."""
path = getattr(self, "_pending_reply_path", "")
if not path or not os.path.exists(path):
return {}
try:
with open(path, encoding="utf-8") as handle:
raw = json.load(handle)
except Exception:
return {}
if not isinstance(raw, dict):
return {}
restored = {}
now = time.time()
for key, value in raw.items():
if not isinstance(value, dict):
continue
try:
fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if len(fp) not in (_LEGACY_SESSION_FP_BYTES, _SESSION_FP_BYTES):
continue
try:
updated_at = float(value.get("updated_at", 0.0) or 0.0)
except (TypeError, ValueError):
updated_at = 0.0
try:
created_at = float(value.get("created_at", now) or now)
except (TypeError, ValueError):
created_at = now
# A confirmed unread badge or an in-flight send is durable work,
# not a cache entry. Silently expiring it recreates the exact
# "red dot disappeared but no reply" failure after downtime.
durable = bool(
value.get("confirmed_unread", False)
or str(value.get("send_state") or "")
)
if updated_at and now - updated_at > 24 * 60 * 60 and not durable:
continue
registration_lead = value.get("registration_lead") or {}
if not isinstance(registration_lead, dict):
registration_lead = {}
send_state = str(value.get("send_state") or "")
if send_state and send_state not in {
"sending", "sent_uncommitted", "uncertain",
}:
send_state = "uncertain"
try:
uncertain_since = max(
0.0,
float(value.get("uncertain_since", 0.0) or 0.0),
)
except (TypeError, ValueError):
uncertain_since = 0.0
try:
send_dispatched_at = max(
0.0,
float(value.get("send_dispatched_at", 0.0) or 0.0),
)
except (TypeError, ValueError):
send_dispatched_at = 0.0
raw_baseline_blocks = value.get("send_baseline_blocks")
send_baseline_blocks = None
if isinstance(raw_baseline_blocks, list) and raw_baseline_blocks:
parsed_baseline = [
[str(item[0]), str(item[1])]
for item in raw_baseline_blocks
if isinstance(item, (list, tuple)) and len(item) == 2
]
if len(parsed_baseline) == len(raw_baseline_blocks):
send_baseline_blocks = parsed_baseline
state = {
"batch_ready": bool(value.get("batch_ready", False)),
"confirmed_unread": bool(value.get("confirmed_unread", False)),
"requires_visual_proof": bool(
value.get("requires_visual_proof", False)
),
"visual_rejection_count": max(
0,
int(value.get("visual_rejection_count", 0) or 0)
if isinstance(
value.get("visual_rejection_count", 0),
(int, float),
)
else 0,
),
"chat_text": str(value.get("chat_text") or ""),
"last_lines": list(value.get("last_lines") or []),
"created_at": created_at,
"updated_at": updated_at or now,
# monotonic timestamps are process-local and cannot survive restart.
"last_resume_attempt": 0.0,
"send_state": send_state,
"ctrl_enter_attempted": bool(
value.get("ctrl_enter_attempted", False)
),
"uncertain_since": uncertain_since,
"send_dispatched_at": send_dispatched_at,
"reply_text": str(value.get("reply_text") or ""),
"staged_user_text": str(value.get("staged_user_text") or ""),
"staged_reply_text": str(value.get("staged_reply_text") or ""),
"exchange_id": str(value.get("exchange_id") or ""),
"customer_speaker": str(value.get("customer_speaker") or ""),
"registration_lead": dict(registration_lead),
"archive_enabled": bool(value.get("archive_enabled", True)),
"render_identities": [],
}
if send_baseline_blocks is not None:
state["send_baseline_blocks"] = send_baseline_blocks
state["send_reply_was_visible"] = bool(
value.get("send_reply_was_visible", False)
)
state["send_known_outgoing_speakers"] = [
str(speaker).strip()
for speaker in value.get("send_known_outgoing_speakers") or []
if str(speaker).strip()
]
if not state["batch_ready"]:
for field in (
"batch_started_at",
"batch_deadline_at",
"batch_window_seconds",
):
try:
state[field] = max(0.0, float(value.get(field, 0.0) or 0.0))
except (TypeError, ValueError):
state[field] = 0.0
for encoded in value.get("render_identities") or []:
encoded = str(encoded or "")
try:
render_id = bytes.fromhex(encoded)
except ValueError:
continue
if len(render_id) == _SESSION_FP_BYTES:
state["render_identities"].append(render_id.hex())
state["render_identities"] = sorted(set(state["render_identities"]))
for field in (
"identity_signature",
"generation_surface_signature",
"send_surface_signature",
):
encoded = str(value.get(field) or "")
try:
state[field] = bytes.fromhex(encoded) if encoded else b""
except ValueError:
state[field] = b""
restored[fp.hex()] = state
if restored:
print(f" [待回复恢复] 已从磁盘恢复 {len(restored)} 个未完成任务。")
return restored
def _persist_pending_replies(self) -> bool:
"""Atomically persist the minimum state needed after an unread badge disappears."""
path = getattr(self, "_pending_reply_path", "")
if not path:
return True
serializable = {}
for key, value in getattr(self, "_pending_reply_sessions", {}).items():
if not isinstance(value, dict):
continue
item = {
"batch_ready": bool(value.get("batch_ready", False)),
"confirmed_unread": bool(value.get("confirmed_unread", False)),
"requires_visual_proof": bool(
value.get("requires_visual_proof", False)
),
"visual_rejection_count": max(
0,
int(value.get("visual_rejection_count", 0) or 0)
if isinstance(
value.get("visual_rejection_count", 0),
(int, float),
)
else 0,
),
"chat_text": str(value.get("chat_text") or ""),
"last_lines": list(value.get("last_lines") or []),
"created_at": float(value.get("created_at", time.time()) or time.time()),
"updated_at": float(value.get("updated_at", time.time()) or time.time()),
"batch_started_at": float(value.get("batch_started_at", 0.0) or 0.0),
"batch_deadline_at": float(value.get("batch_deadline_at", 0.0) or 0.0),
"batch_window_seconds": float(value.get("batch_window_seconds", 0.0) or 0.0),
"send_state": str(value.get("send_state") or ""),
"ctrl_enter_attempted": bool(
value.get("ctrl_enter_attempted", False)
),
"uncertain_since": float(
value.get("uncertain_since", 0.0) or 0.0
),
"send_dispatched_at": float(
value.get("send_dispatched_at", 0.0) or 0.0
),
"reply_text": str(value.get("reply_text") or ""),
"staged_user_text": str(value.get("staged_user_text") or ""),
"staged_reply_text": str(value.get("staged_reply_text") or ""),
"exchange_id": str(value.get("exchange_id") or ""),
"customer_speaker": str(value.get("customer_speaker") or ""),
"registration_lead": (
dict(value.get("registration_lead") or {})
if isinstance(value.get("registration_lead") or {}, dict)
else {}
),
"archive_enabled": bool(value.get("archive_enabled", True)),
"render_identities": sorted(
{
str(encoded)
for encoded in value.get("render_identities") or []
if encoded
}
),
}
raw_baseline_blocks = value.get("send_baseline_blocks")
if isinstance(raw_baseline_blocks, list) and raw_baseline_blocks:
parsed_baseline = [
[str(block[0]), str(block[1])]
for block in raw_baseline_blocks
if isinstance(block, (list, tuple)) and len(block) == 2
]
if len(parsed_baseline) == len(raw_baseline_blocks):
item["send_baseline_blocks"] = parsed_baseline
item["send_reply_was_visible"] = bool(
value.get("send_reply_was_visible", False)
)
item["send_known_outgoing_speakers"] = sorted(
{
str(speaker).strip()
for speaker in value.get(
"send_known_outgoing_speakers"
) or []
if str(speaker).strip()
}
)
for field in (
"identity_signature",
"generation_surface_signature",
"send_surface_signature",
):
signature = value.get(field)
item[field] = bytes(signature).hex() if signature else ""
serializable[str(key)] = item
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(serializable, handle, ensure_ascii=False, indent=2)
os.replace(tmp, path)
return True
except Exception as exc:
print(f" [待回复恢复] [!] 保存未完成任务失败: {exc}")
return False
def _mark_reply_pending(
self,
fp: bytes,
batch_ready: bool = False,
confirmed_unread: bool = False,
requires_visual_proof: bool = False,
bind_identity: bool = True,
) -> bool:
pending = getattr(self, "_pending_reply_sessions", None)
if pending is None:
pending = self._pending_reply_sessions = {}
state = pending.setdefault(
fp.hex(),
{
"batch_ready": False,
"confirmed_unread": False,
"requires_visual_proof": False,
"visual_rejection_count": 0,
"created_at": time.time(),
},
)
if batch_ready:
state["batch_ready"] = True
state.pop("batch_started_at", None)
state.pop("batch_deadline_at", None)
state.pop("batch_window_seconds", None)
if confirmed_unread:
state["confirmed_unread"] = True
if requires_visual_proof:
state["requires_visual_proof"] = True
render_ids = self._live_render_ids_for(fp)
if render_ids:
state["render_identities"] = sorted(
set(state.get("render_identities") or []).union(render_ids)
)
# 头像会被多人复用,不能只靠头像哈希恢复已读但未回复的会话
# 首次建立 pending 时同时冻结聊天标题区域指纹,后续模型调用和发送前都复核
if bind_identity and not state.get("identity_signature"):
identity = getattr(self, "_active_identity_signature", None)
if not identity:
try:
identity = self._chat_identity_signature()
except Exception:
identity = b""
if identity:
state["identity_signature"] = identity
state["updated_at"] = time.time()
return self._persist_pending_replies()
def _pending_reply_state(self, fp: bytes) -> dict | None:
return getattr(self, "_pending_reply_sessions", {}).get(fp.hex())
def _forget_uncertain_tracking(self, fp: bytes) -> None:
key = bytes(fp or b"").hex()
for attr in (
"_uncertain_send_last_check",
"_uncertain_send_last_log",
"_uncertain_send_last_surface",
):
getattr(self, attr, {}).pop(key, None)
def _reset_pending_batch(self, fp: bytes) -> None:
"""Start a fresh configured merge window after confirmed new content."""
state = self._pending_reply_state(fp)
if state is None:
return
state["batch_ready"] = False
state.pop("batch_started_at", None)
state.pop("batch_deadline_at", None)
state.pop("batch_window_seconds", None)
state.pop("generation_surface_signature", None)
state.pop("send_surface_signature", None)
state.pop("send_state", None)
state.pop("ctrl_enter_attempted", None)
state.pop("uncertain_since", None)
state.pop("reply_text", None)
state.pop("staged_user_text", None)
state.pop("staged_reply_text", None)
state.pop("exchange_id", None)
state.pop("customer_speaker", None)
state.pop("registration_lead", None)
state.pop("archive_enabled", None)
state.pop("send_dispatched_at", None)
state.pop("send_baseline_blocks", None)
state.pop("send_reply_was_visible", None)
state.pop("send_known_outgoing_speakers", None)
state["updated_at"] = time.time()
self._forget_uncertain_tracking(fp)
self._persist_pending_replies()
# 连续几次证实“这一页没有输入框”才判定不可回复。一次判定太容易被一帧
# 没渲染完的窗口骗到,而误判的代价是一个真客户被静默移出队列
_COMPOSERLESS_STRIKES = 3
# 判定后不是永久拉黑:企微改版或本来就误判时还得能自己走回来,隔一段时间
# 放它再试一次
_UNREPLIABLE_RECHECK_SECONDS = 6 * 3600.0
def _load_unrepliable_sessions(self) -> dict:
"""Restore the set of sessions proved to have no composer."""
path = getattr(self, "_unrepliable_path", "")
if not path or not os.path.exists(path):
return {}
try:
with open(path, encoding="utf-8") as handle:
raw = json.load(handle)
except Exception:
return {}
if not isinstance(raw, dict):
return {}
restored = {}
for key, value in raw.items():
try:
fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if len(fp) not in (_LEGACY_SESSION_FP_BYTES, _SESSION_FP_BYTES):
continue
try:
restored[str(key)] = float(value)
except (TypeError, ValueError):
continue
return restored
def _persist_unrepliable_sessions(self) -> None:
path = getattr(self, "_unrepliable_path", "")
if not path:
return
try:
payload = json.dumps(
getattr(self, "_unrepliable_sessions", {}),
ensure_ascii=False,
indent=2,
)
temp = f"{path}.tmp"
with open(temp, "w", encoding="utf-8") as handle:
handle.write(payload)
os.replace(temp, path)
except Exception:
pass
def _session_is_unrepliable(self, fp: bytes) -> bool:
"""Has this session already been proved to have no input box?"""
marked_at = getattr(self, "_unrepliable_sessions", {}).get(
bytes(fp or b"").hex()
)
if marked_at is None:
return False
if time.time() - float(marked_at) >= self._UNREPLIABLE_RECHECK_SECONDS:
self._forget_unrepliable_session(fp)
return False
return True
def _forget_unrepliable_session(self, fp: bytes) -> None:
key = bytes(fp or b"").hex()
getattr(self, "_composerless_strikes", {}).pop(key, None)
if getattr(self, "_unrepliable_sessions", {}).pop(key, None) is not None:
self._persist_unrepliable_sessions()
def _session_accepts_replies(self, fp: bytes) -> bool:
"""Confirm the open page has a composer before spending an AI call.
订阅号和系统号的聊天页没有输入框,回复注定发不出去。不先拦住的话,
每一轮都会重新打开它、调一次模型、再卡在发送保护上,真正的客户被挤到
后面——用户看到的就是“机器人不回消息了”。
"""
key = bytes(fp or b"").hex()
if not key:
return True
if self._session_is_unrepliable(fp):
return False
strikes = getattr(self, "_composerless_strikes", None)
if strikes is None:
strikes = self._composerless_strikes = {}
if bool(getattr(self, "_composer_geometry_valid", True)):
strikes.pop(key, None)
return True
count = int(strikes.get(key, 0)) + 1
strikes[key] = count
if count < self._COMPOSERLESS_STRIKES:
return False
strikes.pop(key, None)
self._unrepliable_sessions[key] = time.time()
self._persist_unrepliable_sessions()
self._clear_reply_pending(fp)
print(
f" [会话过滤] 该会话连续 {count} 次没有输入框,判定为订阅号/系统号,"
"已移出回复队列,不再重复调用模型。"
f"{self._UNREPLIABLE_RECHECK_SECONDS / 3600:.0f} 小时后会自动复查一次。"
)
return False
def _clear_reply_pending(self, fp: bytes) -> None:
key = fp.hex()
getattr(self, "_pending_reply_sessions", {}).pop(key, None)
getattr(self, "_pending_scan_progress", {}).pop(key, None)
# 取消/判定无新消息时同步丢弃尚未发送的档案交换,避免以后误提交
getattr(self, "_pending_exchanges", {}).pop(key, None)
self._forget_uncertain_tracking(fp)
self._persist_pending_replies()
def _commit_staged_exchange(self, session_id) -> None:
key = str(session_id or "")
exchange = getattr(self, "_pending_exchanges", {}).pop(key, None)
pending_state = getattr(self, "_pending_reply_sessions", {}).get(key) or {}
if not exchange:
staged_user = str(pending_state.get("staged_user_text") or "")
staged_reply = str(pending_state.get("staged_reply_text") or "")
if staged_user and staged_reply:
exchange = (staged_user, staged_reply)
snapshot_lines = pending_state.get("last_lines") or []
try:
fp = bytes.fromhex(key)
except (TypeError, ValueError):
return
if exchange and bool(pending_state.get("archive_enabled", True)):
exchange_id = str(pending_state.get("exchange_id") or "")
if exchange_id and hasattr(self.store, "append_exchange_once"):
self.store.append_exchange_once(
key,
exchange[0],
exchange[1],
exchange_id,
)
else:
self.remember_exchange(fp, exchange[0], exchange[1])
registration_lead = pending_state.get("registration_lead") or {}
if isinstance(registration_lead, dict) and registration_lead:
try:
from registration_store import RegistrationStore
allowed = {
name: registration_lead.get(name, "")
for name in (
"session_id", "contact", "symptom", "status",
"note", "last_user", "last_reply",
)
}
RegistrationStore().add_or_update(**allowed)
except Exception as exc:
print(f" [挂号] [!] 已发送回复,但挂号登记写入失败: {exc}")
if snapshot_lines:
self.store.set_last_lines(key, snapshot_lines)
self.store.save()
@staticmethod
def _normalized_message_text(value: str) -> str:
text = unicodedata.normalize("NFKC", str(value or ""))
text = re.sub(r"[\u200b-\u200d\u2060\ufeff]", "", text)
return " ".join(text.split())
def _input_editor_looks_blank(self) -> bool | None:
"""Use editor-body pixels to distinguish an empty field from copy lag.
Ctrl+C on an empty WeCom editor intentionally leaves the clipboard
unchanged. A delayed/failed Ctrl+C does the same, so marker equality
alone is not enough: a selected human draft produces many dark/blue
columns while an empty editor contains at most a thin caret.
"""
if not bool(getattr(self, "_composer_geometry_valid", False)):
return None
scale = max(0.75, float(getattr(self, "scale", 1.0) or 1.0))
def frame_is_blank(full: np.ndarray) -> bool | None:
try:
height, width = full.shape[:2]
x1 = max(
0,
int(
getattr(self, "_list_x", 0)
+ getattr(self, "_list_w", 0)
+ 16 * scale
),
)
x2 = min(width, width - int(160 * scale))
y1 = max(
0,
int(getattr(self, "_composer_rel_top", height))
+ int(42 * scale),
)
y2 = min(height, height - int(10 * scale))
if (
x2 <= x1
or y2 - y1 < max(10, int(20 * scale))
):
return None
body = full[y1:y2, x1:x2, :3].astype(np.int16)
if not body.size:
return None
# Works in both light and dark themes: the editor background
# occupies most pixels, while selected text/draft glyphs
# differ materially from that local median colour.
background = np.median(
body.reshape(-1, body.shape[2]),
axis=0,
)
is_background = np.max(np.abs(body - background), axis=2) < 18
# 采样框会盖到输入面板的圆角边框以及它下方的窗口底色。必须先
# 收缩到编辑区内部:否则一个空输入框的几乎每一列都会因为边
# 被算成“有草稿”,自动发送将永远过不了焦点校验
# 边框和面板外底色是整行整列都不含背景色的实心线,草稿文字再
# 宽也会在同一行里留下大片背景,因此用“几乎没有背景色”区分,
# 绝不能用背景占比过半之类的判据把草稿所在行本身排除掉
solid_free = 0.1
rows = _largest_true_span(
is_background.mean(axis=1) >= solid_free
)
if rows is None:
return None
# y1 取在工具栏下方,本身就落在编辑区内。最长的非实心区间没
# 覆盖到顶部时说明画面与预期不符,此时宁可判为未知而不发送
if rows[0] > max(2, int(4 * scale)):
return None
columns = _largest_true_span(
is_background[rows[0]:rows[1]].mean(axis=0) >= solid_free
)
if columns is None:
return None
inset = max(1, int(2 * scale))
row_start, row_stop = rows[0] + inset, rows[1] - inset
col_start, col_stop = columns[0] + inset, columns[1] - inset
if (
row_stop - row_start < max(6, int(10 * scale))
or col_stop - col_start < max(20, int(40 * scale))
):
return None
active = ~is_background[row_start:row_stop, col_start:col_stop]
active_columns = int(active.any(axis=0).sum())
return active_columns <= max(4, int(np.ceil(3.5 * scale)))
except Exception:
return None
try:
first = frame_is_blank(self._capture_full_window())
if first is not True:
return first
time.sleep(0.1)
second = frame_is_blank(self._capture_full_window())
return True if second is True else second
except Exception:
return None
def _read_input_draft(self) -> str | None:
"""Read the editor without sending; ``None`` means identity is ambiguous."""
marker = f"__wecom_empty_{time.time_ns()}__"
selection_made = False
try:
pyperclip.copy(marker)
pyautogui.click(self.input_x, self.input_y)
pyautogui.hotkey('ctrl', 'a')
selection_made = True
pyautogui.hotkey('ctrl', 'c')
copied = marker
for index in range(6):
time.sleep(0.1)
copied = str(pyperclip.paste() or "")
if copied != marker:
return copied
blank = self._input_editor_looks_blank()
return "" if blank is True else None
except Exception:
return None
finally:
if selection_made:
try:
pyautogui.press('end')
except Exception:
pass
def _same_chat_is_open(
self,
expected_fp: bytes,
expected_identity: bytes = b"",
) -> bool:
"""Read-only proof that receipt checks still target the original chat."""
try:
full = self._capture_full_window()
current_fp = (
self._raw_selected_session_fingerprint()
or self._selected_session_fingerprint()
)
current_identity = self._chat_identity_signature()
except Exception:
return False
nav_ok = self._message_nav_selected(full)
if not nav_ok:
nav_ok = bool(self._target_chat_ready(expected_fp))
identity_ok = self._chat_target_matches(
expected_fp,
expected_identity
or bytes(getattr(self, "_active_identity_signature", b"") or b""),
current_fp=current_fp,
current_identity=current_identity,
selected_fp_checked=True,
)
return bool(
nav_ok
and current_fp
and identity_ok
)
def _send_receipt_matches(
self,
reply_text: str,
customer_speaker: str,
before_surface: bytes = b"",
before_blocks: list | None = None,
reply_was_visible: bool = False,
) -> bool | None:
"""Require a newly-added exact outgoing block and a surface change."""
self._last_send_receipt_followup = None
self._last_send_receipt_visible_text = ""
visible = self._visible_last_message_matches(
reply_text,
customer_speaker,
before_blocks,
reply_was_visible,
)
if visible is not True:
return visible
before_surface = bytes(before_surface or b"")
if not before_surface:
return None
current_surface = self._chat_surface_signature()
if not current_surface or current_surface == before_surface:
return False
return True
def _wait_for_visible_reply(
self,
reply_text: str,
customer_speaker: str = "",
before_surface: bytes = b"",
before_blocks: list | None = None,
reply_was_visible: bool = False,
expected_fp: bytes = b"",
expected_identity: bytes = b"",
) -> bool:
"""Bound receipt checks by a real deadline and the original chat identity."""
deadline = time.monotonic() + SEND_RECEIPT_TIMEOUT_SECONDS
checks = max(1, int(SEND_RECEIPT_MAX_CHECKS))
for index in range(checks):
if expected_fp and not self._same_chat_is_open(
expected_fp,
expected_identity,
):
return False
if self._send_receipt_matches(
reply_text,
customer_speaker,
before_surface,
before_blocks,
reply_was_visible,
) is True:
return True
remaining = deadline - time.monotonic()
if index + 1 >= checks or remaining <= 0:
break
time.sleep(
min(SEND_RECEIPT_CHECK_INTERVAL_SECONDS, remaining)
)
return False
@staticmethod
def _speaker_header_match(line: str):
"""Reject media-duration lines that resemble 'speaker + HH:MM' headers."""
try:
from ai_chat import detect_media_types
if detect_media_types(line):
return None
except ImportError:
pass
return _CHAT_SPEAKER_HEADER_RE.match(str(line or "").strip())
@staticmethod
def _copied_message_blocks(value: str) -> list[tuple[str, str]]:
blocks = []
speaker = ""
body = []
for raw_line in str(value or "").splitlines():
line = raw_line.strip()
match = WeChatBot._speaker_header_match(line)
if match:
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
speaker = match.group("speaker").strip()
body = []
elif speaker and line:
body.append(line)
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
return blocks
def _known_outgoing_speakers(self) -> set[str]:
"""Return sender labels proven by configuration or right-side bubbles."""
known = set(getattr(self, "_known_agent_speakers", set()) or set())
try:
from ai_config import AI_AGENT_NAME
configured = str(AI_AGENT_NAME or "").strip()
if configured:
known.add(configured)
except ImportError:
pass
session_fp = bytes(getattr(self, "_active_session_fp", b"") or b"")
store = getattr(self, "store", None)
if session_fp and store is not None and hasattr(store, "outgoing_speakers"):
try:
persisted = store.outgoing_speakers(session_fp.hex())
if isinstance(persisted, (list, tuple, set)):
known.update(
str(speaker).strip()
for speaker in persisted
if str(speaker).strip()
)
except Exception:
pass
self._known_agent_speakers = known
return known
def _remember_known_outgoing_speaker(self, speaker: str) -> None:
"""Remember only labels tied to an exact message proven on the right."""
name = str(speaker or "").strip()
if not name:
return
self._known_agent_speakers = set(
getattr(self, "_known_agent_speakers", set()) or set()
)
self._known_agent_speakers.add(name)
session_fp = bytes(getattr(self, "_active_session_fp", b"") or b"")
store = getattr(self, "store", None)
if session_fp and store is not None and hasattr(store, "add_outgoing_speaker"):
try:
store.add_outgoing_speaker(session_fp.hex(), name)
except Exception:
pass
def _last_visible_bubble_is_outgoing(self) -> bool | None:
"""Infer the side of the lowest visible message bubble from the current frame."""
if (
getattr(self, "_composer_geometry_valid", True) is False
or getattr(self, "_chat_geometry_valid", True) is False
):
return None
try:
full = self._capture_full_window()
x1 = max(0, int(self._chat_rel_x))
y1 = max(0, int(self._chat_rel_y))
x2 = min(full.shape[1], x1 + int(self._chat_rel_w))
y2 = min(full.shape[0], y1 + int(self._chat_rel_h))
image = self._without_chat_scrollbar(full[y1:y2, x1:x2])
grid = self._ink_grid(
image,
36,
48,
threshold=20.0,
coverage_threshold=0.045,
)
occupied_rows = np.where(grid.any(axis=1))[0]
if not len(occupied_rows):
return None
bottom = int(occupied_rows[-1])
band = grid[max(0, bottom - 4):bottom + 1]
midpoint = band.shape[1] // 2
left = int(band[:, :midpoint].sum())
right = int(band[:, midpoint:].sum())
if right >= max(2, int(left * 1.5)):
return True
if left >= max(2, int(right * 1.5)):
return False
except Exception:
pass
return None
def _normalized_copied_blocks(self, visible_text: str) -> list[list[str]]:
"""Build stable transcript anchors retaining each copied time header."""
blocks: list[list[str]] = []
header = ""
body: list[str] = []
for raw_line in str(visible_text or "").splitlines():
line = raw_line.strip()
match = self._speaker_header_match(line)
if match:
if header:
blocks.append([
re.sub(r"\s+", " ", header).strip(),
self._normalized_message_text("\n".join(body)),
])
header = line
body = []
elif header and line:
body.append(line)
if header:
blocks.append([
re.sub(r"\s+", " ", header).strip(),
self._normalized_message_text("\n".join(body)),
])
return blocks
@staticmethod
def _stored_send_baseline(state: dict) -> list[list[str]] | None:
"""Return only a complete non-empty persisted block anchor."""
raw = state.get("send_baseline_blocks") if isinstance(state, dict) else None
if not isinstance(raw, list) or not raw:
return None
parsed = [
[str(item[0]), str(item[1])]
for item in raw
if isinstance(item, (list, tuple)) and len(item) == 2
]
return parsed if len(parsed) == len(raw) else None
@staticmethod
def _blocks_after_baseline(
baseline: list[list[str]],
current: list[list[str]],
) -> list[list[str]] | None:
"""Return only appended blocks, tolerating old blocks scrolled off-screen."""
if not baseline:
return None
max_k = min(len(baseline), len(current))
for size in range(max_k, 0, -1):
if baseline[-size:] == current[:size]:
return current[size:]
return None
@staticmethod
def _followup_evidence_after_reply(
blocks: list[tuple[str, str]],
reply_index: int,
customer_speaker: str,
known_agent_speakers: set[str],
agent_name: str,
) -> bool | None:
"""Classify later copied blocks from the same receipt snapshot."""
later = [
(str(speaker).strip(), str(body).strip())
for speaker, body in blocks[int(reply_index) + 1:]
if str(body).strip()
]
if not later:
return False
customer_speaker = str(customer_speaker or "").strip()
if customer_speaker and any(
speaker == customer_speaker for speaker, _body in later
):
return True
unknown_seen = False
for speaker, _body in later:
if speaker in known_agent_speakers or (
agent_name and agent_name in speaker
):
continue
unknown_seen = True
return None if unknown_seen else False
def _visible_last_message_matches(
self,
expected_reply: str,
customer_speaker: str = "",
before_blocks: list | None = None,
reply_was_visible: bool = False,
) -> bool | None:
"""Prove the copied last block is our expected reply, never the customer's echo."""
expected = self._normalized_message_text(expected_reply)
if not expected:
return None
try:
visible = self.extract_chat_text(
screens=1,
wait_for_idle=False,
)
except Exception:
return None
if not str(visible or "").strip():
return None
self._last_send_receipt_visible_text = str(visible)
blocks = self._copied_message_blocks(visible)
normalized_blocks = self._normalized_copied_blocks(visible)
if not blocks or not normalized_blocks:
return None
candidate_offset = 0
if before_blocks is not None:
if not isinstance(before_blocks, list):
return None
normalized_before = [
[str(item[0]), str(item[1])]
for item in before_blocks
if isinstance(item, (list, tuple)) and len(item) == 2
]
if not normalized_before or len(normalized_before) != len(before_blocks):
return None
appended = self._blocks_after_baseline(
normalized_before,
normalized_blocks,
)
if appended is None:
return None
candidate_offset = len(normalized_blocks) - len(appended)
elif reply_was_visible:
# Without a block anchor, an old identical reply cannot prove this
# transaction. Keep it uncertain rather than accepting a duplicate.
return False
matching = [
(index, block_speaker)
for index, (block_speaker, block_body) in enumerate(blocks)
if index >= candidate_offset
and self._normalized_message_text(block_body) == expected
]
if not matching:
return False
try:
from ai_config import AI_AGENT_NAME
agent_name = str(AI_AGENT_NAME or "").strip()
except ImportError:
agent_name = ""
known_agent_speakers = self._known_outgoing_speakers()
outgoing_side = self._last_visible_bubble_is_outgoing()
customer_speaker = str(customer_speaker or "").strip()
last_index, last_speaker = matching[-1]
last_speaker = last_speaker.strip()
def remember_followup(reply_index: int) -> None:
self._last_send_receipt_followup = (
self._followup_evidence_after_reply(
blocks,
reply_index,
customer_speaker,
known_agent_speakers,
agent_name,
)
)
if customer_speaker and last_speaker == customer_speaker:
return False
if (
last_speaker in known_agent_speakers
or (agent_name and agent_name in last_speaker)
):
remember_followup(last_index)
return True
if last_index == len(blocks) - 1 and outgoing_side is not None:
if outgoing_side and last_speaker:
self._remember_known_outgoing_speaker(last_speaker)
remember_followup(last_index)
return outgoing_side
# 若我方回复之后客户又追发了一条,末条已不再等 expected。只有复
# 说话人能明确证明前一块是我方时,才允许提交旧事务并继续处理追发
for index, block_speaker in reversed(matching):
speaker_name = block_speaker.strip()
if (
speaker_name in known_agent_speakers
or (agent_name and agent_name in speaker_name)
):
remember_followup(index)
return True
# 老任务既没有可靠说话人,也无法从气泡方向证明时,不能 pending
return None
def _visible_reply_has_customer_followup(
self,
expected_reply: str,
customer_speaker: str = "",
) -> bool:
"""True only when copied blocks prove a customer message follows our reply."""
expected = self._normalized_message_text(expected_reply)
if not expected:
return False
try:
visible = self.extract_chat_text(screens=1, wait_for_idle=False)
blocks = self._copied_message_blocks(visible)
except Exception:
return False
try:
from ai_config import AI_AGENT_NAME
agent_name = str(AI_AGENT_NAME or "").strip()
except ImportError:
agent_name = ""
known_agent_speakers = self._known_outgoing_speakers()
customer_speaker = str(customer_speaker or "").strip()
proven_matches = []
for index, (speaker, body) in enumerate(blocks):
if self._normalized_message_text(body) != expected:
continue
speaker = speaker.strip()
if customer_speaker and speaker == customer_speaker:
continue
outgoing_proven = bool(
speaker in known_agent_speakers
or (agent_name and agent_name in speaker)
)
if outgoing_proven:
proven_matches.append(index)
if not proven_matches:
return False
# 只锚定最新一次已证明为我方的同文回复;更早的常用短句(如“好的”)
# 后面有客户消息,不能冒充本轮刚发送后的追发
latest_reply_index = proven_matches[-1]
later = blocks[latest_reply_index + 1:]
if customer_speaker:
return any(
name.strip() == customer_speaker and bool(text.strip())
for name, text in later
)
if agent_name:
return any(
agent_name not in name.strip() and bool(text.strip())
for name, text in later
)
return False
def _visible_customer_followup_after_state(
self,
state: dict,
before_blocks: list[list[str]] | None = None,
) -> bool:
"""Prove a newer block from the same customer without judging old send success."""
baseline_text = "\n".join(
str(line) for line in state.get("last_lines") or []
)
baseline = before_blocks or self._normalized_copied_blocks(
baseline_text
)
if not baseline:
return False
visible = str(
getattr(self, "_last_send_receipt_visible_text", "") or ""
)
if not visible:
try:
visible = self.extract_chat_text(
screens=1,
wait_for_idle=False,
)
except Exception:
return False
current = self._normalized_copied_blocks(visible)
blocks = self._copied_message_blocks(visible)
if not current or len(current) != len(blocks):
return False
appended = self._blocks_after_baseline(baseline, current)
if not appended:
return False
offset = len(current) - len(appended)
customer_speaker = str(state.get("customer_speaker") or "").strip()
if not customer_speaker:
baseline_blocks = self._copied_message_blocks(baseline_text)
if baseline_blocks:
candidate = baseline_blocks[-1][0].strip()
if candidate not in self._known_outgoing_speakers():
customer_speaker = candidate
if not customer_speaker:
return False
return any(
speaker.strip() == customer_speaker
and bool(self._normalized_message_text(body))
for speaker, body in blocks[offset:]
)
def _queue_followup_after_confirmed_send(self, fp: bytes) -> None:
"""Keep a customer follow-up as a fresh pending task after committing the old send."""
self._mark_reply_pending(fp, confirmed_unread=True)
state = self._pending_reply_state(fp)
if state is not None:
state["batch_ready"] = False
state["updated_at"] = time.time()
self._persist_pending_replies()
print(" [消息续接] 回复后检测到客户追发,已保留为下一轮待回复任务。")
def _try_ctrl_enter_for_retained_draft(
self,
reply_text: str,
pending_state: dict,
expected_fp: bytes,
expected_identity: bytes = b"",
) -> bool:
"""Use Ctrl+Enter only after the input box proves Enter merely retained our draft."""
expected = self._normalized_message_text(reply_text)
if not expected or pending_state.get("ctrl_enter_attempted", False):
return False
if not self._mouse_is_idle_now():
return False
if not expected_fp or not self._same_chat_is_open(
expected_fp,
expected_identity,
):
return False
old_clipboard = ""
self._begin_bot_mouse()
try:
try:
old_clipboard = pyperclip.paste()
except Exception:
return False
draft = self._read_input_draft()
if draft is None or self._normalized_message_text(draft) != expected:
return False
stable_surface = self._chat_surface_signature()
time.sleep(0.25)
if not self._same_chat_is_open(expected_fp, expected_identity):
print(" [发送兼容] 检测草稿期间聊天对象发生变化,已禁止 Ctrl+Enter。")
return False
confirmed_draft = self._read_input_draft()
if (
confirmed_draft is None
or self._normalized_message_text(confirmed_draft) != expected
or not stable_surface
or self._chat_surface_signature() != stable_surface
):
return False
previous_send_state = pending_state.get("send_state")
pending_state["send_state"] = "sending"
pending_state["reply_text"] = reply_text
pending_state["updated_at"] = time.time()
if not self._persist_pending_replies():
if previous_send_state:
pending_state["send_state"] = previous_send_state
else:
pending_state.pop("send_state", None)
return False
print(" [发送兼容] 检测到 Enter 仅保留草稿,改用 Ctrl+Enter 发送。")
pyautogui.hotkey('ctrl', 'enter')
self._refresh_send_reservation()
pending_state["ctrl_enter_attempted"] = True
pending_state["send_state"] = "sent_uncommitted"
pending_state["send_dispatched_at"] = time.time()
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
time.sleep(0.5)
return True
finally:
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
self._end_bot_mouse()
def _reconcile_uncertain_send(self, fp: bytes, state: dict) -> str:
"""Return sent/unsent/uncertain without ever blindly resending after a crash."""
send_state = str(state.get("send_state") or "")
if not send_state:
return "unsent"
known_states = {"sending", "sent_uncommitted", "uncertain"}
state_was_uncertain = send_state == "uncertain"
if send_state not in known_states:
# Future/corrupt transaction states must fail closed after restart.
send_state = "uncertain"
state["send_state"] = "uncertain"
key = fp.hex()
now = time.monotonic()
last_checks = getattr(self, "_uncertain_send_last_check", None)
if last_checks is None:
last_checks = self._uncertain_send_last_check = {}
if (
send_state == "uncertain"
and now - float(last_checks.get(key, 0.0) or 0.0)
< UNCERTAIN_SEND_RECHECK_SECONDS
):
return "uncertain"
last_checks[key] = now
before_surface = bytes(
state.get("send_surface_signature")
or state.get("generation_surface_signature")
or b""
)
current_surface = self._chat_surface_signature()
last_surfaces = getattr(self, "_uncertain_send_last_surface", None)
if last_surfaces is None:
last_surfaces = self._uncertain_send_last_surface = {}
observed_surface = bytes(
last_surfaces.get(key)
or before_surface
or b""
)
if current_surface:
last_surfaces[key] = current_surface
reply_text = str(
state.get("reply_text")
or state.get("staged_reply_text")
or ""
)
# Once a transaction is uncertain, reconciliation is strictly
# read-only. If the chat surface has not changed, even clipboard
# extraction cannot reveal new receipt evidence and would only move
# the user's mouse every five seconds.
surface_changed = bool(
current_surface
and observed_surface
and current_surface != observed_surface
)
visible_match = None
self._last_send_receipt_visible_text = ""
before_blocks = self._stored_send_baseline(state)
known_speakers = set(getattr(self, "_known_agent_speakers", set()) or set())
known_speakers.update(
str(speaker).strip()
for speaker in state.get("send_known_outgoing_speakers") or []
if str(speaker).strip()
)
self._known_agent_speakers = known_speakers
if (
before_blocks is not None
and (not state_was_uncertain or surface_changed or not observed_surface)
):
visible_match = self._send_receipt_matches(
reply_text=reply_text,
customer_speaker=str(state.get("customer_speaker") or ""),
before_surface=before_surface,
before_blocks=before_blocks,
reply_was_visible=bool(
state.get("send_reply_was_visible", False)
),
)
if visible_match is True:
has_followup = getattr(
self,
"_last_send_receipt_followup",
None,
)
self._commit_staged_exchange(fp.hex())
self._clear_reply_pending(fp)
if has_followup is not False:
self._queue_followup_after_confirmed_send(fp)
if has_followup is None:
print(" [消息续接] 回执画面中存在方向不明确的后续消息,已保留为下一轮待核对任务。")
else:
self._remember_active_surface(fp)
print(" [待回复恢复] 已确认回复实际发出,完成档案提交且不会重复发送。")
return "sent"
if surface_changed and self._visible_customer_followup_after_state(
state,
before_blocks,
):
# A new customer turn makes the old draft obsolete. Abandon that
# draft without ever resending it, then let the normal merge path
# answer the latest visible conversation.
self._reset_pending_batch(fp)
refreshed = self._pending_reply_state(fp)
if refreshed is not None:
refreshed["confirmed_unread"] = True
refreshed["updated_at"] = time.time()
self._persist_pending_replies()
print(" [发送对账] 检测到客户后续新消息,已放弃结果不明的旧草稿并重新合并;旧回复不会重发。")
return "unsent"
# An unchanged chat surface cannot prove whether Enter ran immediately
# before a crash. Keep only this conversation isolated; other unread
# conversations remain eligible for processing.
first_transition = not state_was_uncertain
state["send_state"] = "uncertain"
if float(state.get("uncertain_since", 0.0) or 0.0) <= 0:
state["uncertain_since"] = time.time()
first_transition = True
if first_transition:
state["updated_at"] = time.time()
self._persist_pending_replies()
last_logs = getattr(self, "_uncertain_send_last_log", None)
if last_logs is None:
last_logs = self._uncertain_send_last_log = {}
if (
key not in last_logs
or now - float(last_logs.get(key, 0.0) or 0.0)
>= UNCERTAIN_SEND_LOG_SECONDS
):
print(" [发送对账] 发送结果暂未确认,已保留当前任务并后台核对;不会重复发送,也不会阻塞其他会话。")
last_logs[key] = now
return "uncertain"
def click_session(
self,
rel_y: int,
expected_fp: bytes | None = None,
row_center: bool = False,
) -> bool:
"""点击会话并校验实际打开的会话,避免列表重排造成误点。"""
self._last_click_failure_reason = "unknown"
if not getattr(self, "_session_geometry_valid", True):
self._last_click_failure_reason = "invalid_geometry"
print(" [页面校验] 会话列表空间不足,已禁止窗口外点击。")
return False
if not self.wait_for_mouse_idle():
self._last_click_failure_reason = "mouse_busy"
return False
try:
full = self._capture_full_window()
except Exception:
full = None
if not self._message_nav_selected(full):
self._last_click_failure_reason = "not_message_before_click"
print(" [页面校验] 点击会话前发现当前不在消息页,先恢复页面并取消本次点击。")
self._ensure_message_workspace("点击会话前", full=full)
return False
if expected_fp is not None:
try:
latest = self.capture_session_list()
legacy_expected = len(expected_fp) == _LEGACY_SESSION_FP_BYTES
def matches_expected(image, y, *, centered=False):
if legacy_expected:
if centered:
actual = self._legacy_session_fingerprint(
image,
y,
row_center=True,
)
else:
actual = self._legacy_session_fingerprint(image, y)
return actual == expected_fp
if centered:
actual = self._session_fingerprint(
image,
y,
row_center=True,
)
else:
actual = self._session_fingerprint(image, y)
return self._session_fp_matches(
actual,
expected_fp,
)
if row_center:
pending_state = self._pending_reply_state(expected_fp)
allow_flat_pending = bool(
(pending_state or {}).get("requires_visual_proof", False)
or self._flat_session_is_known(expected_fp)
)
allow_unrounded_pending = bool(
len(expected_fp) == _SESSION_FP_BYTES
and (pending_state or {}).get("requires_visual_proof", False)
and not self._flat_session_rejected(expected_fp)
)
conversation_shape_ok = self._is_real_conversation(
latest,
int(rel_y),
quiet=True,
row_center=True,
allow_flat=allow_flat_pending,
)
target_still_present = (
0 <= int(rel_y) < latest.shape[0]
and matches_expected(latest, int(rel_y), centered=True)
and (conversation_shape_ok or allow_unrounded_pending)
)
else:
latest_badges = self.detect_badge_rows(latest)
matching_rows = [
int(y)
for y in latest_badges
if matches_expected(latest, y)
]
target_still_present = len(matching_rows) == 1
if len(matching_rows) > 1:
print(" [页面校验] 多个会话具有相同视觉身份,已拒绝猜测目标。")
if target_still_present:
# 会话可能在识别到点击之间重排;按复合指纹使用最新坐标
rel_y = matching_rows[0]
except Exception:
target_still_present = False
if not target_still_present:
self._last_click_failure_reason = "target_changed_before_click"
print(" [页面校验] 点击前会话列表已变化,已取消这次点击。")
return False
before_identity = self._chat_identity_signature()
previous_active_fp = getattr(self, "_active_session_fp", None)
self._begin_bot_mouse()
try:
click_y = (
int(rel_y)
if row_center or expected_fp is None
else self._row_center_from_badge(latest, rel_y)
)
screen_y = self.list_region["top"] + click_y
if all(hasattr(self, name) for name in ("L", "T", "R", "B")) and not (
self.L <= self.list_click_x < self.R
and self.T <= screen_y < self.B
):
self._last_click_failure_reason = "invalid_click_coordinate"
print(" [页面校验] 会话点击坐标超出企业微信窗口,已取消操作。")
return False
pyautogui.click(self.list_click_x, screen_y)
time.sleep(0.6) # 等待右侧聊天面板渲染
finally:
self._end_bot_mouse()
# A flat text avatar and a built-in application icon can look nearly
# identical. Only classify the click as leaving the message workspace
# when two independent post-click captures agree. A single GPU/DOM
# transition frame is common in WeCom and previously caused real
# contacts to be permanently added to the system-entry blacklist.
try:
after_click_full = self._capture_full_window()
except Exception:
after_click_full = None
after_click_valid = bool(
after_click_full is not None
and getattr(after_click_full, "ndim", 0) == 3
)
navigation_missed_twice = False
if after_click_valid and not self._message_nav_selected(after_click_full):
time.sleep(0.18)
try:
confirmed_full = self._capture_full_window()
except Exception:
confirmed_full = None
confirmed_valid = bool(
confirmed_full is not None
and getattr(confirmed_full, "ndim", 0) == 3
)
if confirmed_valid and not self._message_nav_selected(confirmed_full):
navigation_missed_twice = True
identity = self._chat_identity_signature()
# Do not run the selected row through the icon-style tool filter here.
# The row was uniquely validated while unread, and a real external
# WeChat contact can have the same green icon shape as a built-in app.
opened_fp = self._raw_selected_session_fingerprint()
if not opened_fp and not navigation_missed_twice:
# Compatibility fallback for themes where the selected-row strip
# is not detectable. It is deliberately disabled for the
# two-navigation-miss conflict path, where only raw row evidence
# is allowed to overrule a possible tool-page transition.
opened_fp = self._selected_session_fingerprint()
opened_matches = True
if expected_fp is not None:
if len(expected_fp) == _LEGACY_SESSION_FP_BYTES:
try:
selected_list = self.capture_session_list()
selected_y = self.detect_selected_row(selected_list)
opened_matches = bool(
selected_y >= 0
and self._legacy_session_fingerprint(
selected_list,
selected_y,
row_center=True,
) == expected_fp
)
except Exception:
opened_matches = False
else:
opened_matches = bool(
opened_fp
and self._session_fp_matches(opened_fp, expected_fp)
)
if (
not opened_matches
and opened_fp
and self._remember_live_render_alias(opened_fp, expected_fp)
):
# The selected row is a live render-normalized match for
# the exact row validated immediately before the click.
# Keep this alias process-local so archived 40-byte keys
# retain their historical byte semantics.
opened_matches = True
if navigation_missed_twice:
strong_chat_evidence = bool(
expected_fp is not None
and opened_matches
and identity
and self._chat_surface_signature()
)
if strong_chat_evidence:
# High-DPI/theme sampling can miss the blue Messages highlight.
# The exact selected target + title + chat surface is stronger
# evidence and must undo any earlier icon-style rejection.
self._confirm_flat_session(
opened_fp,
self._pending_reply_state(expected_fp),
)
print(" [页面校验] 导航选中取色不稳定,但目标会话、标题和聊天区均已确认。")
elif not opened_matches:
self._last_click_failure_reason = "left_message_workspace"
print(" [页面校验] 点击后导航与目标选中行均消失,已确认离开消息页。")
return False
else:
self._last_click_failure_reason = "navigation_unconfirmed"
print(" [页面校验] 导航取色异常且聊天区证据不足,保留任务等待重试。")
return False
if not identity:
self._last_click_failure_reason = "missing_chat_identity"
print(" [页面校验] 点击后无法读取聊天标题区域,已停止后续发送。")
return False
# The title is allowed to stay unchanged when the unread row is the
# conversation that was already open (especially row0 after a geometry
# refresh, where _active_session_fp is intentionally reset). Selected
# row identity is the stronger proof and must be checked before the
# unchanged-title blocker heuristic.
if opened_matches:
self._last_click_failure_reason = ""
self._remember_active_surface(opened_fp)
return True
if (
expected_fp is not None
and expected_fp != previous_active_fp
and before_identity
and identity == before_identity
):
if not self._dismiss_internal_blocker("切换会话时"):
if not self._run_ai_page_guard("切换会话被拦截"):
self._escape_proven_blocker("切换会话时")
self._last_click_failure_reason = "click_blocked"
print(" [页面校验] 点击被弹窗或其他页面拦截,已清理页面并等待重试。")
return False
if not opened_matches:
self._last_click_failure_reason = "selected_fingerprint_mismatch"
print(" [页面校验] 会话列表发生变化,实际打开对象与目标不一致,已停止后续发送。")
return False
return False
def send_reply(
self,
text: str = None,
session_id=None,
expected_fp: bytes | None = None,
) -> bool:
"""校验当前会话后发送回复;发送后保留聊天页,不跳转工具页面。"""
if self._security_gate_visible():
return False
if not self._await_send_gate():
return False
if not self.wait_for_mouse_idle():
return False
# AI 请求期间用户或企业微信可能打开了文件、文档等页面。输入前再次清理
# 避免固定输入坐标落到弹窗按钮或文件列表上
closed_owned = self._dismiss_owned_blocking_window()
if closed_owned:
# “已处理”也可能表示顽固子窗仍在、当前仅做了限频冻结
# 即使确已关闭,窗口层级和焦点刚发生变化,本轮也不应沿用旧几何
# 继续粘贴;pending 会让下一轮重新打开并完整校验目标会话
print(" [发送保护] 发送前处理了阻塞子窗口,本轮保留任务并重新校验。")
return False
if not self._ensure_visible():
return False
closed_internal = self._dismiss_internal_blocker("发送前")
if closed_internal:
print(" [发送保护] 发送前处理了页面弹窗,本轮保留任务并重新校验。")
return False
try:
full = self._capture_full_window()
except Exception:
full = None
geometry_changed = False
if full is not None and hasattr(self, "_list_x"):
geometry_changed = self._refresh_message_geometry(full)
if hasattr(self, "_composer_geometry_valid") and not bool(
self._composer_geometry_valid
):
print(" [发送保护] 无法确认消息区与输入区分隔线,已禁止粘贴和发送。")
return False
if geometry_changed and expected_fp is not None:
self._reset_pending_batch(expected_fp)
print(" [发送保护] 输入面板尺寸刚发生变化,已按新消息区重新校验后再回复。")
return False
if not self._message_nav_selected(full):
ready_fp = (
self._target_chat_ready(expected_fp)
if expected_fp is not None
else None
)
if not ready_fp:
print(" [发送保护] 当前不是消息页面,已禁止粘贴和发送并尝试自动恢复。")
self._ensure_message_workspace("发送前", full=full)
return False
self._confirm_flat_session(
ready_fp,
self._pending_reply_state(expected_fp),
)
print(" [发送保护] 导航取色异常,但目标会话与输入聊天区已确认,继续发送校验。")
if not getattr(self, "_input_geometry_valid", True):
print(" [发送保护] 聊天输入区域空间不足,已禁止窗口外粘贴和发送。")
return False
if all(hasattr(self, name) for name in ("L", "T", "R", "B")) and not (
self.L <= self.input_x < self.R
and self.T <= self.input_y < self.B
):
print(" [发送保护] 输入坐标超出企业微信窗口,已取消粘贴和发送。")
return False
pending_state = {}
pending_identity = b""
pending_surface = b""
if expected_fp is not None:
current_fp = (
self._raw_selected_session_fingerprint()
or self._selected_session_fingerprint()
)
current_identity = self._chat_identity_signature()
pending_state = self._pending_reply_state(expected_fp) or {}
pending_identity = pending_state.get("identity_signature")
pending_surface = pending_state.get("generation_surface_signature")
current_surface = self._chat_surface_signature()
expected_identity = bytes(
pending_identity
or getattr(self, "_active_identity_signature", b"")
or b""
)
identity_ok = self._chat_target_matches(
expected_fp,
expected_identity,
current_fp=current_fp,
current_identity=current_identity,
selected_fp_checked=True,
)
identity_drifted = bool(
identity_ok
and current_identity
and (
current_identity
!= bytes(getattr(self, "_active_identity_signature", b"") or b"")
or (
bool(pending_identity)
and current_identity != pending_identity
)
)
)
if identity_drifted:
if not self._accept_selected_title_render(
expected_fp,
pending_state,
current_identity,
current_fp=current_fp,
selected_fp_checked=True,
):
identity_ok = False
else:
pending_identity = current_identity
surface_changed = bool(pending_surface) and (
not current_surface or current_surface != pending_surface
)
if (
not identity_ok
or surface_changed
or not current_fp
):
if surface_changed:
print(" [发送保护] 生成回复后又收到新消息,已取消旧回复并重新合并。")
self._reset_pending_batch(expected_fp)
else:
print(" [发送保护] 当前聊天对象已经变化,已取消粘贴和发送。")
self._run_ai_page_guard("发送前会话校验异常")
return False
reply_text = text or AUTO_REPLY_TEXT
expected_reply = self._normalized_message_text(reply_text)
if not expected_reply:
print(" [发送保护] 回复内容为空,已取消发送。")
return False
old_clipboard = ""
clipboard_saved = False
self._begin_bot_mouse()
try:
try:
old_clipboard = pyperclip.paste()
clipboard_saved = True
except Exception:
print(" [发送保护] 无法读取系统剪贴板,已取消发送。")
return False
existing_draft = self._read_input_draft()
if existing_draft is None:
print(" [发送保护] 无法确认输入框焦点,已取消发送。")
return False
if self._normalized_message_text(existing_draft):
pyautogui.press('end')
print(" [发送保护] 检测到人工草稿,已保留原内容并取消自动发送。")
return False
try:
pyperclip.copy(reply_text)
except Exception:
print(" [发送保护] 无法写入系统剪贴板,已取消发送。")
return False
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.25)
pasted_draft = self._read_input_draft()
pasted_normalized = (
self._normalized_message_text(pasted_draft)
if pasted_draft is not None
else ""
)
if pasted_draft is None or pasted_normalized != expected_reply:
if (
pasted_normalized
and expected_reply.startswith(pasted_normalized)
):
# The field was proven empty immediately before the bot's
# paste, so an exact prefix can only be our partial paste.
pyautogui.hotkey('ctrl', 'a')
pyautogui.press('backspace')
print(" [发送保护] 已清除本次未完整写入的自动回复草稿。")
print(" [发送保护] 回复未能完整写入聊天输入框,已禁止按下发送键。")
return False
# 回读 Ctrl+A 选中了整段草稿,先收起选择再进行最终会话校验
pyautogui.press('end')
# 粘贴到按 Enter 之间仍有约数百毫秒,客户可能恰好补发一句,或人
# 切走会话。必须在真正发送的最后一刻再次校验,不能沿用粘贴前结果
if expected_fp is not None:
try:
final_full = self._capture_full_window()
except Exception:
final_full = None
if hasattr(self, "_composer_rel_top") and hasattr(self, "_list_x"):
final_composer_top = infer_composer_top(
final_full,
int(self._list_x + self._list_w),
self.scale,
)
composer_stable = bool(
final_composer_top is not None
and abs(
int(final_composer_top)
- int(self._composer_rel_top)
)
<= max(2, int(2 * self.scale))
)
else:
# Unit/legacy callers without connected-window geometry
# still rely on the identity/surface checks below.
composer_stable = True
final_fp = (
self._raw_selected_session_fingerprint()
or self._selected_session_fingerprint()
)
final_identity = self._chat_identity_signature()
final_surface = self._chat_surface_signature()
final_nav_ok = self._message_nav_selected(final_full)
if not final_nav_ok:
final_nav_ok = bool(self._target_chat_ready(expected_fp))
final_same_chat = bool(
final_nav_ok
and final_fp
and self._chat_target_matches(
expected_fp,
bytes(
pending_identity
or getattr(self, "_active_identity_signature", b"")
or b""
),
current_fp=final_fp,
current_identity=final_identity,
selected_fp_checked=True,
)
)
if final_same_chat and final_identity != bytes(
pending_identity
or getattr(self, "_active_identity_signature", b"")
or b""
):
final_same_chat = self._accept_selected_title_render(
expected_fp,
pending_state,
final_identity,
current_fp=final_fp,
selected_fp_checked=True,
)
if final_same_chat:
pending_identity = final_identity
final_surface_changed = bool(
not final_surface
or (
pending_surface
and final_surface != pending_surface
)
)
if not final_same_chat or not composer_stable or final_surface_changed:
if final_same_chat:
# 仍在原输入框,安全清掉尚未发送的旧草稿
pyautogui.hotkey('ctrl', 'a')
pyautogui.press('backspace')
if not composer_stable:
print(" [发送保护] 按 Enter 前输入面板尺寸发生变化,已清除旧草稿并重新校验。")
self._reset_pending_batch(expected_fp)
elif final_surface_changed:
print(" [发送保护] 按 Enter 前又收到新消息,已清除旧草稿并重新合并。")
self._reset_pending_batch(expected_fp)
else:
print(" [发送保护] 按 Enter 前聊天对象发生变化,已禁止发送。")
return False
if pending_state:
baseline_text = "\n".join(
str(line)
for line in pending_state.get("last_lines") or []
)
baseline_blocks = self._normalized_copied_blocks(
baseline_text
)
if not baseline_blocks:
pyautogui.hotkey('ctrl', 'a')
pyautogui.press('backspace')
print(" [发送保护] 无法建立发送前聊天锚点,已清除自动草稿并取消发送。")
return False
pending_state["send_state"] = "sending"
pending_state["reply_text"] = reply_text
pending_state["send_surface_signature"] = final_surface or pending_surface
pending_state["send_baseline_blocks"] = baseline_blocks
pending_state["send_reply_was_visible"] = any(
block_body == expected_reply
for _block_header, block_body in baseline_blocks
)
pending_state["send_known_outgoing_speakers"] = sorted(
self._known_outgoing_speakers()
)
pending_state["send_dispatched_at"] = time.time()
pending_state["updated_at"] = time.time()
if not self._persist_pending_replies():
pyautogui.hotkey('ctrl', 'a')
pyautogui.press('backspace')
for field in (
"send_state",
"reply_text",
"send_surface_signature",
"send_baseline_blocks",
"send_reply_was_visible",
"send_known_outgoing_speakers",
"send_dispatched_at",
):
pending_state.pop(field, None)
print(" [发送保护] 无法持久化发送事务,已清除草稿并取消发送。")
return False
try:
pyautogui.press('enter')
except Exception:
if pending_state:
pending_state["send_state"] = "uncertain"
if float(pending_state.get("uncertain_since", 0.0) or 0.0) <= 0:
pending_state["uncertain_since"] = time.time()
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
print(" [发送保护] 发送快捷键执行异常,已隔离当前任务且不会自动重发。")
return False
self._record_send(session_id)
if pending_state:
pending_state["send_state"] = "sent_uncommitted"
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
time.sleep(0.3)
finally:
if clipboard_saved:
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
self._end_bot_mouse()
if pending_state:
receipt_surface = bytes(
pending_state.get("send_surface_signature")
or pending_surface
or b""
)
receipt_blocks = self._stored_send_baseline(pending_state)
receipt_was_visible = bool(
pending_state.get("send_reply_was_visible", False)
)
same_chat = self._same_chat_is_open(
bytes(expected_fp or b""),
bytes(pending_identity or b""),
)
visible_sent = False
if same_chat and receipt_blocks is not None:
visible_sent = self._send_receipt_matches(
reply_text=reply_text,
customer_speaker=str(
pending_state.get("customer_speaker") or ""
),
before_surface=receipt_surface,
before_blocks=receipt_blocks,
reply_was_visible=receipt_was_visible,
)
if (
visible_sent is not True
and self._mouse_is_idle_now()
and self._try_ctrl_enter_for_retained_draft(
reply_text,
pending_state,
expected_fp,
bytes(pending_identity or b""),
)
):
pass
if visible_sent is not True and receipt_blocks is not None:
visible_sent = self._wait_for_visible_reply(
reply_text=reply_text,
customer_speaker=str(
pending_state.get("customer_speaker") or ""
),
before_surface=receipt_surface,
before_blocks=receipt_blocks,
reply_was_visible=receipt_was_visible,
expected_fp=bytes(expected_fp or b""),
expected_identity=bytes(pending_identity or b""),
)
if visible_sent is not True:
# Enter 可能因发送快捷键设置、焦点丢失或页面延迟而没有生效
# 结果未被“我方末条气泡”唯一证明时,隔离 uncertain
# 后续只做对账,绝不盲目重发,也不提前写档案与挂号登记
pending_state["send_state"] = "uncertain"
if float(pending_state.get("uncertain_since", 0.0) or 0.0) <= 0:
pending_state["uncertain_since"] = time.time()
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
print(" [发送保护] 发送后暂未确认我方消息,已保留本会话并后台对账;不会重复发送,也不会阻塞其他会话。")
return False
has_followup = getattr(
self,
"_last_send_receipt_followup",
None,
)
else:
has_followup = False
self._commit_staged_exchange(session_id)
if expected_fp is not None:
self._clear_reply_pending(expected_fp)
if has_followup is not False:
self._queue_followup_after_confirmed_send(expected_fp)
if has_followup is None:
print(" [消息续接] 回执画面中存在方向不明确的后续消息,已保留为下一轮待核对任务。")
if has_followup is False:
self._remember_active_surface(
expected_fp or self._selected_session_fingerprint()
)
return True
def _deselect_session(self):
"""
兼容旧调用的安全空操作。
旧版本会点击一个系统工具会话来取消客户会话选中,这会造成页面跳转,
还可能误点真实会话。现在由当前聊天页画面指纹负责捕获后续新消息,
因此必须保留客户聊天页,不再通过任何点击进行“取消选中”。
"""
return
def _scroll_session_list_top(self):
"""
把会话列表滚回最顶端。
企业微信收到新消息时会把对应会话【置顶到列表最上方】;
如果列表被用户翻到中间/下面,置顶的红点不在可视区内,
机器人既检测不到也点不到,新消息就漏回复了。
另外列表停在半格位置时,行号网格(session_item_h)的计算也会错位。
每次轮询前大幅上滚一次即可归位(已在顶部时滚动无副作用)。
"""
if not getattr(self, "_session_geometry_valid", True):
print(" [未读扫描] 会话列表几何无效,已禁止窗口外滚动。")
return
if not self.wait_for_mouse_idle():
return
cx = self.list_region['left'] + self.list_region['width'] // 2
cy = self.list_region['top'] + self.list_region['height'] // 2
if all(hasattr(self, name) for name in ("L", "T", "R", "B")) and not (
self.L <= cx < self.R and self.T <= cy < self.B
):
print(" [未读扫描] 会话列表滚动坐标超出窗口,已取消操作。")
return
old_pause = pyautogui.PAUSE
pyautogui.PAUSE = 0
self._begin_bot_mouse()
try:
pyautogui.moveTo(cx, cy)
pyautogui.scroll(50, cx, cy) # pyautogui 参数本身就是滚轮格数
# ★ 滚完把鼠标移出会话列表(停到右侧聊天区顶部):
# 鼠标悬停会让所在行出现悬停高亮,干扰截图取色(头像指纹/红点检测)
park_x = min(
max(self.L + 1, self._chat_region['left'] + self._chat_region['width'] // 2),
self.R - 1,
)
park_y = min(
max(self.T + 1, self._chat_region['top'] - int(10 * self.scale)),
self.B - 1,
)
pyautogui.moveTo(park_x, park_y)
except Exception as e:
print(f" [~] _scroll_session_list_top: {e}")
finally:
pyautogui.PAUSE = old_pause
self._end_bot_mouse()
time.sleep(0.15)
@staticmethod
def _session_page_signature(img: np.ndarray) -> bytes:
if img is None or getattr(img, "ndim", 0) != 3 or not getattr(img, "size", 0):
return b""
# 去掉易变化的最右滚动条并量化颜色;到底后重复截图会得到同一签名
width = max(1, img.shape[1] - 8)
sample = np.ascontiguousarray((img[::4, :width:4, :3] // 8).astype(np.uint8))
return hashlib.blake2b(sample.tobytes(), digest_size=16).digest()
def _session_pages_overlap_at_shift(
self,
before: np.ndarray,
after: np.ndarray,
shift: int,
) -> bool:
"""Prove that ``after`` is ``before`` scrolled upward by ``shift`` pixels."""
if (
before is None
or after is None
or getattr(before, "ndim", 0) != 3
or getattr(after, "ndim", 0) != 3
or before.shape[:2] != after.shape[:2]
):
return False
shift = int(shift)
height, width = before.shape[:2]
min_overlap = max(12, int(getattr(self, "session_item_h", SESSION_ITEM_H)))
if shift <= 0 or height - shift < min_overlap:
return False
# The scrollbar and its thumb do not move with list content. Compare only
# the content area, quantized enough to tolerate antialiasing noise.
scale = max(0.75, float(getattr(self, "scale", 1.0) or 1.0))
content_width = max(1, width - max(8, int(6 * scale)))
old = (before[shift:, :content_width, :3] // 8).astype(np.int16)
new = (after[:height - shift, :content_width, :3] // 8).astype(np.int16)
if not old.size or old.shape != new.shape:
return False
old_bg = np.median(old.reshape(-1, 3), axis=0)
new_bg = np.median(new.reshape(-1, 3), axis=0)
informative = (
(np.max(np.abs(old - old_bg), axis=2) >= 2)
| (np.max(np.abs(new - new_bg), axis=2) >= 2)
)
informative_count = int(informative.sum())
if informative_count < max(24, content_width):
return False
aligned = np.max(np.abs(old - new), axis=2) <= 1
return float(aligned[informative].mean()) >= 0.90
def _scroll_session_list_page(self, before: np.ndarray) -> np.ndarray | None:
"""向下翻一页并返回新截图;页面未变化(到底)时返回 None。"""
if not getattr(self, "_session_geometry_valid", True):
print(" [未读扫描] 会话列表几何无效,已禁止窗口外翻页。")
return None
if not self.wait_for_mouse_idle():
return None
cx = self.list_region["left"] + self.list_region["width"] // 2
cy = self.list_region["top"] + self.list_region["height"] // 2
if all(hasattr(self, name) for name in ("L", "T", "R", "B")) and not (
self.L <= cx < self.R and self.T <= cy < self.B
):
print(" [未读扫描] 会话列表翻页坐标超出窗口,已取消操作。")
return None
self._begin_bot_mouse()
try:
pyautogui.moveTo(cx, cy)
pyautogui.scroll(-SESSION_SCAN_SCROLL_CLICKS, cx, cy)
park_x = min(
max(self.L + 1, self._chat_region["left"] + self._chat_region["width"] // 2),
self.R - 1,
)
park_y = min(
max(self.T + 1, self._chat_region["top"] - int(10 * self.scale)),
self.B - 1,
)
pyautogui.moveTo(park_x, park_y)
except Exception as e:
print(f" [未读扫描] 翻页失败: {e}")
return None
finally:
self._end_bot_mouse()
time.sleep(0.25)
try:
after = self.capture_session_list()
except Exception:
return None
if self._session_page_signature(after) == self._session_page_signature(before):
return None
return after
def _global_unread_signature(self, full: np.ndarray) -> bytes:
"""返回“消息”导航行中的全局未读徽章签名;空字节表示没有全局未读。"""
if full is None or getattr(full, "ndim", 0) != 3:
return b""
scale = max(0.75, float(self.scale or 1.0))
y1 = max(0, int(55 * scale))
y2 = min(full.shape[0], max(y1 + 1, int(110 * scale)))
x2 = min(full.shape[1], max(1, int(self._list_x)))
roi = full[y1:y2, :x2, :3]
if roi.size == 0:
return b""
red = roi[:, :, 2].astype(np.int16)
green = roi[:, :, 1].astype(np.int16)
blue = roi[:, :, 0].astype(np.int16)
mask = (
(red >= BADGE_R_MIN)
& (red <= BADGE_R_MAX)
& (green <= BADGE_G_MAX)
& (blue <= BADGE_B_MAX)
)
if int(mask.sum()) < MIN_RED_PIXELS:
return b""
ys, xs = np.where(mask)
bounds = np.array(
[int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max()), int(mask.sum())],
dtype=np.int32,
)
return hashlib.blake2b(bounds.tobytes(), digest_size=12).digest()
def _deep_unread_scan_allowed(self, full: np.ndarray) -> bool:
signature = self._global_unread_signature(full)
if not signature:
self._last_global_unread_signature = b""
return False
now = time.monotonic()
changed = signature != getattr(self, "_last_global_unread_signature", b"")
elapsed = now - float(getattr(self, "_last_session_scan_ts", 0.0) or 0.0)
if not changed and elapsed < SESSION_SCAN_COOLDOWN_SECONDS:
return False
self._last_global_unread_signature = signature
self._last_session_scan_ts = now
return True
def _target_from_session_image(
self,
img: np.ndarray,
processed_fp: set,
non_conv_fp: set,
) -> tuple[int, bytes] | None:
for badge_y in self.detect_badge_rows(img):
fp = self._session_fingerprint(img, badge_y)
if fp in processed_fp or fp in non_conv_fp:
continue
# 自动点击的身份必须同时包含头像和名称;空/旧式头像键不能作
# “未知方形头像”的放行依据
if len(fp) != _SESSION_FP_BYTES:
non_conv_fp.add(fp)
continue
if self._flat_session_rejected(fp):
non_conv_fp.add(fp)
continue
rounded_conversation = self._is_real_conversation(
img,
badge_y,
quiet=True,
allow_flat=True,
)
requires_visual_proof = (
not rounded_conversation
or self._flat_row_requires_visual_proof(
img,
badge_y,
fp,
row_center=False,
)
)
if requires_visual_proof:
# detect_badge_rows 已提供真实未读事件,fp 又包含头像与名称
# 即使头像没有可见圆角也先保留任务;点击后仍须通过消息页
# 标题、选中行和聊天区校验,系统入口会被连续两帧导航检查排除
proof_fps = getattr(self, "_flat_visual_proof_fps", None)
if proof_fps is None:
proof_fps = self._flat_visual_proof_fps = set()
proof_fps.add(fp.hex())
candidate_kind = "图标型头像" if not rounded_conversation else "纯色文字头像"
print(
f" [会话识别] 检测到未读{candidate_kind}候选,"
"将先确认消息页与会话身份再回复。"
)
return int(badge_y), fp
return None
def _find_next_unread_session(
self,
processed_fp: set,
non_conv_fp: set,
full: np.ndarray = None,
) -> tuple[np.ndarray, int, bytes] | None:
"""查当前页;必要时从顶部逐页向下查找未置顶、屏外的未读会话。"""
try:
current = self.capture_session_list()
except Exception:
return None
target = self._target_from_session_image(current, processed_fp, non_conv_fp)
if target is not None:
self._unread_scan_resume = False
return current, target[0], target[1]
try:
full = full if full is not None else self._capture_full_window()
except Exception:
full = None
resuming = bool(getattr(self, "_unread_scan_resume", False))
scan_allowed = self._deep_unread_scan_allowed(full)
if not scan_allowed and resuming:
# The cooldown prevents repeatedly restarting a full top-down scan,
# but it must not cancel one that already made forward progress.
# Continue only while the global unread badge is still visible.
try:
scan_allowed = bool(self._global_unread_signature(full))
except Exception:
scan_allowed = False
if not scan_allowed:
if resuming:
self._unread_scan_resume = False
self._scroll_session_list_top()
return None
if resuming:
print(" [未读扫描] 继续从上轮停留位置向下查找未置顶未读…")
page = current
else:
print(" [未读扫描] 当前可视区没有客户红点,开始从列表顶部逐页查找未置顶未读…")
self._scroll_session_list_top()
try:
page = self.capture_session_list()
except Exception:
return None
seen = set()
scan_started = time.monotonic()
scan_complete = False
for page_index in range(SESSION_SCAN_MAX_PAGES):
if page_index and time.monotonic() - scan_started >= SESSION_SCAN_MAX_SECONDS:
break
page_signature = self._session_page_signature(page)
if not page_signature or page_signature in seen:
scan_complete = True
break
seen.add(page_signature)
target = self._target_from_session_image(page, processed_fp, non_conv_fp)
if target is not None:
self._unread_scan_resume = False
print(f" [未读扫描] 在第 {page_index + 1} 页找到客户未读。")
return page, target[0], target[1]
next_page = self._scroll_session_list_page(page)
if next_page is None:
scan_complete = True
break
page = next_page
if scan_complete:
self._unread_scan_resume = False
self._scroll_session_list_top()
print(" [未读扫描] 已扫到列表底部,本轮没有找到可回复的客户未读。")
else:
self._unread_scan_resume = True
print(" [未读扫描] 列表较长,本轮已达到 45 秒安全扫描上限;下轮从当前位置继续。")
return None
def _pending_rows_on_page(self, img: np.ndarray, target_fp: bytes) -> list[int]:
"""Return every row matching a current or previous-version composite key."""
if (
img is None
or getattr(img, "ndim", 0) != 3
or len(target_fp) not in (_LEGACY_SESSION_FP_BYTES, _SESSION_FP_BYTES)
):
return []
item_h = max(1, int(self.session_item_h))
phases = {item_h // 2}
phase_proven = False
try:
selected = self.detect_selected_row(img)
if selected >= 0:
phases.add(int(selected) % item_h)
phase_proven = True
except Exception:
pass
try:
for badge_y in self.detect_badge_rows(img):
phases.add(self._row_center_from_badge(img, badge_y) % item_h)
phase_proven = True
except Exception:
pass
drift = max(1, int(3 * max(0.75, float(self.scale or 1.0))))
tested = set()
matched_rows = []
pending_state = self._pending_reply_state(target_fp)
allow_unrounded_pending = bool(
len(target_fp) == _SESSION_FP_BYTES
and pending_state
and pending_state.get("requires_visual_proof")
and not self._flat_session_rejected(target_fp)
)
def remember_if_target(y: int, *, canonical: bool = True) -> None:
y = int(y)
if y in tested or y < 1 or y >= img.shape[0] - 1:
return
tested.add(y)
if canonical:
if len(target_fp) == _LEGACY_SESSION_FP_BYTES:
candidate = self._legacy_session_fingerprint(
img,
y,
row_center=True,
)
matches = candidate == target_fp
else:
candidate = self._session_fingerprint(
img,
y,
row_center=True,
)
matches = self._session_fp_matches(candidate, target_fp)
if (
not matches
and candidate
and self._remember_live_render_alias(candidate, target_fp)
):
matches = True
else:
# Exhaustive fallback must not register hundreds of off-row samples
# as known identities. Compare the raw avatar plus exact name hash.
avatar = self._raw_session_fingerprint(
img,
y,
row_center=True,
)
if len(avatar) != _AVATAR_FP_BYTES:
return
avatar_distance = (
int.from_bytes(avatar, "big")
^ int.from_bytes(target_fp[:_AVATAR_FP_BYTES], "big")
).bit_count()
if avatar_distance > self._FP_HAMMING_TOL:
return
if len(target_fp) == _LEGACY_SESSION_FP_BYTES:
name = self._legacy_session_name_fingerprint(
img,
y,
row_center=True,
)
else:
name = self._session_name_fingerprint(
img,
y,
row_center=True,
)
matches = bool(name) and name == target_fp[_AVATAR_FP_BYTES:]
if not matches:
return
conversation_shape_ok = self._is_real_conversation(
img,
y,
quiet=True,
row_center=True,
# The fingerprint already proves this is the saved pending target.
# Permit WeCom's rounded solid-color text avatars here while the
# corner test still rejects non-rounded system tiles.
allow_flat=True,
)
if not conversation_shape_ok and not allow_unrounded_pending:
return
if not any(
abs(y - previous) <= max(drift * 2, item_h // 3)
for previous in matched_rows
):
matched_rows.append(y)
for phase in phases:
for base_y in range(int(phase), img.shape[0], item_h):
for y in (base_y, base_y - drift, base_y + drift):
remember_if_target(y)
if not matched_rows and not phase_proven:
# A wheel-scrolled list can begin with a clipped row, so its row-center
# phase is unrelated to item_h//2. A read pending row has no badge from
# which to infer that phase. Probe every remaining vertical position;
# the avatar prefilter keeps the more expensive 256-bit name comparison
# limited to plausible rows.
for y in range(1, img.shape[0] - 1):
remember_if_target(y, canonical=False)
return matched_rows
def _pending_row_on_page(self, img: np.ndarray, target_fp: bytes) -> int | None:
"""Compatibility wrapper that only returns a page-local unique match."""
matched_rows = self._pending_rows_on_page(img, target_fp)
if len(matched_rows) == 1:
return matched_rows[0]
if len(matched_rows) > 1:
print(" [待回复恢复] 多个列表行具有同一视觉身份,已拒绝自动选择。")
return None
def _find_pending_session(
self,
target_fp: bytes,
) -> tuple[np.ndarray, int] | None:
"""Search all pages, resuming safely when one poll reaches its time budget."""
key = bytes(target_fp or b"").hex()
progress_by_key = getattr(self, "_pending_scan_progress", None)
if progress_by_key is None:
progress_by_key = self._pending_scan_progress = {}
self._pending_scan_incomplete = False
progress = progress_by_key.get(key)
if progress is not None:
try:
page = self.capture_session_list()
except Exception:
return None
expected_signature = bytes(progress.get("page_signature") or b"")
if (
not expected_signature
or self._session_page_signature(page) != expected_signature
):
# A new unread can reorder the list while a long scan is paused.
# Accumulated page indexes are then no longer trustworthy, so
# restart from the top instead of replaying a stale coordinate.
progress_by_key.pop(key, None)
progress = None
print(" [待回复恢复] 扫描期间会话列表发生变化,已从顶部重新校验。")
if progress is None:
self._scroll_session_list_top()
try:
page = self.capture_session_list()
except Exception:
return None
seen = set()
matches = []
previous_page = None
previous_occurrences = []
page_index = 0
else:
seen = set(progress.get("seen") or set())
matches = list(progress.get("matches") or [])
previous_page = progress.get("previous_page")
previous_occurrences = list(
progress.get("previous_occurrences") or []
)
page_index = max(0, int(progress.get("page_index", 0) or 0))
# ``matches`` contains physical conversations, not screenshots. With a
# small wheel step the same list row is visible on several adjacent pages;
# carry its cluster id forward only when the page pixels prove the exact
# vertical scroll relationship. Visually identical but separate rows stay
# as separate clusters and therefore keep the existing fail-closed policy.
scan_started = time.monotonic()
scan_complete = False
processed_this_call = 0
while page_index < SESSION_SCAN_MAX_PAGES:
if (
processed_this_call
and time.monotonic() - scan_started >= SESSION_SCAN_MAX_SECONDS
):
break
signature = self._session_page_signature(page)
if not signature or signature in seen:
scan_complete = True
break
seen.add(signature)
current_occurrences = []
used_clusters = set()
overlap_cache = {}
for row_center in self._pending_rows_on_page(page, target_fp):
predecessor_clusters = set()
if previous_page is not None:
for previous_row, cluster_id in previous_occurrences:
shift = int(previous_row) - int(row_center)
if shift <= 0:
continue
if shift not in overlap_cache:
overlap_cache[shift] = self._session_pages_overlap_at_shift(
previous_page,
page,
shift,
)
if overlap_cache[shift]:
predecessor_clusters.add(cluster_id)
if (
len(predecessor_clusters) == 1
and next(iter(predecessor_clusters)) not in used_clusters
):
cluster_id = next(iter(predecessor_clusters))
else:
cluster_id = len(matches)
matches.append((page_index, signature, int(row_center)))
used_clusters.add(cluster_id)
current_occurrences.append((int(row_center), cluster_id))
previous_page = page
previous_occurrences = current_occurrences
processed_this_call += 1
next_page = self._scroll_session_list_page(page)
if next_page is None:
scan_complete = True
break
page = next_page
page_index += 1
# 不能在第一页命中后立刻点击:同头像同名称的另一个会话可能在后续页
# 必须扫完整个列表并证明全局唯一,再回放到目标页重新截图校验
if not scan_complete:
if page_index >= SESSION_SCAN_MAX_PAGES:
progress_by_key.pop(key, None)
self._scroll_session_list_top()
print(" [待回复恢复] 会话列表超过安全扫描页数,已禁止猜测目标。")
return None
progress_by_key[key] = {
# ``page`` is the next unprocessed page and the UI is deliberately
# left there. The next polling turn must recapture the same page
# before it may reuse any accumulated uniqueness evidence.
"page_signature": self._session_page_signature(page),
"page_index": page_index,
"seen": set(seen),
"matches": list(matches),
"previous_page": previous_page,
"previous_occurrences": list(previous_occurrences),
}
self._pending_scan_incomplete = True
print(" [待回复恢复] 列表较长,本轮达到扫描上限;下轮从当前位置继续。")
return None
progress_by_key.pop(key, None)
self._scroll_session_list_top()
if len(matches) != 1:
if len(matches) > 1:
print(" [待回复恢复] 跨分页发现多个相同视觉身份,已拒绝自动选择。")
return None
target_page_index, expected_page_signature, _old_row = matches[0]
try:
replay_page = self.capture_session_list()
except Exception:
return None
for _ in range(target_page_index):
replay_page = self._scroll_session_list_page(replay_page)
if replay_page is None:
self._scroll_session_list_top()
return None
if self._session_page_signature(replay_page) != expected_page_signature:
print(" [待回复恢复] 回放列表时页面内容已变化,本轮取消选择。")
self._scroll_session_list_top()
return None
replay_rows = self._pending_rows_on_page(replay_page, target_fp)
if len(replay_rows) != 1:
self._scroll_session_list_top()
return None
return replay_page, replay_rows[0]
# 反复恢复失败的任务必须逐步让路。翻页扫描一轮最多占 SESSION_SCAN_MAX_SECONDS
# 如果每一轮都为同一个认不回来的任务重扫,新到的未读会话就永远排不上队,表现
# 就是「回复几次之后彻底不回了」。计数只留在内存里:重启后重试一次是合理的,
# 不值得为它去改持久化字段白名单
_RESUME_BACKOFF_MAX_SECONDS = 600.0
def _resume_cooldown_seconds(self, state: dict) -> float:
"""恢复失败越多次,下一次重试等得越久。"""
try:
failures = int(state.get("resume_failures", 0) or 0)
except (TypeError, ValueError):
failures = 0
if failures <= 0:
return SESSION_SCAN_COOLDOWN_SECONDS
return min(
self._RESUME_BACKOFF_MAX_SECONDS,
SESSION_SCAN_COOLDOWN_SECONDS * float(2 ** min(failures, 8)),
)
def _note_resume_failure(self, state: dict) -> None:
try:
failures = int(state.get("resume_failures", 0) or 0)
except (TypeError, ValueError):
failures = 0
state["resume_failures"] = failures + 1
@staticmethod
def _pending_queue_order(pending: dict, active_fp: bytes | None) -> list:
"""当前会话先收尾,其余按到达先后排队,一次只服务一个。
过去按字典顺序取任务:正在回复的会话如果排在后面,就会被另一个会话
抢先点走——当前这条回复只能等下一轮重来,客户看到的就是迟迟不回。
`created_at` 会落盘,重启后排队顺序依然成立。
"""
active_key = bytes(active_fp or b"").hex()
def arrival(item) -> float:
state = item[1]
try:
return float(state.get("created_at", 0.0) or 0.0)
except (TypeError, ValueError):
return 0.0
return sorted(
list(pending.items()),
key=lambda item: (0 if item[0] == active_key else 1, arrival(item)),
)
# 当前会话最多独占多久。一次正常回复是「合并 2s + 模型 5~15s + 发送 3s」,
# 60 秒够重试两三轮;再长就不是"还没回完"而是卡住了,继续压着只会让后面
# 排队的客户一起陪葬,所以到点放行,让队列先走,稍后再回来收它
_ACTIVE_SESSION_HOLD_SECONDS = 60.0
def _hold_for_unfinished_active_session(self) -> bool:
"""Keep this round on the open chat until its reply is actually finished.
`_resume_orphaned_pending_reply` 对当前会话是直接跳过的(本轮已由
`_check_selected_session` 处理过)。少了这道闸,当前会话这一轮没回成
就会立刻掉进未读扫描、点开别人——这条回复只能等下一轮从头再来,客户
看到的就是"回一半不回了"。
"""
pending = getattr(self, "_pending_reply_sessions", {})
key = bytes(getattr(self, "_active_session_fp", None) or b"").hex()
if not key or key not in pending:
self._active_hold_key = ""
self._active_hold_since = 0.0
self._active_hold_expired = False
return False
now = time.monotonic()
if getattr(self, "_active_hold_key", "") != key:
self._active_hold_key = key
self._active_hold_since = now
self._active_hold_expired = False
return True
if getattr(self, "_active_hold_expired", False):
return False
waited = now - float(getattr(self, "_active_hold_since", 0.0) or 0.0)
if waited < self._ACTIVE_SESSION_HOLD_SECONDS:
return True
self._active_hold_expired = True
print(
f" [排队] 当前会话已占用 {waited:.0f} 秒仍未回完,先放行其他会话,"
"稍后再回来处理它。"
)
return False
def _resume_orphaned_pending_reply(self) -> bool:
"""Reopen one read-but-unreplied conversation and continue its saved batch.
返回 True 表示本轮已经在某个会话上做完实际动作(页面已经停在它上面),
`_poll_once` 据此结束本轮。找不到目标之类的「没做成任何事」必须返回
False,否则一个永远认不回来的任务会把未读扫描整个挡掉。
"""
pending = getattr(self, "_pending_reply_sessions", {})
if not pending:
return False
now = time.monotonic()
active_fp = getattr(self, "_active_session_fp", None)
try:
visible_identity = self._chat_identity_signature()
except Exception:
visible_identity = b""
active_identity = getattr(self, "_active_identity_signature", None)
for key, state in self._pending_queue_order(pending, active_fp):
try:
target_fp = bytes.fromhex(key)
except (TypeError, ValueError):
pending.pop(key, None)
self._persist_pending_replies()
continue
if self._session_is_unrepliable(target_fp):
# 已经证实没有输入框,连打开都不必,免得每轮都被它占满
self._clear_reply_pending(target_fp)
continue
legacy_pending = len(target_fp) == _LEGACY_SESSION_FP_BYTES
if legacy_pending and not state.get("identity_signature"):
print(" [待回复恢复] 上一版任务缺少聊天标题证据,已保留但禁止自动点击发送。")
continue
# 当前会话刚刚已由 _check_selected_session 处理,本轮不重复请求
if (
target_fp == active_fp
and visible_identity
and active_identity
and self._chat_target_matches(
target_fp,
bytes(state.get("identity_signature") or active_identity),
current_identity=visible_identity,
)
):
expected_active_identity = bytes(
state.get("identity_signature") or active_identity
)
if (
visible_identity != expected_active_identity
and not self._accept_selected_title_render(
target_fp,
state,
visible_identity,
)
):
continue
send_reconciliation = self._reconcile_uncertain_send(target_fp, state)
if send_reconciliation == "sent":
return True
if send_reconciliation == "uncertain":
# Isolate only this transaction. Keep walking the queue so
# one delayed receipt cannot stop every other customer.
continue
continue
# Any off-screen transaction with a dispatched/possibly-dispatched
# keypress is read-only. Reopening it through a long paginated
# scan can block ordinary unsent customers and still cannot prove
# whether Enter ran. Reconcile it opportunistically only when the
# exact chat is already selected.
if str(state.get("send_state") or "") in {
"sending",
"sent_uncommitted",
"uncertain",
}:
continue
last_attempt = float(state.get("last_resume_attempt", 0.0) or 0.0)
scan_in_progress = key in getattr(self, "_pending_scan_progress", {})
if (
not scan_in_progress
and now - last_attempt < self._resume_cooldown_seconds(state)
):
continue
state["last_resume_attempt"] = now
state["updated_at"] = time.time()
self._persist_pending_replies()
if not self._send_gate_open():
return False
found = self._find_pending_session(target_fp)
if found is None:
# A long list is scanned in bounded chunks. Stop walking the
# queue here so another pending task cannot move the list and
# invalidate the current target's accumulated page evidence
# 但必须返 False,让本轮继续去处理新到的未读会话
if getattr(self, "_pending_scan_incomplete", False):
return False
self._note_resume_failure(state)
continue
_page, row_center = found
print(" [待回复恢复] 找到已读未回复会话,正在按头像和名称复合指纹重新打开。")
if not self.click_session(
row_center,
expected_fp=target_fp,
row_center=True,
):
# 没能重新打开目标,本轮什么都没做成,不能因此吃掉未读扫描
self._note_resume_failure(state)
return False
if not state.get("identity_signature"):
# Pre-click reservations intentionally have no title: bind it
# only after the exact pending row has been reopened safely.
self._mark_reply_pending(target_fp)
state = self._pending_reply_state(target_fp) or state
expected_identity = state.get("identity_signature")
current_identity = self._chat_identity_signature()
if legacy_pending:
identity_matches = bool(
current_identity
and expected_identity
and current_identity == expected_identity
)
else:
identity_matches = self._chat_target_matches(
target_fp,
bytes(expected_identity or b""),
current_identity=current_identity,
)
if not identity_matches:
# 相同/默认头像可能属于不同联系人。标题不一致时绝不复用缓存文本
# 留住 pending 并等待后续列表变化或人工回到原会话
print(" [待回复恢复] 头像相同但聊天标题不一致,已禁止回复。")
self._selected_tracking_initialized = False
self._active_session_fp = None
self._active_chat_signature = None
self._active_identity_signature = None
continue
if (
not legacy_pending
and current_identity != bytes(expected_identity or b"")
and not self._accept_selected_title_render(
target_fp,
state,
current_identity,
)
):
print(" [待回复恢复] 标题变化未能通过复合会话指纹确认,已保留任务。")
continue
if legacy_pending:
current_fp = self._selected_session_fingerprint()
if not current_fp or len(current_fp) != _SESSION_FP_BYTES:
print(" [待回复恢复] 无法把上一版任务绑定到新版会话指纹,已禁止回复。")
self._note_resume_failure(state)
return True
migration_text = str(state.get("chat_text") or "")
if not migration_text:
migration_text = "\n".join(state.get("last_lines") or [])
self._ensure_session_archive_key(current_fp, migration_text)
migrated_state = self._pending_reply_state(current_fp)
if migrated_state is None:
print(" [待回复恢复] 上一版任务归属未能唯一证明,已保留但禁止回复。")
self._note_resume_failure(state)
return True
target_fp = current_fp
state = migrated_state
send_reconciliation = self._reconcile_uncertain_send(target_fp, state)
if send_reconciliation == "sent":
return True
if send_reconciliation == "uncertain":
continue
# 以下失败都发生在目标会话已经重新打开之后:页面就停在它上面,
# 立刻去点别的未读会显得来回横跳,所以仍然结束本轮;但都记一次失败,
# 让退避生效,避免同一个任务把之后每一轮都占满
if not self._session_accepts_replies(target_fp):
self._note_resume_failure(state)
return False
if not state.get("batch_ready", False):
if not self._wait_for_message_batch(target_fp):
self._note_resume_failure(state)
return True
self._mark_reply_pending(target_fp, batch_ready=True)
reply_text = self._generate_ai_reply(
target_fp,
confirmed_unread=bool(state.get("confirmed_unread", False)),
)
if not reply_text:
self._note_resume_failure(state)
return True
if not self._activate_wx():
self._note_resume_failure(state)
return True
time.sleep(0.2)
if self.send_reply(
reply_text,
session_id=target_fp.hex(),
expected_fp=target_fp,
):
state.pop("resume_failures", None)
else:
self._note_resume_failure(state)
return True
return False
def detect_selected_row(self, img: np.ndarray) -> int:
"""
检测会话列表中是否有会话处于「选中」状态(蓝色高亮行)。
返回选中行的相对 Y 中心;没有选中行返回 -1。
采样每行右侧边缘的背景色:选中行为高饱和蓝色,未选中为白色/浅灰(悬停)。
"""
strip_w = max(2, int(12 * self.scale))
x1 = max(0, img.shape[1] - int(28 * self.scale))
x2 = min(img.shape[1], x1 + strip_w)
if x2 <= x1:
return -1
strip = img[:, x1:x2, :3].astype(np.int16)
blue, green, red = strip[:, :, 0], strip[:, :, 1], strip[:, :, 2]
selected_rows = (
(blue > 160) & (blue - red > 45) & (blue - green > 18)
).mean(axis=1) >= 0.45
indices = np.where(selected_rows)[0]
if not len(indices):
return -1
group = [int(indices[0])]
groups = []
for row in indices[1:]:
row = int(row)
if row - group[-1] <= 2:
group.append(row)
else:
groups.append(group)
group = [row]
groups.append(group)
min_height = max(4, int(8 * self.scale))
for rows in groups:
if len(rows) >= min_height:
return int(round((rows[0] + rows[-1]) / 2))
return -1
def _clipboard_tail_is_explicit_customer_text(self, chat_text: str) -> bool:
"""True only when copied text explicitly ends with a non-agent speaker block."""
lines = [line.strip() for line in str(chat_text or "").splitlines() if line.strip()]
last_header_index = -1
last_speaker = ""
for index, line in enumerate(lines):
match = self._speaker_header_match(line)
if match:
last_header_index = index
last_speaker = match.group("speaker").strip()
if last_header_index < 0 or last_header_index >= len(lines) - 1:
return False
try:
from ai_config import AI_AGENT_NAME
agent_name = str(AI_AGENT_NAME or "").strip()
except ImportError:
agent_name = ""
return bool(last_speaker) and not (
last_speaker in self._known_outgoing_speakers()
or (agent_name and agent_name in last_speaker)
)
def _has_pending_customer_message(self, chat_text: str, fp: bytes) -> bool:
"""
判断当前打开会话的【最后一条消息】是否为对方发的、尚未回复的新消息。
只接受已配置/已由右侧气泡证明的我方身份,绝不以正文相同推断方向。
"""
if not chat_text:
return False
lines = [l.strip() for l in chat_text.splitlines() if l.strip()]
# 纯时间戳/日期/系统提示行不算消息内容
meta_pat = re.compile(
r'^(\d{1,2}:\d{2}(:\d{2})?)$'
r'|^(\d{1,2}月\d{1,2}日.*)$'
r'|^(昨天.*|星期.*)$'
r'|^以上是打招呼内容$'
r'|^你已添加了.*$'
)
content = [l for l in lines if not meta_pat.match(l)]
if not content:
return False
# 解析「说话人 时间」头部(群聊/部分版本的复制格式)
last_speaker = ""
for line in lines:
match = self._speaker_header_match(line)
if match:
last_speaker = match.group("speaker").strip()
known_agent_speakers = self._known_outgoing_speakers()
try:
from ai_config import AI_AGENT_NAME
agent_name = str(AI_AGENT_NAME or "").strip()
except ImportError:
agent_name = ""
if last_speaker and (
last_speaker in known_agent_speakers
or (agent_name and agent_name in last_speaker)
):
return False
# 配置昵称可能与企微实际发送者名称不同。若文字证据仍无法定向,使用
# 当前最末气泡的左右位置作最后一道保护,避免把人工发出的右侧消息再回复
outgoing_side = self._last_visible_bubble_is_outgoing()
if outgoing_side is True:
return False
return True
def _check_selected_session(self, img: np.ndarray = None) -> bool:
"""
只读监控当前打开的客户会话,处理「选中期间新消息没有红点」的问题。
首次看到或人工切换会话时只建立画面基线;后续消息区域没有变化时完全
不移动鼠标。只有同一个会话的消息画面发生变化时,才提取文字并判断回复。
"""
try:
full = self._capture_full_window()
except Exception:
full = None
if not self._message_nav_selected(full):
candidate_fps = []
active_fp = getattr(self, "_active_session_fp", None)
if active_fp:
candidate_fps.append(active_fp)
for key in getattr(self, "_pending_reply_sessions", {}):
try:
candidate = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if candidate not in candidate_fps:
candidate_fps.append(candidate)
matched_expected = None
ready_fp = None
for candidate in candidate_fps:
ready_fp = self._target_chat_ready(candidate)
if ready_fp:
matched_expected = candidate
break
if not ready_fp:
self._ensure_message_workspace("会话守护", full=full)
return False
self._confirm_flat_session(
ready_fp,
self._pending_reply_state(matched_expected),
)
try:
img = img if img is not None else self.capture_session_list()
sel_y = self.detect_selected_row(img)
except Exception:
return False
current_identity = self._chat_identity_signature()
if not current_identity:
if getattr(self, "_selected_tracking_initialized", False):
self._run_ai_page_guard("聊天标题不可识别")
return False
requires_visual_proof = False
if sel_y >= 0:
fp = self._session_fingerprint(img, sel_y, row_center=True)
requires_visual_proof = self._flat_row_requires_visual_proof(
img,
sel_y,
fp,
row_center=True,
)
# 系统工具页不读取、不点击,也不作为当前客户会话跟踪
if self._is_tool_selected(img, sel_y):
return False
elif (
self._active_session_fp is not None
and current_identity
and current_identity == self._active_identity_signature
):
# 部分企业微信主题没有明显的蓝色选中行;机器人自己打开过会话后
# 可用标题指纹继续安全跟踪,而不依赖选中背景色
fp = self._active_session_fp
else:
# 标题发生变化说明人工切换了聊天或页面;没有可靠会话标识时停止跟踪
self._selected_tracking_initialized = False
self._active_session_fp = None
self._active_chat_signature = None
self._active_identity_signature = None
return False
pending_state = self._pending_reply_state(fp)
if pending_state is None and len(fp) == _SESSION_FP_BYTES:
transition_matches = []
for key, state in getattr(self, "_pending_reply_sessions", {}).items():
try:
pending_fp = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if len(pending_fp) != _SESSION_FP_BYTES:
continue
if (
self._session_fp_matches(fp, pending_fp)
or self._live_render_transition_match(fp, pending_fp)
):
transition_matches.append((pending_fp, state))
if len(transition_matches) == 1:
pending_fp, pending_state = transition_matches[0]
self._remember_live_render_alias(fp, pending_fp)
fp = pending_fp
if not pending_state.get("identity_signature"):
pending_state["identity_signature"] = current_identity
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
print(" [待回复恢复] 已按持久化渲染身份找回红点消失前的会话任务。")
elif len(transition_matches) > 1:
print(" [待回复恢复] 多个未完成任务具有相同渲染身份,已拒绝自动绑定。")
self._ensure_session_archive_key(fp)
signature = self._chat_surface_signature()
if not signature:
return False
if pending_state is None:
pending_state = self._pending_reply_state(fp)
expected_identity = (pending_state or {}).get("identity_signature")
if expected_identity:
if not self._chat_target_matches(
fp,
bytes(expected_identity),
current_identity=current_identity,
):
print(" [待回复恢复] 当前聊天标题与原待回复会话不一致,已禁止回复。")
return False
if (
current_identity != expected_identity
and not self._accept_selected_title_render(
fp,
pending_state,
current_identity,
)
):
print(" [待回复恢复] 标题变化未能通过复合会话指纹确认,已保留任务。")
return False
if pending_state is not None:
send_reconciliation = self._reconcile_uncertain_send(fp, pending_state)
if send_reconciliation == "sent":
return True
if send_reconciliation == "uncertain":
return False
# “sending + 发送前后画面相同”才可证 Enter 未生效。此时事务已
# 退回普 pending,并重新走合并、生成和发送保护
pending_state = self._pending_reply_state(fp)
startup_chat_text = None
# 启动时已有会话打开,或人工切换到了另一个会话:通常只建立基线
# 但若当前可见内容以一条没人回过的客户消息收尾,说明这是之前被点击
# 已读却没有完成回复的客户消息,必须恢复而不能再次吞掉
#
# 判断“是否仍在跟同一个会话”必须走 `_session_fp_matches`:同一个联系人
# 头像哈希在两帧之间会漂几位(6 位容差正是为此存在),未读粗体与选中常规
# 体的栅格差异也只 `_live_session_fp_aliases` 消化。裸 `!=` 把这两层全绕
# 过去,同一个人每次重新渲染都会被当成“刚切过来的新会话”,本轮到达的消
# 于是被并进基线吞掉,客户必须再发一条才能得到回复
tracking_same_session = bool(
self._selected_tracking_initialized
and self._active_session_fp
and self._session_fp_matches(bytes(self._active_session_fp), fp)
)
if not tracking_same_session and pending_state is None:
self._selected_tracking_initialized = True
self._active_session_fp = fp
self._active_identity_signature = current_identity
self._active_chat_signature = signature
if self._activate_wx():
try:
startup_chat_text = self.extract_chat_text(screens=1)
except Exception:
startup_chat_text = ""
if startup_chat_text:
# Visible speaker evidence can safely migrate an older archive
# key before comparing snapshots.
self._ensure_session_archive_key(fp, startup_chat_text)
recovered_delta = []
try:
if startup_chat_text:
visible_lines = [
line for line in startup_chat_text.splitlines()
if line.strip()
]
# 没有档案的会话取不到快照,此时整屏都算新增;究竟有没有人
# 在等回复,仍 `_has_pending_customer_message` 按最后一
# 消息的方向判定,绝不会因为“没建过档”就把消息直接吞掉
# `last_lines` 会为不存在的键建出空档案,必须先判存在。)
previous_lines = (
self.store.last_lines(fp.hex())
if self.store.has_record(fp.hex())
else []
)
recovered_delta = self._delta_lines(
previous_lines,
visible_lines,
)
except Exception:
recovered_delta = []
recovered_text = "\n".join(recovered_delta)
if (
recovered_text
and self._has_pending_customer_message(recovered_text, fp)
):
self._mark_reply_pending(
fp,
requires_visual_proof=requires_visual_proof,
)
pending_state = self._pending_reply_state(fp)
print("\n[待回复恢复] 发现已读但没有回复的客户消息,正在恢复本次回复...")
else:
print(" [会话守护] 已建立当前聊天页基线;画面未变化时不会操作鼠标。")
return False
if pending_state is not None and not self._session_fp_matches(
bytes(self._active_session_fp or b""),
fp,
):
# 人工切走后又回到这条已读未回复会话:以当前可靠选中行重新绑定,
# 后续发送保护仍会再次校验标题和头像指纹
self._selected_tracking_initialized = True
self._active_session_fp = fp
self._active_identity_signature = current_identity
self._active_chat_signature = signature
if self._active_chat_signature == signature and pending_state is None:
return False
if pending_state is not None:
print("\n[会话守护] 当前会话仍有已读未回复任务,正在安全重试...")
else:
print("\n[会话守护] 当前聊天页出现新内容,检查是否为客户未回复消息...")
if not self._activate_wx():
return False
# 只提取最新一屏做判断(增量上下文由会话档案提供,无需翻屏)
chat_text = (
startup_chat_text
if startup_chat_text is not None
else self.extract_chat_text(screens=1)
)
text_pending = bool(str(chat_text or "").strip()) and self._has_pending_customer_message(
chat_text,
fp,
)
# 图片、动画表情和语音气泡经常不会进入剪贴板,此时只能看到画面
# 指纹变化,或复制到的仍是上一轮文字。不能再把它当页面异常直接丢弃;
# 生成阶段会用聊天区局部截图判断是否确有新的客户媒体消息
# 当前选中会话没有可靠红点事件 ID;既然画面签名已经变化,即使剪贴
# 只复制到一句配图文字,也要让视觉模型确认是否同时存在图片或表情
force_media_check = True
if not str(chat_text or "").strip():
print(" [会话守护] 页面有变化但剪贴板为空,将按非文字消息检查。")
elif force_media_check:
print(" [会话守护] 复制文字未证明有新客户消息,将用视觉确认是否新增媒体。")
if pending_state is None:
self._mark_reply_pending(
fp,
requires_visual_proof=requires_visual_proof,
)
pending_state = self._pending_reply_state(fp)
if not pending_state.get("batch_ready", False):
print(" [会话守护] 发现客户新消息,先合并连续消息再回复。")
if not self._wait_for_message_batch(fp):
return False
self._mark_reply_pending(fp, batch_ready=True)
# 合并窗口结束后重新读取,确保模型拿到窗口内最后到达的所有消息
final_surface_before = self._chat_surface_signature()
if not final_surface_before:
return False
chat_text = self.extract_chat_text(screens=1)
final_surface_after = self._chat_surface_signature()
if (
not final_surface_after
or final_surface_after != final_surface_before
):
print(" [消息合并] 最终提取期间又到达新消息,重新开始消息合并等待。")
self._reset_pending_batch(fp)
return False
final_text_pending = bool(str(chat_text or "").strip()) and self._has_pending_customer_message(
chat_text,
fp,
)
# 合并期间若又到达了可复制的新文字,就可以回到可靠的文字判定;
# 显式媒体占位符仍会在生成阶段自动触发混合视觉
force_media_check = True
if not self._send_gate_open():
return False
if not self._session_accepts_replies(fp):
return False
reply_text = self._generate_ai_reply(
fp,
chat_text=chat_text,
force_media_check=force_media_check,
reliable_text_pending=final_text_pending,
)
if not reply_text:
print(" [回复保护] 没有得到可靠回复,本次不发送固定套话。")
return False
if not self._activate_wx():
return False
time.sleep(0.2)
sent = self.send_reply(
reply_text,
session_id=fp.hex(),
expected_fp=fp,
)
time.sleep(0.5)
return sent
def _generate_ai_reply(
self,
fp: bytes,
chat_text: str = None,
force_media_check: bool = False,
confirmed_unread: bool = False,
reliable_text_pending: bool = False,
) -> str:
"""
对【当前已打开】的会话执行 AI 回复流程:
从会话档案取历史上下文 + 增量提取新消息 → 调用 AI → 回写档案。
chat_text 可传入已提取好的一屏文本(避免重复框选),仍会走增量比对。
force_media_check 用于“画面变化但剪贴板没有新增文字”的媒体消息场景。
confirmed_unread 表示调用前确实检测并点开了该会话的未读徽章。
返回回复文本;没有可靠的新客户内容或 AI 失败时返回 None。
"""
reply_text = None
try:
from ai_config import AI_ENABLED, AI_USE_VISION, AI_CONTEXT_ENABLED
initial_pending_state = self._pending_reply_state(fp)
requires_visual_proof = bool(
(initial_pending_state or {}).get("requires_visual_proof", False)
)
if not AI_ENABLED:
if requires_visual_proof:
print(" [AI 页面确认] 纯色头像候选尚未确认,AI 关闭时禁止自动发送。")
return None
return AUTO_REPLY_TEXT
from ai_chat import (
get_ai_reply,
call_ai_text,
detect_media_types,
latest_customer_turn,
media_archive_text,
media_text_content,
safe_media_reply,
VISION_NO_INCOMING,
VISION_VOICE_NEEDS_TEXT,
_humanize,
)
ai_reply = None
vision_media_types = set()
vision_voice_transcribed = False
vision_attempted = False
history = None
if initial_pending_state is not None:
confirmed_unread = confirmed_unread or bool(
initial_pending_state.get("confirmed_unread", False)
)
expected_identity = initial_pending_state.get("identity_signature")
if expected_identity:
current_identity = self._chat_identity_signature()
if not self._chat_target_matches(
fp,
bytes(expected_identity),
current_identity=current_identity,
):
print(" [回复保护] 当前聊天标题与待回复任务不一致,已取消模型调用。")
return None
if (
current_identity != expected_identity
and not self._accept_selected_title_render(
fp,
initial_pending_state,
current_identity,
)
):
print(" [回复保护] 标题变化未能通过复合会话指纹确认,已取消模型调用。")
return None
try:
had_record_before = bool(self.store.has_record(fp.hex()))
except Exception:
# 单元隔离或旧调用没有提供 store 时维持原文字路径
had_record_before = True
extract_surface_before = self._chat_surface_signature()
if initial_pending_state is not None and not extract_surface_before:
print(" [回复保护] 无法建立最终提取前的消息布局基线,本次暂不调用模型。")
return None
# 无论是否开启全局视觉模式,都先做一次增量文字提取。这样混合的
# “文 + 图片/表情”批次不会丢掉文字,也能持续更新档案快照
time.sleep(0.5)
raw_clipboard_had_text = bool(str(chat_text or "").strip())
chat_text = self.extract_context_for(
fp,
pre_text=chat_text,
defer_snapshot=True,
)
extract_surface_after = self._chat_surface_signature()
if initial_pending_state is not None and (
not extract_surface_after
or extract_surface_after != extract_surface_before
):
print(" [回复保护] 最终提取期间又出现新消息,重新开始消息合并等待。")
self._reset_pending_batch(fp)
return None
pending_state = self._pending_reply_state(fp)
if pending_state is not None:
confirmed_unread = confirmed_unread or bool(
pending_state.get("confirmed_unread", False)
)
requires_visual_proof = requires_visual_proof or bool(
pending_state.get("requires_visual_proof", False)
)
cached_pending_text = str(
(pending_state or {}).get("chat_text") or ""
).strip()
if cached_pending_text and chat_text:
# 上一次模型发送失败后又到了新文字:把两段都留在同一个待回复
# 批次里;包含关系可避免重试时重复拼接相同内容
if chat_text in cached_pending_text:
chat_text = cached_pending_text
elif cached_pending_text not in chat_text:
chat_text = cached_pending_text + "\n" + chat_text
elif cached_pending_text and not chat_text:
chat_text = cached_pending_text
print(" [AI] 已恢复上次未成功发送的待回复内容")
if pending_state is not None and chat_text:
pending_state["chat_text"] = chat_text
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
self._ensure_session_archive_key(fp, chat_text)
# 迁移校验完成后再读取历史,确保老版本的一对一会话上下文继续生效,
# 同头像但归属不明的旧档案则绝不会自动注入模型
history = self.get_session_history(fp) if AI_CONTEXT_ENABLED else None
if history:
print(f" [AI] 会话档案提供历史上下文 {len(history)} 条")
first_record_batch = not had_record_before
# 首次建档复制的是整屏历史,里面的旧图片、旧语音占位符绝不能冒充
# 本轮媒体。只在已有快照后的增量文本上信任媒体类型;首次未读一
# 由截图确认最末端新气泡(无论它是文字、图片还是表情)
current_customer_turn = latest_customer_turn(chat_text or "")
first_explicit_customer_text = bool(
first_record_batch
and self._clipboard_tail_is_explicit_customer_text(chat_text)
and media_text_content(chat_text)
)
first_untrusted_batch = bool(
first_record_batch and not first_explicit_customer_text
)
media_types = (
set()
if first_untrusted_batch
else detect_media_types(current_customer_turn)
)
visible_media_text = (
"" if first_untrusted_batch else media_text_content(chat_text)
)
reliable_text_pending = reliable_text_pending or bool(
visible_media_text
and self._clipboard_tail_is_explicit_customer_text(chat_text)
)
if requires_visual_proof and reliable_text_pending:
# A copied block ending in an explicit non-agent speaker header
# is deterministic chat evidence. Keep vision enabled for any
# media, but do not let a contradictory page guess permanently
# blacklist a real text-avatar contact.
self._confirm_flat_session(fp, pending_state)
requires_visual_proof = False
media_event = (
bool(media_types)
or force_media_check
or confirmed_unread
or first_record_batch
or not bool(chat_text.strip())
)
if chat_text:
print(f" [AI] 本次提取的新内容:\n{chat_text[:200]}")
generation_surface = extract_surface_after
if pending_state is not None and not generation_surface:
print(" [回复保护] 无法建立模型调用前的消息布局基线,本次暂不调用模型。")
return None
if pending_state is not None and generation_surface:
pending_state["generation_surface_signature"] = generation_surface
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
# 截图只有语音气泡和时长,并不包含声音。纯语音不调用模型猜测,
# 直接请求客户补发文字;若企微已显示转写,visible_media_text 非空
# 后续仍可按可见转写与同批文字正常处理
pure_untranscribed_voice = (
media_types == {"voice"} and not visible_media_text
)
if pure_untranscribed_voice and not requires_visual_proof:
print(" [AI] 检测到未转写语音,使用不猜测内容的安全回复")
ai_reply = safe_media_reply(media_types)
elif AI_USE_VISION or media_event:
try:
image_bytes = self.capture_chat_area()
except Exception as exc:
print(f" [AI] [!] 聊天消息区域截图失败: {exc}")
image_bytes = b""
if image_bytes:
print(
" [AI] 媒体/视觉模式,已截取完整聊天消息区域 "
f"({len(image_bytes)} bytes)"
)
if not image_bytes:
ai_reply = ""
else:
try:
vision_attempted = True
# 首次建档的剪贴板是整屏历史,不把其末尾旧文字冒充本轮
# 提示给视觉模型;截图本身负责确认最末端新气泡
vision_chat_text = "" if first_untrusted_batch else chat_text
ai_reply = get_ai_reply(
chat_text=vision_chat_text,
image_bytes=image_bytes,
history=history,
force_vision=True,
media_types=media_types,
)
vision_media_types = set(
getattr(ai_reply, "media_types", ()) or ()
)
vision_voice_transcribed = bool(
getattr(ai_reply, "voice_transcribed", False)
)
media_types.update(vision_media_types)
except Exception as exc:
print(f" [AI] [!] 媒体视觉请求失败: {exc}")
ai_reply = ""
if ai_reply == VISION_NO_INCOMING:
if (
requires_visual_proof
and not (confirmed_unread or reliable_text_pending)
):
rejection_count = int(
(pending_state or {}).get("visual_rejection_count", 0)
or 0
) + 1
if pending_state is not None:
pending_state["visual_rejection_count"] = rejection_count
pending_state["updated_at"] = time.time()
self._persist_pending_replies()
if rejection_count < 2:
print(
" [AI 页面确认] 首次判断不是客户消息页,"
"保留任务并在下一轮独立复核。"
)
return None
rejected = getattr(self, "_flat_rejected_session_fps", None)
if rejected is None:
rejected = self._flat_rejected_session_fps = set()
rejected.add(fp.hex())
getattr(self, "_flat_visual_proof_fps", set()).discard(fp.hex())
print(" [AI 页面确认] 纯色头像候选不是客户消息页,已跳过且不会发送。")
self._clear_reply_pending(fp)
return None
if confirmed_unread or reliable_text_pending:
# 红点是独立于视觉模型的确定性新消息证据。两者冲突时不能
# 删除任务:有明确可复制文字就按文字回答,否则安全追问
print(" [AI] 视觉结果与已确认未读冲突,改用可验证内容安全回复")
if visible_media_text and not first_untrusted_batch:
safe_text = chat_text
if (
media_types
or force_media_check
or confirmed_unread
or reliable_text_pending
):
safe_text = (
"【系统媒体提示】视觉结果与未读标记冲突。只回答本轮可见文字;"
"不得猜测媒体内容,不得声称已经执行操作。\n"
+ chat_text
)
ai_reply = call_ai_text(safe_text, history=history)
else:
ai_reply = (
safe_media_reply(media_types)
if media_types
else safe_media_reply()
)
else:
print(" [AI] 视觉确认没有新的客户消息,本次不发送")
self._clear_reply_pending(fp)
self._remember_active_surface(fp)
return None
if ai_reply == VISION_VOICE_NEEDS_TEXT:
if visible_media_text:
warning = (
"【系统媒体提示】同批语音没有可靠转写。只回答下方可见文字;"
"不得猜测语音内容,不得声称已经执行操作。\n"
)
ai_reply = call_ai_text(warning + chat_text, history=history)
else:
ai_reply = safe_media_reply({"voice"})
if vision_attempted and ai_reply and requires_visual_proof:
self._confirm_flat_session(fp, pending_state)
requires_visual_proof = False
if not ai_reply:
if requires_visual_proof:
print(" [AI 页面确认] 尚未确认这是客户消息页,保留任务且不发送兜底话术。")
return None
# 多模态不受支持时,混合批次仍可只回答可见文字,但明确
# 禁止模型臆测图片/语音,也禁止谎称已执行客户要求
if (
visible_media_text
and not first_untrusted_batch
and (
media_types
or confirmed_unread
or reliable_text_pending
or not force_media_check
)
):
warning = (
"【系统媒体提示】同批媒体当前未能可靠读取。只根据下方可见文字回复;"
"不得推测媒体内容,不得声称已经修改、提交、预约或执行操作。\n"
)
print(" [AI] 视觉不可用,降级为同批可见文字回复")
ai_reply = call_ai_text(warning + chat_text, history=history)
elif media_types:
print(" [AI] 视觉不可用,使用对应媒体类型的安全追问")
ai_reply = safe_media_reply(media_types)
elif confirmed_unread and (
force_media_check
or first_untrusted_batch
or not bool(str(chat_text or "").strip())
):
# 红点已经证明有一条新消息,但企微没有提供可复制占位符
# 视觉不可用时仍要回一条不猜类型与内容的安全追问,避免红点
# 消失后任务永久卡死
print(" [AI] 已确认未读但媒体类型未知,使用通用安全追问")
ai_reply = safe_media_reply()
elif force_media_check and raw_clipboard_had_text:
# 当前已打开会话只有画面变化、没有红点证明,而剪贴板仍是
# 上一轮旧文字;此时不能确定性发送任何内容
print(" [AI] 视觉失败且剪贴板只有旧文字,保留任务稍后重试")
return None
else:
# 仅凭当前页画面变化不能证明客户发了媒体;视觉失败
# 宁可保留任务,也不能确定性发送“收到非文字消息”
print(" [AI] 无法确认本轮非文字内容,保留任务稍后重试")
return None
elif chat_text:
ai_reply = call_ai_text(chat_text, history=history)
else:
return None
if ai_reply:
if generation_surface:
current_surface = self._chat_surface_signature()
if not current_surface or current_surface != generation_surface:
print(" [回复保护] 模型处理期间又出现新消息,已取消旧回复并重新合并。")
self._reset_pending_batch(fp)
return None
if first_untrusted_batch and not media_types:
customer_text = "(首次会话的新消息已由聊天截图确认)"
else:
customer_text = (
media_archive_text(
chat_text,
media_types,
voice_transcribed=vision_voice_transcribed,
)
if media_event or media_types
else latest_customer_turn(chat_text or "")
)
# 医院名强制甄养堂 + 挂号话术;有挂号需求则写入登记表
lead = None
try:
from registration_store import process_registration_reply
agent = ""
try:
from ai_config import AI_AGENT_NAME
agent = AI_AGENT_NAME
except Exception:
pass
ai_reply, lead = process_registration_reply(
session_id=fp.hex(),
user_text=customer_text,
reply_text=ai_reply,
agent_name=agent,
persist=False,
)
if lead:
print(
f" [挂号] 待发送成功后登记 → {lead.get('contact')}"
f"{lead.get('status')}|病症:{lead.get('symptom') or '待问清'}"
)
except Exception as e:
print(f" [挂号] [!] 登记处理失败: {e}")
# 挂号流程可能改写回复,发送前再统一做一次短句净化。
reply_text = _humanize(ai_reply)
print(f" [AI] 回复内容: {reply_text[:60]}{'...' if len(reply_text) > 60 else ''}")
# 先暂存本轮上下文;只 send_reply 确认真正发送成功后才写入档案
# AI 等待期间人工切换了聊天对象,发送保护会取消发送,也不
# 把这条未发送回复错误记录成“已回复”
customer_speaker = ""
if not first_untrusted_batch:
for raw_line in str(chat_text or "").splitlines():
match = self._speaker_header_match(raw_line.strip())
if match:
customer_speaker = match.group("speaker").strip()
self._stage_exchange(
fp,
customer_text,
reply_text,
customer_speaker=customer_speaker,
archive_enabled=AI_CONTEXT_ENABLED,
registration_lead=lead,
)
else:
print(" [AI] [!] AI 未返回有效回复,本次不发送")
except ImportError:
pass
except Exception as e:
print(f" [AI] [!] AI 调用异常: {e}")
return reply_text
# ── 5. 主轮询循环 ─────────────────────────────────────────────────────────
def loop(self):
"""持续轮询:截图 → 红点识别 → 点击会话 → 发送回复"""
print(f"[*] 监听启动(每 {POLL_INTERVAL}s 轮询一次,Ctrl+C 停止)...")
try:
while True:
self._poll_once()
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
print("\n[*] 监听已手动终止。")
except Exception as e:
print(f"\n[-] 致命异常: {e}")
raise
def _poll_once(self):
"""
执行一次轮询主逻辑。
核心设计:每处理完一个会话后【重新截图】重新定位,
避免因会话列表自动重排(最新消息上移)导致坐标过期,从而遗漏后续红点。
整个过程循环,直到会话列表中没有更多新消息为止。
"""
# 所有恢复和关闭动作都先尊重人工鼠标空闲设置,避免用户操作时抢控制权
if not self.wait_for_mouse_idle():
return
if self._dismiss_owned_blocking_window():
return
if not self._ensure_visible():
return
if self._security_gate_visible():
return
try:
full = self._capture_full_window()
except Exception as e:
print(f"[-] 页面状态预检失败: {e}")
return
if not self._message_nav_selected(full):
candidate_fps = []
active_fp = getattr(self, "_active_session_fp", None)
if active_fp:
candidate_fps.append(active_fp)
for key in getattr(self, "_pending_reply_sessions", {}):
try:
candidate = bytes.fromhex(str(key))
except (TypeError, ValueError):
continue
if candidate not in candidate_fps:
candidate_fps.append(candidate)
ready_fp = None
for candidate in candidate_fps:
ready_fp = self._target_chat_ready(candidate)
if ready_fp:
break
if ready_fp:
self._confirm_flat_session(
ready_fp,
self._pending_reply_state(candidate),
)
self._remember_active_surface(ready_fp)
print(" [页面托管] 导航取色异常,但待回复目标聊天区仍可用,继续处理。")
else:
print(" [页面托管] 当前选中的不是“消息”,正在自动恢复工作页。")
self._ensure_message_workspace("轮询前", full=full)
return
self._refresh_message_geometry(full)
# 只有确认处于消息工作区后,才在其内部寻找真正遮挡聊天的模态弹窗
if self._dismiss_internal_blocker("轮询前"):
return
try:
preview = self.capture_session_list()
except Exception as e:
print(f"[-] 会话列表预检失败: {e}")
return
# 安全模式和命令行模式统一先守护当前聊天。过去安全模式在当前可视
# 没红点时直接 return,正是屏外未读永远漏掉的根因
if not self.safe_window_mode and not self._did_initial_cleanup:
self._did_initial_cleanup = True
self._activate_wx()
try:
self._check_selected_session(preview)
except Exception as e:
print(f"[-] 会话守护预检失败: {e}")
return
# 打开未读会话后红点会立即消失。若等待/模型调用期间人工切换页面
# 仍按头像指纹重新遍历会话列表,恢复原 pending,而不依赖红点再次出现
if self._resume_orphaned_pending_reply():
return
# 当前会话还没回完就不许被别的会话抢走:本轮到此为止,下一轮
# `_check_selected_session` 仍停在它身上继续重试
if self._hold_for_unfinished_active_session():
return
# 清空内存行号黑名单(会话列表动态重排,行号黑名单仅在单次轮询内的 MAX_PER_ROUND 循环中有效)
self.false_pos_rows.clear()
# 本轮去重:用「头 + 会话名称复合指纹」标识会话,不随列表重排/行号变化而失效
# (行号去重在列表重排时会把后续会话误判成"已处理"而漏掉,导致 B 不被处理。)
processed_fp = set() # 本轮已回复过的会话指纹
non_conv_fp = set() # 已判定为系统工具/非真实会话的指纹(避免重复判断与刷屏)
MAX_PER_ROUND = MAX_REPLIES_PER_ROUND
for _ in range(MAX_PER_ROUND):
try:
latest_full = self._capture_full_window()
found = self._find_next_unread_session(
processed_fp,
non_conv_fp,
full=latest_full,
)
except Exception as e:
print(f"[-] 截图/识别异常: {e}")
break
if found is None:
break
img, rel_y, target_fp = found
# 达到固定发送频率上限时,不点开未读会话,留到后续轮询再处理。
if not self._send_gate_open():
break
requires_visual_proof = (
target_fp.hex()
in getattr(self, "_flat_visual_proof_fps", set())
)
# Opening a row immediately clears its unread badge in WeCom. The
# durable task therefore has to exist *before* the physical click;
# otherwise any post-click page/title/render check can lose the
# only trigger forever. Do not bind the title yet because the
# previously selected chat may still be visible before the click.
pending_saved = self._mark_reply_pending(
target_fp,
confirmed_unread=True,
requires_visual_proof=requires_visual_proof,
bind_identity=False,
)
if pending_saved is False:
print(" [待回复恢复] 未读任务无法落盘,已禁止点击以避免丢失消息。")
break
row_idx = rel_y // max(1, self.session_item_h)
screen_y = self.list_region["top"] + self._row_center_from_badge(img, rel_y)
# Windows 控制台仍可能使用 GBK;避免非 BMP Emoji 让整个轮询因
# UnicodeEncodeError 中断
print(f"\n[新消息] 正在处理 row{row_idx}(坐标: {self.list_click_x}, {screen_y}")
# 企业微信已是置顶窗口,直接激活焦点后打开该会话
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,取消本次操作。")
break
if not self.click_session(rel_y, expected_fp=target_fp):
flat_candidate = (
target_fp.hex()
in getattr(self, "_flat_visual_proof_fps", set())
)
# click_session distinguishes a proven two-frame navigation
# exit from a stale fingerprint/title check. Do not repeat a
# one-frame guess here: that used to blacklist genuine WeCom
# text-avatar contacts after transient rendering.
left_message_workspace = (
getattr(self, "_last_click_failure_reason", "")
== "left_message_workspace"
)
if left_message_workspace:
# A two-frame transition away from Messages is stronger
# evidence than the icon colour heuristic. Some real
# WeCom application icons use gradients and are therefore
# not reported as `flat_candidate`; reject them here as
# well so the same row cannot starve all later unread chats.
rejected = getattr(self, "_flat_rejected_session_fps", None)
if rejected is None:
rejected = self._flat_rejected_session_fps = set()
rejected.add(target_fp.hex())
non_conv_fp.add(target_fp)
self._clear_reply_pending(target_fp)
if flat_candidate:
self._flat_visual_proof_fps.discard(target_fp.hex())
print(" [会话识别] 连续两帧确认候选离开消息页,已排除系统入口并继续扫描。")
self._ensure_message_workspace("系统入口识别")
continue
print(" [页面校验] 未能可靠打开目标会话,本轮停止,等待下次重新识别。")
break
# ── AI 回复流程 ──
self._mark_reply_pending(
target_fp,
confirmed_unread=True,
requires_visual_proof=requires_visual_proof,
)
if not self._session_accepts_replies(target_fp):
continue
if not self._wait_for_message_batch(target_fp):
print(" [消息合并] 未能安全完成本轮收集,已取消回复。")
break
self._mark_reply_pending(target_fp, batch_ready=True)
reply_text = self._generate_ai_reply(
target_fp,
confirmed_unread=True,
)
if not reply_text:
print(" [回复保护] AI 未生成可靠回复,本次不发送固定套话。")
break
# ★ AI 请求可能耗时较长,发送前只重新激活窗口焦点(不改变当前打开的会话)。
# 【关键修复·防串聊天】绝对不能再用旧坐标 rel_y 重新点击会话列表:
# 在 AI 处理这几秒内,若其他用户发来新消息,企业微信会把对方会话置顶(左侧列表重排),
# 此时 rel_y 对应的位置已经变成了另一个会话,重新点击会把聊天面板切走,
# 导致本该发给 A 的回复被发给了 B(串聊天)。
# 而已经打开的聊天面板在收到他人消息时【不会】自动切换,所以无需重新点击,
# 直接在当前会话的输入框发送即可。
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,本次回复未发送。")
break
time.sleep(0.2)
if not self.send_reply(
reply_text,
session_id=target_fp.hex(),
expected_fp=target_fp,
):
print(" [发送保护] 本次回复未发送,已停止继续处理当前批次。")
break
# 标记该会话本轮已处理(无论回复成功与否,避免红点延迟消失或列表重排导致重复处理)
processed_fp.add(target_fp)
# 验证:重新截图,按「会话复合指纹」判断该会话的红点是否已消失
time.sleep(1.5)
try:
verify_img = self.capture_session_list()
after_badges = self.detect_badge_rows(verify_img)
after_fps = {self._session_fingerprint(verify_img, y) for y in after_badges}
except Exception:
after_fps = set()
if any(self._session_fp_matches(fp, target_fp) for fp in after_fps):
print(f" [!] row{row_idx} 回复后红点未消失(本轮不再重复处理该会话)")
else:
print(f" [完成] 回复完成,红点已消失 → row{row_idx}")
time.sleep(0.5)
# ──────────────────────────────────────────────────────────────────────────────
# 标定模式:验证区域划分和输入框坐标是否正确
# ──────────────────────────────────────────────────────────────────────────────
def calibrate_mode():
"""截取会话列表截图并保存,供肉眼确认区域是否正确"""
print("[标定模式] 截取会话列表区域截图...")
bot = WeChatBot()
if not bot.connect():
return
img = bot.capture_session_list()
badges = bot.detect_badge_rows(img)
path = save_debug_screenshot(img, "calibrate_list.png")
print(f"[+] 截图已保存: {path}")
print(f"[+] 标定模式检测到未读红点 Y 相对行号: {badges}")
print(f" 如果图片内容是企业微信的聊天列表,说明区域划分正确。")
print(f" 如果显示的是其他区域,请调整 NAV_BAR_W / SESSION_LIST_W 常量。")
print()
# 测试输入框:高亮鼠标移动到输入框位置(3秒后移动)
print(f"[+] 3 秒后将鼠标移动到估算的输入框位置 ({bot.input_x}, {bot.input_y})")
print(f" 请观察鼠标是否落在企业微信聊天输入框内。")
time.sleep(3)
pyautogui.moveTo(bot.input_x, bot.input_y, duration=0.5)
time.sleep(2)
print("[+] 标定完成。如位置不对,请调整 INPUT_Y_FROM_BOTTOM / INPUT_X_RATIO 常量。")
def test_input_mode():
"""测试模式:直接点击输入框并发送一次测试消息"""
print("[测试输入模式] 将在 3 秒后点击输入框并发送测试消息...")
bot = WeChatBot()
if not bot.connect():
return
time.sleep(3)
bot.send_reply()
print("[+] 测试消息已发送。")
# ──────────────────────────────────────────────────────────────────────────────
# 入口
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
args = sys.argv[1:]
print("=" * 55)
print(" 企业微信 RPA 机器人 v3.1")
print(" 技术路线: 截图 → 色彩识别 → 坐标点击")
print("=" * 55)
if "--calibrate" in args:
calibrate_mode()
elif "--test-input" in args:
test_input_mode()
else:
bot = WeChatBot()
if not bot.connect():
print()
print("[提示] 请确认:")
print(" 1. 企业微信主窗口已在桌面显示(不是最小化)")
print(" 2. 已点击左侧【消息】图标,使会话列表可见")
sys.exit(1)
print("-" * 55)
bot.loop()