feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布

This commit is contained in:
2026-09-16 10:12:49 +08:00
commit c3b98f4c4e
69 changed files with 19828 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
"""UIA 兼容适配层。
基于 PyPI ``uiautomation`` 封装,并补充 wechatauto 依赖的扩展能力:
- :attr:`Control.runtimeid` —— 控件 RuntimeId 的字符串形式,用于消息去重/定位
- :meth:`Control.ScreenShot` —— 截取控件区域保存为图片文件
- :func:`RollIntoView` —— 将目标元素滚动到窗口可见区域内
- :func:`CheckElementPosition` / :func:`IsElementInWindow` —— 元素与窗口位置关系判断
Windows 微信 4.x 客户端为 Qt 应用(UIA 类名前缀 ``mmui::``),
仅暴露 UIA 标准接口,因此本层不依赖具体微信版本。
"""
from __future__ import annotations
import os
import queue
import tempfile
import threading
import time
from typing import Any
from PIL import Image as _PILImage
# uiautomation 和 comtypes 初始化(COM + UIA)可能在部分系统上阻塞
# (如微信或其他 Qt 应用占用 COM),改为延迟导入:仅在首次访问本包属性时
# 才加载,避免 ``import wechatauto`` 时卡死。
_uia_loaded = False
_UiaControl = None
_control_from_handle = None
_CoInitializeEx = None
def _ensure_uia():
"""延迟加载 uiautomation 并完成 monkey-patch,只执行一次。"""
global _uia_loaded, _UiaControl, _control_from_handle, _CoInitializeEx
if _uia_loaded:
return
_uia_loaded = True
from comtypes import CoInitializeEx as _ci
from uiautomation import Control as _uc, ControlFromHandle as _cfh
_CoInitializeEx = _ci
_UiaControl = _uc
_control_from_handle = _cfh
_uc.runtimeid = property(_get_runtimeid)
_uc.ScreenShot = _screen_shot
# 把 __all__ 中的名称绑定到模块全局,让 from uiautomation import * 的
# 等效访问能直接命中,不再触发 __getattr__ 回退查找
import uiautomation as _mod
for _n in __all__:
if _n not in globals():
_v = getattr(_mod, _n, None)
if _v is not None:
globals()[_n] = _v
def __getattr__(name: str) -> Any:
"""延迟导入:首次访问时加载 uiautomation。"""
_ensure_uia()
try:
return globals()[name]
except KeyError:
# 从 uiautomation 星号导入的名称,首次访问时从实际模块取回
import uiautomation as _mod
val = getattr(_mod, name, _SENTINEL)
if val is _SENTINEL:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
globals()[name] = val
return val
_SENTINEL = object()
__all__ = [
'ControlFromHandle',
'GetUiClassNameWithTimeout',
'WalkControl',
'RollIntoView',
'CheckElementPosition',
'IsElementInWindow',
'GetElementPositionDescription',
]
# UIA 类名以 Qt 内部消息泵开头的隐藏窗口(如 WPS 等 Qt 应用),
# 其 UIA 服务可能长时间无响应导致调用阻塞,扫描时应直接跳过
QT_INTERNAL_WIN_CLASS_PREFIX = 'QEventDispatcherWin32_Internal_'
def GetUiClassNameWithTimeout(hwnd, timeout: float = 3.0) -> 'str | None':
"""带超时保护地获取窗口的 UIA 类名。
部分 Qt 应用的隐藏窗口(如 ``QEventDispatcherWin32_Internal_*``UIA 服务异常,
直接调用会无限阻塞。本函数在守护线程中调用底层接口,超时后放弃该窗口。
Args:
hwnd: 窗口句柄
timeout: 超时时间,单位秒
Returns:
str: 窗口的 UIA 类名;超时/失败时返回 None
"""
result_queue = queue.Queue()
_ensure_uia()
def _probe():
try:
_CoInitializeEx()
control = _control_from_handle(hwnd)
result_queue.put(control.ClassName if control is not None else None)
except Exception as e: # noqa: BLE001
result_queue.put(e)
worker = threading.Thread(target=_probe, daemon=True)
worker.start()
worker.join(timeout)
if worker.is_alive():
# 底层 UIA 调用被异常进程阻塞,放弃等待(守护线程随进程退出)
return None
try:
result = result_queue.get_nowait()
except queue.Empty:
return None
if isinstance(result, Exception):
return None
return result
# ----------------------------------------------------------------------------
# Control 扩展:runtimeid
# ----------------------------------------------------------------------------
def _get_runtimeid(self) -> str:
"""返回控件 RuntimeId 的字符串形式,用于唯一标识控件。"""
return ''.join(str(i) for i in self.GetRuntimeId())
# ----------------------------------------------------------------------------
# Control 扩展:ScreenShot
# ----------------------------------------------------------------------------
def _screen_shot(self, savePath: str = None, crop: tuple = (0, 0, 0, 0),
crop_percentage: bool = False, return_img=False):
"""截取控件区域并保存/返回图片。
Args:
savePath: 保存路径,不指定则使用临时文件
crop: 裁剪量 (left, top, right, bottom)
crop_percentage: crop 值是否为百分比
return_img: 为 True 时直接返回 PIL Image 对象
Returns:
str: 图片保存路径;return_img=True 时返回 PIL Image
"""
rect = self.BoundingRectangle
w, h = rect.width(), rect.height()
if crop_percentage:
crop = (
int(w * crop[0] / 100),
int(h * crop[1] / 100),
int(w * crop[2] / 100),
int(h * crop[3] / 100),
)
cw = max(w - crop[0] - crop[2], 1)
ch = max(h - crop[1] - crop[3], 1)
if savePath is None:
fd, savePath = tempfile.mkstemp(prefix='wechatauto_image_', suffix='.png')
os.close(fd)
os.remove(savePath)
ok = self.CaptureToImage(savePath, x=crop[0], y=crop[1], width=cw, height=ch)
if not ok:
raise RuntimeError(f'截图失败: {self}')
if return_img:
img = _PILImage.open(savePath)
try:
os.remove(savePath)
except Exception:
pass
return img
return savePath
# ----------------------------------------------------------------------------
# 模块级工具函数
# ----------------------------------------------------------------------------
def RollIntoView(win, ele, equal=True, bias=0):
"""将目标元素滚动到主窗口内可见区域。
Args:
win: 主窗口元素 (Control对象)
ele: 目标元素 (Control对象)
bias: 偏移量,元素边缘需要超过这个量才算完全在窗口内 (默认为0)
"""
# 获取窗口和元素的边界矩形
win_rect = win.BoundingRectangle
ele_rect = ele.BoundingRectangle
# 计算窗口的有效显示区域(考虑bias偏移)
win_top = win_rect.top + bias
win_bottom = win_rect.bottom - bias
win_height = win_bottom - win_top
# 获取元素的位置信息
ele_top = ele_rect.top
ele_bottom = ele_rect.bottom
ele_height = ele_rect.height()
ele_ycenter = ele_rect.ycenter()
# 如果元素高度超过窗口高度,只需要确保元素中心在窗口内
if ele_height > win_height:
# 元素太高,只需要中心点在窗口内即可
target_top = ele_ycenter
target_bottom = ele_ycenter
else:
# 元素高度适中,需要整个元素都在窗口内
target_top = ele_top
target_bottom = ele_bottom
# 执行滚动操作
max_attempts = 100 # 防止无限循环
attempt = 0
while attempt < max_attempts:
# 重新获取当前位置(滚动后位置会变化)
current_ele_rect = ele.BoundingRectangle
if ele_height > win_height:
# 元素太高的情况,检查中心点
current_ycenter = current_ele_rect.ycenter()
if win_top <= current_ycenter <= win_bottom:
break # 中心点已在窗口内,停止滚动
if current_ycenter < win_top:
# 中心点在窗口上方,需要向下滚动
win.WheelUp()
time.sleep(0.1)
elif current_ycenter > win_bottom:
# 中心点在窗口下方,需要向上滚动
win.WheelDown()
time.sleep(0.1)
else:
# 元素高度适中的情况,检查整个元素
current_top = current_ele_rect.top
current_bottom = current_ele_rect.bottom
# 检查是否已经完全在窗口内
if win_top <= current_top and current_bottom <= win_bottom:
break # 元素已完全在窗口内,停止滚动
if current_top < win_top:
# 元素顶部在窗口上方,需要向下滚动
win.WheelUp()
time.sleep(0.1)
elif current_bottom > win_bottom:
# 元素底部在窗口下方,需要向上滚动
win.WheelDown()
time.sleep(0.1)
else:
# 理论上不应该到达这里
break
attempt += 1
if attempt >= max_attempts:
print(f"Warning: 滚动操作达到最大尝试次数({max_attempts}),可能元素无法完全滚动到视图内")
def CheckElementPosition(win, ele, bias=0) -> dict:
"""判断目标元素相对于主窗口的位置关系。
Returns:
dict: 包含各种位置关系判断结果的字典
"""
win_rect = win.BoundingRectangle
ele_rect = ele.BoundingRectangle
win_top = win_rect.top + bias
win_bottom = win_rect.bottom - bias
win_left = win_rect.left + bias
win_right = win_rect.right - bias
ele_top = ele_rect.top
ele_bottom = ele_rect.bottom
ele_left = ele_rect.left
ele_right = ele_rect.right
result = {
'ele_top_above_win_top': ele_top < win_top,
'ele_bottom_below_win_bottom': ele_bottom > win_bottom,
'ele_completely_above_win': ele_bottom <= win_top,
'ele_completely_below_win': ele_top >= win_bottom,
'ele_vertically_inside_win': win_top <= ele_top and ele_bottom <= win_bottom,
'win_vertically_inside_ele': ele_top <= win_top and win_bottom <= ele_bottom,
'ele_left_before_win_left': ele_left < win_left,
'ele_right_after_win_right': ele_right > win_right,
'ele_completely_left_of_win': ele_right <= win_left,
'ele_completely_right_of_win': ele_left >= win_right,
'ele_horizontally_inside_win': win_left <= ele_left and ele_right <= win_right,
'win_horizontally_inside_ele': ele_left <= win_left and win_right <= ele_right,
'ele_completely_inside_win': False,
'win_completely_inside_ele': False,
'ele_and_win_overlap': False,
'ele_and_win_separate': False,
}
result['ele_completely_inside_win'] = (
result['ele_vertically_inside_win'] and result['ele_horizontally_inside_win'])
result['win_completely_inside_ele'] = (
result['win_vertically_inside_ele'] and result['win_horizontally_inside_ele'])
vertical_overlap = not (result['ele_completely_above_win'] or result['ele_completely_below_win'])
horizontal_overlap = not (result['ele_completely_left_of_win'] or result['ele_completely_right_of_win'])
result['ele_and_win_overlap'] = vertical_overlap and horizontal_overlap
result['ele_and_win_separate'] = not result['ele_and_win_overlap']
return result
def IsElementInWindow(win, ele, bias=0) -> bool:
"""简化版本:判断元素是否在窗口内(仅垂直方向)"""
position_info = CheckElementPosition(win, ele, bias)
return position_info['ele_vertically_inside_win']
def GetElementPositionDescription(win, ele, bias=0) -> str:
"""获取元素位置的文字描述"""
result = CheckElementPosition(win, ele, bias)
if result['ele_completely_inside_win']:
return "元素完全在窗口内部"
elif result['win_completely_inside_ele']:
return "窗口完全在元素内部"
elif result['ele_completely_above_win']:
return "元素完全在窗口上方"
elif result['ele_completely_below_win']:
return "元素完全在窗口下方"
elif result['ele_completely_left_of_win']:
return "元素完全在窗口左侧"
elif result['ele_completely_right_of_win']:
return "元素完全在窗口右侧"
elif result['ele_vertically_inside_win']:
return "元素在窗口内,但水平方向超出范围"
elif result['ele_horizontally_inside_win']:
return "元素在窗口内,但垂直方向超出范围"
elif result['ele_and_win_overlap']:
return "元素与窗口部分重叠"
else:
return "元素与窗口完全分离"