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
+23
View File
@@ -0,0 +1,23 @@
from wechatauto.ui.base import BaseUISubWnd, BaseUIWnd
from wechatauto.ui.main import WeChatMainWnd, WeChatSubWnd
from wechatauto.ui.navigationbox import NavigationBox
from wechatauto.ui.sessionbox import SessionBox, SessionElement, SearchResultElement
from wechatauto.ui.chatbox import ChatBox, AtMenu
from wechatauto.ui.component import Menu, SelectContactWnd, WeChatImage, UpdateWindow
__all__ = [
"BaseUISubWnd",
"BaseUIWnd",
"WeChatMainWnd",
"WeChatSubWnd",
"NavigationBox",
"SessionBox",
"SessionElement",
"SearchResultElement",
"ChatBox",
"AtMenu",
"Menu",
"SelectContactWnd",
"WeChatImage",
"UpdateWindow",
]
+64
View File
@@ -0,0 +1,64 @@
from wechatauto import uia
from wechatauto.param import PROJECT_NAME
from wechatauto.logger import wxlog
from wechatauto.utils.lock import uilock
from abc import ABC, abstractmethod
import win32gui
from typing import Union
import time
class BaseUIWnd(ABC):
_ui_cls_name: str = None
_ui_name: str = None
control: uia.Control
@abstractmethod
def _lang(self, text: str): pass
def __repr__(self):
return f"<{PROJECT_NAME} - {self.__class__.__name__} at {hex(id(self))}>"
def __eq__(self, other):
return self.control == other.control
def __bool__(self):
return self.exists()
def _show(self):
if not hasattr(self, 'HWND'):
self.HWND = self.control.GetTopLevelControl().NativeWindowHandle
win32gui.ShowWindow(self.HWND, 1)
win32gui.SetWindowPos(self.HWND, -1, 0, 0, 0, 0, 3)
win32gui.SetWindowPos(self.HWND, -2, 0, 0, 0, 0, 3)
self.control.Show()
@property
def pid(self):
return self.control.ProcessId
@uilock
def close(self):
try:
for i in range(2):
self.control.SendKeys('{Esc}')
except Exception:
pass
def exists(self, wait=0):
try:
result = self.control.Exists(wait)
return result
except Exception:
return False
class BaseUISubWnd(BaseUIWnd):
root: BaseUIWnd
parent: None
def _lang(self, text: str):
if getattr(self, 'parent'):
return self.parent._lang(text)
else:
return self.root._lang(text)
+400
View File
@@ -0,0 +1,400 @@
from wechatauto import uia
from wechatauto.param import (
WxParam,
WxResponse,
)
from wechatauto.utils.win32 import (
SetClipboardFiles,
SetClipboardData,
SetClipboardText
)
from wechatauto.ui.component import (
Menu
)
from wechatauto.logger import wxlog
from .base import (
BaseUISubWnd
)
from wechatauto.msgs.msg import parse_msg
import time
import os
import re
from typing import Iterable, Optional, Sequence, Tuple, Union
def truncate_string(s: str, n: int = 8) -> str:
s = s.replace('\n', '').strip()
return s if len(s) <= n else s[:n] + '...'
USED_MSG_IDS = {}
LAST_MSG_COUNT = {}
class ChatBox(BaseUISubWnd):
"""聊天窗口区域"""
def __init__(self, control: uia.Control, parent):
self.control: uia.Control = control
self.root = parent.root
self.parent = parent # `wx` or `chat`
self.init()
def _lang(self, text: str):
return text
@property
def id(self):
if self.msgbox.Exists(0):
return self.msgbox.runtimeid
return None
@property
def used_msg_ids(self):
if self.id in USED_MSG_IDS:
return USED_MSG_IDS[self.id]
else:
USED_MSG_IDS[self.id] = tuple()
return USED_MSG_IDS[self.id]
@property
def who(self):
if hasattr(self, '_who'):
return self._who
self._who = self.editbox.Name
return self._who
def get_info(self):
chat_info = {}
chat_info_control = self.control.GetParentControl().GroupControl(ClassName="mmui::ChatInfoView")
aid_head = 'top_content_h_view.top_spacing_v_view.top_left_info_v_view.big_title_line_h_view.'
v_view = "top_content_h_view.top_spacing_v_view.top_left_info_v_view"
aids = {
'chatname': "current_chat_name_label",
'chat_count': "current_chat_count_label",
'company': "current_chat_openim_name",
'comicon': "openim_icon"
}
chat_info['chat_type'] = 'friend'
for aid in aids:
control = chat_info_control.TextControl(
AutomationId=aid_head + aids[aid]
)
if control.Exists(0):
if aid == 'chatname':
chat_info['chat_name'] = control.Name
if (
'chat_remark' not in chat_info
and (cnc := chat_info_control.GroupControl(AutomationId=v_view).GroupControl().TextControl()).Exists(0)
):
chat_info['chat_remark'] = chat_info['chat_name']
chat_info['chat_name'] = cnc.Name
elif aid == 'chat_count':
chat_info['group_member_count'] = int(re.findall(r'\d+', control.Name)[0])
chat_info['chat_type'] = 'group'
elif aid == 'company':
chat_info['chat_type'] = 'service'
if chat_info_control.ButtonControl(Name="公众号主页").Exists(0):
chat_info['chat_type'] = 'official'
return chat_info
def _activate_editbox(self):
if not self.editbox.HasKeyboardFocus:
self.editbox.MiddleClick()
def init(self):
self.msgbox = self.control.GroupControl(ClassName="mmui::MessageView").ListControl()
self.editbox = self.control.EditControl(ClassName="mmui::ChatInputField")
self.sendbtn = self.control.ButtonControl(Name=self._lang('发送(S)'))
self.tools = self.control.ToolBarControl()
self._empty = False
if (cid := self.id) and cid not in USED_MSG_IDS:
USED_MSG_IDS[self.id] = tuple((i.runtimeid for i in self.msgbox.GetChildren()))
if not USED_MSG_IDS[cid]:
self._empty = True
def clear_edit(self):
self._show()
self.editbox.Click()
self.editbox.SendKeys('{Ctrl}a', waitTime=0)
self.editbox.SendKeys('{DELETE}')
def send_text(self, content: str):
self._show()
t0 = time.time()
while True:
if time.time() - t0 > 10:
return WxResponse.failure(f'Timeout --> {self.who} - {content}')
SetClipboardText(content)
self._activate_editbox()
self.editbox.SendKeys('{Ctrl}v')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
self.editbox.SendKeys('{Ctrl}v')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
self.editbox.RightClick()
menu = Menu(self)
menu.select('粘贴')
if self.editbox.GetValuePattern().Value.replace('', '').strip():
break
t0 = time.time()
while self.editbox.GetValuePattern().Value:
if time.time() - t0 > 10:
return WxResponse.failure(f'Timeout --> {self.who} - {content}')
self._activate_editbox()
self.sendbtn.Click()
if not self.editbox.GetValuePattern().Value:
return WxResponse.success(f"success")
elif not self.editbox.GetValuePattern().Value.replace('', '').strip():
return self.send_text(content)
def send_msg(self, content: str, clear: bool = True, at=None):
wxlog.debug(f"发送消息: {content}")
if not content and not at:
return WxResponse.failure(f"`content` and `at` can't be empty at the same time")
if clear:
self.clear_edit()
if at:
self.input_at(at)
return self.send_text(content)
def send_file(self, file_path):
wxlog.debug(f"发送文件: {file_path}")
if isinstance(file_path, str):
file_path = [file_path]
file_path = [os.path.abspath(f) for f in file_path]
self.clear_edit()
SetClipboardFiles(file_path)
self.editbox.SendKeys('{Ctrl}v')
self.sendbtn.Click()
def input_at(self, at_list):
if isinstance(at_list, str):
at_list = [at_list]
self._activate_editbox()
for friend in at_list:
self.editbox.SendKeys('@' + friend.replace(' ', ''))
atmenu = AtMenu(self)
atmenu.select(friend)
def get_msgs(self):
if self.msgbox.Exists(0):
return [
parse_msg(msg_control, self)
for msg_control in self._iter_message_controls()
if uia.IsElementInWindow(self.msgbox, msg_control)
]
return []
def get_new_msgs(self):
if not self.msgbox.Exists(0):
return []
msg_controls = self.msgbox.GetChildren()
now_msg_ids = tuple((i.runtimeid for i in msg_controls))
current_msg_count = len(now_msg_ids)
if not now_msg_ids: # 当前没有消息id
return []
# 确保used_msg_ids不为None
current_used_ids = self.used_msg_ids or tuple()
if self._empty and current_used_ids:
self._empty = False
# 获取上次记录的消息数量
last_msg_count = LAST_MSG_COUNT.get(self.id, 0)
# 如果没有历史消息id,初始化
if not current_used_ids:
if not self._empty:
# 初始化时记录当前所有消息id和数量
USED_MSG_IDS[self.id] = now_msg_ids[-100:]
LAST_MSG_COUNT[self.id] = current_msg_count
return []
# 关键改进:基于消息数量变化的检测机制
msg_count_increased = current_msg_count > last_msg_count
if msg_count_increased:
# 消息数量增加了,计算新消息数量
new_msg_count = current_msg_count - last_msg_count
# 取最后N条消息作为候选新消息
candidate_new_ids = now_msg_ids[-new_msg_count:]
# 验证这些ID确实是新的(排除可能的ID重用情况)
used_msg_ids_set = set(current_used_ids)
confirmed_new_ids = []
for msg_id in candidate_new_ids:
if msg_id not in used_msg_ids_set:
confirmed_new_ids.append(msg_id)
# 即使ID重复,如果消息数量确实增加了,也要包含这条消息
# 这是处理快速重复消息的关键逻辑
elif msg_count_increased and len(confirmed_new_ids) < new_msg_count:
# 对于疑似重复ID的情况,仍然当作新消息处理
confirmed_new_ids.append(msg_id)
if confirmed_new_ids:
# 更新记录
USED_MSG_IDS[self.id] = now_msg_ids[-100:]
LAST_MSG_COUNT[self.id] = current_msg_count
# 根据新消息id获取对应的控件
new_controls = [i for i in msg_controls if i.runtimeid in confirmed_new_ids]
return [
parse_msg(msg_control, self)
for msg_control
in new_controls
if msg_control.ControlTypeName == 'ListItemControl'
]
# 如果消息数量没有增加,但可能有ID变化(处理消息刷新的情况)
used_msg_ids_set = set(current_used_ids)
new_ids = [msg_id for msg_id in now_msg_ids if msg_id not in used_msg_ids_set]
if new_ids:
# 更新记录
USED_MSG_IDS[self.id] = now_msg_ids[-100:]
LAST_MSG_COUNT[self.id] = current_msg_count
# 根据新消息id获取对应的控件
new_controls = [i for i in msg_controls if i.runtimeid in new_ids]
return [
parse_msg(msg_control, self)
for msg_control
in new_controls
if msg_control.ControlTypeName == 'ListItemControl'
]
return []
def _update_used_msg_ids(self):
if not self.msgbox.Exists(0):
USED_MSG_IDS[self.id] = tuple()
LAST_MSG_COUNT[self.id] = 0
return
msg_controls = [
ctrl for ctrl in self.msgbox.GetChildren()
if ctrl.ControlTypeName == 'ListItemControl'
]
if not msg_controls:
USED_MSG_IDS[self.id] = tuple()
LAST_MSG_COUNT[self.id] = 0
return
USED_MSG_IDS[self.id] = tuple(ctrl.runtimeid for ctrl in msg_controls[-100:])
LAST_MSG_COUNT[self.id] = len(msg_controls)
def _iter_message_controls(self) -> Iterable[uia.Control]:
if not self.msgbox.Exists(0):
return []
return [
ctrl
for ctrl in self.msgbox.GetChildren()
if ctrl.ControlTypeName == 'ListItemControl'
]
def _normalize_msg_id(self, msg_id: Union[Sequence[int], str, None]) -> Optional[str]:
"""将用户传入的消息ID归一化为 runtimeid 字符串形式。
兼容传入:字符串(如消息对象的 ``msg.id``)或 RuntimeId 整数序列。
"""
if msg_id is None:
return None
if isinstance(msg_id, str):
parts = re.findall(r"\d+", msg_id)
if not parts:
return None
return ''.join(parts)
if isinstance(msg_id, (tuple, list)):
try:
return ''.join(str(int(p)) for p in msg_id)
except (TypeError, ValueError):
return None
return None
def get_msg_by_id(self, msg_id: Union[Sequence[int], str]) -> Optional['Message']:
normalized_id = self._normalize_msg_id(msg_id)
if normalized_id is None:
return None
for msg_control in self._iter_message_controls():
if msg_control.runtimeid == normalized_id:
return parse_msg(msg_control, self)
return None
def get_msg_by_hash(self, msg_hash: str) -> Optional['Message']:
if not msg_hash:
return None
msg_hash = msg_hash.strip()
is_digest = bool(re.fullmatch(r"[0-9a-fA-F]{32}", msg_hash))
controls = list(self._iter_message_controls())
for msg_control in reversed(controls):
msg = parse_msg(msg_control, self)
candidate = msg.hash if is_digest else getattr(msg, 'hash_text', None)
if candidate == msg_hash:
return msg
return None
def get_last_msg(self) -> Optional['Message']:
message_controls = list(self._iter_message_controls())
if not message_controls:
return None
return parse_msg(message_controls[-1], self)
class AtEle:
def __init__(self, control):
self.name = control.Name
self.control = control
class AtMenu(BaseUISubWnd):
_ui_cls_name: str = "mmui::XPopover"
_ui_name: str = "Weixin"
_ui_automation_id = "MentionPopover"
def __init__(self, parent):
self.root = parent.root
self.control = self.root.control.WindowControl(
ClassName=self._ui_cls_name,
Name=self._ui_name,
AutomationId=self._ui_automation_id
)
def clear(self, friend):
if self.exists():
self.control.SendKeys('{ESC}')
for _ in range(len(friend) + 1):
self.root._chat_api.editbox.SendKeys('{BACK}')
def select(self, friend):
friend_ = friend.replace(' ', '')
if self.exists():
ateles = self.control.ListControl().GetChildren()
if len(ateles) == 1:
ateles[0].Click()
return WxResponse.success()
else:
atele = self.control.ListItemControl(Name=friend)
if atele.Exists(0):
uia.RollIntoView(self.control, atele)
atele.Click()
return WxResponse.success()
else:
self.clear(friend_)
return WxResponse.failure('@对象不存在')
else:
self.clear(friend_)
return WxResponse.failure('@选择窗口不存在')
def list(self):
return [AtEle(i) for i in self.control.ListControl().GetChildren()]
+305
View File
@@ -0,0 +1,305 @@
from wechatauto import uia
from wechatauto.utils.win32 import (
FindWindow,
GetAllWindows,
SetClipboardText,
ReadClipboardData
)
from wechatauto.utils.tools import (
find_window_from_root,
find_all_windows_from_root,
is_valid_image,
get_file_dir,
now_time,
)
from .base import BaseUISubWnd
from wechatauto.param import WxParam, WxResponse
from wechatauto.logger import wxlog
from pathlib import Path
from typing import (
List,
Literal
)
import traceback
import shutil
import time
import os
def _find_popup(ui_cls_name: str, win_cls_name: str, win_name: str = None, pid: int = None, timeout: float = 2):
"""查找微信弹窗/浮层窗口。
优先按 UIA 类名匹配(不受 Qt 版本影响),失败时回退到 win32 窗口类名匹配。
"""
t0 = time.time()
while True:
if time.time() - t0 > timeout:
break
wins = find_all_windows_from_root(pid=pid, uiaclsname=ui_cls_name)
if len(wins) > 0:
return wins[0]
wins = find_all_windows_from_root(classname=win_cls_name, name=win_name, pid=pid)
if len(wins) > 0:
return wins[0]
time.sleep(0.05)
return None
class UpdateWindow(BaseUISubWnd):
"""微信更新提示窗口"""
_ui_cls_name: str = "mmui::XView"
_win_cls_name: str = "Qt51514QWindowIcon"
_win_name: str = "微信"
def __init__(self):
wins = GetAllWindows(name=self._win_name, classname=self._win_cls_name)
for win in wins:
self.control = uia.ControlFromHandle(win[0])
if (
(text := self.control.TextControl()).Exists(0)
and text.Name == '新版本'
):
break
def ignore(self):
ignore_btn = self.control.ButtonControl(
ClassName="mmui::XOutlineButton",
Name="忽略本次更新"
)
if ignore_btn.Exists(0):
ignore_btn.Click()
class Menu(BaseUISubWnd):
"""右键菜单窗口"""
_ui_cls_name: str = "mmui::XMenu"
_win_cls_name: str = "Qt51514QWindowToolSaveBits"
_win_name: str = "Weixin"
def __init__(self, parent, timeout=2):
self.parent = parent
self.root = parent.root
self.control = _find_popup(
ui_cls_name=self._ui_cls_name,
win_cls_name=self._win_cls_name,
win_name=self._win_name,
pid=self.root.pid,
timeout=timeout,
)
@property
def option_controls(self):
if not self.control:
return []
return [
i for i in
self.control.GetChildren()
if i.ControlTypeName == 'MenuItemControl'
]
@property
def option_names(self):
return [c.Name for c in self.option_controls]
def select(self, item):
if not self.control or not self.exists(0):
return WxResponse.failure('菜单窗口不存在')
if isinstance(item, int):
options = self.option_controls
if item >= len(options):
return WxResponse.failure(f'菜单选项越界:{item}')
options[item].Click()
return WxResponse.success()
for c in self.option_controls:
if c.Name == item:
c.Click()
return WxResponse.success()
if self.exists(0):
self.close()
return WxResponse.failure(f'未找到选项:{item}')
class SelectContactWnd(BaseUISubWnd):
"""转发消息时的联系人选择窗口"""
_ui_cls_name: str = "mmui::SessionPickerWindow"
_ui_name: str = "微信发送给"
_win_cls_name: str = "Qt51514QWindowIcon"
_win_name: str = "微信发送给"
def __init__(self, parent, timeout=2):
self.parent = parent
self.root = parent.root
self.control = _find_popup(
ui_cls_name=self._ui_cls_name,
win_cls_name=self._win_cls_name,
win_name=self._win_name,
pid=self.root.pid,
timeout=timeout,
)
if self.control:
self.confirm_btn = self.control.ButtonControl(AutomationId="confirm_btn")
def search(self, keyword, interval=0.1):
"""搜索并选择,需完全匹配"""
search_control = self.control.EditControl(ClassName="mmui::XValidatorTextEdit")
SetClipboardText(keyword)
search_control.Click()
search_control.SendKeys('{Ctrl}a')
search_control.RightClick()
menu = Menu(self)
menu.select('粘贴')
time.sleep(interval)
all_controls = []
for c, d in uia.WalkControl(self.control):
all_controls.append(c)
for target in all_controls:
if (
target.ControlTypeName == 'CheckBoxControl'
and target.Name == keyword
):
target.Click()
return True
def confirm(self):
self.confirm_btn.Click()
def send(self, target, interval=0.1):
if isinstance(target, str):
target = [target]
for i in target:
self.search(i, interval)
self.confirm()
class SearchNewFriendWnd(BaseUISubWnd):
"""添加朋友窗口"""
_win_cls_name: str = 'Qt51514QWindowIcon'
_win_name: str = "添加朋友"
def __init__(self):
self.control = find_window_from_root(classname=self._win_cls_name, name=self._win_name)
if self.control:
self.init()
def init(self):
self.apply_btn = self.control.ButtonControl(
AutomationId="fixed_height_v_view.content_v_view.ContactProfileBottomUi.add_friend_button",
Name="添加到通讯录",
ClassName="mmui::XOutlineButton",
searchDepth=9
)
self.search_edit = self.control.EditControl(ClassName="mmui::XValidatorTextEdit", Name="搜索")
self.search_btn = self.control.ButtonControl(ClassName="mmui::XOutlineButton", Name="搜索")
def search(self, keyword):
self.search_edit.SendKeys('{Ctrl}a')
SetClipboardText(keyword)
self.search_edit.SendKeys('{Ctrl}v')
self.search_btn.Click()
def apply(self):
if self.apply_btn.Exists(0):
self.apply_btn.Click()
return WxResponse.success()
else:
return WxResponse.failure('未找到添加按钮')
class WeChatImage(BaseUISubWnd):
"""图片/视频预览窗口"""
_win_cls_name: str = 'Qt51514QWindowIcon'
_win_name: str = "预览"
def __init__(self, parent):
self.parent = parent
self.root = self.parent.root
self.control = find_window_from_root(classname=self._win_cls_name, name=self._win_name)
if self.control:
self.init()
def init(self):
toolbar_control = self.control.GroupControl(ClassName="mmui::PreviewToolbarView")
self.tools = {
btn.Name: btn for ele in toolbar_control.GetChildren()
if (btn := ele.ButtonControl()).Exists(0)
}
if self.control.WindowControl(ClassName="mmui::XPlayerControlView").Exists(0):
self.type = 'video'
else:
self.type = 'image'
def save(self, dir_path=None, timeout=10) -> Path:
"""保存图片/视频
Args:
dir_path (str): 保存文件夹路径
timeout (int, optional): 保存超时时间,默认10秒
Returns:
Path: 文件保存路径,即savepath
"""
image_sufix = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'pic']
if dir_path is None:
dir_path = WxParam.DEFAULT_SAVE_PATH
t0 = time.time()
n = 1
SetClipboardText('')
while True:
if time.time() - t0 > timeout:
if self.control.Exists(0):
self.control.SendKeys('{Esc}')
return WxResponse.failure('下载超时')
if self.control.TextControl(Name="图片过期或已被清理").Exists(0):
return WxResponse.failure('图片过期或已被清理')
try:
self.tools['更多'].Click()
menu = Menu(self.root)
menu.select('复制')
if self.type == 'video':
for _ in range(30):
clipboard_data = ReadClipboardData()
wxlog.debug(f"读取到剪贴板数据:{clipboard_data.keys()}")
if '15' not in clipboard_data:
time.sleep(0.1)
continue
path = clipboard_data['15'][0]
break
else:
clipboard_data = ReadClipboardData()
path = clipboard_data['15'][0]
if not os.path.exists(path):
return WxResponse.failure('微信BUG无法获取该图片,请重新获取')
suffix = os.path.splitext(path)[1]
if (
suffix in image_sufix
and not is_valid_image(path)
) or not os.path.getsize(path):
wxlog.debug("图片格式不正确,删除文件")
os.remove(path)
continue
wxlog.debug(f"读取到图片/视频路径[{os.path.exists(path)}, {os.path.getsize(path)}]{path}")
break
except Exception:
if n > 3:
return WxResponse.failure('微信BUG无法获取该图片,请重新获取')
n += 1
wxlog.debug(traceback.format_exc())
time.sleep(0.1)
filename = f"wechatauto_{self.type}_{now_time()}{suffix}"
filepath = get_file_dir(dir_path) / filename
wxlog.debug(f"保存到文件:{filepath}")
shutil.copyfile(path, filepath)
SetClipboardText('')
if self.control.Exists(0):
wxlog.debug("关闭图片窗口")
self.control.SendKeys('{Esc}')
return filepath
+268
View File
@@ -0,0 +1,268 @@
from .base import BaseUISubWnd, BaseUIWnd
from .navigationbox import NavigationBox
from .sessionbox import SessionBox
from .chatbox import ChatBox
from wechatauto.utils.win32 import (
FindWindow,
GetAllWindows,
GetPathByHwnd,
get_windows_by_pid
)
from wechatauto.param import WxParam, WxResponse, PROJECT_NAME
from wechatauto.logger import wxlog
from wechatauto import uia
from typing import (
Union,
List,
Literal
)
import os
import re
import sys
def find_wechat_windows(ui_cls_name: str) -> List[uia.Control]:
"""从所有顶层窗口中查找指定 UIA 类名的微信窗口控件。
Args:
ui_cls_name: 目标 UIA 类名,如 ``mmui::MainWindow``
Returns:
匹配到的窗口控件列表
"""
targets = []
for hwnd, clsname, winname in GetAllWindows():
# 跳过 Qt 内部消息泵等隐藏窗口,其 UIA 服务可能阻塞调用
if clsname.startswith(uia.QT_INTERNAL_WIN_CLASS_PREFIX):
continue
if uia.GetUiClassNameWithTimeout(hwnd) != ui_cls_name:
continue
try:
control = uia.ControlFromHandle(hwnd)
except Exception:
continue
if control is not None:
targets.append(control)
return targets
class WeChatSubWnd(BaseUISubWnd):
"""微信独立聊天子窗口"""
_ui_cls_name: str = 'mmui::FramelessMainWindow'
_win_cls_name: str = 'Qt51514QWindowIcon'
_chat_api: ChatBox = None
nickname: str = ''
def __init__(
self,
key: Union[str, int],
parent: 'WeChatMainWnd',
timeout: int = 3
):
self.root = self
self.parent = parent
if isinstance(key, str):
hwnd = FindWindow(classname=self._win_cls_name, name=key, timeout=timeout)
else:
hwnd = key
self.control = uia.ControlFromHandle(hwnd)
if self.control is not None:
chatbox_control = self.control.\
GroupControl(ClassName="mmui::ChatMessagePage").\
CustomControl(ClassName="mmui::XSplitterView")
self._chat_api = ChatBox(chatbox_control, self)
self.nickname = self.control.Name
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.nickname}")>'
@property
def pid(self):
if not hasattr(self, '_pid'):
self._pid = self.control.ProcessId
return self._pid
def _get_chatbox(
self,
nickname: str = None,
exact: bool = False
) -> ChatBox:
return self._chat_api
def _get_windows(self):
wins = []
for hwnd in get_windows_by_pid(self.pid):
try:
wins.append(uia.ControlFromHandle(hwnd))
except Exception:
pass
ignore_cls = ['basepopupshadow', 'popupshadow']
return [win for win in wins if win.ClassName not in ignore_cls]
def chat_info(self):
return self._chat_api.get_info()
def send_msg(
self,
msg: str,
who: str = None,
clear: bool = True,
at: Union[str, List[str]] = None,
exact: bool = False,
) -> WxResponse:
chatbox = self._get_chatbox(who, exact)
if chatbox is None:
return WxResponse.failure(f"未找到聊天窗口:{who}")
return chatbox.send_msg(msg, clear, at)
def send_files(
self,
filepath,
who=None,
exact=False
) -> WxResponse:
chatbox = self._get_chatbox(who, exact)
if chatbox is None:
return WxResponse.failure(f"未找到聊天窗口:{who}")
return chatbox.send_file(filepath)
def get_msgs(self):
chatbox = self._get_chatbox()
if chatbox:
return chatbox.get_msgs()
return []
def get_new_msgs(self):
return self._get_chatbox().get_new_msgs()
def get_msg_by_id(self, msg_id):
chatbox = self._get_chatbox()
if chatbox:
return chatbox.get_msg_by_id(msg_id)
def get_msg_by_hash(self, msg_hash: str):
chatbox = self._get_chatbox()
if chatbox:
return chatbox.get_msg_by_hash(msg_hash)
def get_last_msg(self):
chatbox = self._get_chatbox()
if chatbox:
return chatbox.get_last_msg()
class WeChatMainWnd(WeChatSubWnd):
"""微信主窗口"""
_ui_cls_name: str = 'mmui::MainWindow'
_win_cls_name: str = 'Qt51514QWindowIcon'
_ui_name: str = '微信'
def __init__(self, nickname: str = None, hwnd: int = None):
self.root = self
self.parent = self
if hwnd:
self._setup_ui(hwnd)
else:
# 优先按 UIA 类名定位主窗口,兼容不同 Qt 版本的客户端
controls = find_wechat_windows(self._ui_cls_name)
if not controls:
controls = find_wechat_windows(self._ui_cls_name.replace('MainWindow', 'FramelessMainWindow'))
if not controls:
raise Exception('未找到已登录的微信主窗口')
target = None
for control in controls:
if nickname and control.Name != nickname:
continue
target = control
break
if target is None:
target = controls[0]
self._setup_ui(target.NativeWindowHandle)
print(f'初始化成功,获取到已登录窗口:{self.nickname}')
def _setup_ui(self, hwnd: int):
self.HWND = hwnd
self.control = uia.ControlFromHandle(hwnd)
if self.control is not None:
navigation_control = self.control.\
ToolBarControl(ClassName="mmui::MainTabBar", AutomationId='main_tabbar')
sessionbox_control = self.control.\
GroupControl(ClassName="mmui::ChatMasterView")
chatbox_control = self.control.\
GroupControl(ClassName="mmui::ChatMessagePage").\
CustomControl(ClassName="mmui::XSplitterView")
self._navigation_api = NavigationBox(navigation_control, self)
self._session_api = SessionBox(sessionbox_control, self)
self._chat_api = ChatBox(chatbox_control, self)
self.nickname = self.control.Name
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.nickname}")>'
def _get_wx_path(self):
return GetPathByHwnd(self.HWND)
def _get_wx_dir(self):
wxdir = os.path.dirname(self._get_wx_path())
for d in os.listdir(wxdir):
if re.match(r'\d+\.\d+\.\d+\.\d+', d):
return os.path.join(wxdir, d)
def _get_chatbox(
self,
nickname: str = None,
exact: bool = False
) -> ChatBox:
if nickname and (chatbox := WeChatSubWnd(nickname, self, timeout=0)).control:
return chatbox._chat_api
else:
if nickname:
switch_result = self._session_api.switch_chat(keywords=nickname, exact=exact)
if not switch_result:
return None
if self._chat_api.msgbox.Exists(0.5):
return self._chat_api
def switch_chat(
self,
keywords: str,
exact: bool = True,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
return self._session_api.switch_chat(keywords, exact, force, force_wait)
def get_all_sub_wnds(self):
sub_wxs = GetAllWindows(classname=WeChatSubWnd._win_cls_name)
return [
sub_win
for i in sub_wxs
if (
uia.ControlFromHandle(i[0]).ClassName == WeChatSubWnd._ui_cls_name
and (sub_win := WeChatSubWnd(i[0], self)).pid == self.pid
)
]
def get_sub_wnd(self, who: str):
subwins = self.get_all_sub_wnds()
for subwin in subwins:
if subwin.nickname == who:
return subwin
def open_separate_window(self, keywords: str) -> WeChatSubWnd:
if subwin := self.get_sub_wnd(keywords):
wxlog.debug(f"{keywords} 获取到已存在的子窗口: {subwin}")
return subwin
if nickname := self._session_api.switch_chat(keywords):
wxlog.debug(f"{keywords} 切换到聊天窗口: {nickname}")
if subwin := self.get_sub_wnd(nickname):
wxlog.debug(f"{nickname} 获取到已存在的子窗口: {subwin}")
return subwin
else:
keywords = nickname
if result := self._session_api.open_separate_window(keywords):
find_nickname = result['data'].get('nickname', keywords)
return WeChatSubWnd(find_nickname, self)
+62
View File
@@ -0,0 +1,62 @@
from wechatauto import uia
from wechatauto.languages import WECHAT_NAVIGATION_BOX, get_lang
from wechatauto.param import WxParam
class NavigationBox:
"""微信左侧导航栏"""
def __init__(self, control, parent):
self.control: uia.Control = control
self.root = parent.root
self.parent = parent
self.init()
def _lang(self, text):
return get_lang(WECHAT_NAVIGATION_BOX, text)
def init(self):
self.chat_icon = self.control.ButtonControl(Name=self._lang('聊天'))
self.contact_icon = self.control.ButtonControl(Name=self._lang('通讯录'))
self.favorites_icon = self.control.ButtonControl(Name=self._lang('收藏'))
self.files_icon = self.control.ButtonControl(Name=self._lang('聊天文件'))
self.moments_icon = self.control.ButtonControl(Name=self._lang('朋友圈'))
self.browser_icon = self.control.ButtonControl(Name=self._lang('搜一搜'))
self.video_icon = self.control.ButtonControl(Name=self._lang('视频号'))
self.stories_icon = self.control.ButtonControl(Name=self._lang('看一看'))
self.mini_program_icon = self.control.ButtonControl(Name=self._lang('小程序面板'))
self.phone_icon = self.control.ButtonControl(Name=self._lang('手机'))
self.settings_icon = self.control.ButtonControl(Name=self._lang('更多'))
def switch_to_chat_page(self):
self.chat_icon.Click()
def switch_to_contact_page(self):
self.contact_icon.Click()
def switch_to_favorites_page(self):
self.favorites_icon.Click()
def switch_to_files_page(self):
self.files_icon.Click()
def switch_to_moments_page(self):
self.moments_icon.Click()
def switch_to_browser_page(self):
self.browser_icon.Click()
def switch_to_video_page(self):
self.video_icon.Click()
def switch_to_stories_page(self):
self.stories_icon.Click()
def switch_to_mini_program_page(self):
self.mini_program_icon.Click()
def switch_to_phone_page(self):
self.phone_icon.Click()
def switch_to_settings_page(self):
self.settings_icon.Click()
+276
View File
@@ -0,0 +1,276 @@
from __future__ import annotations
from wechatauto import uia
from wechatauto.param import (
WxParam,
WxResponse,
)
from wechatauto.languages import MENU_OPTIONS, get_lang
from wechatauto.ui.component import Menu
from wechatauto.utils.win32 import SetClipboardText
from wechatauto.logger import wxlog
import time
from typing import (
Union,
List
)
import re
class SessionBox:
"""会话列表区域"""
def __init__(self, control, parent):
self.control: uia.Control = control
self.root = parent.root
self.parent = parent
self.init()
def init(self):
self.searchbox = self.control.GroupControl(ClassName="mmui::XSearchField").EditControl()
self.session_list = self.control.GroupControl(ClassName="mmui::ChatSessionList").\
ListControl(ClassName="mmui::XTableView", Name="会话")
self.search_content = self.parent.control.WindowControl(ClassName="mmui::SearchContentPopover")
def roll_up(self, n: int = 5):
self.control.MiddleClick()
self.control.WheelUp(wheelTimes=n)
def roll_down(self, n: int = 5):
self.control.MiddleClick()
self.control.WheelDown(wheelTimes=n)
def get_session(self) -> List['SessionElement']:
if self.session_list.Exists(0):
return [SessionElement(i, self) for i in self.session_list.GetChildren()]
else:
return []
def search(
self,
keywords: str,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
self.searchbox.RightClick()
SetClipboardText(keywords)
menu = Menu(self)
menu.select('粘贴')
self.searchbox.MiddleClick()
search_result = self.search_content.ListControl()
if force:
time.sleep(force_wait)
return [SearchResultElement(i) for i in search_result.GetChildren()]
def switch_chat(
self,
keywords: str,
exact: bool = True,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
wxlog.debug(f"切换聊天窗口: {keywords}, {exact}, {force}, {force_wait}")
search_box = self.search_content.ListControl()
search_result = self.search(keywords, force, force_wait)
t0 = time.time()
while time.time() - t0 < WxParam.SEARCH_CHAT_TIMEOUT:
results = []
search_result_items = search_box.GetChildren()
for search_result_item in search_result_items:
text: str = search_result_item.Name
if exact:
if text == keywords:
search_result_item.Click()
return keywords
elif (
' 微信号: ' in text
and (split := text.split(' 微信号: '))[-1].lower() == keywords.lower()
):
search_result_item.Click()
return split[0]
elif (
' 昵称: ' in text
and (split := text.split(' 昵称: '))[-1].lower() == keywords.lower()
):
search_result_item.Click()
return split[0]
else:
if keywords in text:
search_result_item.Click()
return text
if self.search_content.Exists(0):
self.control.MiddleClick()
def open_separate_window(self, name: str):
wxlog.debug(f"打开独立窗口: {name}")
realname = self.switch_chat(name)
if not realname:
return WxResponse.failure('未找到会话')
time.sleep(0.3)
while True:
session = [i for i in self.get_session() if uia.IsElementInWindow(self.session_list, i.control)][0]
if session.content.startswith(realname):
break
session.double_click()
return WxResponse.success(data={'nickname': realname})
def go_top(self):
wxlog.debug("回到会话列表顶部")
self.control.MiddleClick()
self.control.SendKeys('{Home}')
def go_bottom(self):
wxlog.debug("回到会话列表底部")
self.control.MiddleClick()
self.control.SendKeys('{End}')
class SessionElement:
"""会话列表中的单条会话"""
def __init__(
self,
control: uia.Control,
parent: SessionBox,
):
self.root = parent.root
self.parent = parent
self.control = control
self.content = control.Name
@property
def texts(self) -> List[str]:
"""拆分当前会话控件中的文本行"""
return [
line for line in str(self.content).split('\n')
if line and line.strip()
]
@property
def name(self) -> str:
"""会话名称"""
if self.texts:
return self.texts[0]
return ''
@property
def unread_count(self) -> int:
"""未读消息数量"""
unread_pattern = re.compile(r'\[(\d+)条\]')
for text in self.texts:
if match := unread_pattern.search(text):
return int(match.group(1))
return 0
def _menu_option_text(self, option_key: str) -> str:
return get_lang(MENU_OPTIONS, option_key)
def select_menu_option(self, option_key: str, wait=0.3):
"""根据配置语言选择菜单项"""
option_text = self._menu_option_text(option_key)
return self.select_option(option_text, wait)
def __repr__(self):
content = str(self.content).replace('\n', ' ')
if len(content) > 5:
content = content[:5] + '...'
return f"<wechatauto Session Element({content})>"
def roll_into_view(self):
uia.RollIntoView(self.control.GetParentControl(), self.control)
def _click(self, right: bool = False, double: bool = False):
self.roll_into_view()
if right:
self.control.RightClick()
elif double:
self.control.DoubleClick()
else:
self.control.Click()
def click(self):
self._click()
def right_click(self):
self._click(right=True)
def double_click(self):
self._click()
self._click(double=True)
def select_option(self, option: str, wait=0.3):
self.roll_into_view()
self.control.RightClick()
time.sleep(wait)
menu = Menu(self.parent)
return menu.select(option)
def pin(self):
"""置顶聊天"""
return self.select_menu_option('置顶')
def unpin(self):
"""取消置顶聊天"""
return self.select_menu_option('取消置顶')
def mark_unread(self):
"""标记为未读"""
return self.select_menu_option('标为未读')
def toggle_mute(self):
"""切换消息免打扰状态"""
return self.select_menu_option('消息免打扰')
def open_in_separate_window(self):
"""在独立窗口中打开会话"""
return self.select_menu_option('在独立窗口打开')
def hide(self):
"""不显示聊天"""
return self.select_menu_option('不显示聊天')
def delete(self):
"""删除聊天"""
return self.select_menu_option('删除聊天')
class SearchResultElement:
"""搜索结果中的单条结果"""
def __init__(self, control):
self.control = control
self.content = control.Name
self.type = control.ClassName
def __repr__(self):
content = str(self.content).replace('\n', ' ')
if len(content) > 5:
content = content[:5] + '...'
return f"<wechatauto Search Element({content})>"
def get_all_text(self):
return [
line for line in str(self.content).split('\n')
if line and line.strip()
]
def click(self):
uia.RollIntoView(self.control.GetParentControl(), self.control)
self.control.Click()
def close(self):
self.control.SendKeys('{Esc}')