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()]