feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}')
|
||||
Reference in New Issue
Block a user