This commit is contained in:
Your Name
2026-07-29 09:34:02 +08:00
parent 0ff8943ee2
commit f913a57529
54 changed files with 2453 additions and 378 deletions
+142 -13
View File
@@ -27,6 +27,7 @@ import os
import re
import hashlib
import ctypes
from collections import deque
import win32gui
import win32con
import win32ui
@@ -38,6 +39,7 @@ import numpy as np
from PIL import Image
from conversation_store import ConversationStore
from runtime_paths import application_data_dir
# ──────────────────────────────────────────────────────────────────────────────
# 全局安全设置
@@ -49,9 +51,16 @@ pyautogui.PAUSE = 0.05 # 每次 pyautogui 操作后的基础延时(秒)
# 常量配置(如界面布局变化,只修改这里)
# ──────────────────────────────────────────────────────────────────────────────
WX_WINDOW_CLASS = 'WeWorkWindow' # 企业微信主窗口类名(已通过 inspect_tree.py 确认)
AUTO_REPLY_TEXT = "你好" # 自动回复内容
AUTO_REPLY_TEXT = "在的,您慢慢说,我这边看着呢。" # AI 不可用时的简短兜底回复
POLL_INTERVAL = 2.0 # 轮询间隔(秒)
# 合规发送保护:限制自动回复的连续操作频率,避免短时间内集中发送。
# 这些限制不是为了绕过平台检测,而是为了在业务高峰时主动降载。
MIN_SEND_INTERVAL_SECONDS = 8.0
MAX_SENDS_PER_MINUTE = 4
MAX_SENDS_PER_HOUR = 60
MAX_REPLIES_PER_ROUND = 5
# 人机共存:人工移动鼠标后,机器人暂停;鼠标静止满此秒数才继续操作
MOUSE_IDLE_ENABLED = True
MOUSE_IDLE_SECONDS = 20
@@ -94,7 +103,7 @@ BADGE_SCAN_X_END = 80 # 扫描终点
# ──────────────────────────────────────────────────────────────────────────────
# 全局路径
# ──────────────────────────────────────────────────────────────────────────────
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPT_DIR = str(application_data_dir())
# ──────────────────────────────────────────────────────────────────────────────
# 内存临时黑名单配置
@@ -258,11 +267,42 @@ def capture_window_region(hwnd: int, x: int, y: int, w: int, h: int) -> np.ndarr
def save_debug_screenshot(img_np, filename="debug_list.png"):
"""将 BGRA numpy 数组保存为 PNG"""
import os
path = os.path.join(os.path.dirname(__file__), filename)
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
# ──────────────────────────────────────────────────────────────────────────────
# 主类
# ──────────────────────────────────────────────────────────────────────────────
@@ -306,6 +346,14 @@ class WeChatBot:
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.reply_count = 0
self.L = self.T = self.R = self.B = 0
self._list_x = 0
@@ -519,6 +567,63 @@ class WeChatBot:
self._list_h
)
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 _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
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 capture_chat_area(self) -> bytes:
"""使用 PrintWindow 直接从窗口显存截取聊天消息显示区域,返回 PNG bytes。"""
import io
@@ -1248,10 +1353,14 @@ class WeChatBot:
finally:
self._end_bot_mouse()
def send_reply(self, text: str = None):
def send_reply(self, text: str = None, session_id=None) -> bool:
"""向当前打开的会话发送回复,发送完后取消选中状态。"""
if self._security_gate_visible():
return False
if not self._send_gate_open():
return False
if not self.wait_for_mouse_idle():
return
return False
reply_text = text or AUTO_REPLY_TEXT
self._begin_bot_mouse()
try:
@@ -1264,7 +1373,9 @@ class WeChatBot:
time.sleep(0.3)
finally:
self._end_bot_mouse()
self._record_send(session_id)
self._deselect_session()
return True
def _find_tool_row(self, img: np.ndarray) -> int:
"""
@@ -1463,10 +1574,13 @@ class WeChatBot:
return
print(" [🖱] 发现未回复的客户消息,直接在当前会话回复。")
if not self._send_gate_open():
self._deselect_session()
return
reply_text = self._generate_ai_reply(fp, chat_text=chat_text)
self._activate_wx()
time.sleep(0.2)
self.send_reply(reply_text) # send_reply 内部会取消选中
self.send_reply(reply_text, session_id=fp.hex()) # send_reply 内部会取消选中
time.sleep(0.5)
def _generate_ai_reply(self, fp: bytes, chat_text: str = None) -> str:
@@ -1481,7 +1595,12 @@ class WeChatBot:
from ai_config import AI_ENABLED, AI_USE_VISION, AI_CONTEXT_ENABLED
if not AI_ENABLED:
return None
from ai_chat import get_ai_reply, call_ai_text
from ai_chat import (
get_ai_reply,
call_ai_text,
latest_customer_message,
_humanize,
)
ai_reply = None
# 该会话的历史上下文(来自持久化档案,按会话指纹隔离,重启不丢)
@@ -1514,6 +1633,7 @@ class WeChatBot:
)
if ai_reply:
customer_text = latest_customer_message(chat_text or "")
# 医院名强制甄养堂 + 挂号话术;有挂号需求则写入登记表
try:
from registration_store import process_registration_reply, RegistrationStore
@@ -1525,7 +1645,7 @@ class WeChatBot:
pass
ai_reply, lead = process_registration_reply(
session_id=fp.hex(),
user_text=chat_text or "",
user_text=customer_text,
reply_text=ai_reply,
store=RegistrationStore(),
agent_name=agent,
@@ -1538,14 +1658,15 @@ class WeChatBot:
except Exception as e:
print(f" [挂号] ⚠ 登记处理失败: {e}")
reply_text = ai_reply
# 挂号流程可能改写回复,发送前再统一做一次短句净化。
reply_text = _humanize(ai_reply)
print(f" [AI] 回复内容: {reply_text[:60]}{'...' if len(reply_text) > 60 else ''}")
# 记入该会话的上下文记忆,供下一轮回答衔接
if AI_CONTEXT_ENABLED:
self.remember_exchange(
fp,
chat_text or "(客户发来新消息,内容未能提取为文字)",
ai_reply,
customer_text or "(客户发来新消息,内容未能提取为文字)",
reply_text,
)
else:
print(" [AI] ⚠ AI 未返回有效回复,使用默认回复")
@@ -1588,6 +1709,8 @@ class WeChatBot:
return
if not self._ensure_visible():
return
if self._security_gate_visible():
return
if self.safe_window_mode:
# 先进行只读截图。没有未读红点时直接返回,不滚动、不点击验证页或登录页。
try:
@@ -1612,7 +1735,7 @@ class WeChatBot:
# (行号去重在列表重排时会把后续会话误判成"已处理"而漏掉,导致 B 不被处理。)
processed_fp = set() # 本轮已回复过的会话指纹
non_conv_fp = set() # 已判定为系统工具/非真实会话的指纹(避免重复判断与刷屏)
MAX_PER_ROUND = 30
MAX_PER_ROUND = MAX_REPLIES_PER_ROUND
for _ in range(MAX_PER_ROUND):
try:
@@ -1645,6 +1768,10 @@ class WeChatBot:
if rel_y is None:
break
# 达到固定发送频率上限时,不点开未读会话,留到后续轮询再处理。
if not self._send_gate_open():
break
row_idx = rel_y // self.session_item_h
screen_y = self.list_region["top"] + rel_y
@@ -1670,7 +1797,9 @@ class WeChatBot:
print(" [安全模式] 企业微信已失去前台焦点,本次回复未发送。")
break
time.sleep(0.2)
self.send_reply(reply_text)
if not self.send_reply(reply_text, session_id=target_fp.hex()):
print(" [发送保护] 本次回复未发送,已停止继续处理当前批次。")
break
# 标记该会话本轮已处理(无论回复成功与否,避免红点延迟消失或列表重排导致重复处理)