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
+100
View File
@@ -0,0 +1,100 @@
"""wechatauto —— Windows版本微信客户端(非网页版)自动化。
基于 UIAutomation 技术驱动当前微信4.x客户端,可实现简单的发送、
接收微信消息,编写简单的微信机器人。
"""
from __future__ import annotations
from .wx import WeChat, Chat, Listener
from .param import WxParam, WxResponse, PROJECT_NAME
from .logger import wxlog
from .moment import Moment, MomentDB
from .db import WeChatDB, auto_detect_db_dir, list_accounts
from .media import MediaDownloader
from .guia import (
WeChatGUI,
quick_send,
quick_send_file,
quick_send_image,
quick_reply,
WinInput,
ScreenOCR,
)
from .exceptions import (
NetWorkError,
WechatautoError,
WechatautoNoteLoadTimeoutError,
WechatautoUINotFoundError,
WechatautoNotLoggedInError,
)
from .utils.lock import LockManager, uilock
from .msgs import (
Message,
BaseMessage,
HumanMessage,
TextMessage,
ImageMessage,
VideoMessage,
VoiceMessage,
FileMessage,
QuoteMessage,
LinkMessage,
LocationMessage,
PersonalCardMessage,
OtherMessage,
SystemMessage,
FriendMessage,
SelfMessage,
parse_msg,
)
__version__ = "1.1.7"
__all__ = [
"WeChat",
"Chat",
"Listener",
"WeChatDB",
"auto_detect_db_dir",
"list_accounts",
"MediaDownloader",
"WeChatGUI",
"quick_send",
"quick_send_file",
"quick_send_image",
"quick_reply",
"WinInput",
"ScreenOCR",
"WxParam",
"WxResponse",
"wxlog",
"Moment",
"MomentDB",
"LockManager",
"uilock",
"WechatautoError",
"NetWorkError",
"WechatautoUINotFoundError",
"WechatautoNoteLoadTimeoutError",
"WechatautoNotLoggedInError",
"Message",
"BaseMessage",
"HumanMessage",
"TextMessage",
"ImageMessage",
"VideoMessage",
"VoiceMessage",
"FileMessage",
"QuoteMessage",
"LinkMessage",
"LocationMessage",
"PersonalCardMessage",
"OtherMessage",
"SystemMessage",
"FriendMessage",
"SelfMessage",
"parse_msg",
"PROJECT_NAME",
"__version__",
]
+15
View File
@@ -0,0 +1,15 @@
import argparse
from wechatauto import __version__
def main():
parser = argparse.ArgumentParser(description="wechatauto 命令行工具")
parser.add_argument('--version', '-v', action='store_true', help='显示版本信息')
args = parser.parse_args()
if args.version:
print(f"wechatauto {__version__}")
if __name__ == '__main__':
main()
+1915
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""wechatauto 复刻版使用示例
使用前请确认:
1. 已登录微信 4.x 客户端
2. 微信窗口在任务栏(未被完全最小化/隐藏)
3. 已安装依赖:pip install -e .
"""
from wechatauto import WeChat, WxParam, WxResponse
def demo_basic():
# 初始化,连接到已登录的微信主窗口
wx = WeChat(debug=True)
# 当前登录昵称
print(f"当前登录:{wx.nickname}")
# 获取会话列表
sessions = wx.GetSession()
for session in sessions[:5]:
print(f"会话:{session.name},未读:{session.unread_count}")
# 打开与好友的聊天窗口
who = "文件传输助手"
wx.ChatWith(who)
# 发送文本消息
result = wx.SendMsg("你好,世界!", who)
print(f"发送结果:{result}")
# 发送文件
# wx.SendFiles(r"C:\path\to\file.txt", who)
# 获取当前聊天窗口的所有消息
messages = wx.GetAllMessage()
for msg in messages:
print(f"[{msg.attr}] {msg.content}")
# 获取聊天窗口信息(群聊成员数、聊天类型等)
info = wx.ChatInfo()
print(f"聊天信息:{info}")
def demo_listener():
"""消息监听示例:收到新消息后自动回复"""
wx = WeChat()
def callback(msg, chat):
print(f"收到消息:{chat.who} - {msg.content}")
# 简单自动回复
if msg.is_friend:
chat.SendMsg(f"收到你的消息:{msg.content}")
# 添加监听聊天(会将聊天窗口独立出去)
wx.AddListenChat("文件传输助手", callback=callback)
print("开始监听,按 Ctrl+C 退出")
wx.KeepRunning()
def demo_moments():
"""朋友圈示例(只读,发布功能已舍弃)"""
wx = WeChat()
# 获取朋友圈动态
moments = wx.Moment.GetMoments()
for item in moments[:3]:
print(f"{item.publisher}: {item.text}")
if __name__ == "__main__":
demo_basic()
# demo_listener()
# demo_moments()
+97
View File
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""wechatauto 复刻版 —— 数据库读取示例程序
基于本地数据库解密,读取微信 4.x 的消息与会话,不依赖 UI 自动化。
使用前提:
1. 微信 4.x 已登录运行(首次运行时需要从进程内存提取密钥)
2. 已安装依赖:pip install -e .
用法:
python demo_db.py
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChatDB
def show_account(db: WeChatDB):
info = db.get_self_info()
print("-" * 60)
print("当前账号信息")
print("-" * 60)
print(f" 微信号 : {info['username']}")
print(f" 昵称 : {info['nick_name']}")
print(f" 数据目录: {db.account_dir}")
def show_sessions(db: WeChatDB, limit: int = 8):
print()
print("-" * 60)
print("会话列表(前 %d 条)" % limit)
print("-" * 60)
for s in db.get_sessions(limit=limit):
name = db.get_nickname(s["username"])
if not s["summary"]:
continue
t = time.strftime("%m-%d %H:%M", time.localtime(s["last_time"]))
unread = f" [未读 {s['unread']}]" if s["unread"] else ""
print(f" {name:<20} {t} {s['summary'][:30]}{unread}")
def show_messages(db: WeChatDB, who: str, limit: int = 10, display_name: str = ""):
print()
print("-" * 60)
print(f"最近消息:{display_name or who}")
print("-" * 60)
messages = db.get_messages(who, limit=limit)
if not messages:
print(" (没有找到消息,请确认微信号/群号是否正确)")
return
for m in reversed(messages):
t = time.strftime("%m-%d %H:%M", time.localtime(m["create_time"]))
sender = "" if m["sender_id"] == 2 else "对方"
content = m["content"].replace("\n", " ")
print(f" [{t}] {sender} [{m['type']}] {content[:60]}")
def main():
print("正在初始化微信数据库读取器 ...")
t0 = time.time()
db = WeChatDB()
print(f"初始化完成({time.time() - t0:.1f}s,账号目录:{db.account}")
show_account(db)
show_sessions(db)
who = input("\n输入会话昵称或微信号(直接回车查看示例会话):").strip()
if not who:
who = "文件传输助手"
hits = db.search_contact(who)
if hits:
who = hits[0]["username"]
else:
hits = db.search_contact(who)
if hits and hits[0]["nick_name"] != who:
who = hits[0]["username"]
print(f" 已匹配联系人:{hits[0]['nick_name']}{who}")
show_messages(db, who, display_name=db.get_nickname(who))
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""表情包截图示例 —— 遇到「动画表情」消息时截取其图片
背景:微信 4.x 表情消息在本地数据库中的 content 为加密数据,无法直接
提取原图。因此采用「打开会话 → 滚动到底 → 对最后一条消息区域截图」的
屏幕截图方案(EmojiMessage.capture()),返回 PNG 路径。
用法:
python demo_emoji_capture.py # 默认会话:文件传输助手
python demo_emoji_capture.py 某个群 # 指定会话
python demo_emoji_capture.py --listen 某个群 # 监听模式:收到表情自动截图
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChat
SAVE_DIR = os.path.join(os.path.expanduser("~"), "emoji_capture")
def capture_emoji(msg) -> str | None:
"""对一条表情消息截图,返回 PNG 路径;失败返回 None。
截图位置根据消息方向自动判断:
- msg.attr == 'self' → 右对齐(自己发的)
- 否则 → 左对齐(对方发的)
"""
path = msg.capture(save_dir=SAVE_DIR)
return path
def capture_latest_emoji(wx: WeChat, who: str) -> None:
"""打开会话,截取最新一条表情消息的气泡图片。
说明:capture() 截图的是屏幕上「最新一条消息」的位置,因此表情
必须是该会话的最新一条消息才能截到它;否则请用 --listen 模式,
收到表情时它即为最新一条,可自动截图。
"""
wx.ChatWith(who)
time.sleep(0.5)
msgs = wx.GetAllMessage() # 新→旧排列,msgs[0] 是会话最新一条
if not msgs:
print(f"会话「{who}」没有消息")
return
if getattr(msgs[0], "type", None) != "emotion":
print(f"会话「{who}」最新一条不是表情(是 {msgs[0].type}")
print("提示:capture() 只截取最新一条消息,请用 --listen 模式在收到表情时自动截图")
return
msg = msgs[0]
path = capture_emoji(msg)
if path:
print(f"[表情截图] 方向={msg.attr} 路径={path}")
else:
print("[表情截图] 截图失败(微信窗口是否可见?会话是否在最前?)")
def listen_emoji(wx: WeChat, who: str) -> None:
"""监听模式:实时收到表情消息时自动截图。"""
def callback(msg, chat):
if getattr(msg, "type", None) == "emotion":
path = capture_emoji(msg)
if path:
print(f"[收到表情 {msg.attr}] 截图成功 -> {path}")
else:
print("[收到表情] 截图失败")
wx.AddListenChat(who, callback=callback)
print(f"监听中,Ctrl+C 退出:{who}")
wx.KeepRunning()
def main():
args = [a for a in sys.argv[1:]]
listen = "--listen" in args
names = [a for a in args if not a.startswith("--")]
who = names[0] if names else "送你挖银子"
wx = WeChat()
print(f"当前登录:{wx.nickname}")
if listen:
listen_emoji(wx, who)
else:
capture_latest_emoji(wx, who)
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""wechatauto v1.0.9 转发语音演示 —— 从本地媒体库提取 SILK 并作为文件发送
微信不支持右键直接转发语音消息(无转发入口),本项目实现为:
「本地媒体库提取 SILK 音频文件 → 以文件消息发送给目标联系人」。
链路:
1. 打开会话,定位最近一条语音消息(mmui::ChatVoiceItemView / DB 语音);
2. MediaDownloader.download_voice 从 media_0.db 提取 voice_data 落盘 .silk
3. WeChatGUI.send_file 以文件消息发送给目标。
用法:
python demo_forward_voice.py # 小哲→文件传输助手(安全演示)
python demo_forward_voice.py --target 豆芽 # 指定转发目标
python demo_forward_voice.py --who 群名 --target 某人
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChat
SRC_DEFAULT = "小哲"
TGT_DEFAULT = "文件传输助手"
def main():
args = [a for a in sys.argv[1:]]
who = SRC_DEFAULT
target = TGT_DEFAULT
if "--who" in args:
i = args.index("--who")
who = args[i + 1]
args = args[:i] + args[i + 2:]
if "--target" in args:
i = args.index("--target")
target = args[i + 1]
args = args[:i] + args[i + 2:]
names = [a for a in args if not a.startswith("--")]
if names:
who = names[0]
wx = WeChat()
print(f"当前登录:{wx.nickname}")
print(f"语音来源会话:{who}")
print(f"转发目标:{target}")
if who != wx._cur().who:
r = wx.ChatWith(who)
if not (isinstance(r, str) and r):
print(f" 打开会话「{who}」失败:{r}")
sys.exit(1)
chat = wx._cur()
msgs = chat.GetAllMessage()
voice = None
for m in msgs:
if getattr(m, "type", None) == "voice":
voice = m
break
if voice is None:
print(f" 会话「{who}」最近 50 条中没有语音消息,无法演示。")
sys.exit(1)
print(f" 找到语音消息:local_id={voice.local_id} 方向={voice.attr}")
t0 = time.time()
r = voice.forward_to(target)
print(f" 转发 => {r['status']} :: {r['message']} ({time.time()-t0:.1f}s)")
data = r.get("data") or {}
path = data.get("path")
if path:
print(f" 本地语音文件:{path}")
print("\n转发完成。")
if __name__ == "__main__":
main()
+356
View File
@@ -0,0 +1,356 @@
# -*- coding: utf-8 -*-
"""wechatauto 演示:读取并识别固定群的最新消息(含红包 ZSTD 解析)。
从微信本地数据库直接读取指定群的最新消息,识别每条消息的类型、
发送者昵称与内容;红包消息自动解压 ZSTD 并提取祝福语/红包类型。
用法:
python demo_group_messages.py [群名关键词]
[--limit N] [--list-groups]
[--watch] [--red-only] [--sleep 秒]
参数:
群名关键词 匹配群名(模糊匹配),默认列出所有群后取第一个
--list-groups 仅列出所有群(名称 + wxid + 消息数),不读消息
--limit 读取最新 N 条消息(默认 20)
--watch 轮询模式:每 --sleep 秒读取一次新消息(增量,按 sort_seq)
--sleep 轮询间隔秒数(默认 3)
--red-only 只显示红包消息
例:
python demo_group_messages.py --list-groups
python demo_group_messages.py 家长群
python demo_group_messages.py 家长群 --limit 30
python demo_group_messages.py 家长群 --watch --red-only
python demo_group_messages.py 家长群 --watch --sleep 5
原理:
contact.db 存群(username 含 @chatroom)与成员(wxid → nick_name/remark);
message_*.db 按 Md5(群wxid) 建表存消息,群文本消息的 message_content 形如
"wxid: 内容",发送者 wxid 藏在内容前缀中(4.x 群消息 sender_id 不可靠)。
红包卡片 local_type=0x7D100000031message_content 为「容器头 + ZSTD 压缩
XML」,解压后可读到祝福语、红包类型(sceneid)、sendid 等。
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChatDB
# 红包卡片 local_type = 0x7D100000031
RED_PACKET_TYPE = 0x7D100000031
TYPE_LABEL = {
1: "文本",
3: "图片",
34: "语音",
43: "视频",
47: "动画表情",
48: "位置",
49: "文件/链接/卡片",
10000: "系统消息",
}
def load_nickname_map(db: WeChatDB) -> dict:
"""wxid -> 昵称/备注"""
mapping = {}
for rel, path, _ in db._db_files:
if os.path.basename(path) != "contact.db":
continue
conn = db._open(rel)
try:
rows = conn.execute(
"SELECT username, nick_name, remark FROM contact"
).fetchall()
finally:
conn.close()
for r in rows:
mapping[r[0]] = r[2] or r[1] or r[0]
break
return mapping
def list_groups(db: WeChatDB) -> list:
"""列出所有群(名称 + wxid + 消息数),按消息数倒序"""
groups = []
for rel, path, _ in db._db_files:
if os.path.basename(path) != "contact.db":
continue
conn = db._open(rel)
try:
rows = conn.execute(
"SELECT username, nick_name, remark FROM contact "
"WHERE username LIKE '%@chatroom'"
).fetchall()
finally:
conn.close()
for r in rows:
groups.append({"name": r[2] or r[1] or r[0], "wxid": r[0]})
break
for g in groups:
g["count"] = _chat_message_count(db, g["wxid"])
groups.sort(key=lambda x: -x["count"])
return groups
def _chat_message_count(db: WeChatDB, wxid: str) -> int:
md5hex = hashlib.md5(wxid.encode()).hexdigest()
total = 0
for rel in db._message_dbs():
conn = db._open(rel)
try:
tabs = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")]
for tab in tabs:
if tab == "Msg_%s" % md5hex:
total += conn.execute(
"SELECT COUNT(*) FROM %s" % tab).fetchone()[0]
finally:
conn.close()
return total
def find_group(db: WeChatDB, keyword: str):
kw = keyword.strip()
groups = list_groups(db)
if not kw:
return groups[0] if groups else None
exact = [g for g in groups if g["name"] == kw]
if exact:
return exact[0]
fuzzy = [g for g in groups if kw in g["name"]]
return fuzzy[0] if fuzzy else None
def _raw_messages(db: WeChatDB, wxid: str, limit: int):
"""批量读取该会话最新消息原始行(保留 message_content 二进制),升序返回。
4.x 会把同一会话的表拆分在多个 message_*.db 中(历史遗留),
因此需跨库聚合后按 sort_seq 排序取最新 limit 条。
"""
md5hex = hashlib.md5(wxid.encode()).hexdigest()
tab = "Msg_%s" % md5hex
all_rows = []
for rel in db._message_dbs():
conn = db._open(rel)
try:
exists = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(tab,)).fetchone()
if not exists:
continue
cur = conn.execute(
"SELECT local_id, local_type, real_sender_id, create_time, "
"message_content, sort_seq FROM %s" % tab)
cols = [c[0] for c in cur.description]
all_rows.extend(dict(zip(cols, r)) for r in cur.fetchall())
finally:
conn.close()
all_rows.sort(key=lambda r: r.get("sort_seq") or 0)
return all_rows[-limit:]
def zstd_decompress(content: bytes, max_size: int = 200000) -> str:
"""解 ZSTD:微信 4.x 卡片/红包 message_content 为 ZSTD 压缩 XML"""
if not content:
return ""
if isinstance(content, bytes):
try:
return content.decode("utf-8")
except UnicodeDecodeError:
pass
try:
import zstandard as zstd
dctx = zstd.ZstdDecompressor()
return dctx.decompress(content, max_output_size=max_size).decode(
"utf-8", "ignore")
except ImportError:
return ""
except Exception:
return ""
return str(content)
def parse_red_packet(xml: str) -> dict:
"""从红包 XML 提取关键字段"""
info = {}
if "wcpayinfo" not in xml:
return info
def cdata(tag):
m = re.search(r"<%s><!\[CDATA\[(.*?)\]\]></%s>" % (tag, tag), xml, re.S)
return m.group(1).strip() if m else ""
info["des"] = cdata("des")
info["receivertitle"] = cdata("receivertitle")
info["sendertitle"] = cdata("sendertitle")
info["sceneid"] = cdata("sceneid")
info["paymsgid"] = cdata("paymsgid")
info["fromusername"] = cdata("fromusername")
m = re.search(r"<type><!\[CDATA\[(\d+)\]\]>", xml)
info["type"] = m.group(1) if m else ""
m = re.search(r"total_num=(\d+)", xml)
info["total_num"] = m.group(1) if m else ""
m = re.search(r"<invalidtime><!\[CDATA\[(\d+)\]\]>", xml)
if m:
info["invalidtime"] = int(m.group(1))
return info
def parse_sender(content, sender_id, nickname_map) -> str:
"""解析群消息发送者昵称:
1) 文本 content 前缀 "wxid: 内容"4.x 群消息 sender 藏在这里)
2) 兜底 real_sender_id(已不推荐)
"""
if isinstance(content, bytes):
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = ""
else:
text = content
m = re.match(r"^(wxid_[0-9a-zA-Z_-]+|.*@chatroom):\s*", text)
if m:
wx = m.group(1)
return nickname_map.get(wx, wx), m.end()
return "", 0
def classify_message(row: dict, nickname_map: dict) -> dict:
"""识别单条消息:类型 + 发送者昵称 + 内容"""
local_type = row.get("local_type")
if local_type == RED_PACKET_TYPE:
label = "红包"
else:
label = TYPE_LABEL.get(local_type, "类型%d" % local_type)
content = row.get("message_content")
extra = {}
if local_type == RED_PACKET_TYPE:
xml = zstd_decompress(content)
extra = parse_red_packet(xml)
# 发送者:红包用 XML 里的 fromusernamecontent 是二进制无前缀)
if local_type == RED_PACKET_TYPE and extra.get("fromusername"):
fw = extra["fromusername"]
sender = nickname_map.get(fw, fw)
else:
# 文本等:优先 content 前缀
sender, cut = parse_sender(content, row.get("real_sender_id"), nickname_map)
if not sender:
sender = "成员#%s" % row.get("real_sender_id")
if isinstance(content, bytes):
display = content
else:
display = str(content)
# 文本:去掉 wxid 前缀显示正文
if local_type == 1 and cut:
display = display[cut:]
return {
"local_id": row.get("local_id"),
"type": label,
"sender": sender,
"create_time": row.get("create_time"),
"sort_seq": row.get("sort_seq"),
"content": display,
"extra": extra,
}
def print_message(m: dict) -> None:
line = "[%s] %s %s | %s" % (
m["type"], m["sender"], m["create_time"], str(m["content"])[:80])
print(line)
ex = m["extra"]
if ex:
parts = []
if ex.get("des"):
parts.append("描述: %s" % ex["des"])
if ex.get("receivertitle"):
parts.append("祝福语: %s" % ex["receivertitle"])
if ex.get("sceneid"):
parts.append("类型(sceneid): %s" % ex["sceneid"])
if ex.get("total_num"):
parts.append("个数: %s" % ex["total_num"])
if ex.get("paymsgid"):
parts.append("红包ID: %s" % ex["paymsgid"])
if ex.get("fromusername"):
parts.append("来源: %s" % ex["fromusername"])
if parts:
print(" " + " | ".join(parts))
def main() -> None:
parser = argparse.ArgumentParser(
description="读取并识别固定群的最新消息(含红包解析)")
parser.add_argument("group", nargs="?", default="",
help="群名关键词(模糊匹配)")
parser.add_argument("--list-groups", action="store_true",
help="仅列出所有群,不读消息")
parser.add_argument("--limit", type=int, default=20,
help="读取最新 N 条消息(默认 20")
parser.add_argument("--watch", action="store_true",
help="轮询模式:持续读取新消息")
parser.add_argument("--sleep", type=float, default=3.0,
help="轮询间隔秒数(默认 3")
parser.add_argument("--red-only", action="store_true",
help="只显示红包消息")
args = parser.parse_args()
db = WeChatDB()
nickname_map = load_nickname_map(db)
if args.list_groups:
groups = list_groups(db)
print("群列表(%d 个,按消息数倒序):" % len(groups))
for g in groups:
print(" %-28s %-32s %d" % (g["name"], g["wxid"], g["count"]))
return
group = find_group(db, args.group)
if not group:
print("未找到群:%r(可用 --list-groups 查看全部群)" % args.group)
sys.exit(1)
print("读取群:%s (%s)\n" % (group["name"], group["wxid"]))
since_seq = 0
while True:
rows = _raw_messages(db, group["wxid"], args.limit)
for row in rows:
if args.watch and row.get("sort_seq", 0) <= since_seq:
continue
info = classify_message(row, nickname_map)
if args.red_only and info["type"] != "红包":
continue
print_message(info)
since_seq = max(since_seq, row.get("sort_seq", 0))
if not args.watch:
break
print("\n--- 等待新消息 (%ds) ---" % args.sleep)
time.sleep(args.sleep)
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""wechatauto 复刻版 —— 坐标 + OCR 发送消息示例程序
针对微信 4.1.12+ 自绘聊天界面(UIA 方案失效)的发送路线:
定位窗口 → 打开会话 → 点击输入框 → 剪贴板粘贴 → 点击发送。
使用前提:
1. 微信 4.x 已登录、桌面已解锁;
2. 已安装依赖:pip install -e .
若使用拼音回退输入,另需:pip install pypinyin
用法:
python demo_guia.py
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto.guia import WeChatGUI
def main():
print("=" * 60)
print("wechatauto 复刻版 —— 坐标 + OCR 发送消息示例")
print("=" * 60)
wx = WeChatGUI()
if not wx.desktop_available():
print("\n[错误] 微信窗口不可见(可能处于锁屏/断开的会话),"
"请先解锁桌面后重试。")
sys.exit(1)
wx.bring_to_front()
print(f"已连接到微信主窗口(hwnd={wx.main_hwnd}")
# 1. 列出当前可见会话(OCR
print("\n--- 当前可见会话 ---")
for row in wx.get_sessions()[:10]:
print(f" {row['name']}")
# 2. 打开会话
who = "文件传输助手"
print(f"\n--- 打开会话:{who} ---")
wx.open_chat(who)
time.sleep(0.5)
# 3. 检测输入框
box = wx.get_input_box()
if box:
x0, y0, x1, y1 = box
print(f"检测到输入框(相对坐标):x[{x0}..{x1}] y[{y0}..{y1}]")
else:
print("未检测到输入框")
# 4. 发送消息
text = "这是 wechatauto 复刻版坐标+OCR 自动化测试消息"
print(f"\n--- 发送消息 ---")
print(f"内容:{text}")
result = wx.send_msg(text, verify=True)
print(f"结果:{result}")
if not result:
sys.exit(1)
# 5. 读取最近消息(数据库路线,交叉验证)
try:
from wechatauto.db import WeChatDB
db = WeChatDB()
hits = db.search_contact(who)
if hits:
msgs = db.get_messages(hits[0]["username"], limit=3)
print("\n--- 数据库读回最近 3 条 ---")
for m in reversed(msgs):
sender = "" if m["sender_id"] == 2 else "对方"
t = time.strftime("%H:%M:%S", time.localtime(m["create_time"]))
print(f" [{t}] {sender} {m['content'][:40]}")
except Exception as e:
print(f"(数据库读回失败:{e}")
print("\n完成。")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""实时消息监听示例 —— 基于本地数据库增量轮询(Listener)
用法:
python demo_listen.py [会话名1] [会话名2] ...
# 不带参数则默认监听「文件传输助手」
例:
python demo_listen.py wxid_xxx 123456@chatroom
python demo_listen.py 兔仔仔 我的群 # 昵称/备注会自动映射到会话 username
python demo_listen.py --all # 监听所有非隐藏会话
⚠️ 注意:
names 里最终匹配的是「会话 username」——即 get_sessions() 返回的
wxid_xxx(个聊)或 xxx@chatroom(群聊),不是微信昵称、也不是
你设置的微信号(alias)。传入昵称/备注时本脚本会自动帮你转换;
若转换失败会给出提示,此时请先运行一次本脚本查看列出的会话清单。
"""
from __future__ import annotations
import os
import sys
import time
import threading
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto.db import WeChatDB, Listener
def fmt_time(ts: float) -> str:
return time.strftime("%m-%d %H:%M:%S", time.localtime(ts))
def sender_name(db, sid: int) -> str:
if sid == 2:
return ""
nick = db.get_nickname(sid) if isinstance(sid, str) else None
return nick or f"用户{sid}"
def make_callback(db, chat_name: str):
"""为某个会话生成回调函数。callback(msg: dict, listener)"""
def on_msg(msg: dict, lst: Listener):
sender = sender_name(db, msg["sender_id"])
t = fmt_time(msg["create_time"])
print(f"[{t}] {chat_name} | {sender} ({msg['type']}) {msg['content']}")
# 可在此扩展业务:msg['content'] 含关键字时自动回复等
return on_msg
def resolve_name(db, sessions, raw: str) -> str:
"""把用户输入(username / 昵称 / 备注 / 微信号)解析成会话 username。
匹配优先级:session.username 精确匹配 > contact 的 nick_name/remark 精确匹配
> 未命中直接原样返回(可能本身即为有效 username)。
"""
sessions_by_user = {s["username"]: s for s in sessions}
if raw in sessions_by_user:
return raw
hits = db.search_contact(raw)
if hits:
return hits[0]["username"]
return raw
def main():
names = [a for a in sys.argv[1:] if not a.startswith("-")]
all_chats = "--all" in sys.argv
db = WeChatDB()
info = db.get_self_info()
print(f"账号:{info.get('nick_name') or info.get('username')}")
# 1. 列出当前会话,供挑选(username 就是监听必须使用的值)
sessions = db.get_sessions(limit=30)
#print(f"\n当前会话(共 {len(sessions)} 个,最近 15 个,请把 username 填入 names):")
#for s in sessions[:15]:
#print(f" {s['username']:<24} 未读={s['unread']} {s['summary'][:24] or ''}")
# 2. 确定监听目标
if all_chats:
names = [s["username"] for s in sessions]
elif not names:
names = ["送你挖银子"]
if not names:
print("未找到任何会话,退出")
sys.exit(1)
# 3. 昵称/备注 → username 映射
resolved = [resolve_name(db, sessions, n) for n in names]
for raw, got in zip(names, resolved):
if raw != got:
print(f"{raw}」→ {got}")
# 4. 注册监听(回调在后台线程触发)
lst = Listener(db, interval=1.0)
for name in resolved:
lst.add_listener(name, make_callback(db, name))
print(f" 监听:{name}")
print("\n开始监听(Ctrl+C 停止)...")
lst.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
lst.stop()
print("\n已停止监听。")
if __name__ == "__main__":
main()
+225
View File
@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""wechatauto 演示:读取并下载会话中的媒体文件(图片/语音/视频/文件)。
从微信本地数据库直接取媒体消息并解密落地,无需 GUI 操作。
用法:
python demo_media.py [会话] [--out 目录] [--photos N]
[--limit N] [--ids 3,5,7] [--filter 图片,文件]
[--list] [--open]
默认行为(不带任何选项):
下载文件传输助手里最近 N 张真实照片(jpg/png,自动跳过表情)和所有视频。
参数:
会话 会话名(昵称/备注/username),默认「文件传输助手」
--photos 默认模式下下载照片张数(默认 3)
--limit 列出最近 N 条消息(默认 200)
--ids 仅下载指定 local_id(逗号分隔,此时忽略 --limit)
--filter 只处理指定类型,逗号分隔,可选 图片/语音/视频/文件
--out 下载保存目录(默认 ~/Documents/wechatauto_media
--list 仅列出媒体消息,不下载
--open 下载完成后用系统默认程序打开(仅单文件时有效)
例:
python demo_media.py # 最近 3 张照片+视频
python demo_media.py 兔仔仔 --photos 5 # 指定会话和数量
python demo_media.py 文件传输助手 --list --limit 30
python demo_media.py 我的群 --ids 105,107,109 --out D:\\media
python demo_media.py 文件传输助手 --filter 文件 --open
原理:
WeChatDB.get_messages 列出消息(local_type 识别媒体类型),
MediaDownloader.download_media 按类型自动分发:图片解密 .dat、
语音 SILK 提取、视频/文件从缓存目录复制。
"""
from __future__ import annotations
import argparse
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChatDB, MediaDownloader
MEDIA_TYPES = {"图片": 3, "语音": 34, "视频": 43, "文件": 49}
def resolve_target(db: WeChatDB, raw: str) -> str:
hits = db.search_contact(raw)
return hits[0]["username"] if hits else raw
def fmt_size(n: int) -> str:
if n >= 1024 * 1024:
return f"{n / 1024 / 1024:.1f}MB"
if n >= 1024:
return f"{n / 1024:.1f}KB"
return f"{n}B"
def media_failure_reason(md, who: str, local_id: int) -> str:
"""下载失败时给出细分原因(表情容器 / 未落地 / 不支持)。
图片消息本地缓存的 .dat 解密后若为 wxgf(微信动画表情容器),
库会按设计不落盘;本函数复现该判定以区分「表情」与「真失败」。
"""
row = md.db.get_message_row(who, local_id)
if not row:
return "消息不存在"
t = row["local_type"]
if t == 3: # 图片
md5 = md._img_md5(row)
dat = md._find_dat(who, md5, row["create_time"]) if md5 else None
if not dat:
return "本地无 .dat 缓存(未在微信查看过/已清理)"
try:
data = md.decrypt_image(dat)
except Exception as e:
return f"解密失败:{str(e)[:40]}"
if data[:4] == b"wxgf":
return "微信动画表情容器(wxgf"
return "未知图片格式"
if t in (34, 43, 49):
return "本地缓存未落地或文件已清理"
return "类型不支持"
def main():
parser = argparse.ArgumentParser(
description="wechatauto 演示:读取/下载微信会话中的媒体文件",
add_help=False)
parser.add_argument("target", nargs="?", default="26级金高新生群1群",
help="会话名(昵称/备注/username),默认文件传输助手")
parser.add_argument("--limit", type=int, default=1000, help="列出最近 N 条(默认 200")
parser.add_argument("--photos", type=int, default=10, help="默认模式下载照片张数(默认 3")
parser.add_argument("--ids", default=None,
help="仅下载指定 local_id,逗号分隔,如 105,107")
parser.add_argument("--filter", default=None,
help="仅处理指定类型,逗号分隔:图片/语音/视频/文件")
parser.add_argument("--out", default=None, help="下载保存目录")
parser.add_argument("--image-key", default=None,
help="图片 AES 密钥(16 位,若内存扫描不可用)")
parser.add_argument("--list", action="store_true", help="仅列出媒体消息,不下载")
parser.add_argument("--open", action="store_true",
help="下载后用系统默认程序打开(仅单文件)")
parser.add_argument("-h", "--help", action="help")
args = parser.parse_args()
filt = None
if args.filter:
filt = set(t.strip() for t in args.filter.split(",") if t.strip())
unknown = [t for t in filt if t not in MEDIA_TYPES]
if unknown:
print(f"未知类型:{', '.join(unknown)}(可选:图片/语音/视频/文件)")
sys.exit(1)
print("=" * 60)
print("wechatauto 媒体读取演示")
print("=" * 60)
db = WeChatDB()
info = db.get_self_info()
print(f"账号:{info.get('nick_name') or info.get('username')}")
who = resolve_target(db, args.target)
print(f"会话:{args.target} -> {who}")
# ---- 指定 local_id:直接下载 ----
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip().isdigit()]
if not ids:
print("--ids 无效")
sys.exit(1)
md = MediaDownloader(db, save_dir=args.out, image_key=args.image_key)
for lid in ids:
row = db.get_message_row(who, lid)
if not row:
print(f" local_id={lid} 未找到,跳过")
continue
name = next((k for k, v in MEDIA_TYPES.items() if v == row["local_type"]), "")
t = time.strftime("%m-%d %H:%M", time.localtime(row["create_time"]))
label = name or ("类型%d" % row["local_type"])
print(f"\n[local_id={lid}] {t} {label}")
out = md.download_media(who, lid, args.out)
if out:
print(f" ✅ 已保存:{out} ({fmt_size(os.path.getsize(out))})")
if args.open:
os.startfile(out)
else:
print(f" ⚠️ 下载失败:{media_failure_reason(md, who, lid)}")
print("\n完成。")
sys.exit(0)
# ---- 列出最近消息并筛选媒体 ----
mode = "默认照片+视频" if filt is None else "+".join(sorted(filt))
msgs = db.get_messages(who, limit=args.limit)
media_msgs = []
print(f"\n最近 {len(msgs)} 条消息中的媒体项(模式:{mode}):")
for m in reversed(msgs):
name = MEDIA_TYPES.get(m.get("type"), "")
if not name:
continue
if filt and m.get("type") not in filt:
continue
if filt is None and m.get("type") not in ("图片", "视频"):
continue
t = time.strftime("%m-%d %H:%M", time.localtime(m["create_time"]))
sender = "" if m["sender_id"] == 2 else "对方"
media_msgs.append(m)
print(f" {m['local_id']:<6} {t} {sender} {m.get('type')} {m.get('content', '')[:40]}")
if args.list:
print(f"\n{len(media_msgs)} 条媒体消息(用 --ids 或 --filter 配合下载)。")
sys.exit(0)
if not media_msgs:
print("\n没有符合条件的媒体消息。")
sys.exit(0)
# ---- 下载筛选出的媒体 ----
md = MediaDownloader(db, save_dir=args.out, image_key=args.image_key)
ok = skip = fail = 0
photo_done = 0
for m in media_msgs:
t = time.strftime("%H:%M:%S", time.localtime(m["create_time"]))
print(f"\n[local_id={m['local_id']}] {t} {m.get('type')}")
is_photo_target = filt is None and m.get("type") == "图片"
if is_photo_target and photo_done >= args.photos:
print(f" 已达照片上限 {args.photos} 张,停止")
break
out = md.download_media(who, m["local_id"], args.out)
if out:
print(f"{fmt_size(os.path.getsize(out))} {out}")
ok += 1
if is_photo_target:
photo_done += 1
else:
reason = media_failure_reason(md, who, m["local_id"])
if "表情容器" in reason:
print(f" {reason}")
skip += 1
else:
print(f" ⚠️ 下载失败:{reason}")
fail += 1
print(f"\n完成:成功 {ok} 项,跳过 {skip} 项(表情),失败 {fail} 项。"
+ (f" 保存目录:{os.path.abspath(args.out or md.save_dir)}" if ok else ""))
if args.open and ok == 1:
out = md.download_media(who, media_msgs[0]["local_id"], args.out)
if out:
os.startfile(out)
sys.exit(0 if fail == 0 else 1)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""reply_msg / at_member 实测脚本(对应 README §7 待实测功能)"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto.guia import WeChatGUI
from wechatauto.db import WeChatDB
def db_latest(who, n=5):
"""数据库读回最近 n 条,交叉验证发送结果。"""
try:
db = WeChatDB()
hits = db.search_contact(who)
if not hits:
return []
msgs = db.get_messages(hits[0]["username"], limit=n)
return [f"[{time.strftime('%H:%M:%S', time.localtime(m['create_time']))}] "
f"{'' if m['sender_id'] == 2 else '对方'} {m['content'][:40]}"
for m in reversed(msgs)]
except Exception as e:
return [f"(读回失败:{e}"]
def main():
wx = WeChatGUI()
if not wx.desktop_available():
print("[错误] 微信窗口不可见,请解锁桌面后重试")
sys.exit(1)
# ========== 1. reply_msg:回复最近一条消息 ==========
who = "文件传输助手"
print("=" * 60)
print(f"[1] reply_msg 回复最近一条消息(会话:{who}")
print("=" * 60)
print("数据库当前最近 3 条:")
for line in db_latest(who, 3):
print(" ", line)
r = wx.reply_msg("这是自动回复测试 [reply]", who=who, verify=True)
print(f"\nreply_msg 结果:\n ok={r.is_success}\n 消息={r['message']}\n 数据={r['data']}")
print("\n发送后数据库最近 5 条:")
for line in db_latest(who, 5):
print(" ", line)
# ========== 2. at_member:群聊 @ 成员(改你实际的群名和成员) ==========
group = "STABLE一1一161008" # ← 改成你的群名
member = "文件传输助手" # ← 改成群内的成员名
print("\n" + "=" * 60)
print(f"[2] at_member 群聊 @ 成员(群:{group},成员:{member}")
print("=" * 60)
r2 = wx.at_member(member, "大家看下这条 @ 测试", who=group, verify=True)
print(f"at_member 结果:\n ok={r2.is_success}\n 消息={r2['message']}\n 数据={r2['data']}")
print("\n发送后数据库最近 5 条:")
for line in db_latest(group, 5):
print(" ", line)
print("\n完成。若 ok=False,请把打印的失败信息贴出来。")
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""wechatauto v1.0.9 稳健性演示 —— 失败可回退,绝不崩溃
演示「失败回退」设计原则:
1. 微信未打开时执行 voice_call / poke / open_chat
返回明确失败(WxResponse.failure / False),而非崩溃/抛异常;
2. 微信已打开时展示正常成功路径(语音通话、拍一拍);
3. 截图失败自动回退连通域(表情截图不中断)。
用法:
python demo_robust.py # 自动检测微信状态演示
python demo_robust.py --force-off # 强制演示"微信未打开"分支
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChat
from wechatauto.wx import WxResponse
WHO = "送你挖银子"
def _show(tag: str, r) -> None:
"""统一打印 WxResponse 结果。"""
if isinstance(r, WxResponse):
status = r["status"]
msg = r["message"] or ""
print(f" [{tag}] {status} :: {msg}")
return
print(f" [{tag}] 返回值 {r!r}")
def _guard(func):
"""包装:任何异常都记录为失败而非崩溃。"""
def wrapper(*a, **k):
try:
return func(*a, **k)
except Exception as e:
import traceback
print(f" [异常拦截] {type(e).__name__}: {e}")
print(f" (演示失败回退:未崩溃,异常已兜底)")
return WxResponse.failure(f"异常已拦截:{e}")
return wrapper
def _wechat_alive() -> bool:
"""不实例化 WeChat/WeChatGUI(微信未打开时构造会抛异常),直接探测主窗口。"""
try:
import ctypes
from wechatauto.guia import WX_MAIN_WIN_TITLE
hwnd = ctypes.windll.user32.FindWindowW(None, WX_MAIN_WIN_TITLE)
return bool(hwnd)
except Exception:
return False
def demo_wechat_closed() -> None:
"""微信未打开/未登录时调用各功能,验证返回失败而非崩溃。
注意:不触发热激活(写 Weixin.dll 的 Qt accessibility gate)——微信
未登录时没有可用的 ``mmui::MainWindow``,热激活无意义且属不必要的内存
写入。演示通过给驱动注入「未就绪」状态,让各功能走失败回退分支。
"""
print("\n" + "=" * 60)
print("场景 A:微信未打开/未登录 —— 功能应优雅失败")
print("=" * 60)
try:
from wechatauto.uia_driver import WeChatUIA
eng = WeChatUIA(timeout=8.0) # 可无窗口构造
print(" 构造 UIA 驱动成功(未触碰微信进程)…")
# 阻止 ensure_window 触发热激活:微信未登录时打桩掉窗口探测与
# 唤醒路径,让各功能直接走失败返回(不拉起微信、不写其进程内存)。
eng._find_main = lambda: None
eng._login_window = lambda: None
eng._wechat_hwnds = lambda: []
eng.ensure_window = lambda *a, **k: False
print(" 已打桩窗口探测(微信未登录,跳过热激活与拉起)…")
except Exception as e:
import traceback
print(f" [构造拦截] {type(e).__name__}: {e}")
print(f" (演示失败回退:驱动不可用时降级,不中断)")
_show("voice_call", WxResponse.failure(f"UIA 驱动不可用:{e}"))
_show("poke", WxResponse.failure(f"UIA 驱动不可用:{e}"))
return
t0 = time.time()
ok = _guard(eng.voice_call)(who=WHO)
_show("voice_call",
WxResponse.success("已发起通话") if ok
else WxResponse.failure("UIA 驱动不可用,无法发起通话"))
print(f" (耗时 {time.time()-t0:.1f}s,未崩溃)")
t0 = time.time()
ok = _guard(eng.poke)(who=WHO)
_show("poke",
WxResponse.success("已对 " + WHO + " 拍一拍") if ok
else WxResponse.failure("拍一拍失败(未找到对方消息或菜单不可识别)"))
print(f" (耗时 {time.time()-t0:.1f}s,未崩溃)")
print("\n -> 微信未打开/未登录时:返回明确失败信息,无异常栈、无崩溃。")
print(" 验证调用方只需判断返回值即可安全降级,不干扰其他功能。")
def demo_wechat_open(wx: WeChat) -> None:
"""微信已打开时展示正常成功路径。"""
print("\n" + "=" * 60)
print("场景 B:微信已打开 —— 功能正常工作")
print("=" * 60)
print(f" 打开会话「{WHO}」并验证…")
r = wx.ChatWith(WHO)
_show("ChatWith", r)
if not isinstance(r, str) or not r:
print(" 会话打开失败,跳过成功路径演示。")
return
chat = wx._cur()
try:
ans = input(" 发起语音通话? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
ans = ""
if ans not in ("n", "no"):
_show("voice_call", _guard(chat.VoiceCall)())
time.sleep(1)
print(" (请手动挂断)")
try:
ans = input(" 发起拍一拍? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
ans = ""
if ans not in ("n", "no"):
_show("poke", _guard(chat.Poke)())
print("\n -> 成功路径正常。")
def main():
args = [a for a in sys.argv[1:]]
force_off = "--force-off" in args
alive = _wechat_alive()
if force_off:
alive = False
print(f" 微信主窗口存活:{alive}")
try:
if alive:
wx = WeChat()
print(f"当前登录:{wx.nickname}")
demo_wechat_open(wx)
demo_wechat_closed()
else:
demo_wechat_closed()
except RuntimeError as e:
# 探测误判(如残留窗口/登录态)导致构造失败 → 降级到未打开分支
print(f" [探测误判] WeChat 构造失败:{e}")
print(" (降级演示:按微信未打开处理)")
demo_wechat_closed()
print("\n稳健性演示完成。")
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""wechatauto 测试发送脚本 —— 坐标 + OCR 发送(微信 4.x 自绘界面)
默认发送三件套:一条测试消息 + 一张图片 + 一个文件(README.md)。
用法:
python demo_send.py [目标] [内容] [--image [图片路径]] [--file 文件路径]
[--skip-text] [--skip-image] [--skip-file]
[--verify] [--times N]
参数:
目标 要发送到的会话名(昵称/备注/搜索关键词),默认「文件传输助手」
内容 文本消息内容;不填则生成带时间戳的测试消息
--image 图片路径。不带路径时使用默认图片(RWTemp 最新截图)
--file 文件路径。默认 C:\\Users\\fxj13\\Documents\\Default Project\\README.md
--skip-text 跳过文本消息
--skip-image 跳过图片
--skip-file 跳过文件
--verify 发送后通过本地数据库读回确认
--times 重复发送轮数(每轮三件套,默认 1)
例:
python demo_send.py # 三件套发到文件传输助手
python demo_send.py 兔仔仔 # 三件套发给人
python demo_send.py 文件传输助手 "你好" # 自定义文本
python demo_send.py 我的群 --image C:\\pics\\a.png # 指定图片
python demo_send.py 文件传输助手 --skip-text --skip-image # 只发文件
原理:
WeChatGUI.send_msg 采用 坐标+OCR:激活窗口 → 搜索并打开会话 → 点击输入框
→ 剪贴板粘贴 → 点击发送;可选 verify 用数据库读回(sender_id=2)确认。
"""
from __future__ import annotations
import argparse
import glob
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChatGUI, WeChatDB, WxResponse
# 默认图片:RWTemp 中最新的截图(微信收到的图片会缓存在这里)
DEFAULT_IMAGE = (
r"D:\微信文件\xwechat_files\wxid_gzalsg6ockm822_236a\temp\RWTemp\2026-08"
r"\b0c56fafd84abdd055b91d47b1516550.png"
)
# 默认文件
DEFAULT_FILE = r"C:\Users\fxj13\Documents\Default Project\README.md"
def pick_default_image() -> str:
"""在 RWTemp 目录下找最新的一张图片;找不到则回退到 DEFAULT_IMAGE。"""
base = os.path.dirname(DEFAULT_IMAGE)
if os.path.isdir(base):
imgs = sorted(
glob.glob(os.path.join(base, "*.png"))
+ glob.glob(os.path.join(base, "*.jpg"))
+ glob.glob(os.path.join(base, "*.jpeg")),
key=os.path.getmtime,
)
if imgs:
return imgs[-1]
return DEFAULT_IMAGE
def resolve_target(db: WeChatDB, raw: str) -> str:
"""把输入(username / 昵称 / 备注)解析成会话 username 并打印映射。"""
hits = db.search_contact(raw)
if hits:
return hits[0]["username"]
return raw
def show_result(resp: WxResponse) -> bool:
"""打印发送结果。WxResponse 是 dict,状态值:成功/失败/错误。"""
print(f" [{resp['status']}] {resp['message']}")
return resp.is_success
def read_back(db: WeChatDB, who: str, text: str = None, n: int = 5) -> None:
"""从数据库读回最近消息做交叉验证。"""
msgs = db.get_messages(who, limit=n)
print(f"\n--- 数据库读回最近 {len(msgs)} 条 ---")
for m in reversed(msgs):
sender = "" if m["sender_id"] == 2 else "对方"
t = time.strftime("%H:%M:%S", time.localtime(m["create_time"]))
print(f" [{t}] {sender} {m['content'][:50]}")
if text:
sent = any(m["sender_id"] == 2 and text in (m.get("content") or "")
for m in msgs)
print(f"\n验证结果:{'✅ 已找到我发送的该消息' if sent else '⚠️ 最近记录中未找到该消息(可能未落库或异步延迟)'}")
def main():
parser = argparse.ArgumentParser(
description="wechatauto 测试发送脚本(坐标+OCR 发送)", add_help=False)
parser.add_argument("target", nargs="?", default="文件传输助手",
help="目标会话(昵称/备注/username),默认文件传输助手")
parser.add_argument("content", nargs="?", default=None,
help="文本内容;不填则生成带时间戳的测试消息")
parser.add_argument("--image", nargs="?", const=True, default=None,
help="图片路径。不带路径时用默认图片(RWTemp 最新截图)")
parser.add_argument("--file", nargs="?", const=True, default=None,
help="文件路径。不带路径时用默认文件 README.md")
parser.add_argument("--skip-text", action="store_true", help="跳过文本消息")
parser.add_argument("--skip-image", action="store_true", help="跳过图片")
parser.add_argument("--skip-file", action="store_true", help="跳过文件")
parser.add_argument("--verify", action="store_true", help="发送后读库确认")
parser.add_argument("--times", type=int, default=1, help="重复发送轮数")
parser.add_argument("-h", "--help", action="help")
args = parser.parse_args()
#who = args.target
who = "卢立竺"
text = args.content or f"wechatauto 发送测试 {time.strftime('%H:%M:%S')}"
image = pick_default_image() if (args.image is True or (args.image is None and not args.skip_image)) else args.image
file_ = DEFAULT_FILE if (args.file is True or (args.file is None and not args.skip_file)) else args.file
if args.skip_text:
text = None
if args.skip_image:
image = None
if args.skip_file:
file_ = None
print("=" * 60)
print("wechatauto 测试发送")
print("=" * 60)
if text:
print(f" 文本:{text}")
if image:
print(f" 图片:{image}")
if file_:
print(f" 文件:{file_}")
if not (text or image or file_):
print(" (未选择任何发送内容)")
# 1. 目标会话 username 解析(仅用于读回验证)
db = None
target_username = None
if args.verify:
db = WeChatDB()
info = db.get_self_info()
print(f"账号:{info.get('nick_name') or info.get('username')}")
target_username = resolve_target(db, who)
print(f"目标:{who} -> {target_username}")
# 2. 初始化 GUI 并检查桌面可用
wx = WeChatGUI()
if not wx.desktop_available():
print("\n[错误] 微信窗口不可见(可能锁屏/会话断开),请解锁桌面后重试。")
sys.exit(1)
wx.bring_to_front()
print(f"已连接微信主窗口(hwnd={wx.main_hwnd}")
# 3. 逐轮发送三件套
ok = True
for i in range(args.times):
if args.times > 1:
print(f"\n--- 第 {i + 1}/{args.times} 轮 ---")
if text:
print("\n[发送文本]")
if not show_result(wx.send_msg(text, who, verify=args.verify)):
ok = False
if image:
print("\n[发送图片]")
if not show_result(wx.send_image(image, who, verify=args.verify)):
ok = False
if file_:
print("\n[发送文件]")
if not show_result(wx.send_file(file_, who, verify=args.verify)):
ok = False
# 4. 读回确认
if args.verify and db and target_username:
read_back(db, target_username, text)
print("\n" + ("✅ 全部发送完成。" if ok else "⚠️ 存在失败的发送,请查看上方结果。"))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""wechatauto v1.0.9 新功能演示 —— open_chat 账号搜索 / 语音通话 / 拍一拍 / UIA 表情截图
覆盖本次新增/优化能力:
1. open_chat 账号(wxid)搜索修复:微信搜索框不认 wxid,自动经 DB
映射为昵称/备注/微信号再搜索;
2. Chat.VoiceCall() 发起语音通话(UIA 定位标题栏通话按钮);
3. Chat.Poke() 发起拍一拍(右键头像 + OCR 定位「拍一拍」菜单);
4. UIA 表情包精确截图(EmojiMessage.capture() 优先走 UIA 定位)。
用法:
python demo_v109.py # 全部顺序演示(交互确认)
python demo_v109.py 豆芽 # 指定演示对象
python demo_v109.py --only open_chat 豆芽 # 只跑某一步
注意:语音通话 / 拍一拍会真实触发操作,请确认目标联系人可接受。
"""
from __future__ import annotations
import os
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChat
from wechatauto.wx import WxResponse
WHO_DEFAULT = "送你挖银子"
EMOJI_SAVE_DIR = os.path.join(os.path.expanduser("~"), "emoji_capture")
def _is_ok(r) -> bool:
"""ChatWith 成功返回会话显示名(str),失败返回 None。"""
return isinstance(r, str) and bool(r)
def _confirm(prompt: str) -> bool:
"""交互确认,返回是否继续。"""
try:
return input(prompt + " [y/N] ").strip().lower() in ("y", "yes")
except (EOFError, KeyboardInterrupt):
return True
def demo_open_chat_by_wxid(wx: WeChat) -> None:
"""按账号(wxid)打开会话 —— 验证搜索框不认 wxid 的映射修复。"""
print("\n" + "=" * 60)
print("[1/4] open_chat 账号搜索(wxid 映射)")
print("=" * 60)
db = wx._db
info = db.get_self_info()
self_username = info.get("username", "")
print(f"本机账号:{self_username}")
print("从通讯录中找一个联系人账号(wxid)来测试:")
contacts = db.search_contact("")[:20] or []
if not contacts:
print(" 通讯录为空,跳过。")
return
for i, c in enumerate(contacts[:10]):
name = c.get("nick_name") or c.get("remark") or c.get("username")
print(f" [{i}] {name} (wxid={c.get('username')})")
try:
pick = int(input(" 输入序号:").strip())
except (EOFError, KeyboardInterrupt, ValueError):
return
contact = contacts[pick]
username = contact.get("username", "")
display = contact.get("nick_name") or contact.get("remark") or username
print(f" 尝试 open_chat(wxid={username}) …")
t0 = time.time()
ok = wx._gui.open_chat(username)
print(f" => 打开 {display}(按 wxid{'成功' if ok else '失败'} {time.time()-t0:.1f}s")
if ok:
uia = wx._gui._get_uia()
cur = uia.current_chat() if uia is not None else None
print(f" 当前会话确认:{cur}")
def demo_voice_call(wx: WeChat, who: str) -> None:
"""发起语音通话。"""
print("\n" + "=" * 60)
print("[2/4] 语音通话 VoiceCall")
print("=" * 60)
print(f" 将向「{who}」发起语音通话(真实呼出)…")
if not _confirm(" 确认拨出?"):
print(" 已跳过。")
return
r = wx.ChatWith(who)
if not _is_ok(r):
print(f" 打开会话失败:{r}")
return
t0 = time.time()
r = wx._cur().VoiceCall()
print(f" VoiceCall => {r['status']} {r['message']} ({time.time()-t0:.1f}s)")
print(" (请手动挂断结束通话)")
def demo_poke(wx: WeChat, who: str) -> None:
"""发起拍一拍。"""
print("\n" + "=" * 60)
print("[3/4] 拍一拍 Poke")
print("=" * 60)
print(f" 将对「{who}」发起拍一拍(真实触发)…")
if not _confirm(" 确认发送?"):
print(" 已跳过。")
return
r = wx.ChatWith(who)
if not _is_ok(r):
print(f" 打开会话失败:{r}")
return
t0 = time.time()
r = wx._cur().Poke()
print(f" Poke => {r['status']} {r['message']} ({time.time()-t0:.1f}s)")
def demo_emoji_capture(wx: WeChat, who: str) -> None:
"""UIA 表情截图:找会话最新一条动画表情并截图。"""
print("\n" + "=" * 60)
print("[4/4] UIA 表情包精确截图")
print("=" * 60)
r = wx.ChatWith(who)
if not _is_ok(r):
print(f" 打开会话失败:{r.message}")
return
os.makedirs(EMOJI_SAVE_DIR, exist_ok=True)
chat = wx._cur()
msgs = chat.GetAllMessage()
emo = [m for m in msgs if getattr(m, "type", None) == "emotion"]
if not emo:
print(f" 会话「{who}」最近 50 条无表情消息,跳过(可先发一个表情再跑)。")
return
msg = emo[0]
print(f" 找到表情消息:方向={msg.attr},开始 UIA 截图…")
t0 = time.time()
path = msg.capture(save_dir=EMOJI_SAVE_DIR)
if path:
size = None
try:
from PIL import Image
size = Image.open(path).size
except Exception:
pass
print(f" => 截图成功 {size if size else ''} {time.time()-t0:.1f}s")
print(f" 路径:{path}")
else:
print(f" => 截图失败({time.time()-t0:.1f}s")
def main():
args = [a for a in sys.argv[1:]]
only = None
if "--only" in args:
i = args.index("--only")
only = args[i + 1]
args = args[:i] + args[i + 2:]
names = [a for a in args if not a.startswith("--")]
who = names[0] if names else WHO_DEFAULT
wx = WeChat()
print(f"当前登录:{wx.nickname}")
steps = {
#"open_chat": demo_open_chat_by_wxid,
#"voice": demo_voice_call,
"poke": demo_poke,
#"emoji": demo_emoji_capture,
}
if only:
fn = steps.get(only)
if not fn:
print(f"未知步骤:{only}(可选:{', '.join(steps)}")
sys.exit(1)
fn(wx, who) if only != "open_chat" else fn(wx)
return
#demo_open_chat_by_wxid(wx)
#demo_voice_call(wx, who)
demo_poke(wx, who)
#demo_emoji_capture(wx, who)
#print("\n全部演示完成。")
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""密钥提取失败诊断脚本。
在微信【已登录】状态下运行(务必让微信窗口保持打开):
python -m wechatauto.diagnose_keys
或直接:
python wechatauto/diagnose_keys.py
把输出完整发给维护者。
"""
import json
import os
import subprocess
import sys
import traceback
print("=" * 60)
print("wechatauto-replica key diagnostic")
print("=" * 60)
print("Python:", sys.version.split()[0])
print("Python bits:", 64 if sys.maxsize > 2**32 else 32)
print("OS:", sys.platform)
# 1. library version
try:
import wechatauto
from wechatauto import WeChatDB
from wechatauto.db import _find_account_dirs
print("lib version:", getattr(wechatauto, "__version__", "?"))
import wechatauto.db as dbmod
print("db.py:", dbmod.__file__)
except Exception as e:
print("import error:", repr(e))
traceback.print_exc()
# 2. Weixin processes
print("\n--- Weixin processes ---")
weixin_pids = []
try:
r = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq Weixin.exe", "/FO", "CSV", "/NH"],
capture_output=True, text=True)
print("tasklist:\n%s" % (r.stdout.strip() or "(no Weixin.exe)"))
for line in r.stdout.strip().splitlines():
parts = line.strip('"').split('","')
if len(parts) >= 2 and parts[1].isdigit():
weixin_pids.append(int(parts[1]))
except Exception as e:
print("tasklist failed:", repr(e))
# 2b. per-PID permission / read test (key root-cause for silent 0-key extraction)
print("\n--- per-PID access test ---")
if not weixin_pids:
print("(no Weixin.exe running - open WeChat and log in first)")
else:
import ctypes
from ctypes import wintypes
from wechatauto.db import _MBI
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.OpenProcess.restype = wintypes.HANDLE
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
k32.ReadProcessMemory.argtypes = [
wintypes.HANDLE, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k32.VirtualQueryEx.argtypes = [
wintypes.HANDLE, ctypes.c_void_p, ctypes.POINTER(_MBI), ctypes.c_size_t]
for pid in weixin_pids:
h = k32.OpenProcess(0x0010 | 0x0400, False, pid) # VM_READ | QUERY_INFORMATION
if not h:
err = ctypes.get_last_error()
print("PID %d: OpenProcess FAILED (error %d - likely needs admin / same-elevation)" % (pid, err))
continue
ok = 0
first_region = None
addr = ctypes.c_void_p(0)
for _ in range(4000):
mbi = _MBI()
n = k32.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(_MBI))
if n == 0:
break
if (mbi.State == 0x1000 and (mbi.Protect & 0xFF) & 0xE6
and not (mbi.Protect & 0x100) and 0 < mbi.RegionSize < 0x10000000):
buf = ctypes.create_string_buffer(8)
br = ctypes.c_size_t(0)
if k32.ReadProcessMemory(h, ctypes.c_void_p(mbi.BaseAddress or 0), buf, 8, ctypes.byref(br)) and br.value == 8:
ok += 1
if first_region is None:
first_region = mbi.BaseAddress or 0
break
addr = ctypes.c_void_p((mbi.BaseAddress or 0) + mbi.RegionSize)
ctypes.windll.kernel32.CloseHandle(h)
print("PID %d: OpenProcess OK, first readable region: %s (readable-region check %s)"
% (pid, "0x%x" % first_region if first_region else "NONE", "OK" if ok else "FAILED"))
# 3. data dir detection
print("\n--- data dir ---")
try:
from wechatauto.db import auto_detect_db_dir
d = auto_detect_db_dir()
print("auto_detect_db_dir:", d)
if d and os.path.isdir(d):
for x in os.listdir(d):
if os.path.isdir(os.path.join(d, x, "db_storage")):
print(" account dir:", x)
except Exception as e:
print("dir detect error:", repr(e))
# 4. WeChatDB init (uses cached keys)
print("\n--- WeChatDB init ---")
db = None
try:
db = WeChatDB()
print("workdir:", db.workdir)
print("keys_file:", db.keys_file, "exists:", os.path.exists(db.keys_file))
print("account:", db.account)
if os.path.exists(db.keys_file):
with open(db.keys_file, encoding="utf-8") as f:
keys = json.load(f)
print("keys cached:", len(keys))
for k in sorted(keys):
print(" ", k)
else:
print("keys cached: (file not found)")
print("db_files:", len(db._db_files))
print("keys loaded:", len(db._keys))
missing = [rel for rel, _, _ in db._db_files if rel not in db._keys]
print("missing:", missing)
print("unkeyed:", db.unkeyed)
# which account dirs exist vs which one was picked
print("accounts on disk:", sorted(
os.path.basename(x) for x in _find_account_dirs(db.db_dir)))
print("picked account: ", db.account)
except Exception as e:
print("init error:", repr(e))
traceback.print_exc()
# 5. fresh key extraction from memory
print("\n--- extract_keys from Weixin.exe memory ---")
try:
if db is None:
db = WeChatDB()
pids = db._find_weixin_pids()
print("Weixin PIDs:", pids)
if not pids:
print("no Weixin.exe running - open WeChat and log in first")
else:
keys = db.extract_keys()
print("extracted:", len(keys))
for k in sorted(keys):
print(" ", k)
missing2 = [rel for rel, _, _ in db._db_files if rel not in keys]
print("still missing:", missing2)
except Exception as e:
print("extract error:", repr(e))
traceback.print_exc()
# 6. verify cached keys actually work
print("\n--- verify cached keys ---")
try:
if db is None:
db = WeChatDB()
works = 0
for rel, _, _ in db._db_files:
if db._key_works(rel):
works += 1
print("keys that verify: %d / %d" % (works, len(db._db_files)))
except Exception as e:
print("verify error:", repr(e))
print("=" * 60)
print("done. send the full output to the maintainer.")
print("=" * 60)
+65
View File
@@ -0,0 +1,65 @@
"""项目内部使用的异常类型定义。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class WechatautoError(Exception):
"""基础异常类型。
Args:
message: 错误信息。若未提供则使用 ``default_message``。
detail: 附加的上下文信息,可用于在日志中打印更友好的提示。
"""
default_message: str = ""
message: Optional[str] = None
detail: Optional[str] = None
def __post_init__(self) -> None:
msg = self.message or self.default_message or self.__class__.__name__
super().__init__(msg)
self.message = msg
def __str__(self) -> str:
return self.message or ""
def __repr__(self) -> str:
detail = f", detail={self.detail!r}" if self.detail else ""
return f"{self.__class__.__name__}(message={self.message!r}{detail})"
class NetWorkError(WechatautoError):
"""网络请求相关异常。"""
default_message = "微信无法连接到网络"
class WechatautoUINotFoundError(WechatautoError):
"""当无法定位到指定 UI 控件时抛出。"""
default_message = "未找到目标 UI 控件"
class WechatautoNoteLoadTimeoutError(WechatautoError):
"""微信笔记加载超时异常。"""
default_message = "微信笔记加载超时"
class WechatautoNotLoggedInError(WechatautoError):
"""未找到已登录的微信窗口时抛出。"""
default_message = "未找到已登录的微信主窗口"
__all__ = [
"WechatautoError",
"NetWorkError",
"WechatautoUINotFoundError",
"WechatautoNoteLoadTimeoutError",
"WechatautoNotLoggedInError",
]
+2066
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
"""微信界面文案的多语言配置表。
每一项均为 ``{键: {'cn': ..., 'cn_t': ..., 'en': ...}}`` 结构,
通过 :func:`get_lang` 依据 :attr:`wechatauto.param.WxParam.LANGUAGE`
取值,未命中时回退到简体中文,再回退到键本身。
"""
from wechatauto.param import WxParam
def get_lang(table: dict, key: str) -> str:
item = table.get(key)
if not isinstance(item, dict):
return key
text = item.get(getattr(WxParam, 'LANGUAGE', 'cn'))
if text:
return text
text = item.get('cn')
return text if text else key
WECHAT_MAIN = {
'新的朋友': {'cn': '新的朋友', 'cn_t': '', 'en': ''},
'添加朋友': {'cn': '添加朋友', 'cn_t': '', 'en': ''},
'搜索结果': {'cn': '搜索:', 'cn_t': '', 'en': ''},
'发起群聊': {'cn': '发起群聊', 'cn_t': '', 'en': ''},
'找不到相关账号或内容': {'cn': '找不到相关账号或内容', 'cn_t': '', 'en': ''},
}
WECHAT_NAVIGATION_BOX = {
'聊天': {'cn': '聊天', 'cn_t': '聊天', 'en': 'Chats'},
'通讯录': {'cn': '通讯录', 'cn_t': '通訊錄', 'en': 'Contacts'},
'收藏': {'cn': '收藏', 'cn_t': '收藏', 'en': 'Favorites'},
'聊天文件': {'cn': '聊天文件', 'cn_t': '聊天文件', 'en': 'Files'},
'朋友圈': {'cn': '朋友圈', 'cn_t': '朋友圈', 'en': 'Moments'},
'搜一搜': {'cn': '搜一搜', 'cn_t': '搜一搜', 'en': 'Search'},
'视频号': {'cn': '视频号', 'cn_t': '視頻號', 'en': 'Channels'},
'看一看': {'cn': '看一看', 'cn_t': '看一看', 'en': 'Top Stories'},
'小程序面板': {'cn': '小程序面板', 'cn_t': '小程序面板', 'en': 'Mini Programs'},
'手机': {'cn': '手机', 'cn_t': '手機', 'en': 'Phone'},
'设置及其他': {'cn': '设置及其他', 'cn_t': '設置及其他', 'en': 'Settings'},
'更多': {'cn': '更多', 'cn_t': '更多', 'en': 'More'},
}
WECHAT_CHAT_BOX = {
'查看更多消息': {'cn': '查看更多消息', 'cn_t': '', 'en': ''},
'消息': {'cn': '消息', 'cn_t': '', 'en': ''},
'表情': {'cn': '表情(Alt+E)', 'cn_t': '', 'en': ''},
'发送文件': {'cn': '发送文件', 'cn_t': '', 'en': ''},
'截图': {'cn': '截图', 'cn_t': '', 'en': ''},
'聊天记录': {'cn': '聊天记录', 'cn_t': '', 'en': ''},
'语音聊天': {'cn': '语音聊天', 'cn_t': '', 'en': ''},
'视频聊天': {'cn': '视频聊天', 'cn_t': '', 'en': ''},
'聊天信息': {'cn': '聊天信息', 'cn_t': '', 'en': ''},
'发送': {'cn': '发送(S)', 'cn_t': '', 'en': ''},
'置顶': {'cn': '置顶', 'cn_t': '', 'en': ''},
'最小化': {'cn': '最小化', 'cn_t': '', 'en': ''},
'最大化': {'cn': '最大化', 'cn_t': '', 'en': ''},
'关闭': {'cn': '关闭', 'cn_t': '', 'en': ''},
'多选': {'cn': '多选', 'cn_t': '', 'en': ''},
'以下为新消息': {'cn': '以下为新消息', 'cn_t': '', 'en': ''},
're_新消息按钮': {'cn': r'.*?条新消息', 'cn_t': '', 'en': ''},
}
WECHAT_SESSION_BOX = {
# 聊天页面
'聊天记录': {'cn': '聊天记录', 'cn_t': '', 'en': ''},
'会话': {'cn': '会话', 'cn_t': '', 'en': ''},
'已置顶': {'cn': '已置顶', 'cn_t': '', 'en': ''},
'文件传输助手': {'cn': '文件传输助手', 'cn_t': '', 'en': ''},
'折叠的群聊': {'cn': '折叠的群聊', 'cn_t': '', 'en': ''},
'折叠置顶聊天': {'cn': '折叠置顶聊天', 'cn_t': '', 'en': ''},
'发起群聊': {'cn': '发起群聊', 'cn_t': '', 'en': ''},
'搜索': {'cn': '搜索', 'cn_t': '', 'en': ''},
're_条数': {'cn': r'\[\d+条\]', 'cn_t': '', 'en': ''},
're_置顶聊天': {'cn': r'.*?个置顶聊天', 'cn_t': '', 'en': ''},
# 联系人页面
'添加朋友': {'cn': '添加朋友', 'cn_t': '', 'en': ''},
'联系人': {'cn': '联系人', 'cn_t': '', 'en': ''},
'通讯录管理': {'cn': '通讯录管理', 'cn_t': '', 'en': ''},
'新的朋友': {'cn': '新的朋友', 'cn_t': '', 'en': ''},
'公众号': {'cn': '公众号', 'cn_t': '', 'en': ''},
'企业号': {'cn': '企业号', 'cn_t': '', 'en': ''},
'群聊': {'cn': '群聊', 'cn_t': '', 'en': ''},
# 收藏页面
'分类': {'cn': '分类', 'cn_t': '', 'en': ''},
'新建笔记': {'cn': '新建笔记', 'cn_t': '', 'en': ''},
'全部收藏': {'cn': '全部收藏', 'cn_t': '', 'en': ''},
'最近使用': {'cn': '最近使用', 'cn_t': '', 'en': ''},
'链接': {'cn': '链接', 'cn_t': '', 'en': ''},
'图片与视频': {'cn': '图片与视频', 'cn_t': '', 'en': ''},
'笔记': {'cn': '笔记', 'cn_t': '', 'en': ''},
'文件': {'cn': '文件', 'cn_t': '', 'en': ''},
'分割线': {'cn': '分割线', 'cn_t': '', 'en': ''},
'展开标签': {'cn': '展开标签', 'cn_t': '', 'en': ''},
'折叠标签': {'cn': '折叠标签', 'cn_t': '', 'en': ''},
'标签': {'cn': '标签', 'cn_t': '', 'en': ''},
}
MESSAGES = {
'[图片]': {'cn': '[图片]', 'cn_t': '', 'en': ''},
'[视频]': {'cn': '[视频]', 'cn_t': '', 'en': ''},
'[语音]': {'cn': '[语音]', 'cn_t': '', 'en': ''},
'[音乐]': {'cn': '[音乐]', 'cn_t': '', 'en': ''},
'[位置]': {'cn': '[位置]', 'cn_t': '', 'en': ''},
'[链接]': {'cn': '[链接]', 'cn_t': '', 'en': ''},
'[文件]': {'cn': '[文件]', 'cn_t': '', 'en': ''},
'[名片]': {'cn': '[名片]', 'cn_t': '', 'en': ''},
'[笔记]': {'cn': '[笔记]', 'cn_t': '', 'en': ''},
'[视频号]': {'cn': '[视频号]', 'cn_t': '', 'en': ''},
'[动画表情]': {'cn': '[动画表情]', 'cn_t': '', 'en': ''},
'[聊天记录]': {'cn': '[聊天记录]', 'cn_t': '', 'en': ''},
'微信转账': {'cn': '微信转账', 'cn_t': '', 'en': ''},
'接收中': {'cn': '接收中', 'cn_t': '', 'en': ''},
're_语音': {'cn': r'^\[语音\]\d+秒(,未播放)?$', 'cn_t': '', 'en': ''},
're_引用消息': {'cn': r'(^.+)\n引用.*?的消息 : (.+$)', 'cn_t': '', 'en': ''},
're_拍一拍': {'cn': r'^.+拍了拍.+$', 'cn_t': '', 'en': ''},
}
MENU_OPTIONS = {
# session
'置顶': {'cn': '置顶', 'cn_t': '置頂', 'en': 'Pin'},
'取消置顶': {'cn': '取消置顶', 'cn_t': '取消置頂', 'en': 'Unpin'},
'标为未读': {'cn': '标为未读', 'cn_t': '標為未讀', 'en': 'Mark as unread'},
'消息免打扰': {'cn': '消息免打扰', 'cn_t': '消息免打擾', 'en': 'Mute'},
'在独立窗口打开': {'cn': '在独立窗口打开', 'cn_t': '在獨立窗口打開', 'en': 'Open in separate window'},
'不显示聊天': {'cn': '不显示聊天', 'cn_t': '不顯示聊天', 'en': 'Hide chat'},
'删除聊天': {'cn': '删除聊天', 'cn_t': '刪除聊天', 'en': 'Delete chat'},
# msg
'撤回': {'cn': '撤回', 'cn_t': '撤回', 'en': 'Recall'},
'复制': {'cn': '复制', 'cn_t': '複製', 'en': 'Copy'},
'放大阅读': {'cn': '放大阅读', 'cn_t': '放大閱讀', 'en': 'Enlarge'},
'翻译': {'cn': '翻译', 'cn_t': '翻譯', 'en': 'Translate'},
'转发': {'cn': '转发...', 'cn_t': '轉發...', 'en': 'Forward...'},
'收藏': {'cn': '收藏', 'cn_t': '收藏', 'en': 'Favorite'},
'多选': {'cn': '多选', 'cn_t': '多選', 'en': 'Select multiple'},
'引用': {'cn': '引用', 'cn_t': '引用', 'en': 'Quote'},
'搜一搜': {'cn': '搜一搜', 'cn_t': '搜一搜', 'en': 'Search'},
'删除': {'cn': '删除', 'cn_t': '刪除', 'en': 'Delete'},
'编辑': {'cn': '编辑', 'cn_t': '編輯', 'en': 'Edit'},
'另存为': {'cn': '另存为...', 'cn_t': '另存為...', 'en': 'Save as...'},
'语音转文字': {'cn': '语音转文字', 'cn_t': '語音轉文字', 'en': 'Transcribe'},
'在文件夹中显示': {'cn': '在文件夹中显示', 'cn_t': '在文件夾中顯示', 'en': 'Show in folder'},
# edit
'剪切': {'cn': '剪切', 'cn_t': '剪切', 'en': 'Cut'},
'粘贴': {'cn': '粘贴', 'cn_t': '粘貼', 'en': 'Paste'},
}
MOMENTS = {
'朋友圈': {'cn': '朋友圈', 'cn_t': '朋友圈', 'en': 'Moments'},
'刷新': {'cn': '刷新', 'cn_t': '刷新', 'en': 'Refresh'},
'评论': {'cn': '评论', 'cn_t': '評論', 'en': 'Comment'},
'广告': {'cn': '广告', 'cn_t': '廣告', 'en': 'Advertisement'},
'': {'cn': '', 'cn_t': '', 'en': 'Like'},
'取消': {'cn': '取消', 'cn_t': '取消', 'en': 'Cancel'},
'发送': {'cn': '发送', 'cn_t': '發送', 'en': 'Send'},
'分隔符_点赞': {'cn': '', 'cn_t': '', 'en': ', '},
're_图片数': {'cn': r'包含\d+张图片', 'cn_t': r'包含\d+張圖片', 'en': r'Contains \d+ photos'},
}
MOMENT_PRIVACY = {
'谁可以看': {'cn': '谁可以看', 'cn_t': '誰可以看', 'en': 'Who can see'},
'公开': {'cn': '公开', 'cn_t': '公開', 'en': 'Public'},
'所有朋友可见': {'cn': '所有朋友可见', 'cn_t': '所有朋友可見', 'en': 'All friends'},
'私密': {'cn': '私密', 'cn_t': '私密', 'en': 'Private'},
'仅自己可见': {'cn': '仅自己可见', 'cn_t': '僅自己可見', 'en': 'Only me'},
'白名单': {'cn': '谁可以看', 'cn_t': '誰可以看', 'en': 'Selected friends'},
'黑名单': {'cn': '不给谁看', 'cn_t': '不給誰看', 'en': 'Exclude friends'},
'完成': {'cn': '完成', 'cn_t': '完成', 'en': 'Done'},
'确定': {'cn': '确定', 'cn_t': '確定', 'en': 'OK'},
'取消': {'cn': '取消', 'cn_t': '取消', 'en': 'Cancel'},
}
IMAGE_WINDOW = {
'上一张': {'cn': '上一张', 'cn_t': '上一張', 'en': 'Previous'},
'下一张': {'cn': '下一张', 'cn_t': '下一張', 'en': 'Next'},
'预览': {'cn': '预览', 'cn_t': '預覽', 'en': 'Preview'},
'放大': {'cn': '放大', 'cn_t': '放大', 'en': 'Zoom'},
'缩小': {'cn': '缩小', 'cn_t': '縮小', 'en': 'Shrink'},
'图片原始大小': {'cn': '图片原始大小', 'cn_t': '圖片原始大小', 'en': 'Original size'},
'旋转': {'cn': '旋转', 'cn_t': '旋轉', 'en': 'Rotate'},
'编辑': {'cn': '编辑', 'cn_t': '編輯', 'en': 'Edit'},
'翻译': {'cn': '翻译', 'cn_t': '翻譯', 'en': 'Translate'},
'提取文字': {'cn': '提取文字', 'cn_t': '提取文字', 'en': 'Extract text'},
'识别图中二维码': {'cn': '识别图中二维码', 'cn_t': '識別圖中QR Code', 'en': 'Extract QR Code'},
'另存为': {'cn': '另存为...', 'cn_t': '另存為...', 'en': 'Save as...'},
'更多': {'cn': '更多', 'cn_t': '更多', 'en': 'More'},
'复制': {'cn': '复制', 'cn_t': '複製', 'en': 'Copy'},
}
NEW_FRIEND_ELEMENT = {
'新的朋友': {'cn': '新的朋友', 'cn_t': '新的朋友', 'en': 'New friends'},
'回复': {'cn': '回复', 'cn_t': '回覆', 'en': 'Reply'},
'发送': {'cn': '发送', 'cn_t': '發送', 'en': 'Send'},
'朋友圈': {'cn': '朋友圈', 'cn_t': '朋友圈', 'en': 'Moments'},
'仅聊天': {'cn': '仅聊天', 'cn_t': '僅聊天', 'en': 'Chat only'},
'聊天、朋友圈、微信运动等': {
'cn': '聊天、朋友圈、微信运动等',
'cn_t': '聊天、朋友圈、微信運動等',
'en': 'Chats, Moments, WeRun, etc.',
},
'备注名': {'cn': '备注名', 'cn_t': '備註名', 'en': 'Alias'},
'标签': {'cn': '标签', 'cn_t': '標籤', 'en': 'Tags'},
}
PROFILE_WINDOW = {
'微信号': {'cn': '微信号:', 'cn_t': '微信號:', 'en': 'WeChat ID: '},
'昵称': {'cn': '昵称:', 'cn_t': '暱稱:', 'en': 'Nickname: '},
'地区': {'cn': '地区:', 'cn_t': '地區:', 'en': 'Region: '},
'个性签名': {'cn': '个性签名', 'cn_t': '個性簽名', 'en': 'Signature'},
'来源': {'cn': '来源', 'cn_t': '來源', 'en': 'Source'},
'备注': {'cn': '备注', 'cn_t': '備註', 'en': 'Alias'},
'共同群聊': {'cn': '共同群聊', 'cn_t': '共同群聊', 'en': 'Common groups'},
'添加到通讯录': {'cn': '添加到通讯录', 'cn_t': '添加到通訊錄', 'en': 'Add to contacts'},
'更多': {'cn': '更多', 'cn_t': '更多', 'en': 'More'},
}
WECHAT_BROWSER = {
'关闭': {'cn': '关闭', 'cn_t': '關閉', 'en': 'Close'},
'更多': {'cn': '更多', 'cn_t': '更多', 'en': 'More'},
'地址和搜索栏': {'cn': '地址和搜索栏', 'cn_t': '地址和搜索欄', 'en': 'Address and search bar'},
'转发给朋友': {'cn': '转发给朋友', 'cn_t': '轉發給朋友', 'en': 'Forward to friend'},
'复制链接': {'cn': '复制链接', 'cn_t': '複製鏈接', 'en': 'Copy link'},
}
CHATROOM_DETAIL_WINDOW = {
'聊天信息': {'cn': '聊天信息', 'cn_t': '聊天信息', 'en': 'Chat info'},
'查看更多': {'cn': '查看更多', 'cn_t': '查看更多', 'en': 'View more'},
'群聊名称': {'cn': '群聊名称', 'cn_t': '群聊名稱', 'en': 'Group name'},
'仅群主或管理员可以修改': {'cn': '仅群主或管理员可以修改', 'cn_t': '僅群主或管理員可以修改', 'en': 'Only owner or admins can edit'},
'我在本群的昵称': {'cn': '我在本群的昵称', 'cn_t': '我在本群的暱稱', 'en': 'My nickname in group'},
'仅群主和管理员可编辑': {'cn': '仅群主和管理员可编辑', 'cn_t': '僅群主和管理員可編輯', 'en': 'Only owner and admins can edit'},
'点击编辑群公告': {'cn': '点击编辑群公告', 'cn_t': '點擊編輯群公告', 'en': 'Tap to edit announcement'},
'编辑': {'cn': '编辑', 'cn_t': '編輯', 'en': 'Edit'},
'备注': {'cn': '备注', 'cn_t': '備註', 'en': 'Alias'},
'群公告': {'cn': '群公告', 'cn_t': '群公告', 'en': 'Announcement'},
'完成': {'cn': '完成', 'cn_t': '完成', 'en': 'Done'},
'发布': {'cn': '发布', 'cn_t': '發佈', 'en': 'Publish'},
'退出群聊': {'cn': '退出群聊', 'cn_t': '退出群聊', 'en': 'Leave group'},
'聊天成员': {'cn': '聊天成员', 'cn_t': '聊天成員', 'en': 'Members'},
'添加': {'cn': '添加', 'cn_t': '添加', 'en': 'Add'},
'移出': {'cn': '移出', 'cn_t': '移出', 'en': 'Remove'},
}
ADD_NEW_FRIEND_WINDOW = {
'标签': {'cn': '标签', 'cn_t': '標籤', 'en': 'Tags'},
'确定': {'cn': '确定', 'cn_t': '確定', 'en': 'OK'},
'备注名': {'cn': '备注名', 'cn_t': '備註名', 'en': 'Alias'},
'朋友圈': {'cn': '朋友圈', 'cn_t': '朋友圈', 'en': 'Moments'},
'仅聊天': {'cn': '仅聊天', 'cn_t': '僅聊天', 'en': 'Chat only'},
'聊天、朋友圈、微信运动等': {
'cn': '聊天、朋友圈、微信运动等',
'cn_t': '聊天、朋友圈、微信運動等',
'en': 'Chats, Moments, WeRun, etc.',
},
'你的联系人较多,添加新的朋友时需选择权限': {
'cn': '你的联系人较多,添加新的朋友时需选择权限',
'cn_t': '你的聯繫人較多,添加新的朋友時需選擇權限',
'en': 'You have many contacts, choose permissions when adding friends',
},
'发送添加朋友申请': {'cn': '发送添加朋友申请', 'cn_t': '發送添加朋友申請', 'en': 'Send friend request'},
}
ADD_GROUP_MEMBER_WINDOW = {
'搜索': {'cn': '搜索', 'cn_t': '搜索', 'en': 'Search'},
'确定': {'cn': '确定', 'cn_t': '確定', 'en': 'OK'},
'完成': {'cn': '完成', 'cn_t': '完成', 'en': 'Done'},
'发送': {'cn': '发送', 'cn_t': '發送', 'en': 'Send'},
'已选择联系人': {'cn': '已选择联系人', 'cn_t': '已選擇聯繫人', 'en': 'Selected contacts'},
'请勾选需要添加的联系人': {
'cn': '请勾选需要添加的联系人',
'cn_t': '請勾選需要添加的聯繫人',
'en': 'Please select contacts to add',
},
}
+131
View File
@@ -0,0 +1,131 @@
from .param import WxParam
import logging
import colorama
from pathlib import Path
from datetime import datetime
import sys
import io
colorama.init()
if hasattr(sys.stdout, 'buffer'):
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='ignore')
except Exception:
pass
LOG_COLORS = {
'DEBUG': colorama.Fore.CYAN,
'INFO': colorama.Fore.GREEN,
'WARNING': colorama.Fore.YELLOW,
'ERROR': colorama.Fore.RED,
'CRITICAL': colorama.Fore.MAGENTA
}
class ColoredFormatter(logging.Formatter):
def format(self, record):
levelname = record.levelname
message = super().format(record)
return f"{LOG_COLORS[levelname]}{message}{colorama.Style.RESET_ALL}"
class WechatautoLogger:
name: str = 'wechatauto'
def __init__(self):
self.logger = self.setup_logger()
self.file_handler = None # 先不创建文件处理器
self.set_debug(False)
def setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
# 配置根记录器
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# 添加asyncio日志过滤
logging.getLogger('asyncio').setLevel(logging.WARNING)
# 设置第三方库的日志级别
logging.getLogger('comtypes').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)
# 清除现有处理器
root_logger.handlers.clear()
# 格式
fmt = '%(asctime)s [%(name)s] [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s'
# 控制台处理器(带颜色)
self.console_handler = logging.StreamHandler()
console_formatter = ColoredFormatter(
fmt=fmt,
datefmt="%Y-%m-%d %H:%M:%S"
)
self.console_handler.setFormatter(console_formatter)
self.console_handler.setLevel(logging.DEBUG)
root_logger.addHandler(self.console_handler)
return logging.getLogger(self.name)
def setup_file_logger(self):
"""根据WxParam.ENABLE_FILE_LOGGER决定是否创建文件日志处理器"""
if not WxParam.ENABLE_FILE_LOGGER or self.file_handler is not None:
return
# 文件处理器(无颜色)
log_dir = Path("wechatauto_logs")
log_dir.mkdir(parents=True, exist_ok=True)
# 使用当前时间创建日志文件
current_time = datetime.now().strftime("%Y%m%d")
log_file = log_dir / f"app_{current_time}.log"
self.file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_formatter = logging.Formatter(
'%(asctime)s [%(name)s] [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s',
datefmt="%Y-%m-%d %H:%M:%S"
)
self.file_handler.setFormatter(file_formatter)
self.file_handler.setLevel(logging.DEBUG)
# 将文件处理器添加到日志记录器
logging.getLogger().addHandler(self.file_handler)
def set_debug(self, debug=False):
"""动态设置日志级别"""
if debug:
self.console_handler.setLevel(logging.DEBUG)
else:
self.console_handler.setLevel(logging.INFO)
def _ensure_file_logger(self):
"""确保文件日志处理器被初始化"""
if WxParam.ENABLE_FILE_LOGGER and self.file_handler is None:
self.setup_file_logger()
def debug(self, msg: str, *args, stacklevel=2, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.debug(msg, *args, stacklevel=stacklevel, **kwargs)
def info(self, msg: str, *args, stacklevel=2, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.info(msg, *args, stacklevel=stacklevel, **kwargs)
def warning(self, msg: str, *args, stacklevel=2, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.warning(msg, *args, stacklevel=stacklevel, **kwargs)
def error(self, msg: str, *args, stacklevel=2, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.error(msg, *args, stacklevel=stacklevel, **kwargs)
def critical(self, msg: str, *args, stacklevel=2, **kwargs):
self._ensure_file_logger() # 确保文件日志初始化
self.logger.critical(msg, *args, stacklevel=stacklevel, **kwargs)
# wxlog实例化的地方不再创建文件日志
wxlog = WechatautoLogger()
+664
View File
@@ -0,0 +1,664 @@
# -*- coding: utf-8 -*-
"""微信 4.x 媒体文件读取与下载(图片解密、语音、视频、文件)。
与 :mod:`wechatauto.db` 配合使用:``db`` 提供解密后的消息行(含 local_type、
server_id、packed_info),本模块负责把媒体内容从本地取回/解密/落地。
支持的媒体(local_type 见 :data:`wechatauto.db.MSG_TYPE_NAMES`::
local_type 3 图片 → 会话目录 msg/attach/<会话md5>/<YYYY-MM>/Img/<md5>.dat
local_type 34 语音 → message/media_0.db VoiceInfo.voice_dataSILK 二进制)
local_type 43 视频 → msg/video/<YYYY-MM>/<id>.mp4(未落地时返回 None
local_type 49 文件 → msg/file/<YYYY-MM>/<原文件名>,原名取自
message_resource.db MessageResourceDetail.packed_info
图片加密(v2 格式,本库已在本机验证)::
结构: [6B sig 07 08 56 32 08 07][4B aes_size LE][4B xor_size LE][1B pad]
[aes 密文(ECB, PKCS7, 对齐 16B)][raw 明文][xor 密文]
- AES 密钥: 16 字节 ASCII(字母/数字),仅在 Weixin.exe 进程内存中。
通过 AES-ECB 解首块密文、校验 JPEG/PNG 魔数反推出(内存正则扫描)。
- XOR 密钥: 单字节,从同图缩略图 ``<md5>_t.dat`` 尾部 JPEG 结束标记
``FF D9`` 反推(``key = tail[0] ^ 0xFF``)。
用法::
from wechatauto import WeChatDB, MediaDownloader
db = WeChatDB()
md = MediaDownloader(db)
md.download_media(chat_user, msg_row["local_id"]) # 按类型自动分发
"""
from __future__ import annotations
import ctypes
import glob
import hashlib
import json
import os
import re
import struct
import tempfile
import time
from typing import List, Optional, Tuple
V1_MAGIC = b"\x07\x08\x05\x56\x02\x05"
V2_MAGIC = b"\x07\x08\x56\x32\x08\x07"
V1_HEADER_SZ = 22 # 6B sig + 16B xor key
AES16_RE = re.compile(rb"[0-9a-zA-Z]{16,32}")
DEFAULT_SAVE_PATH = os.path.join(os.path.expanduser("~"), "Documents", "wechatauto_media")
def _jpeg_like(pt: bytes) -> bool:
return (
(pt[:3] == b"\xff\xd8\xff")
or pt[:4] in (b"\x89PNG", b"GIF8", b"RIFF")
or pt[:4] == b"wxgf" # 微信动画表情容器
)
def aligned_aes_block_size(aes_size: int) -> int:
return aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
class MediaDownloader:
"""微信 4.x 媒体下载器"""
def __init__(self, db, save_dir: Optional[str] = None,
image_key: Optional[str] = None,
cfg_dword: Optional[int] = None):
self.db = db
self.save_dir = save_dir or DEFAULT_SAVE_PATH
self._image_key = image_key # 显式注入的图片 AES 密钥
self._cfg_dword = cfg_dword # cfg+0x40, 派生图片密钥(最佳方案)
self._xor_key: Optional[int] = None
self._img_key: Optional[Tuple[str, int]] = None
self._key_probe: Optional[bytes] = None
@staticmethod
def derive_image_keys(cfg_dword: int, wxid: str) -> Tuple[str, int]:
"""cfgDword 派生图片密钥(微信 4.x 最佳方案, 实测 3000/3000 验证)。
imageXorKey = cfgDword & 0xFF
imageAesKey = MD5(str(cfgDword) + wxid)[:16] # 前 16 位即真 AES-128 密钥
"""
xor_key = cfg_dword & 0xFF
aes_key = hashlib.md5(
("%d" % cfg_dword + wxid).encode("utf-8")).hexdigest()[:16]
return aes_key, xor_key
def _derive_cfg_key(self) -> Optional[Tuple[str, int]]:
"""cfgDword 派生并验证; 优先显式注入, 否则用 db.cfg_dword(自动提取)。"""
cfg_dword = self._cfg_dword
if cfg_dword is None:
cfg_dword = getattr(self.db, "cfg_dword", None)
if not cfg_dword:
return None
aes_key, xor_key = self.derive_image_keys(cfg_dword, self.db.wxid)
if self._validate_key(aes_key):
return aes_key, xor_key
return None
# ------------------------------------------------------------------
# 图片密钥(内存扫描 + 缩略图反推)
# ------------------------------------------------------------------
def _probe_ct(self, dat_path: Optional[str] = None) -> bytes:
"""取一张 V2 图片的密文首块,作为密钥反测试样"""
if self._key_probe is not None:
return self._key_probe
if dat_path is None:
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*.dat"))
if not hits:
return b""
dat_path = hits[0]
with open(dat_path, "rb") as f:
head = f.read(32)
if head[:6] == V2_MAGIC:
self._key_probe = head[15:31]
else:
self._key_probe = head[V1_HEADER_SZ: V1_HEADER_SZ + 16]
return self._key_probe
def _validate_key(self, aes_key: str) -> bool:
"""用真实密文首块反测密钥是否有效"""
probe = self._probe_ct()
if not probe:
return False
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
dec = Cipher(algorithms.AES(aes_key.encode()), modes.ECB()).decryptor()
return _jpeg_like(dec.update(probe) + dec.finalize())
except Exception:
return False
def _key_store(self) -> str:
return os.path.join(self.db.workdir, "image_keys.json")
def _load_persisted_key(self) -> Optional[str]:
try:
with open(self._key_store(), "r", encoding="utf-8") as f:
saved = json.load(f)
key = saved.get(self.db.account)
except (OSError, ValueError, json.JSONDecodeError):
return None
if key and self._validate_key(key):
return key
return None
def _persist_key(self, aes_key: str) -> None:
try:
with open(self._key_store(), "r", encoding="utf-8") as f:
saved = json.load(f)
except (OSError, ValueError, json.JSONDecodeError):
saved = {}
saved[self.db.account] = aes_key
try:
os.makedirs(os.path.dirname(self._key_store()), exist_ok=True)
with open(self._key_store(), "w", encoding="utf-8") as f:
json.dump(saved, f, indent=2)
except OSError:
pass
def _collect_templates(self, limit: int = 32, keep: int = 16) -> List[str]:
"""递归收集 *_t.dat 缩略图模板: 按修改时间降序取前 keep 个"""
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*_t.dat"))
hits.sort(key=os.path.getmtime, reverse=True)
return hits[:keep]
def _get_xor_key(self, templates: List[str]) -> Optional[int]:
"""文件尾统计推 XOR 密钥: 缩略图明文为 JPEG, 尾部固定 FF D9。
读每个模板最后 2 字节 (x, y), 统计出现最多的组合;
xorKey = x ^ 0xFF 且校验 y ^ 0xD9 == xorKey 才返回。
"""
tails: Dict[Tuple[int, int], int] = {}
for p in templates:
try:
with open(p, "rb") as f:
f.seek(-2, 2)
tail = f.read(2)
except OSError:
continue
if len(tail) == 2:
tails[(tail[0], tail[1])] = tails.get((tail[0], tail[1]), 0) + 1
for (x, y), _ in sorted(tails.items(), key=lambda kv: -kv[1]):
key = x ^ 0xFF
if y ^ 0xD9 == key:
return key
return None
def _derive_xor_key(self, dat_path: str) -> Optional[int]:
"""从单个 .dat 文件尾部反推 XOR 密钥(JPEG 尾 FF D9 被 XOR 加密)。
key = tail[0] ^ 0xFF,并校验 tail[1] ^ 0xD9 == key。
"""
try:
with open(dat_path, "rb") as f:
f.seek(-2, 2)
tail = f.read(2)
except OSError:
return None
if len(tail) == 2:
key = tail[0] ^ 0xFF
if tail[1] ^ 0xD9 == key:
return key
return None
def _dbg_last_dat(self) -> Optional[str]:
"""返回最近修改的一张 .dat 图片缓存文件路径(用于 XOR 密钥兜底推导)。"""
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*.dat"))
if not hits:
return None
hits.sort(key=os.path.getmtime, reverse=True)
return hits[0]
def _scan_aes_key(self, monitor: bool = False,
monitor_timeout: float = 120.0) -> Optional[str]:
"""扫描 Weixin.exe 内存穷举候选密钥, AES-ECB 解密探针验证。
候选两类模式(YARA 思路):
- ASCII: 非字母数字 + 连续 32 个 [a-zA-Z0-9] + 非字母数字结尾,
每候选取前 16 字节作 AES-128 key 解密探针, 明文为 JPEG SOI 命中;
- UTF-16LE: 字母数字与 0x00 交错形式。
"""
probe = self._probe_ct()
if not probe:
return None
pids = self.db._find_weixin_pids()
if not pids:
return None
from . import db as _dbmod
k32 = _dbmod._k32
MBI = _dbmod._MBI
ASCII32_RE = re.compile(rb"[^a-zA-Z0-9]([a-zA-Z0-9]{32})[^a-zA-Z0-9]")
U16_RE = re.compile(rb"(?:[a-zA-Z0-9]\x00){32}")
def read_mem(h, addr: int, n: int):
buf = ctypes.create_string_buffer(n)
br = ctypes.c_size_t(0)
if k32.ReadProcessMemory(h, ctypes.c_void_p(addr), buf, n, ctypes.byref(br)) and br.value:
return buf.raw[: br.value]
return None
def _try_key(key16: bytes) -> bool:
try:
from cryptography.hazmat.primitives.ciphers import (
Cipher, algorithms, modes,
)
pt = Cipher(algorithms.AES(key16), modes.ECB()).decryptor()
out = pt.update(probe) + pt.finalize()
except Exception:
return False
return out[:3] == b"\xff\xd8\xff" or _jpeg_like(out)
def _candidates(buf: bytes):
for m in ASCII32_RE.finditer(buf):
yield m.group(1)[:16].encode() if isinstance(m.group(1), str) else m.group(1)[:16]
for m in U16_RE.finditer(buf):
yield bytes(b for i, b in enumerate(m.group()) if i % 2 == 0)[:16]
def _scan_once() -> Optional[str]:
# 保持微信进程原顺序扫描(主进程靠前, 命中率高)
for pid in pids:
h = k32.OpenProcess(0x0010 | 0x0400, False, pid)
if not h:
continue
try:
addr = 0
while True:
mbi = MBI()
r = k32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi))
if r == 0:
break
if (
mbi.State == 0x1000
and (mbi.Protect & 0xFF) & 0xE6
and not (mbi.Protect & 0x100)
and 0 < mbi.RegionSize < 0x2000000
):
buf = read_mem(h, mbi.BaseAddress or 0, mbi.RegionSize)
if buf:
for key in _candidates(buf):
if _try_key(key):
return key.decode("ascii", "replace")
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
finally:
k32.CloseHandle(h)
return None
found = _scan_once()
if found or not monitor:
return found
print(
"未在微信进程内存中找到图片 AES 密钥。\n"
"请现在打开微信,进入任意聊天,点击一张图片查看大图,\n"
f"本程序将在 {monitor_timeout:.0f} 秒内自动捕获密钥..."
)
start = time.time()
while time.time() - start < monitor_timeout:
time.sleep(2.0)
found = _scan_once()
if found:
return found
return None
def detect_image_key(self, refresh: bool = False) -> Optional[Tuple[str, int]]:
"""返回 (AES 密钥, XOR 密钥);失败返回 None。结果缓存,refresh=True 强制重扫。
总流程:
定位缓存目录 → 收集 *_t.dat 模板 → 文件尾推 XOR 密钥(众数统计)
→ 文件头取 AES 密文 → cfgDword 派生 / 扫描 Weixin.exe 内存穷举
候选密钥 → AES-ECB 解密验证(JPEG SOI)。
密钥来源优先级:cfgDword 派生(确定性离线, 免看图驻留) → 显式注入
image_key → 本地缓存 → 进程内存扫描。命中后持久化, 下次免扫。
"""
if self._img_key and not refresh:
return self._img_key
probe = self._probe_ct()
if not probe:
return None
templates = self._collect_templates()
# 1) XOR: 模板文件尾众数统计
xor_key = self._get_xor_key(templates)
if xor_key is None:
dat = self._dbg_last_dat()
xor_key = self._derive_xor_key(dat) if dat else 0x88
# 2) AES: cfgDword 派生优先
derived = self._derive_cfg_key()
if derived:
self._img_key = (derived[0], xor_key)
return self._img_key
aes_key = None
if self._image_key and self._validate_key(self._image_key):
aes_key = self._image_key
if not aes_key:
aes_key = self._load_persisted_key()
if not aes_key:
aes_key = self._scan_aes_key(monitor=True)
if aes_key:
self._persist_key(aes_key)
if not aes_key:
return None
self._img_key = (aes_key, xor_key)
return self._img_key
# ------------------------------------------------------------------
# 图片解密
# ------------------------------------------------------------------
def decrypt_image(self, dat_path: str, aes_key: Optional[str] = None,
xor_key: Optional[int] = None) -> bytes:
"""解密单个 .dat 为图片字节(自动识别 v1/v2 格式)"""
with open(dat_path, "rb") as f:
data = f.read()
if not data:
raise ValueError("空文件: %s" % dat_path)
magic = data[:6]
if magic == V2_MAGIC:
return self._decrypt_v2(data, dat_path, aes_key, xor_key)
if magic == V1_MAGIC:
if xor_key is None:
xor_key = self._derive_xor_key(dat_path)
key = data[6:22]
body = data[22:]
return bytes(b ^ (xor_key & 0xFF) for b in body)
# 早期纯异或格式:逐字节 ^ 0xFF(无签名),按 JPEG/PNG 魔数回退判断
for cand in (0x88, 0x30, 0xFF, 0xE9):
out = bytes(b ^ cand for b in data)
if out[:3] == b"\xff\xd8\xff" or out[:4] == b"\x89PNG":
return out
raise ValueError("无法识别的图片加密格式: %s" % dat_path)
def _resolve_aes_key(self) -> Optional[str]:
"""统一密钥解析:显式注入 → 本地缓存 → 内存扫描"""
if self._image_key and self._validate_key(self._image_key):
return self._image_key
cached = self._load_persisted_key()
if cached:
return cached
key = self._scan_aes_key(monitor=True)
if key:
self._persist_key(key)
return key
def _decrypt_v2(self, data: bytes, dat_path: str,
aes_key: Optional[str], xor_key: Optional[int]) -> bytes:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
aes_size, xor_size = struct.unpack_from("<LL", data, 6)
if xor_key is None:
xor_key = self._derive_xor_key(dat_path)
if aes_key is None:
aes_key = self._resolve_aes_key()
if not aes_key:
raise RuntimeError(
"无法获取图片 AES 密钥:请保持微信登录,并先在微信聊天中"
"打开(点击查看大图)任意一张图片,再重试 detect_image_key()"
"或通过 MediaDownloader(image_key='...') 手动传入密钥。"
)
aes_blk = aligned_aes_block_size(aes_size)
off = 15
aes_data = data[off: off + aes_blk]
off += aes_blk
raw_data = data[off: len(data) - xor_size]
xor_data = data[len(data) - xor_size:]
dec = Cipher(algorithms.AES(aes_key.encode()), modes.ECB()).decryptor()
pt = dec.update(aes_data) + dec.finalize()
pad = pt[-1] if pt else 0
if 1 <= pad <= 16 and all(b == pad for b in pt[-pad:]):
pt = pt[:-pad]
return pt + raw_data + bytes(b ^ (xor_key & 0xFF) for b in xor_data)
# ------------------------------------------------------------------
# 定位本地文件
# ------------------------------------------------------------------
def _chat_md5(self, user: str) -> str:
import hashlib
return hashlib.md5(user.encode()).hexdigest()
def _month_of(self, create_time: int) -> str:
return time.strftime("%Y-%m", time.localtime(create_time))
def _find_dat(self, user: str, md5: str, create_time: int,
thumbnail: bool = False) -> Optional[str]:
base = os.path.join(self.db.account_dir, "msg", "attach", self._chat_md5(user))
target = md5 + ("_t.dat" if thumbnail else ".dat")
for root, _, files in os.walk(base):
for f in files:
if f == target:
return os.path.join(root, f)
return None
# ------------------------------------------------------------------
# 各类媒体下载
# ------------------------------------------------------------------
def _out(self, save_dir: Optional[str], name: str) -> str:
d = save_dir or self.save_dir
os.makedirs(d, exist_ok=True)
return os.path.join(d, name)
# ------------------------------------------------------------------
# WXAM (wxgf) 解码:微信 4.x 普通图片的新存储格式,内部为 HEVC 裸流
# ------------------------------------------------------------------
def _extract_hevc(self, data: bytes) -> Optional[bytes]:
"""从 wxgf 容器提取 HEVC Annex-B 裸流(自首个 NALU 起始码起)。"""
start = data.find(b"\x00\x00\x00\x01")
return data[start:] if start >= 0 else None
@staticmethod
def _ffmpeg_exe() -> Optional[str]:
import shutil
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
return None
def _wxgf_to_jpg(self, data: bytes) -> Optional[bytes]:
"""用 ffmpeg 把 wxgf 内的 HEVC 裸流转码为 jpg。失败返回 None。"""
exe = self._ffmpeg_exe()
if exe is None:
return None
hevc = self._extract_hevc(data)
if not hevc:
return None
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "in.hevc")
dst = os.path.join(td, "out.jpg")
with open(src, "wb") as f:
f.write(hevc)
try:
r = subprocess.run(
[exe, "-y", "-v", "error", "-i", src, "-frames:v", "1", dst],
capture_output=True, timeout=30,
)
except Exception:
return None
if r.returncode == 0:
try:
with open(dst, "rb") as f:
out = f.read()
return out if out[:3] == b"\xff\xd8\xff" else None
except OSError:
return None
return None
def _img_md5(self, row: dict) -> Optional[str]:
pi = row.get("packed_info")
content = row.get("content")
for blob in (pi, content):
if isinstance(blob, bytes):
m = re.search(rb"([0-9a-fA-F]{32})", blob)
if m:
return m.group(1).decode().lower()
return None
def download_image(self, user: str, local_id: int, save_dir: Optional[str] = None,
aes_key: Optional[str] = None, xor_key: Optional[int] = None) -> Optional[str]:
"""下载图片消息并解密为 jpg/png/gif,返回落盘路径"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 3:
return None
md5 = self._img_md5(row)
if not md5:
return None
dat_path = self._find_dat(user, md5, row["create_time"])
thumb = False
if not dat_path:
# 群聊图片默认只有缩略图(原图未在微信中点开查看时不下发),回退缩略图
dat_path = self._find_dat(user, md5, row["create_time"], thumbnail=True)
if not dat_path:
return None
thumb = True
data = self.decrypt_image(dat_path, aes_key, xor_key)
suffix = "_thumb" if thumb else ""
if data[:3] == b"\xff\xd8\xff":
ext = "jpg"
elif data[:4] == b"\x89PNG":
ext = "png"
elif data[:3] == b"GIF":
ext = "gif"
elif data[:4] == b"wxgf":
# WXAM 格式:微信 4.x 普通图片也用 HEVC 编码存储(含动画表情)。
# 优先用 ffmpeg 转码为 jpg;不可用时把原始解密数据落盘为 .wxgf 兜底。
jpg = self._wxgf_to_jpg(data)
if jpg is not None:
out = self._out(save_dir, "%s_%s%s.%s" % (user, local_id, suffix, "jpg"))
with open(out, "wb") as f:
f.write(jpg)
return out
out = self._out(save_dir, "%s_%s%s.wxgf" % (user, local_id, suffix))
with open(out, "wb") as f:
f.write(data)
return out
else:
ext = "img"
out = self._out(save_dir, "%s_%s%s.%s" % (user, local_id, suffix, ext))
with open(out, "wb") as f:
f.write(data)
return out
def download_voice(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""语音:media_*.db VoiceInfo.voice_dataSILK 二进制),落盘 .silk
微信按账号/时间把语音分片存到多个 media_*.db,逐个搜索直到找到。
"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 34 or not row["server_id"]:
return None
for rel, path, _ in self.db._db_files:
if not os.path.basename(path).startswith("media_"):
continue
conn = self.db._open(rel)
try:
cid = conn.execute(
"SELECT rowid FROM Name2Id WHERE user_name=?", (user,)
).fetchone()
chat_id = cid[0] if cid else None
if chat_id is None:
continue
v = conn.execute(
"SELECT voice_data FROM VoiceInfo WHERE chat_name_id=? AND svr_id=? "
"ORDER BY create_time DESC LIMIT 1",
(chat_id, row["server_id"]),
).fetchone()
finally:
conn.close()
if v and v["voice_data"]:
out = self._out(save_dir, "%s_%s.silk" % (user, local_id))
with open(out, "wb") as f:
f.write(v["voice_data"])
return out
return None
def download_video(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""视频:按 packed_info 中的 id 在 msg/video 下查找 <id>.mp4"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 43:
return None
pi = row.get("packed_info")
if not isinstance(pi, bytes):
return None
m = re.search(rb"([0-9a-fA-F]{32})", pi)
vid = m.group(1).decode().lower() if m else None
base = os.path.join(self.db.account_dir, "msg", "video")
for root, _, files in os.walk(base):
for f in files:
if vid and f == vid + ".mp4":
out = self._out(save_dir, "%s_%s.mp4" % (user, local_id))
with open(out, "wb") as w:
with open(os.path.join(root, f), "rb") as r:
w.write(r.read())
return out
return None
def _file_name(self, row: dict) -> Optional[str]:
if not row["server_id"]:
return None
for rel, path, _ in self.db._db_files:
if os.path.basename(path) != "message_resource.db":
continue
conn = self.db._open(rel)
try:
r = conn.execute(
"SELECT d.packed_info FROM MessageResourceDetail d "
"LEFT JOIN MessageResourceInfo i ON d.message_id=i.message_id "
"WHERE i.message_svr_id=? LIMIT 1",
(row["server_id"],),
).fetchone()
finally:
conn.close()
if r and r["packed_info"]:
name = r["packed_info"].decode("utf-8", "replace").strip()
name = re.sub(r"[\r\n\x00]+", "", name)
if "/" in name or "\\" in name:
name = name.split("/")[-1].split("\\")[-1]
return name or None
break
return None
def download_file(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""文件:msg/file/<YYYY-MM>/<原文件名>,原文件名来自 message_resource"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 49:
return None
name = self._file_name(row)
if not name:
return None
base = os.path.join(self.db.account_dir, "msg", "file")
for root, _, files in os.walk(base):
for f in files:
if f == name:
out = self._out(save_dir, "%s_%s_%s" % (user, local_id, name))
with open(out, "wb") as w:
with open(os.path.join(root, f), "rb") as r:
w.write(r.read())
return out
return None
def download_media(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""按消息类型自动分发:3 图片 / 34 语音 / 43 视频 / 49 文件"""
row = self.db.get_message_row(user, local_id)
if not row:
return None
t = row["local_type"]
if t == 3:
return self.download_image(user, local_id, save_dir)
if t == 34:
return self.download_voice(user, local_id, save_dir)
if t == 43:
return self.download_video(user, local_id, save_dir)
if t == 49:
return self.download_file(user, local_id, save_dir)
return None
+811
View File
@@ -0,0 +1,811 @@
"""朋友圈(Moments)相关接口实现。
本模块提供两类接口:
- :class:`Moment`UIA 路线,老架构):微信 4.x 自绘界面已不暴露
UIA 节点,此路线在 4.x 上不可用,仅保留兼容;
- :class:`MomentDB`(数据库路线,4.x 推荐):直接读取微信本地
``sns.db`` 的 ``SnsTimeLine`` 表,内容为 ``SnsDataItem`` XML
可稳定获取全部朋友圈(含正文、图片/视频 md5、点赞、评论、定位)。
注:**发朋友圈(PublishMoments)功能已舍弃**——4.x 的发表为自绘
界面操作,无法可靠自动化;本模块仅保留朋友圈读取/点赞/评论能力。
"""
from __future__ import annotations
import html
import os
import re
import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Union
from wechatauto import uia
from wechatauto.languages import MOMENTS, get_lang
from wechatauto.logger import wxlog
from wechatauto.param import WxParam, WxResponse
from wechatauto.ui.base import BaseUISubWnd
from wechatauto.utils.tools import find_all_windows_from_root
from wechatauto.utils.win32 import SetClipboardText
def _lang(table, key: str) -> str:
"""根据当前语言环境返回对应文案。"""
return get_lang(table, key)
def _is_time_line(text: str) -> bool:
"""粗略判断一行文本是否为时间信息。"""
if not text:
return False
patterns = [
r"\d{4}\d{1,2}月\d{1,2}日",
r"\d{2}-\d{2}",
r"\d{1,2}:\d{2}",
r"昨[天日]",
r"星期[一二三四五六日天]",
]
return any(re.search(pattern, text) for pattern in patterns)
def _split_like_names(text: str) -> List[str]:
"""解析点赞字符串。"""
if not text:
return []
like_prefix = _lang(MOMENTS, '')
text = text.strip()
if text.startswith(like_prefix):
text = text[len(like_prefix):].lstrip(': ')
sep = _lang(MOMENTS, '分隔符_点赞')
if sep:
parts = [part.strip() for part in text.split(sep) if part.strip()]
else:
parts = [name.strip() for name in re.split(r'[,:]', text) if name.strip()]
return parts
@dataclass
class MomentComment:
"""朋友圈评论数据结构。"""
author: str
content: str
reply_to: Optional[str] = None
raw: str = ''
@classmethod
def from_text(cls, text: str) -> 'MomentComment':
text = text.strip()
reply_to = None
author = ''
content = text
# 格式示例:"张三 回复 李四:你好" 或 "张三: 哈喽"
match = re.match(r'^(?P<author>[^:]+?)\s*(?:回复\s*(?P<reply>[^:]+?)\s*)?[:](?P<content>.*)$', text)
if match:
author = match.group('author').strip()
reply_to = match.group('reply')
if reply_to:
reply_to = reply_to.strip()
content = match.group('content').strip()
else:
author = ''
content = text.strip()
return cls(author=author, content=content, reply_to=reply_to, raw=text)
class MomentItem(BaseUISubWnd):
"""朋友圈单条动态。"""
def __init__(self, control: uia.Control, parent: 'MomentList'):
self.control = control
self.parent = parent
self.root = parent.root
self._parsed = False
self.nickname: str = ''
self.content: str = ''
self.location: Optional[str] = None
self.time: str = ''
self.likes: List[str] = []
self.comments: List[MomentComment] = []
self.image_count: int = 0
self.is_advertisement: bool = False
self._comment_controls: Dict[str, uia.Control] = {}
# ----------------------------------------------------------------------------------------------
# 数据解析
# ----------------------------------------------------------------------------------------------
def _ensure_parsed(self) -> None:
if self._parsed:
return
raw_text = self.control.Name or ''
lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
if lines:
self.nickname = lines[0]
body_lines = lines[1:]
content_lines: List[str] = []
comment_lines: List[str] = []
likes_line: Optional[str] = None
for line in body_lines:
if not line:
continue
if re.search(_lang(MOMENTS, 're_图片数'), line):
count = re.findall(r'\d+', line)
if count:
self.image_count = int(count[0])
continue
if line.startswith(_lang(MOMENTS, '')):
likes_line = line
continue
if line == _lang(MOMENTS, '评论'):
# 后续均为评论
comment_lines.extend(body_lines[body_lines.index(line) + 1:])
break
if _lang(MOMENTS, '广告') in line:
self.is_advertisement = True
continue
if not self.time and _is_time_line(line):
self.time = line
continue
content_lines.append(line)
# 若未在循环中捕获评论,则继续检查剩余行
if not comment_lines:
collecting = False
for line in body_lines:
if line == _lang(MOMENTS, '评论'):
collecting = True
continue
if collecting:
comment_lines.append(line)
if likes_line:
self.likes = _split_like_names(likes_line)
self.content = '\n'.join(content_lines).strip()
self.comments = [MomentComment.from_text(line) for line in comment_lines if line.strip()]
# 记录可用于回复的控件
for child in self.control.GetChildren():
if child.ControlTypeName == 'TextControl':
text = (child.Name or '').strip()
if text:
self._comment_controls.setdefault(text, child)
self._parsed = True
# ----------------------------------------------------------------------------------------------
# 对外属性访问
# ----------------------------------------------------------------------------------------------
@property
def publisher(self) -> str:
self._ensure_parsed()
return self.nickname
@property
def text(self) -> str:
self._ensure_parsed()
return self.content
@property
def timestamp(self) -> str:
self._ensure_parsed()
return self.time
@property
def like_users(self) -> List[str]:
self._ensure_parsed()
return list(self.likes)
@property
def comment_list(self) -> List[MomentComment]:
self._ensure_parsed()
return list(self.comments)
# ----------------------------------------------------------------------------------------------
# 工具方法
# ----------------------------------------------------------------------------------------------
def find_comment(self, author: str) -> Optional[MomentComment]:
self._ensure_parsed()
for comment in self.comments:
if comment.author == author:
return comment
return None
def get_comment_control(self, comment: MomentComment) -> Optional[uia.Control]:
self._ensure_parsed()
key_candidates = [comment.raw, f"{comment.author}: {comment.content}", f"{comment.author}{comment.content}"]
for key in key_candidates:
if key and key in self._comment_controls:
return self._comment_controls[key]
# fallback: 遍历匹配
for text, ctrl in self._comment_controls.items():
if comment.author and text.startswith(comment.author):
if comment.content in text:
return ctrl
return None
class MomentList(BaseUISubWnd):
"""朋友圈时间线列表。"""
def __init__(self, parent: 'Moment'):
self.parent = parent
self.root = parent.root
self.control = self._locate_list(parent)
self._items: Optional[List[MomentItem]] = None
def _locate_list(self, parent: 'Moment') -> Optional[uia.Control]:
wxlog.debug('尝试定位朋友圈列表控件')
# 首先尝试通过常用 className 定位
candidates: Iterable[uia.Control] = []
try:
candidates = parent._api.control.GetChildren()
except Exception:
candidates = []
queue = list(candidates)
visited = set()
while queue:
ctrl = queue.pop(0)
if ctrl in visited:
continue
visited.add(ctrl)
class_name = getattr(ctrl, 'ClassName', '') or ''
automation_id = getattr(ctrl, 'AutomationId', '') or ''
if ctrl.ControlTypeName == 'ListControl' and ('Moment' in class_name or 'moment' in automation_id.lower()):
wxlog.debug(f'找到疑似朋友圈列表控件:{class_name}')
return ctrl
# 朋友圈列表一般会包含"评论"按钮
children = []
try:
children = ctrl.GetChildren()
except Exception:
children = []
if ctrl.ControlTypeName == 'ListControl':
for child in children:
try:
if getattr(child, 'Name', '') == _lang(MOMENTS, '评论'):
wxlog.debug('通过子元素匹配到朋友圈列表控件')
return ctrl
except Exception:
continue
queue.extend(children)
wxlog.debug('未能定位到朋友圈列表控件')
return None
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def refresh(self) -> None:
self._items = None
def get_items(self, refresh: bool = False) -> List[MomentItem]:
if refresh or self._items is None:
self._items = []
if not self.control:
return self._items
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
try:
if child.ControlTypeName in {'ListItemControl', 'CustomControl'}:
text = getattr(child, 'Name', '') or ''
if text.strip():
self._items.append(MomentItem(child, self))
except Exception:
continue
return list(self._items)
class Moment:
"""朋友圈接口封装。"""
def __init__(self, wx_obj):
self._wx = wx_obj
self._api = wx_obj._api
self.root = wx_obj._api
self._list: Optional[MomentList] = None
# ------------------------------------------------------------------------------------------
# 内部工具
# ------------------------------------------------------------------------------------------
def _ensure_list(self) -> Optional[MomentList]:
if self._list and self._list.exists(0):
return self._list
try:
self._wx.SwitchToMoments()
time.sleep(0.2)
except Exception:
wxlog.debug('切换到朋友圈页面失败')
return None
self._list = MomentList(self)
if not self._list.control:
return None
return self._list
# ------------------------------------------------------------------------------------------
# 对外接口
# ------------------------------------------------------------------------------------------
def GetMoments(self, refresh: bool = False) -> List[MomentItem]:
"""获取朋友圈动态列表。
Args:
refresh: 是否强制刷新控件缓存。
Returns:
List[MomentItem]: 朋友圈动态对象列表。
"""
moment_list = self._ensure_list()
if not moment_list:
return []
return moment_list.get_items(refresh)
def FindMomentByPublisher(self, nickname: str, refresh: bool = False) -> Optional[MomentItem]:
"""根据发布者昵称查找朋友圈动态。"""
nickname = nickname.strip()
for item in self.GetMoments(refresh=refresh):
if item.publisher == nickname:
return item
return None
# ------------------------------------------------------------------------------------------
# 点赞与评论(部分功能依赖 UI 结构,尽量保证稳健)
# ------------------------------------------------------------------------------------------
def _invoke_action_menu(self, item: MomentItem) -> Optional['MomentActionMenu']:
action_button = None
try:
for child in item.control.GetChildren():
if child.ControlTypeName == 'ButtonControl':
action_button = child
break
except Exception:
action_button = None
if action_button:
action_button.Click()
else:
try:
item.control.RightClick()
except Exception:
return None
menu = MomentActionMenu(item)
if not menu.exists(0.5):
return None
return menu
def Like(self, item: MomentItem, cancel: bool = False) -> WxResponse:
menu = self._invoke_action_menu(item)
if not menu:
return WxResponse.failure('未能打开朋友圈操作菜单')
try:
return menu.like(cancel)
finally:
menu.close()
def Comment(self, item: MomentItem, content: str, reply_to: Optional[str] = None) -> WxResponse:
if reply_to:
comment = item.find_comment(reply_to)
if not comment:
return WxResponse.failure('未找到需要回复的评论')
ctrl = item.get_comment_control(comment)
if not ctrl:
return WxResponse.failure('未定位到评论控件')
ctrl.Click()
else:
menu = self._invoke_action_menu(item)
if not menu:
return WxResponse.failure('未能打开朋友圈操作菜单')
try:
result = menu.comment()
finally:
menu.close()
if not result:
return result
dialog = MomentCommentDialog(self)
if not dialog.exists(0.5):
return WxResponse.failure('未弹出评论窗口')
return dialog.send(content)
class MomentActionMenu(BaseUISubWnd):
"""朋友圈点赞/评论菜单。"""
_win_cls_name: str = 'Qt51514QWindowToolSaveBits'
def __init__(self, parent: MomentItem, timeout: float = 1.0):
self.parent = parent
self.root = parent.root
self.control = self._locate(timeout)
def _locate(self, timeout: float) -> Optional[uia.Control]:
t0 = time.time()
while time.time() - t0 <= timeout:
wins = find_all_windows_from_root(classname=self._win_cls_name, pid=self.root.pid)
for win in wins:
try:
children = win.GetChildren()
except Exception:
children = []
for child in children:
name = getattr(child, 'Name', '')
if name in {_lang(MOMENTS, ''), _lang(MOMENTS, '取消'), _lang(MOMENTS, '评论')}:
return win
time.sleep(0.05)
return None
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def _find_button(self, names: Iterable[str]) -> Optional[uia.Control]:
if not self.control:
return None
target_names = list(names)
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName != 'ButtonControl':
continue
name = getattr(child, 'Name', '')
if name in target_names:
return child
return None
def like(self, cancel: bool = False) -> WxResponse:
target_names = [_lang(MOMENTS, '')]
if cancel:
target_names.insert(0, _lang(MOMENTS, '取消'))
button = self._find_button(target_names)
if not button:
return WxResponse.failure('未找到点赞按钮')
button.Click()
return WxResponse.success('操作成功')
def comment(self) -> WxResponse:
button = self._find_button([_lang(MOMENTS, '评论')])
if not button:
return WxResponse.failure('未找到评论按钮')
button.Click()
return WxResponse.success('已触发评论')
def close(self) -> None:
if not self.control:
return
try:
self.control.SendKeys('{Esc}')
except Exception:
pass
class MomentCommentDialog(BaseUISubWnd):
"""朋友圈评论输入窗口。"""
_win_cls_name: str = 'Qt51514QWindowToolSaveBits'
def __init__(self, parent: Moment):
self.parent = parent
self.root = parent.root
self.control = self._locate()
if self.control:
self._init_controls()
def _locate(self) -> Optional[uia.Control]:
wins = find_all_windows_from_root(classname=self._win_cls_name, pid=self.root.pid)
for win in wins:
try:
children = win.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName == 'ButtonControl' and getattr(child, 'Name', '') == _lang(MOMENTS, '发送'):
return win
return None
def _init_controls(self) -> None:
self.edit: Optional[uia.Control] = None
self.send_button: Optional[uia.Control] = None
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName == 'EditControl' and self.edit is None:
self.edit = child
elif child.ControlTypeName == 'ButtonControl' and getattr(child, 'Name', '') == _lang(MOMENTS, '发送'):
self.send_button = child
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def send(self, content: str) -> WxResponse:
if not self.exists(0):
return WxResponse.failure('评论窗口不存在')
if not content:
return WxResponse.failure('评论内容不能为空')
if not self.edit or not self.edit.Exists(0):
return WxResponse.failure('未找到评论输入框')
try:
self.edit.Click()
self.edit.SendKeys('{Ctrl}a')
SetClipboardText(content)
self.edit.SendKeys('{Ctrl}v')
if self.send_button and self.send_button.Exists(0):
self.send_button.Click()
else:
self.edit.SendKeys('{Enter}')
except Exception as exc: # pragma: no cover - UI 交互异常仅记录日志
wxlog.debug(f'发送朋友圈评论失败:{exc}')
return WxResponse.failure('发送评论失败')
return WxResponse.success('评论成功')
# ===========================================================================
# 数据库路线(微信 4.x 推荐):读取 sns.db SnsTimeLine
# ===========================================================================
def _parse_user_comment(block: str) -> dict:
"""解析单个 ``<user_comment>...</user_comment>`` 块。"""
def tag(name: str) -> Optional[str]:
m = re.search(r"<%s>([^<]*)</%s>" % (name, name), block)
return m.group(1).strip() if m else None
return {
"username": tag("username") or "",
"nickname": tag("nickname") or "",
"content": tag("content") or "",
"create_time": int(tag("create_time") or 0),
"type": int(tag("type") or 0),
"comment_id": tag("comment_id") or "",
"ref_comment_id": tag("ref_comment_id") or "",
"b_deleted": int(tag("b_deleted") or 0),
}
class MomentDB:
"""微信 4.x 朋友圈数据库读取器(无需 UI/OCR)。
数据来源:``sns.db`` 的 ``SnsTimeLine`` 表,``content`` 为
``SnsDataItem`` XML。支持解析正文、图片/视频 md5 与本地缓存路径、
点赞、评论、定位。
用法::
from wechatauto import WeChatDB, MomentDB
md = MomentDB(WeChatDB())
for feed in md.get_moments(limit=10):
print(feed["nickname"], feed["text"])
"""
def __init__(self, db):
self.db = db
# ------------------------------------------------------------------
# 数据访问
# ------------------------------------------------------------------
def _open_sns(self):
for rel, path, _ in self.db._db_files:
if os.path.basename(path) == "sns.db":
return self.db._open(rel)
raise RuntimeError("未找到 sns.db(朋友圈库)")
def get_moments(self, limit: int = 20, offset: int = 0,
username: Optional[str] = None) -> List[dict]:
"""读取朋友圈时间线(按 tid 倒序)。username 可过滤指定发布者。"""
conn = self._open_sns()
try:
if username:
rows = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine "
"WHERE user_name=? ORDER BY tid DESC LIMIT ? OFFSET ?",
(username, limit, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine "
"ORDER BY tid DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
finally:
conn.close()
return [self.parse_feed(r["content"], r["user_name"]) for r in rows]
def get_moment(self, tid: int) -> Optional[dict]:
conn = self._open_sns()
try:
r = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE tid=?",
(tid,),
).fetchone()
finally:
conn.close()
return self.parse_feed(r["content"], r["user_name"]) if r else None
def get_my_moments(self, limit: int = 20) -> List[dict]:
return self.get_moments(limit=limit, username=self.db.wxid)
def count(self) -> int:
conn = self._open_sns()
try:
return conn.execute("SELECT count(*) FROM SnsTimeLine").fetchone()[0]
finally:
conn.close()
# ------------------------------------------------------------------
# XML 解析
# ------------------------------------------------------------------
def parse_feed(self, xml: str, fallback_user: str = "") -> dict:
if isinstance(xml, bytes):
xml = xml.decode("utf-8", "replace")
m = re.search(r"<id>(\d+)</id>", xml)
feed_id = m.group(1) if m else ""
m = re.search(r"<username>([^<]+)</username>", xml)
author = m.group(1) if m else (fallback_user or "")
m = re.search(r"<createTime>(\d+)</createTime>", xml)
create_time = int(m.group(1)) if m else 0
m = re.search(r"<contentDesc>([^<]*)</contentDesc>", xml)
text = html.unescape(m.group(1)) if m else ""
m = re.search(r'<location latitude="([^"]*)" longitude="([^"]*)"', xml)
location = {"latitude": m.group(1), "longitude": m.group(2)} if m else None
# 图片 / 视频(mediaList 中的 url 带 md5
images, videos = [], []
for mm in re.finditer(
r'<media>.*?<url[^>]*md5="([0-9a-fA-F]{32})"[^>]*>(.*?)</url>', xml, re.S
):
md5 = mm.group(1).lower()
url = mm.group(2).strip()
if any(ext in url.lower() for ext in (".mp4", ".mov", ".avi")):
videos.append({"md5": md5, "url": url})
else:
images.append({"md5": md5, "url": url})
# 点赞 / 评论
likes, comments = [], []
lm = re.search(r"<like_user_list>(.*?)</like_user_list>", xml, re.S)
if lm:
for blk in re.findall(r"<user_comment>(.*?)</user_comment>", lm.group(1), re.S):
c = _parse_user_comment(blk)
if c["username"]:
likes.append(c)
cm = re.search(r"<comment_user_list>(.*?)</comment_user_list>", xml, re.S)
if cm:
for blk in re.findall(r"<user_comment>(.*?)</user_comment>", cm.group(1), re.S):
c = _parse_user_comment(blk)
if c["username"] and not c["b_deleted"]:
comments.append(c)
return {
"id": feed_id,
"tid": fallback_user,
"username": author,
"nickname": self._nick(author),
"text": text,
"location": location,
"create_time": create_time,
"images": images,
"videos": videos,
"likes": likes,
"comments": comments,
}
def _nick(self, username: str) -> str:
if not username:
return ""
try:
return self.db.get_nickname(username)
except Exception:
return username
# ------------------------------------------------------------------
# 媒体落地
# ------------------------------------------------------------------
@staticmethod
def _cache_root(db) -> str:
return os.path.join(db.account_dir, "cache")
def find_local_media(self, md5: str, kind: str = "image") -> Optional[str]:
"""在 cache/<月>/Sns/<Img|Video> 下按 md5 查找本地缓存文件。"""
if len(md5) < 2:
return None
sub = "Img" if kind == "image" else "Video"
for root, _, files in os.walk(self._cache_root(self.db)):
if os.sep + "Sns" + os.sep + sub not in os.sep + root + os.sep:
continue
for f in files:
if f.startswith(md5):
return os.path.join(root, f)
return None
def download_media(self, media: dict, save_dir: Optional[str] = None,
kind: str = "image") -> Optional[str]:
"""下载朋友圈图片/视频:优先本地缓存,否则从 URL 拉取。"""
save_dir = save_dir or os.path.join(
os.path.expanduser("~"), "Documents", "wechatauto_moments"
)
os.makedirs(save_dir, exist_ok=True)
md5 = (media or {}).get("md5")
local = self.find_local_media(md5, kind) if md5 else None
ext = ".jpg"
if local:
src = local
data = open(src, "rb").read()
else:
url = (media or {}).get("url")
if not url:
return None
try:
import urllib.request
req = urllib.request.Request(
url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0)"}
)
data = urllib.request.urlopen(req, timeout=20).read()
except Exception as e:
wxlog.debug(f'下载朋友圈媒体失败:{e}')
return None
if url.lower().endswith((".mp4", ".mov", ".avi")):
ext = ".mp4"
out = os.path.join(save_dir, "%s%s" % (md5 or os.path.basename(url or "media"), ext))
with open(out, "wb") as f:
f.write(data)
return out
+81
View File
@@ -0,0 +1,81 @@
from .base import Message, BaseMessage, HumanMessage
from .mattr import SystemMessage, FriendMessage, SelfMessage
from .mtype import (
TextMessage,
QuoteMessage,
VoiceMessage,
ImageMessage,
VideoMessage,
FileMessage,
LinkMessage,
LocationMessage,
PersonalCardMessage,
OtherMessage,
)
from .friend import (
FriendTextMessage,
FriendQuoteMessage,
FriendImageMessage,
FriendFileMessage,
FriendVideoMessage,
FriendVoiceMessage,
FriendLinkMessage,
FriendLocationMessage,
FriendPersonalCardMessage,
FriendOtherMessage,
)
from .self import (
SelfTextMessage,
SelfQuoteMessage,
SelfImageMessage,
SelfFileMessage,
SelfVideoMessage,
SelfVoiceMessage,
SelfLinkMessage,
SelfLocationMessage,
SelfPersonalCardMessage,
SelfOtherMessage,
)
from .msg import parse_msg, parse_msg_attr, parse_msg_type
__all__ = [
"Message",
"BaseMessage",
"HumanMessage",
"SystemMessage",
"FriendMessage",
"SelfMessage",
"TextMessage",
"QuoteMessage",
"VoiceMessage",
"ImageMessage",
"VideoMessage",
"FileMessage",
"LinkMessage",
"LocationMessage",
"PersonalCardMessage",
"OtherMessage",
"FriendTextMessage",
"FriendQuoteMessage",
"FriendImageMessage",
"FriendFileMessage",
"FriendVideoMessage",
"FriendVoiceMessage",
"FriendLinkMessage",
"FriendLocationMessage",
"FriendPersonalCardMessage",
"FriendOtherMessage",
"SelfTextMessage",
"SelfQuoteMessage",
"SelfImageMessage",
"SelfFileMessage",
"SelfVideoMessage",
"SelfVoiceMessage",
"SelfLinkMessage",
"SelfLocationMessage",
"SelfPersonalCardMessage",
"SelfOtherMessage",
"parse_msg",
"parse_msg_attr",
"parse_msg_type",
]
+285
View File
@@ -0,0 +1,285 @@
from wechatauto import uia
from wechatauto.ui.component import (
Menu,
SelectContactWnd
)
from wechatauto.utils import uilock
from wechatauto.param import WxParam, WxResponse, PROJECT_NAME
from abc import ABC, abstractmethod
from typing import (
Dict,
List,
Union,
Any,
TYPE_CHECKING,
Iterator,
Tuple
)
from hashlib import md5
if TYPE_CHECKING:
from wechatauto.ui.chatbox import ChatBox
def truncate_string(s: str, n: int = 8) -> str:
s = s.replace('\n', '').strip()
return s if len(s) <= n else s[:n] + '...'
class Message:
"""消息对象基类
该类不会直接实例化,而是作为所有消息类型的基类提供
常用的工具方法。实际的属性均由子类在 ``__init__`` 中
动态注入。
"""
_EXCLUDE_FIELDS = {"control", "parent", "root"}
# region --- 迭代/映射相关 -------------------------------------------------
def _iter_public_items(self) -> Iterator[Tuple[str, Any]]:
"""遍历当前消息可公开的字段"""
if not hasattr(self, "__dict__"):
return
seen = set()
for key, value in self.__dict__.items():
if key.startswith("_") or key in self._EXCLUDE_FIELDS:
continue
if key == "hash" and not WxParam.MESSAGE_HASH:
continue
seen.add(key)
yield key, value
# 补充类级字段(如 type/attr),保证 to_dict/match 可用
for key in ('type', 'attr'):
if key in seen:
continue
if any(key in cls.__dict__ for cls in type(self).__mro__):
yield key, getattr(self, key)
def __iter__(self) -> Iterator[str]:
for key, _ in self._iter_public_items():
yield key
def __len__(self) -> int:
return sum(1 for _ in self._iter_public_items())
def __getitem__(self, item: str) -> Any:
for key, value in self._iter_public_items():
if key == item:
return value
raise KeyError(item)
def __contains__(self, key: object) -> bool:
if not isinstance(key, str):
return False
return any(field == key for field, _ in self._iter_public_items())
# endregion ----------------------------------------------------------------
# region --- 字段访问 -------------------------------------------------------
def keys(self) -> Tuple[str, ...]:
return tuple(key for key, _ in self._iter_public_items())
def values(self) -> Tuple[Any, ...]:
return tuple(value for _, value in self._iter_public_items())
def items(self) -> Tuple[Tuple[str, Any], ...]:
return tuple(self._iter_public_items())
def get(self, key: str, default: Any = None) -> Any:
for field, value in self._iter_public_items():
if field == key:
return value
return default
def to_dict(self) -> Dict[str, Any]:
return dict(self._iter_public_items())
def copy(self) -> Dict[str, Any]:
return self.to_dict().copy()
# endregion ----------------------------------------------------------------
# region --- 状态判断 -------------------------------------------------------
def match(self, **conditions: Any) -> bool:
"""判断当前消息是否同时满足给定的字段条件"""
data = self.to_dict()
return all(data.get(key) == value for key, value in conditions.items())
@property
def is_self(self) -> bool:
return getattr(self, "attr", None) == "self"
@property
def is_friend(self) -> bool:
return getattr(self, "attr", None) == "friend"
@property
def is_system(self) -> bool:
return getattr(self, "attr", None) == "system"
# endregion ----------------------------------------------------------------
# region --- 魔术方法 -------------------------------------------------------
def __str__(self) -> str:
content = getattr(self, "content", None)
if content is None:
return super().__str__()
return str(content)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Message):
return NotImplemented
self_id = getattr(self, "id", None)
other_id = getattr(other, "id", None)
if self_id is not None and other_id is not None:
return self_id == other_id
if WxParam.MESSAGE_HASH:
return getattr(self, "hash", None) == getattr(other, "hash", None)
return self is other
def __hash__(self) -> int:
msg_id = getattr(self, "id", None)
if msg_id is not None:
return hash(msg_id)
if WxParam.MESSAGE_HASH:
return hash(getattr(self, "hash", None))
return super().__hash__()
# endregion ----------------------------------------------------------------
class BaseMessage(Message, ABC):
type: str = 'base'
attr: str = 'base'
control: uia.Control
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
self.parent = parent
self.control = control
self.direction = additonal_attr.get('direction', None)
self.distince = additonal_attr.get('direction_distence', None)
self.root = parent.root
self.id = self.control.runtimeid
self.content = self.control.Name
rect = self.control.BoundingRectangle
self.hash_text = f'({rect.height()},{rect.width()}){self.content}'
self.hash = md5(self.hash_text.encode()).hexdigest()
def __repr__(self):
cls_name = self.__class__.__name__
content = truncate_string(self.content)
return f"<{PROJECT_NAME} - {cls_name}({content}) at {hex(id(self))}>"
def roll_into_view(self):
if not self.exists():
return WxResponse.failure('消息目标控件不存在,无法滚动至显示窗口')
if uia.RollIntoView(
self.parent.msgbox,
self.control
) == 'not exist':
return WxResponse.failure('消息目标控件不存在,无法滚动至显示窗口')
return WxResponse.success('成功')
def exists(self):
if self.control.Exists(0) and self.control.BoundingRectangle.height() > 0:
return True
return False
class HumanMessage(BaseMessage, ABC):
attr = 'human'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
@abstractmethod
def _click(self, x, y, right=False): ...
@abstractmethod
def _bias(self): ...
def click(self):
self._click(right=False, x=self._bias * 2, y=WxParam.DEFAULT_MESSAGE_YBIAS)
def right_click(self):
self._click(right=True, x=self._bias, y=WxParam.DEFAULT_MESSAGE_YBIAS)
@uilock
def select_option(self, option: str, timeout=2) -> WxResponse:
if not self.exists():
return WxResponse.failure('消息对象已失效')
self._click(right=True, x=self._bias * 2, y=WxParam.DEFAULT_MESSAGE_YBIAS)
if menu := Menu(self, timeout):
return menu.select(option)
else:
return WxResponse.failure('操作失败')
@uilock
def forward(
self,
targets: Union[List[str], str],
timeout: int = 3,
interval: float = 0.1
) -> WxResponse:
"""转发消息
Args:
targets (Union[List[str], str]): 目标用户列表
timeout (int, optional): 超时时间,单位为秒,若为None则不启用超时设置
interval (float): 选择联系人时间间隔
Returns:
WxResponse: 调用结果
"""
if not self.exists():
return WxResponse.failure('消息对象已失效')
if not self.select_option('转发...', timeout=timeout):
return WxResponse.failure('当前消息无法转发')
select_wnd = SelectContactWnd(self)
return select_wnd.send(targets, interval=interval)
@uilock
def quote(
self, text: str,
at: Union[List[str], str] = None,
timeout: int = 3
) -> WxResponse:
"""引用消息
Args:
text (str): 引用内容
at (List[str], optional): @用户列表
timeout (int, optional): 超时时间,单位为秒,若为None则不启用超时设置
Returns:
WxResponse: 调用结果
"""
if not self.exists():
return WxResponse.failure('消息对象已失效')
if not self.select_option('引用', timeout=timeout):
return WxResponse.failure('当前消息无法引用')
if at:
self.parent.input_at(at)
return self.parent.send_text(text)
+112
View File
@@ -0,0 +1,112 @@
from .mattr import *
from .mtype import *
class FriendTextMessage(FriendMessage, TextMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendQuoteMessage(FriendMessage, QuoteMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendImageMessage(FriendMessage, ImageMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendEmojiMessage(FriendMessage, EmojiMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendFileMessage(FriendMessage, FileMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendVideoMessage(FriendMessage, VideoMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendVoiceMessage(FriendMessage, VoiceMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendLinkMessage(FriendMessage, LinkMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendLocationMessage(FriendMessage, LocationMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendPersonalCardMessage(FriendMessage, PersonalCardMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class FriendOtherMessage(FriendMessage, OtherMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
+79
View File
@@ -0,0 +1,79 @@
from .base import (
BaseMessage,
HumanMessage
)
from wechatauto import uia
from wechatauto.param import (
WxParam,
WxResponse,
PROJECT_NAME
)
from typing import (
Dict,
List,
Any,
TYPE_CHECKING
)
if TYPE_CHECKING:
from wechatauto.ui.chatbox import ChatBox
class SystemMessage(BaseMessage):
attr = 'system'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
self.sender = 'system'
self.sender_remark = 'system'
class FriendMessage(HumanMessage):
attr = 'friend'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
def _click(self, x, y, right=False):
self.roll_into_view()
if right:
self.control.RightClick(x=x, y=y, ratioX=0, ratioY=0)
else:
self.control.Click(ratioX=0, ratioY=0)
@property
def _bias(self):
return WxParam.DEFAULT_MESSAGE_XBIAS
class SelfMessage(HumanMessage):
attr = 'self'
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
def _click(self, x, y, right=False):
self.roll_into_view()
if right:
self.control.RightClick(x=x, y=y, ratioX=1, ratioY=0)
else:
self.control.Click(x=x, y=y, ratioX=1, ratioY=0)
@property
def _bias(self):
return -WxParam.DEFAULT_MESSAGE_XBIAS
+140
View File
@@ -0,0 +1,140 @@
from wechatauto.utils.tools import (
detect_message_direction
)
from wechatauto import uia
from .mattr import (
SystemMessage,
FriendMessage,
SelfMessage
)
from .mtype import *
from . import self as selfmsg
from . import friend as friendmsg
from typing import (
TYPE_CHECKING,
Literal,
Dict,
Any
)
import os
import re
if TYPE_CHECKING:
from wechatauto.ui.chatbox import ChatBox
def parse_msg_attr(
control: uia.Control,
parent: 'ChatBox'
):
msg_direction_hash = {
'left': 'friend',
'right': 'self'
}
if control.AutomationId:
msg_screenshot = control.ScreenShot()
msg_direction, msg_direction_distence = detect_message_direction(msg_screenshot)
msg_attr = msg_direction_hash.get(msg_direction)
os.remove(msg_screenshot)
additonal_attr = {
'direction': msg_direction,
'direction_distence': msg_direction_distence
}
else:
msg_attr = 'system'
if msg_attr == 'system':
return SystemMessage(control, parent)
elif msg_attr == 'friend':
return parse_msg_type(control, parent, 'Friend', additonal_attr)
elif msg_attr == 'self':
return parse_msg_type(control, parent, 'Self', additonal_attr)
def parse_msg_type(
control: uia.Control,
parent,
attr: Literal['Self', 'Friend'],
additonal_attr: Dict[str, Any]
):
"""
多层次消息类型识别算法
基于ClassName、Name等多重验证确保识别准确性
"""
if attr == 'Friend':
msgtype = friendmsg
else:
msgtype = selfmsg
msg_text = control.Name
msg_classname = control.ClassName
msg_automation_id = control.AutomationId
# 第一层:ClassName强特征识别(最可靠)
classname_result = _classify_by_classname(msg_classname)
if classname_result:
return getattr(msgtype, f'{attr}{classname_result}')(control, parent, additonal_attr)
# 第二层:基于ClassName分类后的详细识别
if msg_classname == "mmui::ChatBubbleItemView":
# Name前缀特征识别
prefix_result = _classify_by_name_prefix(msg_text)
if prefix_result:
return getattr(msgtype, f'{attr}{prefix_result}')(control, parent, additonal_attr)
# 图片消息处理
if msg_text == '图片':
return getattr(msgtype, f'{attr}ImageMessage')(control, parent, additonal_attr)
# 如果都不匹配,归类为其他消息
return getattr(msgtype, f'{attr}OtherMessage')(control, parent, additonal_attr)
elif msg_classname == "mmui::ChatTextItemView":
# 第三层:引用消息处理
if _is_quote_message(msg_text):
return getattr(msgtype, f'{attr}QuoteMessage')(control, parent, additonal_attr)
else:
return getattr(msgtype, f'{attr}TextMessage')(control, parent, additonal_attr)
return getattr(msgtype, f'{attr}OtherMessage')(control, parent, additonal_attr)
def _classify_by_classname(classname: str) -> str:
classname_mapping = {
"mmui::ChatVoiceItemView": "VoiceMessage",
"mmui::ChatPersonalCardItemView": "PersonalCardMessage",
}
return classname_mapping.get(classname, "")
def _classify_by_name_prefix(name: str) -> str:
if name.startswith("[动画表情]") or name.startswith("[动画表情"):
return "EmojiMessage"
if name.startswith("[链接]"):
return "LinkMessage"
elif name.startswith("位置"):
return "LocationMessage"
elif name.startswith("文件\n"):
return "FileMessage"
elif name.startswith("视频"):
return "VideoMessage"
return ""
def _is_quote_message(name: str) -> bool:
quote_pattern = r'^(.*?)\s*\n引用\s+(.+?)\s+的消息\s*:\s*(.*)$'
return bool(re.search(quote_pattern, name, re.DOTALL))
def parse_msg(
control: uia.Control,
parent
):
return parse_msg_attr(control, parent)
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
from .mattr import *
from .mtype import *
class SelfTextMessage(SelfMessage, TextMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfQuoteMessage(SelfMessage, QuoteMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfImageMessage(SelfMessage, ImageMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfEmojiMessage(SelfMessage, EmojiMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfFileMessage(SelfMessage, FileMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfVideoMessage(SelfMessage, VideoMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfVoiceMessage(SelfMessage, VoiceMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfLinkMessage(SelfMessage, LinkMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfLocationMessage(SelfMessage, LocationMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfPersonalCardMessage(SelfMessage, PersonalCardMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
class SelfOtherMessage(SelfMessage, OtherMessage):
def __init__(
self,
control: uia.Control,
parent: "ChatBox",
additonal_attr: Dict[str, Any] = {}
):
super().__init__(control, parent, additonal_attr)
+75
View File
@@ -0,0 +1,75 @@
from typing import Literal
import os
PROJECT_NAME = 'wechatauto'
class WxParam:
# 语言设置
LANGUAGE: Literal['cn', 'cn_t', 'en'] = 'cn'
# 是否启用日志文件
ENABLE_FILE_LOGGER: bool = True
# 下载文件/图片默认保存路径
DEFAULT_SAVE_PATH: str = os.path.join(os.getcwd(), 'wechatauto文件下载')
# 是否启用消息哈希值用于辅助判断消息,开启后会稍微影响性能
MESSAGE_HASH: bool = False
# 头像到消息X偏移量,用于消息定位,点击消息等操作
DEFAULT_MESSAGE_XBIAS = 51
DEFAULT_MESSAGE_YBIAS = 30
# 是否强制重新自动获取X偏移量,如果设置为True,则每次启动都会重新获取
FORCE_MESSAGE_XBIAS: bool = False
# 监听消息时间间隔,单位秒
LISTEN_INTERVAL: int = 1
# 监听执行器线程池大小
LISTENER_EXCUTOR_WORKERS: int = 4
# 搜索聊天对象超时时间,单位秒
SEARCH_CHAT_TIMEOUT: int = 2
# 微信笔记加载超时时间,单位秒
NOTE_LOAD_TIMEOUT: int = 30
# 发送文件超时时间,单位秒
SEND_FILE_TIMEOUT: int = 10
class WxResponse(dict):
def __init__(self, status: str, message: str, data: dict = None):
super().__init__(status=status, message=message, data=data)
def __str__(self):
return str(self.to_dict())
def __repr__(self):
return str(self.to_dict())
def to_dict(self):
return {
'status': self['status'],
'message': self['message'],
'data': self['data']
}
def __bool__(self):
return self.is_success
@property
def is_success(self):
return self['status'] == '成功'
@classmethod
def success(cls, message=None, data: dict = None):
return cls(status="成功", message=message, data=data)
@classmethod
def failure(cls, message: str, data: dict = None):
return cls(status="失败", message=message, data=data)
@classmethod
def error(cls, message: str, data: dict = None):
return cls(status="错误", message=message, data=data)
View File
+220
View File
@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
"""微信 4.x UI 自动化(发送消息)
微信 4.x 主窗口是完全自绘的(MMUIRenderSubWindow*,不同版本后缀不同),UIA 树不可用,
因此采用坐标模拟:激活窗口 → 点击搜索框 → 输入联系人关键词 → Enter
打开会话 → 点击输入框 → 输入文本 → Enter 发送。
坐标是相对窗口的逻辑坐标(自绘 UI 版本差异可能导致偏移,可调整常量)。
验证通过后,可通过读回数据库确认消息是否发送成功(sender_id=2)。
"""
from __future__ import annotations
import ctypes
import time
from ctypes import wintypes
_user32 = ctypes.WinDLL("user32", use_last_error=True)
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_user32.FindWindowW.restype = wintypes.HWND
_user32.FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR]
_user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)]
_user32.GetWindowRect.restype = wintypes.BOOL
_user32.SetForegroundWindow.argtypes = [wintypes.HWND]
_user32.SetForegroundWindow.restype = wintypes.BOOL
_user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
_user32.ShowWindow.restype = wintypes.BOOL
_user32.SetCursorPos.argtypes = [ctypes.c_int, ctypes.c_int]
_user32.SetCursorPos.restype = wintypes.BOOL
_user32.GetDpiForWindow.argtypes = [wintypes.HWND]
_user32.GetDpiForWindow.restype = wintypes.UINT
# WeChat 4.x 主窗口类名
MAIN_WND_CLASS = "Qt51514QWindowIcon"
def _restore_keep_maximize(hwnd: int):
"""取消最小化并显示窗口,同时保留其最大化状态。
``ShowWindow(hwnd, SW_RESTORE)`` 会把最大化窗口恢复为普通大小(窗口被
缩小)。先用 ``GetWindowPlacement`` 记录最大化状态:原为最大化则用
``SW_SHOWMAXIMIZED`` 恢复,否则才走 ``SW_RESTORE``。
"""
if not hwnd or not _user32.IsWindow(hwnd):
return
class _POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
class _WINDOWPLACEMENT(ctypes.Structure):
_fields_ = [
("length", wintypes.UINT),
("flags", wintypes.UINT),
("showCmd", wintypes.UINT),
("ptMinPosition", _POINT),
("ptMaxPosition", _POINT),
("rcNormalPosition", wintypes.RECT),
]
wp = _WINDOWPLACEMENT()
wp.length = ctypes.sizeof(_WINDOWPLACEMENT)
if _user32.GetWindowPlacement(hwnd, ctypes.byref(wp)):
if wp.showCmd == 3: # SW_SHOWMAXIMIZED
_user32.ShowWindow(hwnd, 3) # 保持/恢复最大化
return
_user32.ShowWindow(hwnd, 9) # SW_RESTORE
INPUT_EVENT = 0x0002
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004
VK_RETURN = 0x0D
WM_KEYUP = 0x0101
def _ensure_dpi_aware():
try:
_kernel32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2
except Exception:
pass
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
class _INPUT_UNION(ctypes.Union):
_fields_ = [
("ki", KEYBDINPUT),
("mi", MOUSEINPUT),
]
class _INPUT(ctypes.Structure):
_fields_ = [
("type", wintypes.DWORD),
("union", _INPUT_UNION),
]
def _send_input(scan: int = 0, unicode_char: str = "", flags: int = 0):
inp = _INPUT()
inp.type = INPUT_EVENT
inp.union.ki.wVk = 0
inp.union.ki.wScan = ord(unicode_char) if unicode_char else scan
inp.union.ki.dwFlags = flags | KEYEVENTF_UNICODE if unicode_char else flags
inp.union.ki.time = 0
inp.union.ki.dwExtraInfo = 0
_user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT))
def type_text(text: str, delay: float = 0.01):
"""用 Unicode 输入事件键入文本(含中文)"""
for ch in text:
_send_input(unicode_char=ch, flags=0)
_send_input(unicode_char=ch, flags=KEYEVENTF_KEYUP)
time.sleep(delay)
def press_enter():
_send_input(scan=VK_RETURN, flags=0)
_send_input(scan=VK_RETURN, flags=KEYEVENTF_KEYUP)
def click(x: int, y: int):
_user32.SetCursorPos(x, y)
time.sleep(0.08)
_user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN
_user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP
def find_main_window():
"""返回主窗口句柄(或 None)。先按类名,失败再按标题兜底(兼容不同版本类名)。"""
hwnd = _user32.FindWindowW(MAIN_WND_CLASS, None)
if hwnd:
return hwnd
return _user32.FindWindowW(None, "微信") or None
def window_rect(hwnd):
r = wintypes.RECT()
_user32.GetWindowRect(hwnd, ctypes.byref(r))
return (r.left, r.top, r.right, r.bottom)
def dpi_scale(hwnd) -> float:
return _user32.GetDpiForWindow(hwnd) / 96.0
class WeChatUI:
"""微信 4.x 发送工具(坐标模拟)"""
# 逻辑坐标(相对窗口左上角),自绘 UI 版本差异可在此调整
SEARCH_BOX = (140, 60) # 顶部搜索框
INPUT_BOX = (0.55, 0.88) # 输入框(相对窗口宽/高比例)
def __init__(self, hwnd=None):
_ensure_dpi_aware()
self.hwnd = hwnd or find_main_window()
if not self.hwnd:
raise RuntimeError("未找到微信主窗口")
def activate(self):
_restore_keep_maximize(self.hwnd)
_user32.SetForegroundWindow(self.hwnd)
time.sleep(0.5)
def _pt(self, lx, ly):
"""逻辑坐标 → 屏幕物理坐标"""
left, top, right, bottom = window_rect(self.hwnd)
w, h = right - left, bottom - top
if isinstance(lx, float):
lx = w * lx
if isinstance(ly, float):
ly = h * ly
return int(left + lx), int(top + ly)
def open_chat(self, keyword: str):
"""通过顶部搜索框打开会话(搜索 → Enter 选中第一项)"""
self.activate()
x, y = self._pt(*self.SEARCH_BOX)
click(x, y)
time.sleep(0.6)
type_text(keyword)
time.sleep(0.8)
press_enter()
time.sleep(1.0)
def send(self, text: str):
"""向当前打开的会话发送文本"""
self.activate()
x, y = self._pt(*self.INPUT_BOX)
click(x, y)
time.sleep(0.4)
type_text(text)
time.sleep(0.2)
press_enter()
time.sleep(0.6)
def send_to(self, keyword: str, text: str):
"""搜索并打开会话后发送"""
self.open_chat(keyword)
self.send(text)
+23
View File
@@ -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",
]
+64
View File
@@ -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)
+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()]
+305
View File
@@ -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
+268
View File
@@ -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)
+62
View File
@@ -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()
+276
View File
@@ -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}')
+356
View File
@@ -0,0 +1,356 @@
"""UIA 兼容适配层。
基于 PyPI ``uiautomation`` 封装并补充 wechatauto 依赖的扩展能力
- :attr:`Control.runtimeid` 控件 RuntimeId 的字符串形式用于消息去重/定位
- :meth:`Control.ScreenShot` 截取控件区域保存为图片文件
- :func:`RollIntoView` 将目标元素滚动到窗口可见区域内
- :func:`CheckElementPosition` / :func:`IsElementInWindow` 元素与窗口位置关系判断
Windows 微信 4.x 客户端为 Qt 应用UIA 类名前缀 ``mmui::``
仅暴露 UIA 标准接口因此本层不依赖具体微信版本
"""
from __future__ import annotations
import os
import queue
import tempfile
import threading
import time
from typing import Any
from PIL import Image as _PILImage
# uiautomation 和 comtypes 初始化(COM + UIA)可能在部分系统上阻塞
# (如微信或其他 Qt 应用占用 COM),改为延迟导入:仅在首次访问本包属性时
# 才加载,避免 ``import wechatauto`` 时卡死。
_uia_loaded = False
_UiaControl = None
_control_from_handle = None
_CoInitializeEx = None
def _ensure_uia():
"""延迟加载 uiautomation 并完成 monkey-patch,只执行一次。"""
global _uia_loaded, _UiaControl, _control_from_handle, _CoInitializeEx
if _uia_loaded:
return
_uia_loaded = True
from comtypes import CoInitializeEx as _ci
from uiautomation import Control as _uc, ControlFromHandle as _cfh
_CoInitializeEx = _ci
_UiaControl = _uc
_control_from_handle = _cfh
_uc.runtimeid = property(_get_runtimeid)
_uc.ScreenShot = _screen_shot
# 把 __all__ 中的名称绑定到模块全局,让 from uiautomation import * 的
# 等效访问能直接命中,不再触发 __getattr__ 回退查找
import uiautomation as _mod
for _n in __all__:
if _n not in globals():
_v = getattr(_mod, _n, None)
if _v is not None:
globals()[_n] = _v
def __getattr__(name: str) -> Any:
"""延迟导入:首次访问时加载 uiautomation。"""
_ensure_uia()
try:
return globals()[name]
except KeyError:
# 从 uiautomation 星号导入的名称,首次访问时从实际模块取回
import uiautomation as _mod
val = getattr(_mod, name, _SENTINEL)
if val is _SENTINEL:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
globals()[name] = val
return val
_SENTINEL = object()
__all__ = [
'ControlFromHandle',
'GetUiClassNameWithTimeout',
'WalkControl',
'RollIntoView',
'CheckElementPosition',
'IsElementInWindow',
'GetElementPositionDescription',
]
# UIA 类名以 Qt 内部消息泵开头的隐藏窗口(如 WPS 等 Qt 应用),
# 其 UIA 服务可能长时间无响应导致调用阻塞,扫描时应直接跳过
QT_INTERNAL_WIN_CLASS_PREFIX = 'QEventDispatcherWin32_Internal_'
def GetUiClassNameWithTimeout(hwnd, timeout: float = 3.0) -> 'str | None':
"""带超时保护地获取窗口的 UIA 类名。
部分 Qt 应用的隐藏窗口 ``QEventDispatcherWin32_Internal_*``UIA 服务异常
直接调用会无限阻塞本函数在守护线程中调用底层接口超时后放弃该窗口
Args:
hwnd: 窗口句柄
timeout: 超时时间单位秒
Returns:
str: 窗口的 UIA 类名超时/失败时返回 None
"""
result_queue = queue.Queue()
_ensure_uia()
def _probe():
try:
_CoInitializeEx()
control = _control_from_handle(hwnd)
result_queue.put(control.ClassName if control is not None else None)
except Exception as e: # noqa: BLE001
result_queue.put(e)
worker = threading.Thread(target=_probe, daemon=True)
worker.start()
worker.join(timeout)
if worker.is_alive():
# 底层 UIA 调用被异常进程阻塞,放弃等待(守护线程随进程退出)
return None
try:
result = result_queue.get_nowait()
except queue.Empty:
return None
if isinstance(result, Exception):
return None
return result
# ----------------------------------------------------------------------------
# Control 扩展:runtimeid
# ----------------------------------------------------------------------------
def _get_runtimeid(self) -> str:
"""返回控件 RuntimeId 的字符串形式,用于唯一标识控件。"""
return ''.join(str(i) for i in self.GetRuntimeId())
# ----------------------------------------------------------------------------
# Control 扩展:ScreenShot
# ----------------------------------------------------------------------------
def _screen_shot(self, savePath: str = None, crop: tuple = (0, 0, 0, 0),
crop_percentage: bool = False, return_img=False):
"""截取控件区域并保存/返回图片。
Args:
savePath: 保存路径不指定则使用临时文件
crop: 裁剪量 (left, top, right, bottom)
crop_percentage: crop 值是否为百分比
return_img: True 时直接返回 PIL Image 对象
Returns:
str: 图片保存路径return_img=True 时返回 PIL Image
"""
rect = self.BoundingRectangle
w, h = rect.width(), rect.height()
if crop_percentage:
crop = (
int(w * crop[0] / 100),
int(h * crop[1] / 100),
int(w * crop[2] / 100),
int(h * crop[3] / 100),
)
cw = max(w - crop[0] - crop[2], 1)
ch = max(h - crop[1] - crop[3], 1)
if savePath is None:
fd, savePath = tempfile.mkstemp(prefix='wechatauto_image_', suffix='.png')
os.close(fd)
os.remove(savePath)
ok = self.CaptureToImage(savePath, x=crop[0], y=crop[1], width=cw, height=ch)
if not ok:
raise RuntimeError(f'截图失败: {self}')
if return_img:
img = _PILImage.open(savePath)
try:
os.remove(savePath)
except Exception:
pass
return img
return savePath
# ----------------------------------------------------------------------------
# 模块级工具函数
# ----------------------------------------------------------------------------
def RollIntoView(win, ele, equal=True, bias=0):
"""将目标元素滚动到主窗口内可见区域。
Args:
win: 主窗口元素 (Control对象)
ele: 目标元素 (Control对象)
bias: 偏移量元素边缘需要超过这个量才算完全在窗口内 (默认为0)
"""
# 获取窗口和元素的边界矩形
win_rect = win.BoundingRectangle
ele_rect = ele.BoundingRectangle
# 计算窗口的有效显示区域(考虑bias偏移)
win_top = win_rect.top + bias
win_bottom = win_rect.bottom - bias
win_height = win_bottom - win_top
# 获取元素的位置信息
ele_top = ele_rect.top
ele_bottom = ele_rect.bottom
ele_height = ele_rect.height()
ele_ycenter = ele_rect.ycenter()
# 如果元素高度超过窗口高度,只需要确保元素中心在窗口内
if ele_height > win_height:
# 元素太高,只需要中心点在窗口内即可
target_top = ele_ycenter
target_bottom = ele_ycenter
else:
# 元素高度适中,需要整个元素都在窗口内
target_top = ele_top
target_bottom = ele_bottom
# 执行滚动操作
max_attempts = 100 # 防止无限循环
attempt = 0
while attempt < max_attempts:
# 重新获取当前位置(滚动后位置会变化)
current_ele_rect = ele.BoundingRectangle
if ele_height > win_height:
# 元素太高的情况,检查中心点
current_ycenter = current_ele_rect.ycenter()
if win_top <= current_ycenter <= win_bottom:
break # 中心点已在窗口内,停止滚动
if current_ycenter < win_top:
# 中心点在窗口上方,需要向下滚动
win.WheelUp()
time.sleep(0.1)
elif current_ycenter > win_bottom:
# 中心点在窗口下方,需要向上滚动
win.WheelDown()
time.sleep(0.1)
else:
# 元素高度适中的情况,检查整个元素
current_top = current_ele_rect.top
current_bottom = current_ele_rect.bottom
# 检查是否已经完全在窗口内
if win_top <= current_top and current_bottom <= win_bottom:
break # 元素已完全在窗口内,停止滚动
if current_top < win_top:
# 元素顶部在窗口上方,需要向下滚动
win.WheelUp()
time.sleep(0.1)
elif current_bottom > win_bottom:
# 元素底部在窗口下方,需要向上滚动
win.WheelDown()
time.sleep(0.1)
else:
# 理论上不应该到达这里
break
attempt += 1
if attempt >= max_attempts:
print(f"Warning: 滚动操作达到最大尝试次数({max_attempts}),可能元素无法完全滚动到视图内")
def CheckElementPosition(win, ele, bias=0) -> dict:
"""判断目标元素相对于主窗口的位置关系。
Returns:
dict: 包含各种位置关系判断结果的字典
"""
win_rect = win.BoundingRectangle
ele_rect = ele.BoundingRectangle
win_top = win_rect.top + bias
win_bottom = win_rect.bottom - bias
win_left = win_rect.left + bias
win_right = win_rect.right - bias
ele_top = ele_rect.top
ele_bottom = ele_rect.bottom
ele_left = ele_rect.left
ele_right = ele_rect.right
result = {
'ele_top_above_win_top': ele_top < win_top,
'ele_bottom_below_win_bottom': ele_bottom > win_bottom,
'ele_completely_above_win': ele_bottom <= win_top,
'ele_completely_below_win': ele_top >= win_bottom,
'ele_vertically_inside_win': win_top <= ele_top and ele_bottom <= win_bottom,
'win_vertically_inside_ele': ele_top <= win_top and win_bottom <= ele_bottom,
'ele_left_before_win_left': ele_left < win_left,
'ele_right_after_win_right': ele_right > win_right,
'ele_completely_left_of_win': ele_right <= win_left,
'ele_completely_right_of_win': ele_left >= win_right,
'ele_horizontally_inside_win': win_left <= ele_left and ele_right <= win_right,
'win_horizontally_inside_ele': ele_left <= win_left and win_right <= ele_right,
'ele_completely_inside_win': False,
'win_completely_inside_ele': False,
'ele_and_win_overlap': False,
'ele_and_win_separate': False,
}
result['ele_completely_inside_win'] = (
result['ele_vertically_inside_win'] and result['ele_horizontally_inside_win'])
result['win_completely_inside_ele'] = (
result['win_vertically_inside_ele'] and result['win_horizontally_inside_ele'])
vertical_overlap = not (result['ele_completely_above_win'] or result['ele_completely_below_win'])
horizontal_overlap = not (result['ele_completely_left_of_win'] or result['ele_completely_right_of_win'])
result['ele_and_win_overlap'] = vertical_overlap and horizontal_overlap
result['ele_and_win_separate'] = not result['ele_and_win_overlap']
return result
def IsElementInWindow(win, ele, bias=0) -> bool:
"""简化版本:判断元素是否在窗口内(仅垂直方向)"""
position_info = CheckElementPosition(win, ele, bias)
return position_info['ele_vertically_inside_win']
def GetElementPositionDescription(win, ele, bias=0) -> str:
"""获取元素位置的文字描述"""
result = CheckElementPosition(win, ele, bias)
if result['ele_completely_inside_win']:
return "元素完全在窗口内部"
elif result['win_completely_inside_ele']:
return "窗口完全在元素内部"
elif result['ele_completely_above_win']:
return "元素完全在窗口上方"
elif result['ele_completely_below_win']:
return "元素完全在窗口下方"
elif result['ele_completely_left_of_win']:
return "元素完全在窗口左侧"
elif result['ele_completely_right_of_win']:
return "元素完全在窗口右侧"
elif result['ele_vertically_inside_win']:
return "元素在窗口内,但水平方向超出范围"
elif result['ele_horizontally_inside_win']:
return "元素在窗口内,但垂直方向超出范围"
elif result['ele_and_win_overlap']:
return "元素与窗口部分重叠"
else:
return "元素与窗口完全分离"
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
from wechatauto.utils.win32 import (
GetAllWindows,
GetCursorWindow,
GetPathByHwnd,
FindWindow,
FindWinEx,
SetClipboardText,
SetClipboardFiles,
SetClipboardData,
ReadClipboardData,
PasteFile,
get_windows_by_pid,
GetText,
GetAllWindowExs,
)
from wechatauto.utils.lock import LockManager, uilock
__all__ = [
"GetAllWindows",
"GetCursorWindow",
"GetPathByHwnd",
"FindWindow",
"FindWinEx",
"SetClipboardText",
"SetClipboardFiles",
"SetClipboardData",
"ReadClipboardData",
"PasteFile",
"get_windows_by_pid",
"GetText",
"GetAllWindowExs",
"LockManager",
"uilock",
]
+127
View File
@@ -0,0 +1,127 @@
"""线程、进程与异步环境下的全局 UI 锁。"""
from __future__ import annotations
import asyncio
import functools
import inspect
import multiprocessing
import threading
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Awaitable, Callable, TypeVar, overload
F = TypeVar("F", bound=Callable[..., Any])
AsyncReturn = TypeVar("AsyncReturn")
class LockManager:
"""提供跨线程/进程/异步的锁。
``process_lock``multiprocessing.Lock不可重入同一线程内嵌套
``acquire`` 会永久阻塞因此用线程局部计数实现**同线程重入**同一
线程重复获取时跳过进程锁只需重入线程锁保证
``@uilock`` 修饰的函数互相调用 ``Chat.ForwardVoiceMessage``
内部调用 ``VoiceMessage.forward_to``不会死锁
"""
process_lock = multiprocessing.Lock()
thread_lock = threading.RLock()
_async_lock: asyncio.Lock | None = None
_local = threading.local()
@classmethod
def _get_async_lock(cls) -> asyncio.Lock:
"""返回与当前事件循环绑定的 ``asyncio.Lock``。"""
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
pass
lock = cls._async_lock
if lock is None or (loop and getattr(lock, "_loop", loop) is not loop):
lock = asyncio.Lock()
cls._async_lock = lock
return lock
@classmethod
@contextmanager
def acquire(cls):
"""同步环境下获取锁(同线程可重入)。"""
depth = getattr(cls._local, "depth", 0)
if depth > 0:
# 同线程嵌套:进程锁已被本线程持有,跳过它,只重入线程锁
with cls.thread_lock:
cls._local.depth = depth + 1
try:
yield
finally:
cls._local.depth = depth
return
with cls.process_lock:
with cls.thread_lock:
cls._local.depth = 1
try:
yield
finally:
cls._local.depth = 0
@classmethod
@asynccontextmanager
async def acquire_async(cls):
"""异步环境下获取锁(同线程可重入)。"""
depth = getattr(cls._local, "depth", 0)
if depth > 0:
async with cls._get_async_lock():
with cls.thread_lock:
cls._local.depth = depth + 1
try:
yield
finally:
cls._local.depth = depth
return
async with cls._get_async_lock():
with cls.process_lock:
with cls.thread_lock:
cls._local.depth = 1
try:
yield
finally:
cls._local.depth = 0
@overload
def uilock(func: Callable[..., Awaitable[AsyncReturn]]) -> Callable[..., Awaitable[AsyncReturn]]:
...
@overload
def uilock(func: F) -> F:
...
def uilock(func: F): # type: ignore[misc]
"""确保 UI 自动化操作串行执行的装饰器。"""
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any):
async with LockManager.acquire_async():
return await func(*args, **kwargs)
return async_wrapper
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any):
with LockManager.acquire():
return func(*args, **kwargs)
return sync_wrapper # type: ignore[return-value]
__all__ = ["LockManager", "uilock"]
+340
View File
@@ -0,0 +1,340 @@
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
import math
import re
import shutil
import time
from PIL import Image
from wechatauto.uia import ControlFromHandle, GetUiClassNameWithTimeout, QT_INTERNAL_WIN_CLASS_PREFIX
from wechatauto.utils.win32 import GetAllWindows
def get_file_dir(dir_path=None) -> Path:
if dir_path is None:
dir_path = Path('.').absolute()
elif isinstance(dir_path, str):
dir_path = Path(dir_path)
dir_path.mkdir(parents=True, exist_ok=True)
return dir_path
def find_window_from_root(classname=None, name=None, pid: int = None, uiaclsname: str = None, timeout=1):
t0 = time.time()
while True:
wins = find_all_windows_from_root(classname, name, pid, uiaclsname)
if len(wins) > 0:
return wins[0]
if time.time() - t0 > timeout:
return None
def find_all_windows_from_root(classname: str = None, name: str = None, pid: int = None, uiaclsname: str = None):
"""从全部顶层窗口中找到满足条件的 UIA 控件。
Args:
classname: win32 窗口类名
name: win32 窗口标题
pid: 进程ID
uiaclsname: UIA 类名 mmui::MainWindow优先于 win32 类名使用
"""
windows = GetAllWindows()
targets = []
for window in windows:
# 跳过 Qt 内部消息泵等隐藏窗口,其 UIA 服务可能阻塞调用
if window[1].startswith(QT_INTERNAL_WIN_CLASS_PREFIX):
continue
if (
(all((classname, name)) and classname == window[1] and name == window[2])
or (all((classname, not name)) and classname == window[1])
or (all((not classname, name)) and name == window[2])
or (all((not classname, not name)))
):
try:
if uiaclsname is not None:
if GetUiClassNameWithTimeout(window[0]) != uiaclsname:
continue
targets.append(ControlFromHandle(window[0]))
except Exception:
continue
if pid:
targets = [w for w in targets if w and w.ProcessId == pid]
if uiaclsname:
targets = [w for w in targets if w and w.ClassName == uiaclsname]
return targets
def now_time(fmt='%Y%m%d%H%M%S%f') -> str:
return datetime.now().strftime(fmt)
def parse_wechat_time(time_str: str) -> str:
"""微信消息时间格式转换函数
Args:
time_str: 输入的时间字符串
Returns:
转换后的时间字符串
"""
time_str = time_str.replace('星期天', '星期日')
match = re.match(r'^(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$', time_str)
if match:
month, day, hour, minute, second = match.groups()
current_year = datetime.now().year
return datetime(current_year, int(month), int(day), int(hour), int(minute), int(second)).strftime('%Y-%m-%d %H:%M:%S')
match = re.match(r'^(\d{1,2}):(\d{1,2})$', time_str)
if match:
hour, minute = match.groups()
return datetime.now().strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^昨天 (\d{1,2}):(\d{1,2})$', time_str)
if match:
hour, minute = match.groups()
yesterday = datetime.now() - timedelta(days=1)
return yesterday.strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^星期([一二三四五六日]) (\d{1,2}):(\d{1,2})$', time_str)
if match:
weekday, hour, minute = match.groups()
weekday_num = ['', '', '', '', '', '', ''].index(weekday)
today_weekday = datetime.now().weekday()
delta_days = (today_weekday - weekday_num) % 7
target_day = datetime.now() - timedelta(days=delta_days)
return target_day.strftime('%Y-%m-%d') + f' {hour}:{minute}:00'
match = re.match(r'^(\d{4})年(\d{1,2})月(\d{1,2})日 (\d{1,2}):(\d{1,2})$', time_str)
if match:
year, month, day, hour, minute = match.groups()
return datetime(*[int(i) for i in [year, month, day, hour, minute]]).strftime('%Y-%m-%d %H:%M:%S')
match = re.match(r'^(\d{2})-(\d{2}) (上午|下午) (\d{1,2}):(\d{2})$', time_str)
if match:
month, day, period, hour, minute = match.groups()
current_year = datetime.now().year
hour = int(hour)
if period == '下午' and hour != 12:
hour += 12
elif period == '上午' and hour == 12:
hour = 0
return datetime(current_year, int(month), int(day), hour, int(minute)).strftime('%Y-%m-%d %H:%M:%S')
return time_str
def is_valid_image(file_path) -> bool:
path = Path(file_path)
if not path.exists() or not path.is_file():
return False
try:
with Image.open(path) as img:
img.verify() # 只验证图像,不会完全解码
return True
except Exception:
return False
def delete_update_files():
"""清理微信更新缓存,避免更新弹窗干扰自动化操作"""
home = Path.home()
update_dir = home / 'AppData' / 'Roaming' / 'Tencent' / 'xwechat' / 'update'
if update_dir.exists():
for file in update_dir.iterdir():
try:
shutil.rmtree(file) if file.is_dir() else file.unlink()
except Exception:
pass
# ============================================================================================================================================
# 消息解析方法
# ============================================================================================================================================
def detect_message_direction(
image_path: str,
avatar_height_ratio: float = 0.8,
tolerance: int = 0,
) -> tuple[str, float]:
"""通过截图判断消息气泡的方向。
Args:
image_path: 消息截图路径
avatar_height_ratio: 头像在截图中占据的高度比例
tolerance: 像素颜色比较的容忍度
Returns:
Tuple[str, float]: ``("left", distance)`` ``("right", distance)``
``distance`` 表示从对应方向开始出现气泡的列索引便于后续定位
"""
img = Image.open(image_path)
if img.mode != 'RGB':
img = img.convert('RGB')
w, h = img.size
# 仅取中间 band 区域
band_h = int(h * avatar_height_ratio)
y0 = (h - band_h) // 2
y1 = y0 + band_h
pixels = img.load() # 获取像素访问对象
def is_uniform_column(x: int) -> bool:
base = pixels[x, y0] # 以 band 顶部像素作为参考
for y in range(y0, y1):
r, g, b = pixels[x, y]
if (abs(r - base[0]) > tolerance or
abs(g - base[1]) > tolerance or
abs(b - base[2]) > tolerance):
return False
return True
# 从左边扫描
left_idx = math.inf
for x in range(w):
if not is_uniform_column(x):
left_idx = x
break
# 从右边扫描
right_idx = math.inf
for offset, x in enumerate(range(w - 1, -1, -1)):
if not is_uniform_column(x):
right_idx = offset # 距右边界的列数
break
if left_idx == math.inf and right_idx == math.inf:
# 都没找到变化列,兜底
return 'right', math.inf
if left_idx <= right_idx:
return 'left', float(left_idx)
return 'right', float(right_idx)
def calculate_pixel_variance(region) -> float:
"""计算图像区域的像素变化程度"""
if region.size[0] == 0 or region.size[1] == 0:
return 0
# 获取所有像素值
pixels = list(region.getdata())
if not pixels:
return 0
# 分别计算R、G、B通道的方差
r_values = [p[0] for p in pixels]
g_values = [p[1] for p in pixels]
b_values = [p[2] for p in pixels]
r_variance = calculate_variance(r_values)
g_variance = calculate_variance(g_values)
b_variance = calculate_variance(b_values)
return r_variance + g_variance + b_variance
def calculate_variance(values) -> float:
"""计算数值列表的方差"""
if not values:
return 0
# 计算平均值
mean = sum(values) / len(values)
# 计算方差
variance = sum((x - mean) ** 2 for x in values) / len(values)
return variance
def calculate_color_diversity(region) -> float:
"""计算区域颜色多样性(备用方法)"""
pixels = list(region.getdata())
if not pixels:
return 0
# 统计不同颜色的数量
color_set = set(pixels)
unique_colors = len(color_set)
# 计算颜色多样性比例
diversity_ratio = unique_colors / len(pixels)
return diversity_ratio
def detect_message_direction_enhanced(
image_path: str,
avatar_width_ratio: float = 0.15,
avatar_height_ratio: float = 0.8,
) -> tuple[str, float]:
"""增强版检测,结合方差和颜色多样性"""
img = Image.open(image_path).convert('RGB')
width, height = img.size
avatar_width = int(width * avatar_width_ratio)
avatar_height = int(height * avatar_height_ratio)
avatar_start_y = (height - avatar_height) // 2
avatar_end_y = avatar_start_y + avatar_height
# 截取左右头像区域
left_box = (0, avatar_start_y, avatar_width, avatar_end_y)
right_box = (width - avatar_width, avatar_start_y, width, avatar_end_y)
left_region = img.crop(left_box)
right_region = img.crop(right_box)
# 计算方差和颜色多样性
left_variance = calculate_pixel_variance(left_region)
right_variance = calculate_pixel_variance(right_region)
left_diversity = calculate_color_diversity(left_region)
right_diversity = calculate_color_diversity(right_region)
# 综合评分(方差权重0.7,多样性权重0.3)
left_score = left_variance * 0.7 + left_diversity * 1000 * 0.3
right_score = right_variance * 0.7 + right_diversity * 1000 * 0.3
if left_score > right_score:
return 'left', float(left_score)
return 'right', float(right_score)
def batch_detect_messages(image_paths, method='basic', **kwargs):
"""批量检测多条消息的方向"""
results = []
detect_func = detect_message_direction if method == 'basic' else detect_message_direction_enhanced
for path in image_paths:
try:
result = detect_func(path, **kwargs)
if isinstance(result, tuple):
direction, distance = result
else:
direction, distance = result, None
sender = '对方' if direction == 'left' else '自己'
results.append({
'path': path,
'direction': direction,
'sender': sender,
'distance': distance,
})
except Exception as e:
results.append({
'path': path,
'direction': 'unknown',
'sender': '未知',
'error': str(e)
})
return results
+402
View File
@@ -0,0 +1,402 @@
import os
import time
import struct
import shutil
import traceback
import ctypes
from typing import List, Optional, Sequence, Tuple, Union
import win32ui
import win32gui
import win32api
import win32con
import win32process
import win32clipboard
import pyperclip
import psutil
from PIL import Image
def GetAllWindows(name=None, classname=None) -> List[Tuple[int, str, str]]:
"""获取所有顶层窗口的信息,返回 (窗口句柄, 类名, 窗口标题) 列表"""
windows = []
def enum_windows_proc(hwnd, extra):
class_name = win32gui.GetClassName(hwnd)
window_title = win32gui.GetWindowText(hwnd)
windows.append((hwnd, class_name, window_title))
win32gui.EnumWindows(enum_windows_proc, None)
if name:
windows = [i for i in windows if i[-1] == name]
if classname:
windows = [i for i in windows if i[1] == classname]
return windows
def GetCursorWindow():
x, y = win32api.GetCursorPos()
hwnd = win32gui.WindowFromPoint((x, y))
window_title = win32gui.GetWindowText(hwnd)
class_name = win32gui.GetClassName(hwnd)
return hwnd, window_title, class_name
def set_cursor_pos(x, y):
win32api.SetCursorPos((x, y))
def Click(rect):
x = (rect.left + rect.right) // 2
y = (rect.top + rect.bottom) // 2
set_cursor_pos(x, y)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, x, y, 0, 0)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, x, y, 0, 0)
def GetPathByHwnd(hwnd) -> Optional[str]:
try:
thread_id, process_id = win32process.GetWindowThreadProcessId(hwnd)
process = psutil.Process(process_id)
return process.exe()
except Exception:
return None
def GetVersionByPath(file_path) -> Optional[str]:
try:
info = win32api.GetFileVersionInfo(file_path, '\\')
version = "{}.{}.{}.{}".format(win32api.HIWORD(info['FileVersionMS']),
win32api.LOWORD(info['FileVersionMS']),
win32api.HIWORD(info['FileVersionLS']),
win32api.LOWORD(info['FileVersionLS']))
except Exception:
version = None
return version
def capture(hwnd, bbox) -> Image.Image:
"""截取指定窗口的指定区域,返回 PIL 图像"""
window_rect = win32gui.GetWindowRect(hwnd)
win_left, win_top, win_right, win_bottom = window_rect
win_width = win_right - win_left
win_height = win_bottom - win_top
# 获取窗口的设备上下文
hwndDC = win32gui.GetWindowDC(hwnd)
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
saveDC = mfcDC.CreateCompatibleDC()
# 创建位图对象保存整个窗口截图
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, win_width, win_height)
saveDC.SelectObject(saveBitMap)
# 使用PrintWindow捕获整个窗口(包括被遮挡或最小化的窗口)
result = ctypes.windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 3)
# 转换为PIL图像
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
im = Image.frombuffer(
'RGB',
(bmpinfo['bmWidth'], bmpinfo['bmHeight']),
bmpstr, 'raw', 'BGRX', 0, 1)
# 释放资源
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(hwnd, hwndDC)
# 计算bbox相对于窗口左上角的坐标
bbox_left, bbox_top, bbox_right, bbox_bottom = bbox
crop_left = bbox_left - win_left
crop_top = bbox_top - win_top
crop_right = bbox_right - win_left
crop_bottom = bbox_bottom - win_top
# 裁剪目标区域
cropped_im = im.crop((crop_left, crop_top, crop_right, crop_bottom))
return cropped_im
def GetText(HWND) -> str:
length = win32gui.SendMessage(HWND, win32con.WM_GETTEXTLENGTH) * 2
buffer = win32gui.PyMakeBuffer(length)
win32api.SendMessage(HWND, win32con.WM_GETTEXT, length, buffer)
address, length_ = win32gui.PyGetBufferAddressAndLen(buffer[:-1])
text = win32gui.PyGetString(address, length_)[:int(length / 2)]
buffer.release()
return text
def GetAllWindowExs(HWND) -> Optional[List[list]]:
if not HWND:
return None
handles = []
win32gui.EnumChildWindows(
HWND, lambda hwnd, param: param.append([hwnd, win32gui.GetClassName(hwnd), GetText(hwnd)]), handles)
return handles
def FindWindow(classname=None, name=None, timeout=0) -> int:
t0 = time.time()
while True:
HWND = win32gui.FindWindow(classname, name)
if HWND:
break
if time.time() - t0 > timeout:
break
time.sleep(0.01)
return HWND
def FindWinEx(HWND, classname=None, name=None) -> list:
hwnds_classname = []
hwnds_name = []
def find_classname(hwnd, classname):
classname_ = win32gui.GetClassName(hwnd)
if classname_ == classname:
if hwnd not in hwnds_classname:
hwnds_classname.append(hwnd)
def find_name(hwnd, name):
name_ = GetText(hwnd)
if name in name_:
if hwnd not in hwnds_name:
hwnds_name.append(hwnd)
if classname:
win32gui.EnumChildWindows(HWND, find_classname, classname)
if name:
win32gui.EnumChildWindows(HWND, find_name, name)
if classname and name:
hwnds = [hwnd for hwnd in hwnds_classname if hwnd in hwnds_name]
else:
hwnds = hwnds_classname + hwnds_name
return hwnds
def ClipboardFormats(unit=0, *units) -> List[int]:
units = list(units)
retry_count = 5
while retry_count > 0:
try:
win32clipboard.OpenClipboard()
try:
u = win32clipboard.EnumClipboardFormats(unit)
finally:
win32clipboard.CloseClipboard()
break
except Exception:
retry_count -= 1
units.append(u)
if u:
units = ClipboardFormats(u, *units)
return units
def ReadClipboardData() -> dict:
Dict = {}
formats = ClipboardFormats()
for i in formats:
if i == 0:
continue
retry_count = 5
while retry_count > 0:
try:
win32clipboard.OpenClipboard()
try:
data = win32clipboard.GetClipboardData(i)
Dict[str(i)] = data
finally:
win32clipboard.CloseClipboard()
break
except Exception:
retry_count -= 1
return Dict
def SetClipboardData(data_dict: dict) -> None:
try:
# 打开剪贴板
win32clipboard.OpenClipboard()
# 清空剪贴板
win32clipboard.EmptyClipboard()
# 遍历数据字典,设置各种格式的数据
for format_id, data in data_dict.items():
# 将字符串格式ID转换为整数
format_num = int(format_id)
if isinstance(data, str):
# 如果是字符串,使用Unicode格式
win32clipboard.SetClipboardData(format_num, data)
elif isinstance(data, bytes):
# 如果是字节数据,直接设置
win32clipboard.SetClipboardData(format_num, data)
except Exception as e:
print(f"设置剪贴板数据时出错: {e}")
finally:
# 关闭剪贴板
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def SetClipboardText(text: str) -> None:
pyperclip.copy(text)
class DROPFILES(ctypes.Structure):
_fields_ = [
("pFiles", ctypes.c_uint),
("x", ctypes.c_long),
("y", ctypes.c_long),
("fNC", ctypes.c_int),
("fWide", ctypes.c_bool),
]
def set_files_to_clipboard(file_paths: Union[str, Sequence[str]]) -> bool:
"""将文件路径列表设置到剪贴板的 CF_HDROP 格式"""
# 如果传入的是单个字符串,转换为列表
if isinstance(file_paths, str):
file_paths = [file_paths]
# 验证文件路径是否存在
valid_paths = []
for path in file_paths:
if os.path.exists(path):
# 转换为绝对路径
abs_path = os.path.abspath(path)
valid_paths.append(abs_path)
else:
raise ValueError(f"文件路径不存在: {path}")
if not valid_paths:
return False
try:
# 打开剪贴板
win32clipboard.OpenClipboard()
# 清空剪贴板
win32clipboard.EmptyClipboard()
# 计算偏移量(DROPFILES结构大小为20字节)
offset = 20
# 构建DROPFILES头部
dropfiles_header = struct.pack('<LLLLL',
offset, # pFiles偏移量
0, # pt.x
0, # pt.y
0, # fNC
1) # fWide (使用Unicode)
# 构建文件路径字符串(Unicode,以双null结尾)
file_list = []
for path in valid_paths:
# 转换为Unicode字节
file_list.append(path.encode('utf-16le'))
file_list.append(b'\x00\x00') # Unicode null终止符
# 添加额外的双null作为列表结束标记
file_list.append(b'\x00\x00')
# 合并所有数据
file_data = b''.join(file_list)
hdrop_data = dropfiles_header + file_data
# 设置到剪贴板
win32clipboard.SetClipboardData(win32con.CF_HDROP, hdrop_data)
return True
except Exception:
traceback.print_exc()
return False
finally:
# 关闭剪贴板
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def SetClipboardFiles(paths) -> bool:
return set_files_to_clipboard(paths)
def PasteFile(folder) -> bool:
folder = os.path.realpath(folder)
if not os.path.exists(folder):
os.makedirs(folder)
t0 = time.time()
while True:
if time.time() - t0 > 10:
raise TimeoutError(f"读取剪贴板文件超时!")
try:
win32clipboard.OpenClipboard()
if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_HDROP):
files = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)
for file in files:
filename = os.path.basename(file)
dest_file = os.path.join(folder, filename)
shutil.copy2(file, dest_file)
return True
else:
print("剪贴板中没有文件")
return False
except Exception:
pass
finally:
try:
win32clipboard.CloseClipboard()
except Exception:
pass
def enum_windows_by_pid(pid: int) -> List[int]:
window_list = []
def enum_callback(hwnd, lParam):
# 获取窗口的进程ID
_, process_id = win32process.GetWindowThreadProcessId(hwnd)
# 如果是目标进程,检查窗口是否可见
if process_id == pid:
if is_window_visible(hwnd):
window_list.append(hwnd)
return True
win32gui.EnumWindows(enum_callback, None)
return window_list
def is_window_visible(hwnd) -> bool:
# 检查窗口是否可见
style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)
# 检查窗口是否有 WS_VISIBLE 标志,且不是最小化的
if style & win32con.WS_VISIBLE and not win32gui.IsIconic(hwnd):
return True
return False
def get_windows_by_pid(pid: int) -> List[int]:
while True:
try:
windows = enum_windows_by_pid(pid)
return windows
except Exception:
time.sleep(0.1)
+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}')