This commit is contained in:
Your Name
2026-07-23 11:30:11 +08:00
parent 32895b1591
commit 45b3bc0852
28 changed files with 7967 additions and 1338 deletions
+213 -38
View File
@@ -167,12 +167,50 @@ def find_wx_hwnd() -> int:
return hwnd
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)
@@ -262,6 +300,12 @@ class WeChatBot:
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.L = self.T = self.R = self.B = 0
self._list_x = 0
@@ -280,14 +324,39 @@ class WeChatBot:
self.badge_scan_x_end = BADGE_SCAN_X_END
# ── 1. 窗口挂载层 ─────────────────────────────────────────────────────────
def connect(self) -> bool:
def connect(self, activate: bool = True, wait_if_missing: bool = False) -> bool:
"""查找并挂载企业微信主窗口,计算所有关键区域坐标"""
print("[*] 正在查找企业微信主窗口...")
self.hwnd = find_wx_hwnd()
if not self.hwnd:
print("[-] 未找到企业微信!请确认已登录且主窗口存在(托盘图标双击打开)。")
return False
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:
@@ -305,23 +374,65 @@ class WeChatBot:
if self.scale != 1.0:
print(f"[+] 检测到系统 DPI 缩放比例: {self.scale * 100:.1f}%,启用自适应几何缩放。")
# 如果窗口被最小化,先还原它
restore_window(self.hwnd)
time.sleep(0.3) # 等待窗口动画完成
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
# 用 win32gui 读取真实的窗口屏幕坐标(不受最小化影响)
left, top, right, bottom = win32gui.GetWindowRect(self.hwnd)
# 最小化时 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:
print(f"[-] 窗口尺寸异常 ({W}×{H}),请手动将企业微信拖到屏幕上。")
return False
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)
print(f"[+] 挂载成功: HWND=0x{self.hwnd:08X}, ClassName='{cls}', Title='{title}'")
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}")
# ── 计算各区域坐标 ──
@@ -548,8 +659,10 @@ class WeChatBot:
except Exception:
pass
# 2. 确保企业微信在前台并取得焦点
self._activate_wx()
# 2. 确保企业微信在前台。安全模式下不会主动抢回焦点
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,取消聊天内容提取。")
return ''
# 先在聊天区底部空白处点一下,确保焦点落在聊天面板而非别处
pyautogui.click(center_x, top + height - margin)
time.sleep(0.2)
@@ -962,7 +1075,10 @@ class WeChatBot:
print("[!] 企业微信窗口句柄已失效(主面板可能被关闭),尝试自动重新挂载...")
self.hwnd = 0
try:
ok = self.connect()
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
@@ -973,9 +1089,14 @@ class WeChatBot:
print("[-] 自动重连失败:未找到企业微信主窗口,请重新打开主面板(双击托盘图标),将自动恢复监听。")
return False
self._reconnect_fails = 0
if self._topmost:
if self._topmost and not self.safe_window_mode:
self.set_topmost(True)
print("[+] 自动重连成功,恢复监听。")
if not self.hwnd:
print("[+] 企业微信进程仍在,等待主窗口创建后自动切到前台。")
elif self._window_ready:
print("[+] 自动重连成功,恢复监听。")
else:
print("[+] 已重新挂载企业微信,等待主界面恢复后继续监听。")
return True
# ── 人机共存:鼠标空闲检测 ────────────────────────────────────────────────
@@ -1046,11 +1167,44 @@ class WeChatBot:
"""
# 句柄失效时先尝试自动重连,避免后续 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)
@@ -1061,6 +1215,7 @@ class WeChatBot:
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()
@@ -1068,12 +1223,17 @@ class WeChatBot:
def _activate_wx(self):
"""将企业微信激活到前台并确保可见。"""
self._ensure_visible()
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 click_session(self, rel_y: int):
@@ -1416,24 +1576,35 @@ class WeChatBot:
避免因会话列表自动重排(最新消息上移)导致坐标过期,从而遗漏后续红点。
整个过程循环,直到会话列表中没有更多新消息为止。
"""
# 人机共存:有人在动鼠标时先等待静止,避免抢鼠标
if not self.wait_for_mouse_idle():
return
# 每次轮询开始前,确保企业微信窗口可见且置顶(句柄失效会自动重连)
if not self._ensure_visible():
return
# 首次轮询先做一次界面清理:关闭上次运行可能遗留的搜索弹层、取消遗留的会话选中
if not self._did_initial_cleanup:
self._did_initial_cleanup = True
self._activate_wx()
self._deselect_session()
# ★ 把会话列表滚回顶端:新消息会话被企业微信置顶到列表最上方,
# 若列表被翻到下面,红点在可视区外会漏检测(截图区域只有可视部分)
self._scroll_session_list_top()
# ★ 有会话被手动点开时,选中期间来的新消息会被自动已读(不出红点),
# 先检查该会话有没有未回复的消息并处理,再取消选中恢复红点机制
self._check_selected_session()
# 主动激活模式先把企业微信恢复到前台,再等待鼠标空闲;
# 被动模式仍优先尊重人工操作,不抢占当前窗口。
if self.auto_activate_window:
if not self._ensure_visible():
return
if not self.wait_for_mouse_idle():
return
else:
if not self.wait_for_mouse_idle():
return
if not self._ensure_visible():
return
if self.safe_window_mode:
# 先进行只读截图。没有未读红点时直接返回,不滚动、不点击验证页或登录页。
try:
preview = self.capture_session_list()
if not self.detect_badge_rows(preview):
return
except Exception as e:
print(f"[-] 安全预检失败: {e}")
return
else:
# 命令行旧模式保留首次清理和列表复位逻辑。
if not self._did_initial_cleanup:
self._did_initial_cleanup = True
self._activate_wx()
self._deselect_session()
self._scroll_session_list_top()
self._check_selected_session()
# 清空内存行号黑名单(会话列表动态重排,行号黑名单仅在单次轮询内的 MAX_PER_ROUND 循环中有效)
self.false_pos_rows.clear()
@@ -1480,7 +1651,9 @@ class WeChatBot:
print(f"\n[🔔] 正在处理 row{row_idx}(坐标: {self.list_click_x}, {screen_y}")
# 企业微信已是置顶窗口,直接激活焦点后打开该会话
self._activate_wx()
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,取消本次操作。")
break
self.click_session(rel_y)
# ── AI 回复流程 ──
@@ -1493,7 +1666,9 @@ class WeChatBot:
# 导致本该发给 A 的回复被发给了 B(串聊天)。
# 而已经打开的聊天面板在收到他人消息时【不会】自动切换,所以无需重新点击,
# 直接在当前会话的输入框发送即可。
self._activate_wx()
if not self._activate_wx():
print(" [安全模式] 企业微信已失去前台焦点,本次回复未发送。")
break
time.sleep(0.2)
self.send_reply(reply_text)