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