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
+810
View File
@@ -0,0 +1,810 @@
"""wechatauto 顶层 API —— 兼容当前微信 4.x 客户端。
实现说明
========
早期版本基于 UIAutomation``mmui::*`` 控件树)驱动微信。当前 4.1.x 客户端
冷启动时 UIA 树只暴露 ``Qt51514QWindowIcon`` + ``MMUIRenderSubWindow*`` 空壳
(原 wxauto UI 方案因此失效);通过热激活 Qt accessibility gate(见
:mod:`wechatauto.uia_driver`)后可物化 ``mmui::*`` 完整控件树。
本模块把 :class:`WeChat` / :class:`Chat` 的公共 API 重新实现为
「UIA 优先(:class:`wechatauto.uia_driver.WeChatUIA`+ 坐标/OCR
:class:`wechatauto.guia.WeChatGUI`+ 本地数据库
:class:`wechatauto.db.WeChatDB`)」混合技术栈,**保持方法签名不变**,
原有调用方代码无需改动即可运行。
:class:`Listener` 抽象类保留仅为向后兼容(已由 :mod:`wechatauto.db` 的
``Listener`` 取代)。
"""
from __future__ import annotations
import ctypes
import os
import re
import threading
import time
from abc import ABC, abstractmethod
from typing import (
Callable,
TYPE_CHECKING,
Union,
List,
Dict,
Literal,
Optional,
)
from wechatauto.param import WxParam, WxResponse, PROJECT_NAME
from wechatauto.logger import wxlog
from wechatauto.utils.lock import uilock
if TYPE_CHECKING:
from wechatauto.msgs.base import Message
# ---------------------------------------------------------------------------
# 兼容占位:UIA 时代的监听器抽象基类(保留导出,不再使用)
# ---------------------------------------------------------------------------
class Listener(ABC):
"""监听器抽象基类(兼容保留)。
当前版本请使用 :class:`wechatauto.db.Listener`。
"""
@abstractmethod
def _get_listen_messages(self):
...
# ---------------------------------------------------------------------------
# DB 消息 → Message 对象适配
# ---------------------------------------------------------------------------
class _FakeRect:
"""伪矩形,供现有 Message 类计算 hash 使用。"""
def __init__(self):
self.top = self.left = self.bottom = self.right = 0
def height(self):
return self.bottom - self.top
def width(self):
return self.right - self.left
class _DBMessageControl:
"""让 DB 消息复用现有 Message 子类的轻量伪控件。
仅提供 ``Name`` / ``runtimeid`` / ``BoundingRectangle`` / ``Exists``
等只读接口;交互类操作(点击/滚动)因 DB 消息无对应控件而明确报错。
"""
def __init__(self, content: str, msg_id):
self.Name = content or ''
self.AutomationId = None
self.ClassName = "mmui::ChatTextItemView"
self.runtimeid = str(msg_id)
self._rect = _FakeRect()
@property
def BoundingRectangle(self):
return self._rect
def Exists(self, timeout=0) -> bool:
return True
def GetChildren(self):
return []
def Click(self, *args, **kwargs):
raise NotImplementedError('DB 消息不支持点击操作')
def RightClick(self, *args, **kwargs):
raise NotImplementedError('DB 消息不支持右键操作')
class _DBMessageParent:
"""Message 所需的 parent 占位(root 指向 Chat)。"""
def __init__(self, chat):
self.root = chat
self.msgbox = None
class _AllMessageChat:
"""AddListenAll 使用的轻量 Chat 占位(仅含 .who,不触发 GUI 初始化)。"""
def __init__(self, username: str):
self.who = username
self._wxid = username
def _extract_group_sender(content) -> str:
"""群消息内容形如 ``wxid_xxx:\\n正文``,提取发送者 wxid。"""
if isinstance(content, bytes):
content = content.decode('utf-8', errors='ignore')
m = re.match(r'^(wxid_[0-9a-zA-Z_]+):\s*\n', content or '')
return m.group(1) if m else ''
def _pick_msg_class(is_self: bool, mtype: Optional[str], content: str):
from wechatauto.msgs import friend as friendmsg
from wechatauto.msgs import self as selfmsg
mod = selfmsg if is_self else friendmsg
def get(name):
return getattr(mod, name)
if mtype == '文本':
return get('SelfTextMessage' if is_self else 'FriendTextMessage')
if mtype == '图片':
return get('SelfImageMessage' if is_self else 'FriendImageMessage')
if mtype == '语音':
return get('SelfVoiceMessage' if is_self else 'FriendVoiceMessage')
if mtype == '视频':
return get('SelfVideoMessage' if is_self else 'FriendVideoMessage')
if mtype == '位置':
return get('SelfLocationMessage' if is_self else 'FriendLocationMessage')
if mtype == '文件/链接/卡片':
head = (content or '')[:8]
if '[链接' in head or head.startswith('链接'):
return get('SelfLinkMessage' if is_self else 'FriendLinkMessage')
if head.startswith('文件') or '[文件' in head:
return get('SelfFileMessage' if is_self else 'FriendFileMessage')
if head.startswith('位置') or head.startswith('[位置'):
return get('SelfLocationMessage' if is_self else 'FriendLocationMessage')
if '[个人名片' in head or '[名片' in head:
return get('SelfPersonalCardMessage' if is_self else 'FriendPersonalCardMessage')
return get('SelfOtherMessage' if is_self else 'FriendOtherMessage')
if mtype == '动画表情':
return get('SelfEmojiMessage' if is_self else 'FriendEmojiMessage')
return get('SelfOtherMessage' if is_self else 'FriendOtherMessage')
def _db_row_to_message(row: dict, chat: 'Chat', self_wxid: str = None) -> 'Message':
"""把 db.py 的消息行转换为现有 Message 子类实例。
direction 判定:``sender_id == 2`` 视为自己(与 guia 发送校验一致),
也可用 self_wxid 比对兜底。
"""
from wechatauto.db import WeChatDB
from wechatauto.msgs.mattr import SystemMessage
mtype = row.get('type')
if mtype is None and row.get('local_type') is not None:
mtype = WeChatDB._msg_type_name(row.get('local_type'))
content = row.get('content') or ''
if isinstance(content, bytes):
content = WeChatDB._friendly_content(content, mtype)
sender_id = row.get('sender_id')
is_self = sender_id == 2 or bool(self_wxid and str(sender_id) == str(self_wxid))
ctrl = _DBMessageControl(content, row.get('local_id'))
parent = _DBMessageParent(chat)
if mtype == '系统消息':
msg = SystemMessage(ctrl, parent)
else:
cls = _pick_msg_class(is_self, mtype, content)
msg = cls(ctrl, parent)
# 附加 DB 元数据
msg.local_id = row.get('local_id')
msg.sort_seq = row.get('sort_seq')
msg.create_time = row.get('create_time')
msg.wxid = sender_id
msg.attr = 'self' if is_self else 'friend'
sender = _extract_group_sender(content) or getattr(chat, 'who', '')
msg.sender = sender or getattr(chat, 'who', '')
msg.sender_remark = msg.sender
return msg
class SessionItem:
"""会话列表条目(兼容 SessionElement 常用字段)。"""
def __init__(self, name: str, unread: int = 0, summary: str = '',
last_time: int = 0, username: str = ''):
self.name = name
self.unread = unread
self.summary = summary
self.last_time = last_time
self.username = username
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__}("{self.name}")>'
def _resolve_wxid(db, name: str) -> str:
"""把会话显示名解析为数据库 wxid;文件传输助手/未知则原样返回。"""
if name in ('filehelper', '文件传输助手'):
return 'filehelper'
try:
for hit in db.search_contact(name):
if name in (hit.get('nick_name'), hit.get('remark')):
return hit['username']
except Exception:
pass
return name
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
class Chat:
"""聊天窗口实例(基于 GUI + 本地数据库)。"""
def __init__(self, who: str = None, gui=None, db=None):
from wechatauto.guia import WeChatGUI
from wechatauto.db import WeChatDB
self.who = who or ''
self._gui = gui or WeChatGUI()
self._db = db or WeChatDB()
self._wxid = _resolve_wxid(self._db, self.who)
self._last_seq: Optional[int] = None
def __repr__(self):
return f'<{PROJECT_NAME} - {self.__class__.__name__} object("{self.who}")>'
def __str__(self):
return self.who or self.nickname
def __add__(self, other):
return (self.who or '') + other
def __radd__(self, other):
return other + (self.who or '')
# -- 展示 -------------------------------------------------------------
def Show(self):
"""打开该会话的聊天窗口并置前。"""
self._gui.open_chat(self.who)
def Close(self) -> None:
"""关闭聊天(GUI 模式下无独立窗口,置前即可)。"""
self._gui.bring_to_front()
@uilock
def VoiceCall(self, who: str = None, video: bool = False) -> WxResponse:
"""发起语音/视频通话。
Args:
who: 通话对象,不指定则使用当前聊天对象
video: True 尝试视频通话(当前版本未暴露视频按钮,通常失败)
Returns:
WxResponse
"""
target = who or self.who
uia = self._gui._get_uia()
if uia is None:
return WxResponse.failure('UIA 驱动不可用,无法发起通话')
if not uia.voice_call(target, video=video):
return WxResponse.failure('通话发起失败(可能未打开会话或控件不可用)')
return WxResponse.success(f'已发起通话:{target}')
@uilock
def Poke(self, who: str = None) -> WxResponse:
"""对联系人发起「拍一拍」(右键头像 → 点击拍一拍)。
Args:
who: 拍一拍对象,不指定则使用当前聊天对象
Returns:
WxResponse
"""
target = who or self.who
uia = self._gui._get_uia()
if uia is None:
return WxResponse.failure('UIA 驱动不可用,无法发起拍一拍')
if not uia.poke(target):
return WxResponse.failure('拍一拍失败(未找到对方消息或菜单不可识别)')
return WxResponse.success(f'已对 {target} 拍一拍')
@uilock
def RecallLastMessage(self, who: str = None) -> WxResponse:
"""撤回当前会话最近一条自己发送的消息。
Args:
who: 会话对象,不指定则使用当前聊天对象
Returns:
WxResponse
"""
target = who or self.who
uia = self._gui._get_uia()
if uia is None:
return WxResponse.failure('UIA 驱动不可用,无法撤回消息')
if not uia.recall_last_message(target):
return WxResponse.failure('撤回失败(消息已过期或控件不可识别)')
return WxResponse.success(f'已撤回对 {target} 发送的最近一条消息')
@uilock
def ForwardVoiceMessage(
self,
who: str = None,
target: str = None,
save_dir: str = None,
) -> WxResponse:
"""转发语音消息(从本地媒体库提取 SILK 文件发送给目标)。
微信不支持右键直接转发语音,故实现为「找到本地语音文件 → 以文件
消息发送」。默认转发本会话最近一条语音到 target(不指定则发给
本会话对象自身)。
Args:
who: 语音所在会话,不指定则用当前会话
target: 转发目标联系人,不指定则转发给 who 本身
save_dir: 语音文件临时保存目录
Returns:
WxResponse
"""
chat = Chat(who or self.who, self._gui, self._db) if who else self
msgs = chat.GetAllMessage()
for m in msgs:
if getattr(m, 'type', None) == 'voice':
return m.forward_to(target or chat.who, save_dir=save_dir)
return WxResponse.failure(f'会话「{chat.who}」最近 50 条中没有语音消息')
# -- 信息 -------------------------------------------------------------
def ChatInfo(self) -> Dict[str, str]:
"""获取聊天窗口信息。"""
info = {'chat_name': self.who, 'chat_type': 'friend'}
if self._wxid and self._wxid.endswith('@chatroom'):
info['chat_type'] = 'group'
return info
# -- 发送 -------------------------------------------------------------
@uilock
def SendMsg(
self,
msg: str,
who: str = None,
clear: bool = True,
at: Union[str, List[str]] = None,
exact: bool = False,
) -> WxResponse:
"""发送消息。
Args:
msg: 消息内容
who: 发送对象,不指定则发送给当前聊天对象
clear: 是否发送前清空编辑框(GUI 路径恒清理)
at: @对象(支持 str 或 list
exact: 是否精确匹配会话名
Returns:
WxResponse
"""
target = who or self.who
if at:
return self._gui.at_member(at, msg, target)
return self._gui.send_msg(msg, target)
@uilock
def SendFiles(
self,
filepath,
who=None,
exact=False
) -> WxResponse:
"""向当前聊天窗口发送文件/图片。
Args:
filepath: 文件绝对路径(str 或 list)
who: 发送对象,不指定则发送给当前聊天对象
exact: 是否精确匹配会话名
Returns:
WxResponse
"""
target = who or self.who
if isinstance(filepath, (list, tuple)):
result = None
for p in filepath:
result = self._gui.send_file(p, target)
return result or WxResponse.failure('文件列表为空')
return self._gui.send_file(filepath, target)
# -- 读取 -------------------------------------------------------------
def GetAllMessage(self) -> List['Message']:
"""获取当前聊天窗口最近 50 条消息。"""
rows = self._db.get_messages(self._wxid, limit=50)
self_wxid = self._db.get_self_info()['username']
return [_db_row_to_message(r, self, self_wxid) for r in rows]
def GetNewMessage(self) -> List['Message']:
"""获取新消息(首次调用仅建立基线,返回空列表)。"""
latest = self._db.get_messages(self._wxid, limit=1)
current = latest[0]['sort_seq'] if latest else 0
if self._last_seq is None:
self._last_seq = current
return []
if current <= self._last_seq:
return []
rows = self._db.get_new_messages(self._wxid, since_seq=self._last_seq)
self._last_seq = current
self_wxid = self._db.get_self_info()['username']
return [_db_row_to_message(r, self, self_wxid) for r in rows]
def GetMessageById(self, msg_id) -> Optional['Message']:
"""根据消息 local_id 获取消息实例。"""
try:
local_id = int(str(msg_id).replace('db-', ''))
except (TypeError, ValueError):
return None
row = self._db.get_message_row(self._wxid, local_id)
if not row:
return None
return _db_row_to_message(row, self)
def GetMessageByHash(self, msg_hash: str) -> Optional['Message']:
"""根据消息哈希值获取消息实例。"""
if not msg_hash:
return None
self_wxid = self._db.get_self_info()['username']
for row in self._db.get_messages(self._wxid, limit=200):
m = _db_row_to_message(row, self, self_wxid)
if m.hash == msg_hash or getattr(m, 'hash_text', None) == msg_hash:
return m
return None
def GetLastMessage(self) -> Optional['Message']:
"""获取当前聊天窗口的最后一条消息。"""
rows = self._db.get_messages(self._wxid, limit=1)
if not rows:
return None
return _db_row_to_message(rows[0], self)
# ---------------------------------------------------------------------------
# WeChat
# ---------------------------------------------------------------------------
class WeChat(Chat, Listener):
"""微信主窗口实例(兼容 API)。"""
def __init__(
self,
nickname: str = None,
start_listener: bool = False,
debug: bool = False,
**kwargs
):
from wechatauto.guia import WeChatGUI
from wechatauto.db import WeChatDB
self._gui = WeChatGUI()
self._db = WeChatDB()
info = self._db.get_self_info()
self.nickname = nickname or info.get('nick_name') or info.get('username') or ''
self.who = self.nickname
self._wxid = info.get('username') or ''
self.listen: Dict[str, tuple] = {}
self._listener = None
self._listen_wrappers: Dict[str, Callable] = {}
self._listener_is_listening = False
self._listener_stop_event = threading.Event()
self._current_chat: Optional['Chat'] = None
self._listen_all_active = False
self._listen_all_callback: Optional[Callable] = None
if start_listener:
self._listener_start()
if debug:
wxlog.set_debug(True)
wxlog.debug('Debug mode is on')
# -- 监听(基于 db.Listener------------------------------------------
def _listener_start(self):
from wechatauto.db import Listener as DBListener
if self._listener is not None:
if self._listener._thread and self._listener._thread.is_alive():
return
self._listener = None
self._listener = DBListener(self._db, interval=WxParam.LISTEN_INTERVAL)
for name, (chat, _cb) in self.listen.items():
wrapper = self._make_listen_cb(chat, _cb)
self._listen_wrappers[name] = wrapper
self._listener.add_listener(chat._wxid, wrapper)
self._listener.start()
self._listener_is_listening = True
self._listener_stop_event.clear()
def _listener_stop(self):
if self._listener is not None:
self._listener.stop()
self._listener_is_listening = False
self._listener_stop_event.set()
def _make_listen_cb(self, chat: 'Chat', callback: Callable) -> Callable:
self_wxid = self._db.get_self_info()['username']
def _wrapper(row: dict, listener) -> None:
try:
msg = _db_row_to_message(row, chat, self_wxid)
callback(msg, chat)
except Exception:
import traceback
wxlog.debug(f'监听消息回调发生错误:{traceback.format_exc()}')
return _wrapper
def _get_listen_messages(self):
"""兼容占位:实际监听由 db.Listener 完成。"""
return
@uilock
def AddListenChat(
self,
nickname: str,
callback: Callable[['Message', 'Chat'], None],
) -> WxResponse:
"""添加监听聊天。
Args:
nickname: 要监听的聊天对象(显示名)
callback: 回调函数,参数为 (Message 对象, Chat 对象)
Returns:
Chat 对象(监听成功后返回)
"""
if not self._listener_is_listening:
wxlog.debug('检测到未开启监听器,开启监听器')
self._listener_start()
if nickname in self.listen:
return WxResponse.failure('该聊天已监听')
chat = Chat(nickname, self._gui, self._db)
if self._db.get_messages(chat._wxid, limit=1) == [] and not chat._wxid:
return WxResponse.failure('找不到聊天窗口')
self.listen[nickname] = (chat, callback)
wrapper = self._make_listen_cb(chat, callback)
self._listen_wrappers[nickname] = wrapper
if self._listener is not None:
self._listener.add_listener(chat._wxid, wrapper)
return chat
def AddListenAll(
self,
callback: Callable[['Message', 'Chat'], None],
discover: bool = True,
) -> WxResponse:
"""监听所有会话的新消息(包括好友、群聊、文件传输助手等)。
Args:
callback: 回调函数,参数为 (Message 对象, Chat-like 对象)。
Chat-like 对象的 .who 属性为会话原始 username。
discover: 为 True 时自动发现新出现的会话(如新群聊)并注册
回调,无需重复调用。默认 True。
Returns:
WxResponse
示例::
wx = WeChat()
def on_all(msg, chat):
print(f'[{chat.who}] {msg.content}')
wx.AddListenAll(on_all)
wx.StartListening()
"""
if not self._listener_is_listening:
wxlog.debug('检测到未开启监听器,开启监听器')
self._listener_start()
if getattr(self, '_listen_all_active', False):
return WxResponse.failure('已开启全局监听')
self_wxid = self._db.get_self_info()['username']
def _wrap(row: dict, listener) -> None:
try:
username = row.get('username', '')
fake_chat = _AllMessageChat(username)
msg = _db_row_to_message(row, fake_chat, self_wxid)
callback(msg, fake_chat)
except Exception:
import traceback
wxlog.debug(f'全局监听回调发生错误:{traceback.format_exc()}')
self._listen_all_callback = callback
self._listen_all_active = True
if self._listener is not None:
self._listener.add_all(_wrap, discover=discover)
return WxResponse.success('已开启全局监听')
def RemoveListenAll(self) -> WxResponse:
"""停止全局监听。"""
if not getattr(self, '_listen_all_active', False):
return WxResponse.failure('未开启全局监听')
self._listen_all_active = False
self._listen_all_callback = None
if self._listener is not None:
self._listener._discover_new = False
self._listener._all_callback = None
return WxResponse.success('已停止全局监听')
def StartListening(self) -> None:
"""启动监听。"""
self._listener_start()
def StopListening(self, remove: bool = True) -> None:
"""停止监听。
Args:
remove: 是否同时移除所有监听对象
"""
self._listener_stop()
if remove:
self.listen.clear()
self._listen_wrappers.clear()
self._listen_all_active = False
self._listen_all_callback = None
@uilock
def RemoveListenChat(
self,
nickname: str,
close_window: bool = True
) -> WxResponse:
"""移除监听聊天。
Args:
nickname: 要移除监听的聊天对象
close_window: 是否关闭聊天窗口(GUI 模式忽略)
Returns:
WxResponse
"""
if nickname not in self.listen:
return WxResponse.failure('未找到监听对象')
chat, _cb = self.listen[nickname]
if self._listener is not None:
wrapper = self._listen_wrappers.pop(nickname, None)
if wrapper is not None:
self._listener.remove_listener(chat._wxid, wrapper)
del self.listen[nickname]
return WxResponse.success()
def KeepRunning(self):
"""阻塞主线程直到手动停止监听。"""
while not self._listener_stop_event.is_set():
try:
time.sleep(1)
except KeyboardInterrupt:
wxlog.debug(f'wechatauto("{self.nickname}") shutdown')
self.StopListening(True)
break
# -- 会话 -------------------------------------------------------------
def GetSession(self) -> List['SessionItem']:
"""获取当前会话列表。"""
sessions = []
for row in self._db.get_sessions(limit=50):
username = row.get('username') or ''
name = row.get('last_sender') or username
if not name or name == username:
try:
nick = self._db.get_nickname(username)
name = nick or username
except Exception:
name = username
sessions.append(SessionItem(
name=name,
unread=row.get('unread', 0),
summary=row.get('summary', ''),
last_time=row.get('last_time', 0),
username=username,
))
return sessions
@uilock
def ChatWith(
self,
who: str,
exact: bool = True,
force: bool = False,
force_wait: Union[float, int] = 0.5
):
"""打开聊天窗口。
Args:
who: 要聊天的对象
exact: 搜索会话时是否精确匹配
force: 忽略(兼容保留)
force_wait: 忽略(兼容保留)
Returns:
str: 成功时返回会话显示名,失败返回 None
"""
chat = Chat(who, self._gui, self._db)
self._gui.open_chat(chat.who)
if self._gui.get_input_box():
self._current_chat = chat
self.who = chat.who
self._wxid = chat._wxid
return chat.who
self._gui.open_chat(chat.who)
if self._gui.get_input_box():
self._current_chat = chat
self.who = chat.who
self._wxid = chat._wxid
return chat.who
return None
# -- 消息读取(委托给当前打开的会话)----------------------------------
def _cur(self) -> 'Chat':
return self._current_chat if self._current_chat is not None else self
def GetAllMessage(self) -> List['Message']:
"""获取当前打开会话的最近 50 条消息。"""
return self._cur().GetAllMessage()
def GetNewMessage(self) -> List['Message']:
"""获取当前打开会话的新消息。"""
return self._cur().GetNewMessage()
def GetMessageById(self, msg_id) -> Optional['Message']:
"""根据消息 local_id 获取消息实例。"""
return self._cur().GetMessageById(msg_id)
def GetMessageByHash(self, msg_hash: str) -> Optional['Message']:
"""根据消息哈希值获取消息实例。"""
return self._cur().GetMessageByHash(msg_hash)
def GetLastMessage(self) -> Optional['Message']:
"""获取当前打开会话的最后一条消息。"""
return self._cur().GetLastMessage()
def GetSubWindow(self, nickname: str) -> Optional['Chat']:
"""获取子窗口实例(GUI 模式下返回对应 Chat 对象)。"""
chat = Chat(nickname, self._gui, self._db)
try:
hits = self._db.search_contact(nickname)
except Exception:
hits = []
if hits or nickname in ('filehelper', '文件传输助手'):
return chat
return None
def GetAllSubWindow(self) -> List['Chat']:
"""获取所有子窗口实例(GUI 模式下无独立子窗口,返回空列表)。"""
return []
# -- 路径 / 生命周期 ---------------------------------------------------
@property
def path(self):
from wechatauto.utils.win32 import GetPathByHwnd
return GetPathByHwnd(self._gui.main_hwnd)
@property
def dir(self):
wxdir = self.path
if not wxdir:
return None
wxdir = os.path.dirname(wxdir)
for d in os.listdir(wxdir):
if re.match(r'\d+\.\d+\.\d+\.\d+', d):
return os.path.join(wxdir, d)
return None
def ShutDown(self):
"""强制退出微信进程。"""
pid = ctypes.c_ulong()
ctypes.windll.user32.GetWindowThreadProcessId(
self._gui.main_hwnd, ctypes.byref(pid))
if pid.value:
os.system(f'taskkill /f /pid {pid.value}')