更新
This commit is contained in:
+213
-38
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user