Files
wechatauto-replica/wechatauto/ui/chatbox.py
T

401 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()]