Files

2067 lines
91 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""wechatauto.guia —— 微信 4.x 自绘界面「坐标 + OCR」自动化发送模块
背景
----
微信 4.1.12+ 的聊天区域改用自绘渲染(``MMUIRenderSubWindow*``,不同版本
后缀不同,如 ``MMUIRenderSubWindowHW`` / ``MMUIRenderSubWindow``),
对 UIAutomation / MSAA 不再暴露任何无障碍节点,因此原 wechatauto 的 UIA
方案在 4.1.x 上失效。本项目已通过「本地数据库解密」(``wechatauto/db.py``
解决读消息;本模块针对**发送消息**补充一条「坐标 + OCR」路线:
* 通过 Win32 定位微信主窗口与其内的 QtQuick 渲染子窗口(多特征兜底:
标题 / 类名前缀 / 进程名 weixin.exe / 可见 / 大尺寸联合评分,类名从
「硬条件」降级为「软条件」,Qt 升级改名也不失效;找不到渲染子窗口
时直接回退用主窗口矩形计算坐标);
* 布局动态校准:首次运行时实测侧栏边界 / 搜索框 / 发送按钮锚点,保存到
``~/.wechatauto/layout-<机器>.json``,之后自动加载,DPI/布局漂移时
输入框探测连续失败会自动重校准;
* 用 Windows OCRWinRT ``Windows.Media.Ocr``)识别会话列表 /
输入框 / 发送按钮在屏幕上的位置;
* 用真实鼠标事件 + 剪贴板粘贴(Ctrl+V)输入文字(避免中文输入法拦截);
* 点击「发送」按钮完成发送。
坐标系说明
----------
微信最大化时渲染子窗口与屏幕重合(本机 3072x1920)。本模块一律以
**渲染子窗口坐标系**(相对坐标)描述布局,运行时通过渲染窗口的屏幕
矩形换算成屏幕绝对坐标,从而兼容窗口缩放/未最大化的情况。
使用前提:
1. 微信 4.x 已登录并**解锁桌面**(锁屏状态下无法 GUI 操作);
2. ``pip install -e .`` 安装依赖(含 ``winsdk``)。
示例:
from wechatauto.guia import WeChatGUI
wx = WeChatGUI()
wx.bring_to_front()
wx.open_chat('文件传输助手')
wx.send_msg('你好,这是自动化测试')
"""
from __future__ import annotations
import asyncio
import ctypes
import json
import os
import platform
import re
import tempfile
import time
from ctypes import wintypes
from typing import Dict, List, Optional, Tuple
from PIL import Image
from wechatauto.logger import wxlog
from wechatauto.param import WxResponse
# ---------------------------------------------------------------------------
# DPI:模块加载时立即设为 PER_MONITOR_AWARE_V2,保证后续所有
# GetWindowRect / GetSystemMetrics / ImageGrab 截图 / SetCursorPos 点击
# 都处于同一坐标系(物理像素)。
# ---------------------------------------------------------------------------
try:
ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
except Exception:
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass
# ---------------------------------------------------------------------------
# Win32 原生常量与结构
# ---------------------------------------------------------------------------
# keyboard / mouse 输入标志
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_ABSOLUTE = 0x8000
# 虚拟键
VK_CONTROL = 0x11
VK_SHIFT = 0x10
VK_RETURN = 0x0D
VK_SPACE = 0x20
VK_ESCAPE = 0x1B
VK_DELETE = 0x2E
VK_A = 0x41
VK_C = 0x43
VK_V = 0x56
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
class _INPUT_UNION(ctypes.Union):
_fields_ = [("ki", KEYBDINPUT)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("u", _INPUT_UNION)]
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
class _MOUSE_UNION(ctypes.Union):
_fields_ = [("mi", MOUSEINPUT)]
class MOUSE_INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("u", _MOUSE_UNION)]
# 微信 4.x 窗口类名(不同版本/机型存在差异,统一用前缀匹配兼容所有变体)
# 主窗口:Qt51514QWindowIcon;渲染子窗口:MMUIRenderSubWindow /
# MMUIRenderSubWindowHW / 未来其他后缀。按前缀识别可覆盖全部已知/未知变体。
WX_MAIN_WIN_CLASS_PREFIX = "Qt51514QWindowIcon"
WX_MAIN_WIN_TITLE = "微信"
WX_RENDER_WIN_CLASS_PREFIX = "MMUIRenderSubWindow"
# 布局比例常量(相对渲染窗口尺寸,跨分辨率自适应)
# 侧栏宽度约占窗口 22%(微信 4.x 侧栏为固定逻辑宽度,最大化时≈0.22);
# 其余布局元素按其与侧栏宽/窗口高的实测比例换算,保证不同 DPI/分辨率
# 与窗口尺寸下无需改动即可工作。
SIDEBAR_LEFT = 0
SIDEBAR_RATIO = 0.22 # 侧栏宽度 / 窗口宽度
SIDEBAR_TOP = 0.05 # 会话列表顶部起始(相对窗口高)
SEARCH_BOX_RATIO = (0.18, 0.041, 0.86, 0.079) # (x0,y0,x1,y1)x 相对侧栏宽、y 相对窗口高
SEND_BUTTON_RATIO = (0.78, 0.92, 0.995, 0.99) # 「发送」按钮检索区(相对窗口)
# 多特征兜底:类名只是「软条件」之一,还需 进程名/可见/大尺寸/标题 等特征
# 联合判断,避免 Qt 升级改名(Qt51514 → Qt6xxx)后主窗口定位失效。
PROCESS_NAME = 'weixin.exe' # 微信进程名(小写)
MIN_WINDOW_SIZE = 800 # 主窗口最小边长(像素),小于此视为非主窗
MAIN_TITLE_KEYWORDS = ('微信', 'Weixin', 'WeChat')
# 布局校准配置目录:~/.wechatauto/layout-<机器标识>.json
LAYOUT_CONFIG_DIR = os.path.join(os.path.expanduser('~'), '.wechatauto')
def _machine_id() -> str:
"""机器标识:主机名 + 屏幕分辨率,用于区分不同机器/DPI 的布局配置。"""
try:
node = platform.node() or 'unknown'
except Exception:
node = 'unknown'
cx = ctypes.windll.user32.GetSystemMetrics(0) # SM_CXSCREEN
cy = ctypes.windll.user32.GetSystemMetrics(1) # SM_CYSCREEN
safe = re.sub(r'[^\w\-]', '_', node)
return f'{safe}_{cx}x{cy}'
def _layout_path() -> str:
"""返回本机布局校准文件路径(不存在则创建目录)。"""
if not os.path.isdir(LAYOUT_CONFIG_DIR):
try:
os.makedirs(LAYOUT_CONFIG_DIR, exist_ok=True)
except Exception:
pass
return os.path.join(LAYOUT_CONFIG_DIR, f'layout-{_machine_id()}.json')
def _restore_keep_maximize(user32, hwnd: int):
"""取消最小化并显示窗口,同时保留其最大化状态。
``ShowWindow(hwnd, SW_RESTORE)`` 会把最大化窗口恢复为普通大小
(窗口被缩小)。这里先用 ``GetWindowPlacement`` 记录其最大化状态:
原为最大化则用 ``SW_SHOWMAXIMIZED`` 恢复,否则才走 ``SW_RESTORE``。
"""
if not hwnd or not user32.IsWindow(hwnd):
return
class _POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
class _WINDOWPLACEMENT(ctypes.Structure):
_fields_ = [
("length", wintypes.UINT),
("flags", wintypes.UINT),
("showCmd", wintypes.UINT),
("ptMinPosition", _POINT),
("ptMaxPosition", _POINT),
("rcNormalPosition", wintypes.RECT),
]
wp = _WINDOWPLACEMENT()
wp.length = ctypes.sizeof(_WINDOWPLACEMENT)
if user32.GetWindowPlacement(hwnd, ctypes.byref(wp)):
if wp.showCmd == 3: # SW_SHOWMAXIMIZED
user32.ShowWindow(hwnd, 3) # 保持/恢复最大化
return
user32.ShowWindow(hwnd, 9) # SW_RESTORE
# ---------------------------------------------------------------------------
# 底层输入封装
# ---------------------------------------------------------------------------
class WinInput:
"""基于 Win32 的真实鼠标/键盘输入(DPI 感知)。"""
def __init__(self):
user32 = ctypes.windll.user32
# 保持 DPI-UNAWARE:整条管线(GetWindowRect / 截图 / SetCursorPos 点击)
# 都使用系统虚拟化后的逻辑坐标,保证截图与点击坐标一致。
# (若设为 DPI-aware,屏幕仅 1536x960 物理像素,而截图仍按
# GetWindowRect 返回的逻辑矩形(如 3018x1818)采集,会造成
# 截图与 SetCursorPos 物理坐标错位。)
self._user32 = user32
self.screen_w = user32.GetSystemMetrics(0)
self.screen_h = user32.GetSystemMetrics(1)
wxlog.debug(f'WinInput 初始化,屏幕尺寸:{self.screen_w}x{self.screen_h}')
# -- 鼠标 ----------------------------------------------------------
def real_click(self, x: int, y: int, right: bool = False):
"""SetCursorPos + mouse_event 的「真实」点击,坐标为本机像素。"""
u = self._user32
u.SetCursorPos(int(x), int(y))
time.sleep(0.15)
down = MOUSEEVENTF_RIGHTDOWN if right else MOUSEEVENTF_LEFTDOWN
up = MOUSEEVENTF_RIGHTUP if right else MOUSEEVENTF_LEFTUP
u.mouse_event(down, 0, 0, 0, 0)
u.mouse_event(up, 0, 0, 0, 0)
time.sleep(0.3)
def send_input_click(self, x: int, y: int):
"""SendInput 绝对坐标点击(按真实屏幕尺寸缩放)。"""
u = self._user32
n = int(x * 65535 // self.screen_w)
m = int(y * 65535 // self.screen_h)
for flags in (MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_LEFTUP):
inp = MOUSE_INPUT()
inp.type = 0
inp.u.mi.dx = n
inp.u.mi.dy = m
inp.u.mi.dwFlags = flags
u.SendInput(1, ctypes.byref(inp), ctypes.sizeof(MOUSE_INPUT))
time.sleep(0.06)
time.sleep(0.3)
def wheel(self, delta: int = -300):
"""滚轮滚动(delta 为正向上,负向下)。"""
u = self._user32
u.mouse_event(MOUSEEVENTF_WHEEL, 0, 0, delta, 0)
time.sleep(0.4)
# -- 键盘 ----------------------------------------------------------
def key(self, vk: int, ctrl: bool = False, shift: bool = False):
"""发送一次虚拟键(可带 Ctrl/Shift)。"""
mods = [(VK_CONTROL, ctrl), (VK_SHIFT, shift)]
for vk_mod, on in mods:
if on:
self._raw_key(vk_mod, down=True)
self._raw_key(vk, down=True)
self._raw_key(vk, down=False)
for vk_mod, on in reversed(mods):
if on:
self._raw_key(vk_mod, down=False)
def _raw_key(self, vk: int, down: bool):
"""发送一次虚拟键事件。
优先使用 ``keybd_event``(老式 API):在部分远程/虚拟化会话中
``SendInput`` 键盘事件会被系统丢弃(返回 0),而 ``keybd_event``
与 ``mouse_event`` 走同一套老式输入管线,与鼠标点击一致可用。
"""
flags = KEYEVENTF_KEYUP if not down else 0
ctypes.windll.user32.keybd_event(vk & 0xFFFF, 0, flags, 0)
time.sleep(0.05)
def type_unicode(self, text: str):
"""以 SendInput Unicode 方式键入文本(绕开键盘布局/大小写问题)。"""
u = self._user32
for ch in text:
down = INPUT()
down.type = 1
down.u.ki.wScan = ord(ch)
down.u.ki.dwFlags = KEYEVENTF_UNICODE
up = INPUT()
up.type = 1
up.u.ki.wScan = ord(ch)
up.u.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP
u.SendInput(1, ctypes.byref(down), ctypes.sizeof(INPUT))
time.sleep(0.02)
u.SendInput(1, ctypes.byref(up), ctypes.sizeof(INPUT))
time.sleep(0.04)
def type_pinyin(self, pinyin: str):
"""以虚拟键逐字母输入拼音(供中文输入法组合,如 ``ceshi`` → 测试)。"""
for ch in pinyin.lower():
if 'a' <= ch <= 'z':
self.key(ord(ch) - 32)
elif ch == ' ':
self.key(VK_SPACE)
time.sleep(0.05)
# ---------------------------------------------------------------------------
# OCR 封装(WinRT
# ---------------------------------------------------------------------------
class ScreenOCR:
"""Windows 自带 OCRWinRT Windows.Media.Ocr)封装。
用法:``ScreenOCR.recognize(pil_image)`` 返回
``[(text, x, y, w, h), ...]``,坐标为图像内相对坐标。
"""
@staticmethod
def recognize(image: Image.Image) -> List[Tuple[str, int, int, int, int]]:
from winsdk.windows.media.ocr import OcrEngine
from winsdk.windows.graphics.imaging import BitmapDecoder
from winsdk.windows.storage import StorageFile
async def _run() -> List[Tuple[str, int, int, int, int]]:
tmp = os.path.join(tempfile.gettempdir(), 'wechatauto_ocr_tmp.png')
image.save(tmp)
f = await StorageFile.get_file_from_path_async(tmp)
s = await f.open_async(0)
dec = await BitmapDecoder.create_async(s)
bmp = await dec.get_software_bitmap_async()
eng = OcrEngine.try_create_from_user_profile_languages()
if eng is None:
return []
res = await eng.recognize_async(bmp)
out = []
for line in res.lines:
text = line.text.replace(' ', '')
if not text:
continue
r = line.words[0].bounding_rect
out.append((text, int(r.x), int(r.y), int(r.width), int(r.height)))
return out
try:
return asyncio.run(asyncio.wait_for(_run(), timeout=8))
except asyncio.TimeoutError:
wxlog.debug('OCR 识别超时(8s')
return []
except Exception as e:
wxlog.debug(f'OCR 识别失败:{e}')
return []
# ---------------------------------------------------------------------------
# 微信主流程
# ---------------------------------------------------------------------------
class WeChatGUI:
"""基于坐标 + OCR 的微信 4.x 自动化实例。
Attributes:
main_hwnd: 微信主窗口句柄
render_hwnd: QtQuick 渲染子窗口句柄
render_rect: 渲染窗口屏幕矩形 (left, top, right, bottom)
"""
def __init__(self, title: str = WX_MAIN_WIN_TITLE, hwnd: int = None,
calibrate: bool = False):
self._input = WinInput()
self._sidebar_ratio = SIDEBAR_RATIO
self._send_button_ratio = SEND_BUTTON_RATIO
self.main_hwnd = hwnd or self._find_main_window(title)
if not self.main_hwnd:
raise RuntimeError('未找到微信主窗口,请确认微信已登录并运行')
self.render_hwnd = self._find_render_window(self.main_hwnd)
if not self.render_hwnd:
# 找不到渲染子窗口(Qt 改版等)→ 直接用主窗口矩形计算坐标
wxlog.warning('未找到微信渲染子窗口,回退用主窗口矩形定位坐标')
self.render_hwnd = self.main_hwnd
self._update_render_rect()
self.pid = self._get_pid(self.main_hwnd)
self._current_chat = None # 最近打开的会话名,用于连续发送时复用
self._last_input_box = None # 最近一次成功发送的输入框位置,连续发送复用
self._uia = None # UIA 引擎(惰性创建,仅当可用时启用)
self._uia_tried = False
# 布局校准:显式 calibrate=True 强制重校准;否则加载本机已校准配置,
# 没有配置则自动校准一次(OCR 可用时),实现「跑一次永久兼容」。
if not calibrate:
calibrate = not self._load_layout()
if calibrate:
self.calibrate_layout()
wxlog.info(
f'WeChatGUI 初始化成功:hwnd={self.main_hwnd}, '
f'render={self.render_hwnd}, rect={self.render_rect}')
# ------------------------------------------------------------------
# 窗口定位
# ------------------------------------------------------------------
def _process_name(self, pid: int) -> str:
"""返回 pid 对应进程的可执行文件名(小写),失败返回空串。"""
if not pid:
return ''
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h = ctypes.windll.kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return ''
try:
buf = ctypes.create_unicode_buffer(1024)
size = wintypes.DWORD(1024)
ok = ctypes.windll.kernel32.QueryFullProcessImageNameW(
h, 0, buf, ctypes.byref(size))
if ok:
return os.path.basename(buf.value).lower()
except Exception:
return ''
finally:
ctypes.windll.kernel32.CloseHandle(h)
return ''
def _looks_like_main_window(self, hwnd: int) -> bool:
"""多特征联合判断是否为微信主窗口(类名只是软条件之一)。
特征:标题含微信关键字 / 类名前缀 Qt51514QWindowIcon* /
进程名为 weixin.exe / 可见 / 尺寸 >= MIN_WINDOW_SIZE。任一项都不
单独成立,评分(权重和)>=5 才认可,防止 Qt 升级改名后失效。
"""
u = self._input._user32
if not u.IsWindow(hwnd) or not u.IsWindowVisible(hwnd):
return False
buf = ctypes.create_unicode_buffer(256)
cls = ctypes.create_unicode_buffer(256)
u.GetWindowTextW(hwnd, buf, 256)
u.GetClassNameW(hwnd, cls, 256)
title, cls_name = buf.value.strip(), cls.value
score = 0
if any(k in title for k in MAIN_TITLE_KEYWORDS):
score += 5
if cls_name.startswith(WX_MAIN_WIN_CLASS_PREFIX):
score += 4
if self._process_name(self._get_pid(hwnd)) == PROCESS_NAME:
score += 3
rr = wintypes.RECT()
u.GetWindowRect(hwnd, ctypes.byref(rr))
if (rr.right - rr.left) >= MIN_WINDOW_SIZE \
or (rr.bottom - rr.top) >= MIN_WINDOW_SIZE:
score += 2
return score >= 5
def _find_main_window(self, title: str) -> int:
"""多特征兜底定位微信主窗口。
类名前缀不再是硬条件(Qt 升级 Qt51514→Qt6xxx 后会改名),改为在
所有可见顶层窗口中按「标题/类名/进程名/可见/尺寸」评分,取最高分。
找不到时退回按标题精确查找。
"""
u = self._input._user32
scored = []
cb_ref = []
def _cb(h, lp):
if not u.IsWindowVisible(h):
return True
buf = ctypes.create_unicode_buffer(256)
cls = ctypes.create_unicode_buffer(256)
u.GetWindowTextW(h, buf, 256)
u.GetClassNameW(h, cls, 256)
title_text, cls_name = buf.value.strip(), cls.value
if not (title_text or cls_name):
return True
score = 0
if any(k in title_text for k in MAIN_TITLE_KEYWORDS):
score += 5
if cls_name.startswith(WX_MAIN_WIN_CLASS_PREFIX):
score += 4
if self._process_name(self._get_pid(h)) == PROCESS_NAME:
score += 3
rr = wintypes.RECT()
u.GetWindowRect(h, ctypes.byref(rr))
w = rr.right - rr.left
ht = rr.bottom - rr.top
if w > 0 and ht > 0:
if w >= MIN_WINDOW_SIZE or ht >= MIN_WINDOW_SIZE:
score += 2
if score >= 5:
scored.append((score, w * ht, h))
return True
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
cb_ref.append(CB(_cb))
u.EnumWindows(cb_ref[0], 0)
if scored:
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
return scored[0][2]
# 完全找不到时退回按标题精确查找
hwnd = u.FindWindowW(None, title)
if hwnd and self._looks_like_main_window(hwnd):
return hwnd
return 0
def _find_render_window(self, main_hwnd: int) -> int:
u = self._input._user32
found = []
cb_ref = []
def _cb(h, lp):
cls = ctypes.create_unicode_buffer(256)
u.GetClassNameW(h, cls, 256)
if not cls.value.startswith(WX_RENDER_WIN_CLASS_PREFIX):
return True
rr = wintypes.RECT()
u.GetWindowRect(h, ctypes.byref(rr))
w = rr.right - rr.left
ht = rr.bottom - rr.top
if w > 0 and ht > 0:
found.append((w * ht, h))
return True
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
cb_ref.append(CB(_cb))
u.EnumChildWindows(main_hwnd, cb_ref[0], 0)
if not found:
return 0
found.sort(key=lambda t: t[0], reverse=True)
return found[0][1]
def _get_pid(self, hwnd: int) -> int:
pid = wintypes.DWORD()
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
return pid.value
def _update_render_rect(self):
rr = wintypes.RECT()
ctypes.windll.user32.GetWindowRect(self.render_hwnd, ctypes.byref(rr))
self.render_rect = (rr.left, rr.top, rr.right, rr.bottom)
self.origin_x, self.origin_y = rr.left, rr.top
self.render_w = rr.right - rr.left
self.render_h = rr.bottom - rr.top
self._update_layout()
def _update_layout(self):
"""根据当前渲染窗口尺寸换算各布局区域(渲染相对坐标)。
所有硬编码像素坐标替换为按比例计算,保证跨 DPI/分辨率/窗口
尺寸一致:``sidebar_right``(右面板左边界)、``search_box``
(左侧搜索框)、``send_button_region``(发送按钮检索区)。
比例优先取布局校准结果(``_sidebar_ratio`` / ``_send_button_ratio``),
未校准时回落到模块默认常量。
"""
sb_ratio = getattr(self, '_sidebar_ratio', SIDEBAR_RATIO)
send_ratio = getattr(self, '_send_button_ratio', SEND_BUTTON_RATIO)
self.sidebar_right = max(120, int(self.render_w * sb_ratio))
self.right_pane_left = self.sidebar_right
sx0, sy0, sx1, sy1 = SEARCH_BOX_RATIO
self.search_box = (
int(self.sidebar_right * sx0), int(self.render_h * sy0),
int(self.sidebar_right * sx1), int(self.render_h * sy1))
bx0, by0, bx1, by1 = send_ratio
self.send_button_region = (
int(self.render_w * bx0), int(self.render_h * by0),
int(self.render_w * bx1), int(self.render_h * by1))
# ------------------------------------------------------------------
# 布局动态校准(防 DPI/布局漂移)
# ------------------------------------------------------------------
def _detect_sidebar_ratio(self) -> Optional[float]:
"""OCR 锚点法实测侧栏宽度比例,失败返回 None。
微信 4.x 侧栏背景与消息区同为白色,像素边界几乎不可见,唯一稳定
的结构锚点是搜索框内的「搜索」占位文本(文本中心 ≈ 侧栏宽的 0.28,
实测于本机 3072x1920)。结果钳制在 [0.14, 0.30],超出正常范围视为
误检(如 OCR 读到别处的「搜索」),返回 None 由调用方回退默认比例。
"""
try:
for _ in range(2):
lines = self.ocr((0, 0, self.render_w, self.render_h))
for text, x, y, w, h in lines:
t = (text or '').strip()
if '搜索' in t and x < self.render_w * 0.35 \
and y < self.render_h * 0.3:
cx = x + w // 2
ratio = (cx / 0.28) / self.render_w
if 0.14 <= ratio <= 0.30:
return ratio
time.sleep(0.8)
return None
except Exception:
return None
def calibrate_layout(self, save: bool = True) -> bool:
"""检测布局锚点并实测布局比例,写入 ``~/.wechatauto/layout-<机器>.json``。
锚点(实测驱动,所有结果都钳制在合理范围,防误检破坏可用布局):
* 侧栏宽度:OCR 找「搜索」占位文本,反推侧栏右边界;
* 发送按钮:OCR 在右下角找「发送」文本,反推检索区比例
(发送按钮仅输入框有内容时可见,找不到就保持默认)。
校准采用「保守优先」策略:任一项检测失败即用模块默认比例,宁可
不做调整也不产生错误的坐标。返回是否成功。
"""
def _run_with_timeout(fn, timeout=5):
result = [None]
def _target():
try:
result[0] = fn()
except Exception:
pass
t = threading.Thread(target=_target, daemon=True)
t.start()
t.join(timeout)
return result[0]
try:
self._update_render_rect()
self.bring_to_front()
time.sleep(0.8)
self._update_render_rect()
layout: Dict[str, object] = {'machine': _machine_id()}
# 1) 侧栏宽度:OCR「搜索」锚点(限制 5s 超时)
sb = _run_with_timeout(self._detect_sidebar_ratio, timeout=5)
layout['sidebar_ratio'] = float(sb) if sb else SIDEBAR_RATIO
# 2) 发送按钮:OCR「发送」(仅右下角检索区,限制 5s 超时)
try:
lines = _run_with_timeout(
lambda: self.ocr((int(self.render_w * 0.5),
int(self.render_h * 0.7),
self.render_w, self.render_h)),
timeout=5)
lines = lines or []
except Exception:
lines = []
send = None
for text, x, y, w, h in lines:
if (text or '').strip() == '发送':
send = (x, y, w, h)
break
if send:
sx, sy, sw, sh = send
# 检索区需略大于按钮本体,OCR 才能稳定命中;四周留边距
pad_x, pad_y = 40, 20
x0 = max(0, sx - pad_x)
y0 = max(0, sy - pad_y)
x1 = min(self.render_w, sx + sw + pad_x)
y1 = min(self.render_h, sy + sh + pad_y)
layout['send_button_ratio'] = [
x0 / self.render_w, y0 / self.render_h,
x1 / self.render_w, y1 / self.render_h]
else:
layout['send_button_ratio'] = list(SEND_BUTTON_RATIO)
layout['render_w'] = self.render_w
layout['render_h'] = self.render_h
layout['date'] = time.strftime('%Y-%m-%d %H:%M:%S')
if save:
try:
with open(_layout_path(), 'w', encoding='utf-8') as f:
json.dump(layout, f, ensure_ascii=False, indent=2)
except Exception as e:
wxlog.debug(f'保存布局校准文件失败:{e}')
self._apply_layout(layout)
wxlog.info(
f'布局校准完成:sidebar_ratio={layout["sidebar_ratio"]:.3f}, '
f'send_button_ratio={layout["send_button_ratio"]}')
return True
except Exception as e:
wxlog.debug(f'布局校准失败:{e}')
return False
def _apply_layout(self, d: dict) -> None:
"""应用布局校准结果并重算各布局区域。"""
self._sidebar_ratio = float(d.get('sidebar_ratio', SIDEBAR_RATIO))
sb = d.get('send_button_ratio')
if isinstance(sb, (list, tuple)) and len(sb) == 4:
try:
self._send_button_ratio = tuple(float(v) for v in sb)
except Exception:
self._send_button_ratio = SEND_BUTTON_RATIO
self._update_layout()
def _load_layout(self) -> bool:
"""运行前自动加载本机已校准的布局配置,返回是否成功采用。
窗口尺寸与校准当时差异过大(>15%,如窗口缩放/未最大化)时视为
布局不匹配,拒绝采用并触发重新校准。
"""
p = _layout_path()
if not os.path.isfile(p):
return False
try:
with open(p, encoding='utf-8') as f:
d = json.load(f)
except Exception:
return False
if abs(float(d.get('render_w', 0) or 0) - self.render_w) \
/ max(self.render_w, 1) > 0.15:
wxlog.info(
f'布局配置与当前窗口尺寸差异过大,忽略并重新校准'
f'(校准={d.get("render_w")} vs 当前={self.render_w}')
return False
self._apply_layout(d)
wxlog.info(f'已加载布局校准:sidebar_ratio={self._sidebar_ratio:.3f}')
return True
def use_window(self, top_hwnd: int) -> bool:
"""把 GUI 操作目标切换到指定顶层微信窗口(主窗或独立聊天窗)。
微信 4.x 通过搜索打开会话时会生成独立聊天窗口(标题如
“昵称与X的聊天记录”)。切换后 render_rect/origin 等随新窗口更新,
click/paste/OCR 等原语无需改动即可在新窗口内工作。找不到渲染
子窗口时直接用该顶层窗口矩形计算坐标。
"""
if not top_hwnd or not self._input._user32.IsWindow(top_hwnd):
return False
render = self._find_render_window(top_hwnd)
if not render:
render = top_hwnd # 渲染窗口缺失时回退用窗口矩形
self.main_hwnd = top_hwnd
self.render_hwnd = render
self._update_render_rect()
return True
def _find_chat_window(self, name: str) -> int:
"""在当前微信进程的所有可见窗口中,找标题含 name 前2字与“聊天记录”的独立聊天窗。"""
u = self._input._user32
wx_pid = self._get_pid(self.main_hwnd)
frag = name[:2]
found = 0
cb_ref = []
def _cb(h, lp):
nonlocal found
pid = wintypes.DWORD()
u.GetWindowThreadProcessId(h, ctypes.byref(pid))
if pid.value != wx_pid or not u.IsWindowVisible(h):
return True
buf = ctypes.create_unicode_buffer(256)
u.GetWindowTextW(h, buf, 256)
title = buf.value
if title and '聊天记录' in title and frag in title:
found = h
return False
return True
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
cb_ref.append(CB(_cb))
u.EnumWindows(cb_ref[0], 0)
return found
def is_alive(self) -> bool:
return bool(self._input._user32.IsWindow(self.main_hwnd))
# ------------------------------------------------------------------
# 前台与可用性
# ------------------------------------------------------------------
def bring_to_front(self, keep_topmost: bool = False) -> bool:
"""将微信窗口置为前台,返回是否成功。
依次尝试:恢复窗口 → 置顶(HWND_TOPMOST) → SetForegroundWindow
并配合 AttachThreadInput 解除焦点抢占限制。
参数 ``keep_topmost``:为 True 时保持置顶(适合在被其他窗口遮挡
的环境下连续操作,操作完成后可调用 ``restore_zorder()`` 取消置顶);
为 False 时操作结束后立即取消置顶。
"""
u = self._input._user32
SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW = 0x2, 0x1, 0x40
# 清除前台锁(SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001,值=0 表示无延迟)
SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001
ctypes.windll.user32.SystemParametersInfoW(
SPI_SETFOREGROUNDLOCKTIMEOUT, 0, None, 0)
for attempt in range(5):
fg = u.GetForegroundWindow()
if fg == self.main_hwnd:
break
tid_fg = u.GetWindowThreadProcessId(fg, None) if fg else 0
tid_t = u.GetWindowThreadProcessId(self.main_hwnd, None)
if tid_fg:
u.AttachThreadInput(tid_fg, tid_t, True)
_restore_keep_maximize(u, self.main_hwnd)
u.SetWindowPos(self.main_hwnd, -1, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW) # HWND_TOPMOST
u.SetForegroundWindow(self.main_hwnd)
u.SetActiveWindow(self.main_hwnd)
u.BringWindowToTop(self.main_hwnd)
if self.render_hwnd and u.GetFocus() != self.render_hwnd:
u.SetFocus(self.render_hwnd)
if tid_fg:
u.AttachThreadInput(tid_fg, tid_t, False)
time.sleep(0.6)
if u.GetForegroundWindow() == self.main_hwnd:
break
if not keep_topmost:
u.SetWindowPos(self.main_hwnd, -2, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE) # HWND_NOTOPMOST
fg = u.GetForegroundWindow()
return fg == self.main_hwnd or fg is None
def restore_zorder(self):
"""取消置顶,恢复普通 Z 序(配合 ``bring_to_front(keep_topmost=True)`` 使用)。"""
self._input._user32.SetWindowPos(self.main_hwnd, -2, 0, 0, 0, 0, 0x2 | 0x1)
def _minimize_blockers(self) -> int:
"""最小化所有与微信主窗口重叠的非微信顶层窗口,返回处理个数。
微信被 Chrome/OpenCode 等窗口覆盖时,鼠标点击的命中测试按 Z 序
会落到覆盖层上,必须先把遮挡窗口最小化才能操作微信。
只处理「可见 + 与微信重叠 + 非微信进程 + 非系统装饰窗口」的窗口。
"""
u = self._input._user32
wx_pid = self._get_pid(self.main_hwnd)
rect = wintypes.RECT()
u.GetWindowRect(self.main_hwnd, ctypes.byref(rect))
wx = (rect.left, rect.top, rect.right, rect.bottom)
skip_classes = ("Progman", "WorkerW", "Shell_TrayWnd", "kugou_ui",
"MSCTFIME UI", "IME")
targets = []
def _cb(h, lp):
if not u.IsWindowVisible(h) or u.IsIconic(h):
return True
cls_buf = ctypes.create_unicode_buffer(256)
u.GetClassNameW(h, cls_buf, 256)
cls = cls_buf.value
if cls in skip_classes or cls.startswith("Windows.UI.Core"):
return True
if self._get_pid(h) == wx_pid:
return True # 微信自身窗口不动
r2 = wintypes.RECT()
u.GetWindowRect(h, ctypes.byref(r2))
if not (r2.right > wx[0] and r2.left < wx[2]
and r2.bottom > wx[1] and r2.top < wx[3]):
return True # 与微信不重叠
title_buf = ctypes.create_unicode_buffer(256)
u.GetWindowTextW(h, title_buf, 256)
if cls == "Chrome_WidgetWin_1" or title_buf.value.strip():
targets.append(h)
return True
cb_ref = []
CB = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
cb_ref.append(CB(_cb))
u.EnumWindows(cb_ref[0], 0)
for h in targets:
u.ShowWindow(h, 6) # SW_MINIMIZE
if targets:
wxlog.info(f'自动最小化遮挡窗口 {len(targets)} 个')
return len(targets)
def ensure_visible(self) -> bool:
"""自动最小化遮挡窗口并把微信置于前台,返回桌面是否可用。
发送类操作前调用,替代「手动最小化 Chrome 再置顶微信」的步骤。
遮挡窗口最小化后微信仍保持置顶,便于连续多次发送。
批量发送(三件套等)会逐条调用本方法,为避免每条都重复
枚举窗口 / 置顶(上轮实测每次开销 20s+),15 秒内已成功
前置过且窗口仍存活则直接复用,不再重复扫描。
"""
if (getattr(self, '_last_visible_ok', False)
and getattr(self, '_last_visible_ts', 0) > time.time() - 15
and self.is_alive()):
return True
self._minimize_blockers()
time.sleep(0.8)
self.bring_to_front(keep_topmost=True)
time.sleep(0.5)
self._update_render_rect()
ok = self.desktop_available()
self._last_visible_ok = ok
self._last_visible_ts = time.time()
return ok
def wx_click(self, x: int, y: int, right: bool = False):
"""点击微信渲染窗口内的坐标。
微信渲染子窗口(MMUIRenderSubWindow*)设置了
``WS_EX_LAYERED | WS_EX_TRANSPARENT``,导致 mouse_event
的点击会穿透到主窗口而无法到达渲染层。此方法在点击前后
临时去掉 ``WS_EX_TRANSPARENT``,使点击能被渲染窗口接收,
随即恢复原样式以保证画面正常合成。
"""
u = self._input._user32
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
old_ex = u.GetWindowLongW(self.render_hwnd, GWL_EXSTYLE)
if old_ex & WS_EX_TRANSPARENT:
u.SetWindowLongW(self.render_hwnd, GWL_EXSTYLE,
old_ex & ~WS_EX_TRANSPARENT)
time.sleep(0.05)
try:
self._input.real_click(x, y, right=right)
finally:
if old_ex & WS_EX_TRANSPARENT:
u.SetWindowLongW(self.render_hwnd, GWL_EXSTYLE, old_ex)
time.sleep(0.05)
def wx_wheel(self, delta: int):
"""滚轮滚动渲染窗口内的内容。
与 wx_click 同理:渲染子窗口带 WS_EX_TRANSPARENTmouse_event 的
滚轮事件会穿透到下层窗口,必须临时去掉该样式再滚动。
"""
u = self._input._user32
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
old_ex = u.GetWindowLongW(self.render_hwnd, GWL_EXSTYLE)
if old_ex & WS_EX_TRANSPARENT:
u.SetWindowLongW(self.render_hwnd, GWL_EXSTYLE,
old_ex & ~WS_EX_TRANSPARENT)
time.sleep(0.05)
try:
self._input.wheel(delta)
finally:
if old_ex & WS_EX_TRANSPARENT:
u.SetWindowLongW(self.render_hwnd, GWL_EXSTYLE, old_ex)
time.sleep(0.05)
def desktop_available(self) -> bool:
"""检查微信窗口是否真的可见(锁屏/会话断开时返回 False)。"""
if not self.is_alive():
return False
try:
img = self._grab_screen(self.render_rect)
except Exception:
return False
px = img.load()
w, h = img.size
white = total = 0
for y in range(0, h, 16):
for x in range(0, w, 16):
r, g, b = px[x, y]
total += 1
if r > 230 and g > 230 and b > 230:
white += 1
return white / max(total, 1) > 0.05
# ------------------------------------------------------------------
# 截图与 OCR
# ------------------------------------------------------------------
def _grab_screen(self, box: Optional[Tuple[int, int, int, int]] = None) -> Image.Image:
"""截取屏幕区域(带重试)。box 为屏幕绝对坐标。"""
from PIL import ImageGrab
last = None
for _ in range(6):
try:
return ImageGrab.grab(bbox=box) if box else ImageGrab.grab()
except Exception as e:
last = e
time.sleep(0.5)
raise RuntimeError(f'屏幕截图失败:{last}')
def _rel_to_screen(self, rel: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
x0, y0, x1, y1 = rel
return (self.origin_x + x0, self.origin_y + y0,
self.origin_x + x1, self.origin_y + y1)
def ocr(self, rel_box: Tuple[int, int, int, int]) -> List[Tuple[str, int, int, int, int]]:
"""对渲染窗口相对区域做 OCR,返回 (text, rel_x, rel_y, w, h)。"""
screen_box = self._rel_to_screen(rel_box)
img = self._grab_screen(screen_box)
res = ScreenOCR.recognize(img)
out = []
for text, x, y, w, h in res:
out.append((text, rel_box[0] + x, rel_box[1] + y, w, h))
return out
def ocr_zoomed(self, rel_box: Tuple[int, int, int, int],
scale: int = 3) -> List[Tuple[str, int, int, int, int]]:
"""对渲染窗口相对区域放大 scale 倍后 OCR,返回渲染相对坐标。
微信 4.x 的小字号标题(尤其含生僻字如「卢立竺」)原尺寸 OCR 常
漏识别或读出乱码,放大后识别率显著提升。坐标按 1/scale 还原。
"""
screen_box = self._rel_to_screen(rel_box)
img = self._grab_screen(screen_box)
w, h = img.size
img = img.resize((w * scale, h * scale), Image.LANCZOS)
res = ScreenOCR.recognize(img)
out = []
for text, x, y, w, h in res:
out.append((text, rel_box[0] + x // scale,
rel_box[1] + y // scale, w // scale, h // scale))
return out
# ------------------------------------------------------------------
# 会话列表
# ------------------------------------------------------------------
def get_sessions(self, zoomed: bool = False) -> List[Dict[str, object]]:
"""OCR 识别左侧会话列表,返回 [{name, x, y, w, h}](渲染相对坐标)。
会话名文本约占侧栏宽的 30%~100%;左侧 <30% 为头像/未读角标,
右侧为时间戳。均按比例过滤,跨分辨率一致。
zoomed=True 时对整块侧栏放大 3 倍后 OCR,用于原尺寸扫不到
(生僻字/小字号)时兜底;速度较慢,仅按需启用。
"""
rel = (SIDEBAR_LEFT, int(self.render_h * SIDEBAR_TOP),
self.sidebar_right, self.render_h)
lines = self.ocr_zoomed(rel, scale=3) if zoomed else self.ocr(rel)
rows = []
for text, x, y, w, h in lines:
t = (text or '').strip()
if not t:
continue
if x < self.sidebar_right * 0.30: # 头像/图标/角标区
continue
if any(k in t for k in ('搜索', '聊天', '通讯录')):
continue
rows.append({'name': t, 'x': x, 'y': y, 'w': w, 'h': h})
return rows
@staticmethod
def _name_matches(ocr_name: str, target: str) -> bool:
"""OCR 会话名的韧性匹配,容忍首尾字符截断/粘连。
微信 4.x 标题 OCR 常把首字截掉('文件传输助手'→'件传输助'),
因此不能只用精确相等或 startswith。匹配优先级:
1. 完整相等
2. 识别名是目标名的一段(截首/截尾/截中)
3. 识别名更长时目标名是其子串(识别名带多余字符)
"""
a = (ocr_name or '').strip()
b = (target or '').strip()
if not a or not b:
return False
if a == b:
return True
# 截断容忍:OCR 名是目标名的一段(识别名比目标短,属截首/截尾/截中)
if a in b:
return True
# 识别名更长(含多余字符):仅当识别名 >= 3 字时接受子串命中,
# 避免目标名(如 2 字)是较长识别名前缀导致的误点
if b in a and len(a) >= 3:
return True
return False
def find_session(self, name: str, max_scroll: int = 3) -> Optional[Tuple[int, int]]:
"""在会话列表中查找指定会话,返回可点击的 (相对x, 相对y)。
点击点取 OCR 文本框中心(而非硬编码列坐标),自动适配任何
窗口宽度/DPI。渲染可能在窗口置前台后短暂未刷新,先重复纯 OCR
再滚动;找不到时悬停列表滚动重试。
滚动方向自适应:先向上翻找列表上半段的高亮会话(已打开的会话
会被置顶),找不到再向下滚动其它区域。
"""
u = self._input._user32
def _scan(zoomed: bool = False) -> Optional[Tuple[int, int]]:
# 按 y 排序,OCR 返回顺序不可靠,保证点击视觉上正确的行
for row in sorted(self.get_sessions(zoomed=zoomed), key=lambda r: r['y']):
if self._name_matches(row['name'], name):
return (row['x'] + row['w'] // 2, row['y'] + row['h'] // 2)
return None
def _scan_vote(zoomed: bool = False, rounds: int = 4,
min_votes: int = 2,
tol: int = 30) -> Optional[Tuple[int, int]]:
"""多轮 OCR 投票找会话行,抗单轮识别抖动。
WinRT OCR 对生僻字/小字号(如「卢立竺」)存在抖动:同一行
不同轮次可能读出「卢立竺」或「亠人五」。逐轮扫描并把命中行
按 y 聚类,票数达到 min_votes 才返回,显著降低误配率。
"""
hits = [] # (y, x, w, h)
for _ in range(rounds):
# 每轮只取 y 最靠前的一个命中,避免同轮多个误配行干扰
for row in sorted(self.get_sessions(zoomed=zoomed),
key=lambda r: r['y']):
if self._name_matches(row['name'], name):
hits.append((row['y'], row['x'], row['w'], row['h']))
break
time.sleep(0.15)
if not hits:
return None
hits.sort()
clusters = [] # [y均值, x均值, w均值, h均值, 票数]
for y, x, w, h in hits:
for c in clusters:
if abs(c[0] - y) <= tol:
c[4] += 1
n = c[4]
c[0] = (c[0] * (n - 1) + y) / n
c[1] = (c[1] * (n - 1) + x) / n
c[2] = max(c[2], w)
c[3] = max(c[3], h)
break
else:
clusters.append([float(y), float(x), w, h, 1])
best = max(clusters, key=lambda c: c[4])
if best[4] < min_votes:
return None
return (int(best[1] + best[2] // 2), int(best[0] + best[3] // 2))
for _ in range(2): # 先不带滚动重试 OCR,等渲染刷新
hit = _scan()
if hit:
return hit
time.sleep(0.4)
# 原尺寸扫不到 → 放大 3 倍多轮投票(生僻字/小字号会话,如「卢立竺」)
hit = _scan_vote(zoomed=True)
if hit:
return hit
for _ in range(max_scroll):
hit = _scan()
if hit:
return hit
# 未找到 → 悬停会话列表,先向上翻再向下翻各试探
u.SetCursorPos(self.origin_x + self.sidebar_right // 2,
self.origin_y + int(self.render_h * 0.5))
time.sleep(0.2)
self.wx_wheel(-360)
time.sleep(0.6)
hit = _scan()
if hit:
return hit
hit = _scan_vote(zoomed=True)
if hit:
return hit
self.wx_wheel(360)
time.sleep(0.6)
return None
def _row_is_active(self, rel_y: int) -> bool:
"""判断侧栏指定行是否为当前高亮(已打开)的会话。
微信 4.x 活动会话行背景为绿色 (≈21,172,112),普通行为浅灰
(238,238,240)。取会话名右侧空白横向条带统计绿色像素即可判断。
"""
try:
y0 = max(0, rel_y - 6)
x1 = max(self.right_pane_left - 40, 360)
rel = (360, y0, x1, rel_y + 6)
img = self._grab_screen(self._rel_to_screen(rel))
px = img.load()
w, h = img.size
green = 0
for yy in range(0, h, 2):
for xx in range(0, w, 2):
r, g, b = px[xx, yy][:3]
if g > r + 25 and g > b + 25 and g > 100:
green += 1
return green > 20
except Exception:
return False
def _get_uia(self):
"""惰性创建并复用 UIA 引擎;不可用时返回 None(由调用方降级 OCR)。
混合驱动:UIA 树需热激活(写 Weixin.dll 的 Qt accessibility byte)。
首次尝试失败则本次会话内不再重试(避免每条消息都等超时)。
"""
if self._uia_tried:
return self._uia
self._uia_tried = True
try:
from wechatauto.uia_driver import WeChatUIA
eng = WeChatUIA()
if eng.ensure_window():
self._uia = eng
wxlog.info('已启用 UIA 驱动(混合路径:UIA 优先,OCR 兜底)')
else:
wxlog.info('UIA 树不可用,本次会话使用 OCR 驱动')
except Exception as e:
wxlog.debug('初始化 UIA 引擎失败:%s', e)
return self._uia
def open_chat(self, name: str, exact: bool = False) -> bool:
"""打开指定会话的聊天窗口(优先 UIA,其次 OCR 侧栏/搜索)。
微信 4.x 行为:点侧栏会话在主窗右侧面板打开;搜索下拉点联系人则
打开独立聊天窗口(标题“昵称与X的聊天记录”)。本方法两条路都处理,
打开后把 GUI 操作目标切到对应窗口,并验证会话真正打开。
"""
uia = self._get_uia()
if uia is not None and uia.open_chat(name):
self._current_chat = name
return True
if not self.ensure_visible():
wxlog.warning('无法将微信窗口置于前台,尝试继续操作')
# 优先:右侧面板当前已打开目标会话 → 直接成功
# (微信 4.x 已打开的会话才渲染右侧,侧栏查找反而慢且易误配)
self._update_render_rect()
if self._chat_is_open(name) and self._pane_has_content():
return True
# 优先侧栏:多轮「查找 + 点击 + 确认」(侧栏点击最可靠)
for _ in range(3):
pos = self.find_session(name)
if pos:
self.use_window(self.main_hwnd)
rx, ry = pos
# 行已是当前高亮(会话已打开):再点一次会关闭它,直接确认
if self._row_is_active(ry):
if self._pane_has_content():
return True
# 高亮但面板空白:不点击(点击会关闭会话),交给搜索兜底
wxlog.debug(f'会话 {name} 行已高亮但面板空白,跳过点击')
else:
self.wx_click(self.origin_x + rx, self.origin_y + ry)
if self._chat_open_confirmed(name):
return True
wxlog.debug(f'侧栏未确认打开 {name},重试')
time.sleep(0.5)
# 侧栏找不到 → 搜索框回退(搜索下拉易误开独立历史窗,仅作最后手段)
if self._search_chat(name):
sub = self._find_chat_window(name)
if sub:
_restore_keep_maximize(self._input._user32, sub)
self._input._user32.SetForegroundWindow(sub)
time.sleep(0.5)
self.use_window(sub)
time.sleep(0.3)
return True
if self._chat_open_confirmed(name):
return True
return False
def _chat_open_confirmed(self, name: str) -> bool:
"""点击会话后轮询确认已打开。
微信 4.x 聊天标题为浅灰渲染、OCR 常读不到,因此标题命中算最可靠
证据,读不到时以「消息区已渲染非空白」作为会话已打开的判据。
注意:若此前已开着其它会话,面板非空白并不代表目标已打开,
必须优先看标题命中。
"""
for _ in range(5):
time.sleep(0.6)
if self._chat_is_open(name):
return True
# 标题始终读不到才退而求其次:面板有内容且非目标会话也算打开
# (点错会话时标题会匹配到别的名字,标题优先可拦截误开)
for _ in range(3):
time.sleep(0.4)
if self._chat_is_open(name):
return True
if self._pane_has_content():
return True
return False
def _pane_has_content(self) -> bool:
"""右侧消息区是否渲染了内容(非全白空白页)。"""
try:
y0 = int(self.render_h * 0.18)
y1 = int(self.render_h * 0.72)
rel = (self.right_pane_left + 40, y0, self.render_w - 40, y1)
img = self._grab_screen(self._rel_to_screen(rel))
px = img.load()
w, h = img.size
non_white = sum(1 for y in range(0, h, 6) for x in range(0, w, 6)
if sum(px[x, y][:3]) / 3 <= 235)
return non_white > 30
except Exception:
return False
def _chat_is_open(self, name: str) -> bool:
"""检测右侧面板是否打开了指定会话(标题 OCR)。
微信 4.x 标题 OCR 常把首字符截掉(“文件传输助手”→“件传输助”),
因此不能用前 2 字匹配,需用名称中段片段(如第 2-3 字符)。标题
字号小、含生僻字时原尺寸 OCR 会漏识别,需放大后再读;标题行实际
渲染在 y≈80-180(非顶部 15-100),OCR 区需向上/向下多留。
"""
try:
# 标题文本贴右面板左边缘(可能略越过侧栏边界),OCR 区向左多留
x0 = max(0, self.right_pane_left - 60)
res = self.ocr_zoomed((x0, 0, self.render_w, 185), scale=3)
title = ''.join(t.strip() for t, x, y, w, h in res)
if len(name) >= 3:
frags = (name[1:3], name[2:4], name[-2:], name[:2])
else:
frags = (name,)
return any(f and f in title for f in frags)
except Exception:
return False
def _search_chat(self, name: str) -> bool:
"""退路:搜索框 + 剪贴板粘贴搜索,点选名称匹配的第一条联系人。
搜索下拉结果排布:联系人在最上方,下面是「搜索网络结果 / 搜一搜」
等节标题。OCR 结果按 y 排序后,跳过节标题/提示行,点选视觉上
第一条匹配名称的联系人行。
群聊的成员预览行(如「00,包含:卢立竺」)也含目标名片段,若不
排除会误点群聊而非联系人。群聊节标题「群聊」以下的行优先排除,
含「包含」的成员预览行直接跳过。
"""
frag = name[:2]
for _ in range(3):
sb = self._rel_to_screen(self.search_box)
self.wx_click((sb[0] + sb[2]) // 2, (sb[1] + sb[3]) // 2)
time.sleep(0.3)
self._input.key(VK_A, ctrl=True)
self._input.key(VK_DELETE)
self.set_clipboard(name)
self._input.key(VK_V, ctrl=True)
time.sleep(0.8)
res = self.ocr_zoomed((SIDEBAR_LEFT, int(self.render_h * 0.08),
self.sidebar_right, self.render_h), scale=2)
rows = sorted(res, key=lambda r: (r[2], r[1]))
group_section = False
for t, x, y, w, h in rows:
tt = (t.strip() or '')
if not tt:
continue
if tt == '群聊':
group_section = True
continue
if tt.startswith('聊天记录') or tt.startswith('查看全部'):
break
if group_section:
continue
# 跳过“搜索/网络/搜一搜”等节标题与提示行
if (tt.startswith('搜索') or tt.startswith('搜一搜')
or '网络' in tt or '暂无' in tt):
continue
# 群聊成员预览行,跳过以免误点群聊
if '包含' in tt or tt.endswith('群') or tt.endswith('群聊'):
continue
if frag in tt:
self.wx_click(self.origin_x + x + w // 2,
self.origin_y + y + h // 2)
time.sleep(0.8)
return True
return False
# ------------------------------------------------------------------
# 输入框
# ------------------------------------------------------------------
def get_input_box(self) -> Optional[Tuple[int, int, int, int]]:
"""自适应输入框检测,返回渲染相对矩形 (x0,y0,x1,y1)。
原理:输入框是右侧面板底部一块**全宽浅色区**,与上方消息区以一条
约 2px 的浅灰边框线 ((224,224,224)) 分隔。先取若干探针行(自下而上
在输入框下半部找一行,要求该行在 right_pane_left..render_w 范围内
白度 >=0.8),再从探针行向上/向下扫描中心竖线确定输入框上下边界。
探测失败返回 None(不再回退到默认布局,避免在错误坐标上误点)。
"""
for _ in range(6):
box = self._probe_input_box()
if box:
return box
time.sleep(0.5)
self._update_render_rect()
wxlog.debug('未检测到输入框')
# 布局异常自动重校准:本机校准配置可能已不匹配(DPI/布局漂移),
# 重新检测锚点并校准一次后再次探测(每会话只自动触发一次)。
if not getattr(self, '_auto_recalibrated', False):
self._auto_recalibrated = True
wxlog.info('输入框探测连续失败,自动重新校准布局…')
self.calibrate_layout()
for _ in range(3):
box = self._probe_input_box()
if box:
return box
time.sleep(0.5)
self._update_render_rect()
wxlog.debug('未检测到输入框')
# 兜底:输入框与底部工具栏高度固定,窗口缩放只改变消息区高度。
# 实测本机输入框上边 ≈ render_h-391、下边 ≈ render_h-150。
return (self.right_pane_left, self.render_h - int(self.render_h * 0.214),
self.render_w, self.render_h - int(self.render_h * 0.082))
def _probe_input_box(self) -> Optional[Tuple[int, int, int, int]]:
"""探针法定位输入框,失败返回 None。
输入框是右侧面板底部一块全宽浅色区,顶边是一条约 2px 的浅灰
224,224,224)分界线,与上方消息区(白底)分隔。消息区在渲染
未稳定时可能整体发白,需校验分界线是「细线」而非消息区里的
粗灰元素,否则会把消息区误判为输入框。
"""
cx = (self.right_pane_left + self.render_w) // 2
sx = self.origin_x + cx
for probe_off in (150, 120, 200, 250, 350, 100, 450):
probe_y = self.render_h - probe_off
if probe_y <= int(self.render_h * 0.50):
continue
sy = self.origin_y + probe_y
row_img = self._grab_screen(
(self.origin_x + self.right_pane_left, sy,
self.origin_x + self.render_w, sy + 1))
pxx = row_img.load()
w = row_img.size[0]
white = sum(1 for x in range(0, w, 2) if sum(pxx[x, 0]) / 3 > 240)
if white / max(1, (w + 1) // 2) < 0.8:
continue # 该行不在输入框内(如工具条/空白)
scan_top = self.origin_y + int(self.render_h * 0.50)
col = self._grab_screen((sx, scan_top, sx + 1, sy + 1))
pc = col.load()
dy_probe = sy - scan_top
y0 = dy_probe
while y0 > 0 and sum(pc[0, y0]) / 3 > 240:
y0 -= 1
y0 += 1
y1 = dy_probe
while y1 < col.size[1] - 1 and sum(pc[0, y1]) / 3 > 240:
y1 += 1
# 输入框顶边上方必须是 1-4px 的细灰分界线;若是消息区里的
# 粗灰元素(如系统消息/时间分隔),说明渲染未稳定,判为失败。
g = y0 - 1
while g >= 0 and sum(pc[0, g]) / 3 <= 240:
g -= 1
divider_h = (y0 - 1) - g
y0_abs = scan_top + y0 - self.origin_y
y1_abs = scan_top + y1 - self.origin_y
if (1 <= divider_h <= 4
and y1_abs - y0_abs >= 150
and y0_abs >= int(self.render_h * 0.45)
and y1_abs >= int(self.render_h * 0.85)):
return (self.right_pane_left, y0_abs, self.render_w, y1_abs)
return None
def focus_input(self, box: Optional[Tuple[int, int, int, int]] = None) -> bool:
"""点击输入框文本区使其获得焦点。返回是否检测到输入框。
box 可传入已探测好的输入框,避免重复探测在会话切换时偶发失败。
点击点取输入框顶部文本行附近(而非中心),避免点到下方
表情/工具栏而无法聚焦。
"""
if box is None:
box = self.get_input_box()
if not box:
return False
x0, y0, x1, y1 = box
cx = (x0 + x1) // 2
cy = y0 + min(24, (y1 - y0) // 4) # 文本行贴近输入框上沿
self.wx_click(self.origin_x + cx, self.origin_y + cy)
time.sleep(0.6)
return True
# ------------------------------------------------------------------
# 文字输入
# ------------------------------------------------------------------
def set_clipboard(self, text: str):
"""写入系统剪贴板(pyperclip,兼容中文)。"""
import pyperclip
pyperclip.copy(text)
time.sleep(0.2)
def input_text(self, text: str,
box: Optional[Tuple[int, int, int, int]] = None,
fast: bool = False) -> bool:
"""向当前聚焦的输入框输入文字。
优先走「剪贴板 + Ctrl+V」并多次重试确认(输入框探测 / 焦点 /
粘贴都可能偶发失败,整体循环重试);均失败后回退到「拼音 +
回车提交」的输入法组合。
fast=True 时复用传入 box 走单次快速路径(分段连续发送用),
失败即返回 False 由 send_msg 回退到完整流程。
"""
if fast and box:
if self.focus_input(box):
self.set_clipboard(text)
self._input.key(VK_A, ctrl=True)
self._input.key(VK_DELETE)
self._input.key(VK_V, ctrl=True)
time.sleep(0.35)
if self._input_box_has_text(box):
return True
return False
box = None
for attempt in range(1, 7):
box = self.get_input_box()
if not box:
wxlog.debug(f'未探测到输入框(attempt={attempt}),重试')
time.sleep(0.5)
continue
if not self.focus_input(box):
time.sleep(0.3)
continue
self.set_clipboard(text)
self._input.key(VK_A, ctrl=True) # 清空既有内容
self._input.key(VK_DELETE)
self._input.key(VK_V, ctrl=True)
time.sleep(0.8)
if self._input_box_has_text():
self._last_input_box = box
wxlog.debug(f'输入文字(剪贴板粘贴)成功,attempt={attempt}')
return True
wxlog.debug(f'剪贴板粘贴未确认(attempt={attempt}),重试')
time.sleep(0.3)
# 尝试 2:中文输入法拼音(逐字)
wxlog.debug('剪贴板粘贴多次未生效,尝试拼音组合输入')
self.focus_input(box)
self._input.key(VK_A, ctrl=True)
self._input.key(VK_DELETE)
self._input.type_pinyin(self._to_pinyin(text))
self._input.key(VK_RETURN) # 提交候选
time.sleep(0.8)
return self._input_box_has_text()
@staticmethod
def _to_pinyin(text: str) -> str:
"""极简中文 → 拼音映射(仅内置少量常用词,供回退路径使用)。
正式方案建议接入 ``pypinyin``pip install pypinyin)。
"""
table = {
'测试': 'ceshi', '你好': 'nihao', '你好世界': 'nihaoshijie',
'你好,世界': 'nihaoshijie', '消息': 'xiaoxi', '发送': 'fasong',
'自动化': 'zidonghua', '成功': 'chenggong', '文件': 'wenjian',
'文件传输助手': 'wenjianchuanshuzhushou',
}
if text in table:
return table[text]
try:
import pypinyin
return ''.join(pypinyin.lazy_pinyin(text))
except ImportError:
return ''.join(ch for ch in text if '\u4e00' <= ch <= '\u9fff') or text
def _input_box_has_text(self, box=None) -> bool:
"""检测输入框文本区是否有深色像素(文字/光标),确认输入生效。
box 可传入已探测好的输入框,避免连续发送时重复探测。
阈值收紧到 sum<450:空输入框的浅灰占位符约为 (200,200,200) 即
600,避免把占位符误判为未发送的文字导致消息重复发送。
"""
if box is None:
box = self.get_input_box()
if not box:
return False
x0, y0, x1, y1 = box
rel = (x0 + 20, y0 + 10, x1 - 20, y0 + 200)
img = self._grab_screen(self._rel_to_screen(rel))
px = img.load()
dark = sum(1 for y in range(0, img.size[1], 2) for x in range(0, img.size[0], 2)
if sum(px[x, y]) < 450)
return dark > 20
# ------------------------------------------------------------------
# 发送
# ------------------------------------------------------------------
def click_send(self, fast: bool = False) -> bool:
"""发送消息并确认输入框已清空。
优先回车键(输入框刚粘贴完必已聚焦,回车最可靠);回车后输入框
仍有内容则回退到 OCR 定位「发送」按钮点击。每次操作后都必须确认
输入框文本已清空(即消息真正发出),否则重试。
关键防护:发送前若输入框本无文字,回车等于空发,必须判定失败
让上层重试(否则消息未发出却被误判成功)。
fast=True 时仅回车 + 短等待(分段连续发送用),失败返回 False
由 send_msg 回退到完整流程。
"""
box = getattr(self, '_last_input_box', None)
if fast:
if not self._input_box_has_text(box):
return False
self._input.key(VK_RETURN)
time.sleep(0.5)
if not self._input_box_has_text(box):
return True
return False
for attempt in range(3):
if not self._input_box_has_text(box):
wxlog.debug('发送前输入框无文字,跳过空发')
return False
self._input.key(VK_RETURN)
time.sleep(1.0)
if not self._input_box_has_text(box):
return True
wxlog.debug(f'回车发送未生效(attempt={attempt}),改用「发送」按钮')
res = self.ocr(self.send_button_region)
clicked = False
for text, x, y, w, h in res:
if '发送' in text:
self.wx_click(self.origin_x + x + w // 2,
self.origin_y + y + h // 2)
clicked = True
break
if not clicked:
return False
time.sleep(1.0)
if not self._input_box_has_text(box):
return True
return False
def send_msg(self, text: str, who: Optional[str] = None,
verify: bool = False) -> WxResponse:
"""发送一条文本消息。
Args:
text: 消息内容
who: 目标会话名称(默认发送到当前打开的会话)
verify: 是否用数据库读取器回读确认发送成功
Returns:
WxResponse
整条流程带总时限(默认 75s):打开会话 / 输入 / 发送任一步偶发
失败时重试;已发送但 DB 未落库时轮询等待确认,不重复发送。
"""
# 快速路径:连续发送同一会话(如分段回复)时,复用已探测的输入框,
# 跳过 ensure_visible / 会话重检 / 重复探测与截图确认,失败自动回退完整流程。
if (not verify and who
and who == getattr(self, '_current_chat', None)
and getattr(self, '_last_input_box', None) is not None
and self.input_text(text, box=self._last_input_box, fast=True)
and self.click_send(fast=True)):
return WxResponse.success(f'消息已发送:{text}', data={'content': text})
# UIA 路径:热激活后可直接输入+回车发送,无 OCR 抖动,最快。
uia = self._get_uia()
if uia is not None:
if not who or uia.current_chat() == who or uia.open_chat(who):
if uia.send_text(text):
if not verify:
return WxResponse.success(f'消息已发送:{text}', data={'content': text})
if self._verify_sent(text, who):
return WxResponse.success(f'消息已发送并确认:{text}', data={'content': text})
wait_until = time.time() + 8
while time.time() < wait_until:
time.sleep(1.0)
if self._verify_sent(text, who):
return WxResponse.success(f'消息已发送并确认:{text}', data={'content': text})
return WxResponse.failure('消息已操作发送,但数据库未确认', data={'content': text})
if not self.ensure_visible():
return WxResponse.failure('微信窗口不可见(可能锁屏/会话断开)')
self._last_input_box = None
deadline = time.time() + 75
for attempt in range(3):
if who:
if time.time() > deadline:
break
# 会话复用:目标仍是当前已打开会话时跳过 open_chat(每次
# open_chat 都重扫侧栏/点击,是逐条发送的主要耗时点)
if not (who == getattr(self, '_current_chat', None)
and self._chat_is_open(who)):
if not self.open_chat(who):
wxlog.debug(f'open_chat 未确认(attempt={attempt}),重试')
continue
self._current_chat = who
time.sleep(0.8) # 等右侧面板/输入框渲染稳定,避免探测误判
if time.time() > deadline:
break
if not self.input_text(text):
wxlog.debug(f'输入文字失败(attempt={attempt}),重试')
continue
if not self.click_send():
wxlog.debug(f'点击发送未确认清空输入框(attempt={attempt}),重试')
continue
if not verify:
return WxResponse.success(f'消息已发送:{text}', data={'content': text})
if self._verify_sent(text, who):
return WxResponse.success(f'消息已发送并确认:{text}', data={'content': text})
# 可能已发送但 DB 异步落库,轮询确认,不重发避免重复
wait_until = time.time() + 8
while time.time() < wait_until:
time.sleep(1.0)
if self._verify_sent(text, who):
return WxResponse.success(f'消息已发送并确认:{text}', data={'content': text})
return WxResponse.failure('消息已操作发送,但数据库未确认', data={'content': text})
return WxResponse.failure('发送失败:多次重试未完成')
def _get_db(self):
"""惰性创建并复用 WeChatDB(密钥提取/解密较慢,避免每次校验都重建)。"""
if getattr(self, '_cached_db', None) is None:
from wechatauto.db import WeChatDB
self._cached_db = WeChatDB()
return self._cached_db
def _verify_sent(self, text: str, who: Optional[str]) -> bool:
try:
db = self._get_db()
if not who:
who = db.get_self_info()['username']
else:
hits = db.search_contact(who)
if hits:
who = hits[0]["username"]
msgs = db.get_messages(who, limit=3)
for m in msgs:
if db.is_self_sender(m.get('sender_id')) and text in (m.get('content') or ''):
return True
except Exception as e:
wxlog.debug(f'发送校验失败:{e}')
return False
# ------------------------------------------------------------------
# 文件 / 图片发送(剪贴板 CF_HDROP + Ctrl+V
# ------------------------------------------------------------------
@staticmethod
def copy_files_to_clipboard(paths: List[str]) -> bool:
"""把本地文件以 CF_HDROP 格式写入剪贴板,供微信粘贴为附件/图片。
相比走「+ 菜单 → 文件对话框」,该路线不依赖自绘界面图标定位,
兼容性最好:在聊天输入框 Ctrl+V 后,微信会把文件/图片插入草稿。
"""
paths = [os.path.abspath(p) for p in paths]
if not paths:
return False
class DROPFILES(ctypes.Structure):
_fields_ = [
("pFiles", ctypes.c_uint),
("pt_x", ctypes.c_long),
("pt_y", ctypes.c_long),
("fNC", ctypes.c_int),
("fWide", ctypes.c_int),
]
u32 = ctypes.windll.user32
k32 = ctypes.windll.kernel32
u32.OpenClipboard.argtypes = [ctypes.c_void_p]
u32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
k32.GlobalAlloc.restype = ctypes.c_void_p
k32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
k32.GlobalLock.restype = ctypes.c_void_p
k32.GlobalLock.argtypes = [ctypes.c_void_p]
k32.GlobalUnlock.argtypes = [ctypes.c_void_p]
CF_HDROP = 15
try:
if not u32.OpenClipboard(None):
return False
u32.EmptyClipboard()
df = DROPFILES()
df.pFiles = ctypes.sizeof(DROPFILES)
df.fWide = 1
raw = (ctypes.string_at(ctypes.byref(df), ctypes.sizeof(DROPFILES))
+ ("\0".join(paths) + "\0").encode("utf-16-le") + b"\0\0")
h = k32.GlobalAlloc(0x0042, len(raw))
if not h:
u32.CloseClipboard()
return False
dst = k32.GlobalLock(h)
ctypes.memmove(dst, raw, len(raw))
k32.GlobalUnlock(h)
u32.SetClipboardData(CF_HDROP, h)
u32.CloseClipboard()
return True
except Exception as e:
wxlog.debug(f'写入 CF_HDROP 剪贴板失败:{e}')
return False
def _paste_attachment_and_send(self, path: str,
box: Optional[Tuple[int, int, int, int]] = None) -> bool:
"""聚焦输入框 → 粘贴文件 → 检测草稿就绪 → 回车发送。
图片草稿为彩色像素、文件为灰色卡片。图片粘贴偶发失效,
采用「重新聚焦 + 重新粘贴」重试循环提升可靠性。
box 可传入已探测好的输入框(连续发送附件时复用,避免重复探测)。
"""
if not os.path.isfile(path):
wxlog.debug(f'文件不存在:{path}')
return False
is_image = path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
if box is None:
box = self.get_input_box()
wxlog.debug(f'粘贴前输入框探测:{box}')
for attempt in range(3):
if not self.focus_input(box):
return False
if not self.copy_files_to_clipboard([path]):
return False
time.sleep(0.3)
self._input.key(VK_A, ctrl=True)
self._input.key(VK_DELETE)
self._input.key(VK_V, ctrl=True)
if is_image:
draft_ok = self._input_box_has_color_draft(box, wait=6.0)
wxlog.debug(f'粘贴尝试 {attempt + 1} 草稿检测:{draft_ok}')
if draft_ok:
break
else:
time.sleep(2.0)
break
else:
wxlog.debug('多次粘贴仍未检测到图片草稿,仍尝试回车')
self._input.key(VK_RETURN)
time.sleep(1.5)
return True
def _input_box_has_color_draft(self, box: Optional[Tuple[int, int, int, int]] = None,
wait: float = 8.0) -> bool:
"""轮询输入框区域是否存在彩色像素(图片缩略图草稿已渲染)。
图片粘贴后输入框会向上扩展容纳缩略图,因此扫描区域向上多
覆盖一段(y0-150),避免草稿出现在探测基线之上而漏检。
"""
if box is None:
box = self.get_input_box()
if not box:
return False
x0, y0, x1, y1 = box
scan_y0 = max(100, y0 - 150)
scan_y1 = min(self.render_h - 15, y1 + 80)
rel = (x0 + 10, scan_y0, x1 - 10, scan_y1)
screen_rect = self._rel_to_screen(rel)
deadline = time.time() + wait
last_colored = 0
while time.time() < deadline:
try:
img = self._grab_screen(screen_rect)
px = img.load()
colored = 0
for yy in range(0, img.size[1], 4):
for xx in range(0, img.size[0], 4):
r, g, b = px[xx, yy][:3]
if abs(r - g) > 20 or abs(g - b) > 20 or abs(r - b) > 20:
colored += 1
last_colored = colored
if colored > 30:
return True
except Exception:
pass
time.sleep(0.3)
wxlog.debug(f'草稿检测失败:box={box} rel={rel} colored={last_colored}')
return False
def _open_chat_and_settle(self, who: str) -> bool:
"""打开会话并等待切换动画完成,返回输入框是否可探测。
会话消息区若正显示大图,输入框探测会失败(返回 None)。
重复点击会话会改变消息区滚动状态,重试直到探测成功。
"""
for _ in range(5):
self.open_chat(who)
time.sleep(1.2)
if self.get_input_box():
return True
wxlog.debug(f'打开会话后未检测到输入框,重试 open_chat({who})')
return bool(self.get_input_box())
def send_file(self, path: str, who: Optional[str] = None,
verify: bool = False) -> WxResponse:
"""发送本地文件(剪贴板粘贴路线)。"""
if not self.ensure_visible():
return WxResponse.failure('微信窗口不可见(可能锁屏/会话断开)')
# 会话复用:目标仍是当前已打开会话且输入框可探测时跳过 open_chat
# open_chat 重扫侧栏/点击是附件发送的主要耗时点)
if who and not (who == getattr(self, '_current_chat', None)
and self.get_input_box()):
if not self._open_chat_and_settle(who):
return WxResponse.failure(f'无法打开会话:{who}')
self._current_chat = who
box = self.get_input_box()
before_seq = self._target_seq(who)
if not self._paste_attachment_and_send(path, box):
return WxResponse.failure(f'文件发送失败:{path}')
if verify:
ok = self._verify_attachment_sent(path, who, before_seq)
return (WxResponse.success(f'文件已发送并确认:{path}', data={'path': path})
if ok else WxResponse.failure('文件已操作发送,但数据库未确认', data={'path': path}))
return WxResponse.success(f'文件已发送:{path}', data={'path': path})
def send_image(self, path: str, who: Optional[str] = None,
verify: bool = False) -> WxResponse:
"""发送本地图片(剪贴板粘贴路线,微信自动作为图片消息插入)。"""
if not self.ensure_visible():
return WxResponse.failure('微信窗口不可见(可能锁屏/会话断开)')
if who and not (who == getattr(self, '_current_chat', None)
and self.get_input_box()):
if not self._open_chat_and_settle(who):
return WxResponse.failure(f'无法打开会话:{who}')
self._current_chat = who
box = self.get_input_box()
before_seq = self._target_seq(who)
if not self._paste_attachment_and_send(path, box):
return WxResponse.failure(f'图片发送失败:{path}')
if verify:
ok = self._verify_attachment_sent(path, who, before_seq)
return (WxResponse.success(f'图片已发送并确认:{path}', data={'path': path})
if ok else WxResponse.failure('图片已操作发送,但数据库未确认', data={'path': path}))
return WxResponse.success(f'图片已发送:{path}', data={'path': path})
def _target_seq(self, who: Optional[str]) -> int:
"""取目标会话当前最大 sort_seq,作为发送后校验的基线。"""
try:
db = self._get_db()
if not who:
who = db.get_self_info()['username']
else:
hits = db.search_contact(who)
if hits:
who = hits[0]["username"]
msgs = db.get_messages(who, limit=1)
return msgs[0]['sort_seq'] if msgs else 0
except Exception:
return 0
def _verify_attachment_sent(self, path: str, who: Optional[str],
before_seq: int = 0) -> bool:
try:
db = self._get_db()
if not who:
who = db.get_self_info()['username']
else:
hits = db.search_contact(who)
if hits:
who = hits[0]["username"]
fname = os.path.basename(path)
fname_bytes = fname.encode('utf-8')
# 微信会自动重名文件为 名称(n).ext,匹配文件名主干
stem = os.path.splitext(fname)[0]
stem_bytes = stem.encode('utf-8')
is_image = path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
expect_type = '图片' if is_image else '文件/链接/卡片'
# 微信发送后 DB 异步落库,文件消息落库更慢,轮询放宽到 10s
deadline = time.time() + (6.0 if is_image else 10.0)
while time.time() < deadline:
# 优先:发送后出现了 sort_seq 更大的同类型消息(图片消息无文件名,只能靠它)
for m in db.get_new_messages(who, since_seq=before_seq, limit=8):
if m.get('sender_id') != 2 or m.get('type') != expect_type:
continue
# 文件消息再做文件名匹配,图片消息仅凭类型+时序
if is_image:
return True
if fname in (m.get('content') or ''):
return True
row = db.get_message_row(who, m.get('local_id'))
if row and row.get('packed_info') and isinstance(row['packed_info'], bytes):
if fname_bytes in row['packed_info']:
return True
# 兼容重命名:主干匹配 + packed_info 内同一文件(主干名出现即可)
if stem_bytes in row['packed_info']:
return True
time.sleep(0.5)
except Exception as e:
wxlog.debug(f'附件校验失败:{e}')
return False
# ------------------------------------------------------------------
# 回复 / 引用
# ------------------------------------------------------------------
def _last_message_y(self) -> Optional[int]:
"""估算最近一条消息的位置(输入框上沿往上一点)。"""
box = self.get_input_box()
if not box:
return None
return max(100, box[1] - 80)
def reply_msg(self, text: str, who: Optional[str] = None,
target_text: Optional[str] = None, verify: bool = False) -> WxResponse:
"""回复最近一条消息(悬停消息 → 点击「回复」→ 输入 → 发送)。
target_text 用于在 OCR 结果中匹配目标消息(可选)。
"""
if not self.ensure_visible():
return WxResponse.failure('微信窗口不可见(可能锁屏/会话断开)')
if who:
self.open_chat(who)
time.sleep(0.8)
y = self._last_message_y()
if y is None:
return WxResponse.failure('未检测到消息区域')
u = self._input._user32
u.SetCursorPos(self.origin_x + (self.render_w + self.right_pane_left) // 2,
self.origin_y + y)
time.sleep(0.8)
# OCR 悬停工具栏(回复/引用等图标)
region = (self.right_pane_left, y - 60, self.render_w, y + 40)
items = self.ocr(region)
click_pt = None
for text, x, yy, w, h in items:
if '回复' in text or '引用' in text or '转发' in text:
click_pt = (x + w // 2, yy + h // 2)
break
if not click_pt:
wxlog.debug('未识别到回复工具栏,尝试右键菜单')
self.wx_click(self.origin_x + (self.render_w + self.right_pane_left) // 2,
self.origin_y + y, right=True)
time.sleep(0.6)
menu = self.ocr((self.right_pane_left, y, self.render_w, self.render_h))
for text, x, yy, w, h in menu:
if '回复' in text:
click_pt = (x + w // 2, yy + h // 2)
break
if not click_pt:
return WxResponse.failure('未找到回复入口')
self.wx_click(self.origin_x + click_pt[0], self.origin_y + click_pt[1])
time.sleep(0.6)
if not self.input_text(text):
return WxResponse.failure('输入回复内容失败')
self.click_send()
if verify:
ok = self._verify_sent(text, who)
return (WxResponse.success(f'回复已发送并确认:{text}', data={'content': text})
if ok else WxResponse.failure('回复已操作发送,但数据库未确认', data={'content': text}))
return WxResponse.success(f'回复已发送:{text}', data={'content': text})
# ------------------------------------------------------------------
# 艾特成员(群聊)
# ------------------------------------------------------------------
def at_member(self, member: str, text: str, who: Optional[str] = None,
verify: bool = False) -> WxResponse:
"""在群聊中 @ 成员后追加发送 text。
流程:输入框键入 '@' → OCR 成员选择弹层定位成员 → 点击 → 输入正文 → 发送。
"""
if not self.ensure_visible():
return WxResponse.failure('微信窗口不可见(可能锁屏/会话断开)')
if who:
self.open_chat(who)
time.sleep(0.8)
if not self.focus_input():
return WxResponse.failure('输入框不可用')
self._input.type_unicode('@')
time.sleep(0.9)
box = self.get_input_box()
popup = (self.right_pane_left, max(80, (box[1] if box else int(self.render_h * 0.49)) - 500),
self.render_w, (box[1] if box else int(self.render_h * 0.77)))
items = self.ocr(popup)
target = None
for t, x, yy, w, h in items:
if member in t or t.startswith(member):
target = (x + w // 2, yy + h // 2)
break
if not target:
return WxResponse.failure(f'未在成员列表中找到:{member}')
self.wx_click(self.origin_x + target[0], self.origin_y + target[1])
time.sleep(0.6)
if text and not self.input_text(text):
return WxResponse.failure('输入消息正文失败')
self.click_send()
if verify:
ok = self._verify_sent(text, who)
return (WxResponse.success(f'@成员消息已发送并确认', data={'member': member, 'content': text})
if ok else WxResponse.failure('@消息已操作发送,但数据库未确认', data={'member': member}))
return WxResponse.success(f'@成员消息已发送', data={'member': member, 'content': text})
# ---------------------------------------------------------------------------
# 便捷入口
# ---------------------------------------------------------------------------
def quick_send(text: str, who: str = None, verify: bool = False) -> WxResponse:
"""一行式发送消息。
>>> from wechatauto.guia import quick_send
>>> quick_send('你好', '文件传输助手')
"""
wx = WeChatGUI()
return wx.send_msg(text, who, verify)
def quick_send_file(path: str, who: str = None, verify: bool = False) -> WxResponse:
"""一行式发送文件。"""
wx = WeChatGUI()
return wx.send_file(path, who, verify)
def quick_send_image(path: str, who: str = None, verify: bool = False) -> WxResponse:
"""一行式发送图片。"""
wx = WeChatGUI()
return wx.send_image(path, who, verify)
def quick_reply(text: str, who: str = None, verify: bool = False) -> WxResponse:
"""一行式回复最近一条消息。"""
wx = WeChatGUI()
return wx.reply_msg(text, who, verify=verify)