feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布
This commit is contained in:
@@ -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__",
|
||||
]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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=0x7D100000031,message_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 里的 fromusername(content 是二进制无前缀)
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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_data(SILK 二进制)
|
||||
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_data(SILK 二进制),落盘 .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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}')
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
Reference in New Issue
Block a user