feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Runtime logs
|
||||
wxauto_logs/
|
||||
@AutomationLog.txt
|
||||
*.log
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
COMMIT_MSG.txt
|
||||
|
||||
# sibling repo
|
||||
WeChatBot-new/
|
||||
|
||||
# 敏感:API 密钥 / 聊天记录 / 本地数据
|
||||
ai_config.json
|
||||
data/
|
||||
kb/
|
||||
*.lock
|
||||
|
||||
# 虚拟环境
|
||||
.venv_clean/
|
||||
|
||||
# 调试截图与临时产物
|
||||
_debug*.png
|
||||
_now*.png
|
||||
snap_*.png
|
||||
debug_*.png
|
||||
debug_*.txt
|
||||
*.wav
|
||||
*.mp3
|
||||
*.bak_*
|
||||
|
||||
# 诊断脚本(一次性)
|
||||
_check_last_reply.py
|
||||
_diag_sender*.py
|
||||
_read_msgs.py
|
||||
query_default_rec.ps1
|
||||
|
||||
# 大压缩包
|
||||
third_party/*.zip
|
||||
_test_moment.jpg
|
||||
_tree_now.txt
|
||||
|
||||
# 调试目录与 ffmpeg 文档
|
||||
voice_debug/
|
||||
third_party/ffmpeg-master-latest-win64-gpl/doc/
|
||||
third_party/ffmpeg-master-latest-win64-gpl/presets/
|
||||
|
||||
# 大二进制(ffmpeg 等,按需自行下载)
|
||||
third_party/ffmpeg-master-latest-win64-gpl/bin/*.exe
|
||||
third_party/silk-v3-decoder/ffmpeg.exe
|
||||
@@ -0,0 +1,558 @@
|
||||
# wechatauto-replica 详细使用指南 / Detailed Usage Guide
|
||||
|
||||
> 面向**微信 4.x Windows 客户端**(非网页版)的自动化库。本文档覆盖从安装、
|
||||
> 数据库读取、实时监听、消息发送、媒体下载、朋友圈到多账号与常见问题的
|
||||
> 全部用法,并附带可直接运行的示例。
|
||||
>
|
||||
> Automation for the **WeChat 4.x Windows desktop client** (not the web version).
|
||||
> This guide covers everything: installation, database reading, real-time
|
||||
> listening, sending, media download, Moments, multi-account, and FAQ — with
|
||||
> runnable examples throughout.
|
||||
|
||||
---
|
||||
|
||||
## 目录 / Table of Contents
|
||||
|
||||
1. [安装与准备 / Installation & Setup](#1-安装与准备--installation--setup)
|
||||
2. [整体架构 / Architecture Overview](#2-整体架构--architecture-overview)
|
||||
3. [数据库读取 / Database Reading (WeChatDB)](#3-数据库读取--database-reading-wechatdb)
|
||||
4. [实时消息监听 / Real-time Listening](#4-实时消息监听--real-time-message-listening)
|
||||
5. [发送消息 / Sending Messages](#5-发送消息--sending-messages)
|
||||
6. [媒体下载 / Media Download](#6-媒体下载--media-download)
|
||||
7. [朋友圈 / Moments](#7-朋友圈--moments)
|
||||
8. [多账号 / Multi-account](#8-多账号--multi-account)
|
||||
9. [导出聊天记录 / Export](#9-导出聊天记录--exporting-chat-history)
|
||||
10. [群聊操作 / Group Chat](#10-群聊操作专题--group-chat-operations)
|
||||
11. [常见问题与排错 / FAQ](#11-常见问题与排错--faq--troubleshooting)
|
||||
12. [API 速查表 / Quick Reference](#12-api-速查表--api-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装与准备 / Installation & Setup
|
||||
|
||||
### 1.1 环境要求 / Requirements
|
||||
|
||||
| 项目 / Item | 要求 / Requirement |
|
||||
|---|---|
|
||||
| 系统 / OS | Windows 10 / 11 |
|
||||
| Python | 3.9+(已在 3.12 验证 / verified on 3.12) |
|
||||
| 微信 / WeChat | 4.1.12+(数据库读取对版本不敏感 / DB reading is version-insensitive) |
|
||||
| 登录状态 / Login | 微信必须**已登录**(数据库密钥在进程内存中)/ WeChat must be **logged in** (DB keys live in process memory) |
|
||||
|
||||
### 1.2 安装 / Install
|
||||
|
||||
```bash
|
||||
pip install wechatauto-replica
|
||||
|
||||
# 发送路径需要额外依赖(OCR 兜底 + 拼音输入)/ Sending path needs extra deps (OCR fallback + pinyin IME):
|
||||
pip install winsdk pypinyin
|
||||
```
|
||||
|
||||
从源码开发 / From source:
|
||||
|
||||
```bash
|
||||
git clone <仓库地址 / repo>
|
||||
cd wechatauto-replica
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 1.3 验证安装 / Verify
|
||||
|
||||
```python
|
||||
import wechatauto
|
||||
print(wechatauto.__version__) # 1.1.5.1 (beta)
|
||||
```
|
||||
|
||||
> ⚠️ 首次运行 `WeChatDB()` 会扫描微信进程内存提取数据库密钥,首次约 6 秒,
|
||||
> 之后密钥缓存到本地,秒开。
|
||||
> The first `WeChatDB()` call scans the WeChat process memory to extract DB keys
|
||||
> (~6s). Keys are cached locally afterwards, so later runs are instant.
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体架构 / Architecture Overview
|
||||
|
||||
| 能力 / Capability | 技术路线 / Tech | 模块 / Module |
|
||||
|---|---|---|
|
||||
| **读消息 / Read** | 本地 SQLCipher 4 数据库解密 / Local SQLCipher 4 DB decryption | `db.py` (WeChatDB) |
|
||||
| **实时监听 / Listen** | 数据库增量轮询 + 每会话工作线程 / DB incremental polling + per-chat workers | `db.py` (Listener) |
|
||||
| **发消息 / Send** | UIA 优先,坐标 + OCR 兜底 / UIA-first, coordinate + OCR fallback | `guia.py`, `wx.py` |
|
||||
| **媒体下载 / Media** | `.dat` AES 解密 / SILK 语音 / 文件复制 / `.dat` AES decrypt / SILK voice / file copy | `media.py` (MediaDownloader) |
|
||||
| **朋友圈 / Moments** | `sns.db` 直读 + UIA / direct read + UIA | `moment.py` |
|
||||
|
||||
核心对象 / Core objects:
|
||||
|
||||
- `WeChatDB` —— 一切数据读取的入口(解密数据库、查消息、查联系人)/ entry point for all data reading
|
||||
- `Listener` —— 实时监听器(轮询 + 工作线程)/ real-time listener
|
||||
- `MediaDownloader` —— 媒体下载 / media download
|
||||
- `WeChat` / `Chat` —— 面向发送的 wxauto 风格接口 / wxauto-style sending API
|
||||
- `WeChatGUI` / `quick_send` —— 底层 GUI 驱动与便捷函数 / low-level GUI driver + convenience functions
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库读取 / Database Reading (WeChatDB)
|
||||
|
||||
### 3.1 初始化 / Init
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
|
||||
db = WeChatDB() # 自动检测账号与数据目录 / auto-detect account & data dir
|
||||
# db = WeChatDB(account="wxid_xxx") # 多账号时指定 / specify account for multi-account
|
||||
```
|
||||
|
||||
### 3.2 会话(聊天列表)/ Sessions (chat list)
|
||||
|
||||
```python
|
||||
info = db.get_self_info() # 当前账号信息 / current account info
|
||||
for s in db.get_sessions(limit=10): # 会话列表 / session list
|
||||
print(s["username"], s["unread"], s["summary"])
|
||||
```
|
||||
|
||||
`get_sessions()` 返回的 `username` 是**会话唯一标识** / unique session identifier:
|
||||
|
||||
- 私聊 / Private chat:`wxid_xxx`
|
||||
- 群聊 / Group chat:`xxx@chatroom`
|
||||
|
||||
> ⚠️ 后续所有 API 都认 `username` 而非昵称。可用 `search_contact()` 转换。
|
||||
> All APIs take `username`, not nickname. Use `search_contact()` to convert.
|
||||
|
||||
### 3.3 搜索联系人 / Search
|
||||
|
||||
```python
|
||||
hits = db.search_contact("Ayi") # 按昵称/备注/微信号模糊搜索 / fuzzy search
|
||||
print(hits[0]["username"]) # -> wxid_xxx 或 xxx@chatroom
|
||||
|
||||
nick = db.get_nickname("wxid_xxx") # 反查昵称 / reverse lookup nickname
|
||||
```
|
||||
|
||||
### 3.4 读取消息 / Read messages
|
||||
|
||||
```python
|
||||
# 最近 N 条(按 sort_seq 降序)/ latest N (sort_seq desc)
|
||||
msgs = db.get_messages("filehelper", limit=10)
|
||||
for m in msgs:
|
||||
print(m["local_id"], m["type"], m["sender_id"], m["content"], m["create_time"])
|
||||
|
||||
# 单条原始行(媒体下载用,含 server_id / packed_info)/ single raw row
|
||||
row = db.get_message_row("filehelper", 123)
|
||||
```
|
||||
|
||||
消息 dict 字段 / Message dict fields:
|
||||
|
||||
| 字段 / Field | 含义 / Meaning |
|
||||
|---|---|
|
||||
| `local_id` | 消息 ID(下载媒体用)/ message ID (media download) |
|
||||
| `type` | 中文类型:文本/图片/语音/视频/动画表情/文件/系统消息 |
|
||||
| `sender_id` | 发送者 ID(`2` 表示自己;群聊是成员 ID)/ sender (`2` = self) |
|
||||
| `content` | 内容(图片等已转换为可读摘要)/ content |
|
||||
| `create_time` | 时间戳 / timestamp |
|
||||
| `sort_seq` | 全局排序序号(增量监听用)/ global ordering |
|
||||
|
||||
### 3.5 增量消息(供轮询监听)/ Incremental messages
|
||||
|
||||
```python
|
||||
new = db.get_new_messages("filehelper", since_seq=12345, limit=200)
|
||||
```
|
||||
|
||||
### 3.6 按类型批量取媒体 ID / Batch media IDs
|
||||
|
||||
```python
|
||||
# 返回该会话全部图片 local_id(不受总消息分页限制)/ all image IDs, ignores msg-limit
|
||||
img_ids = db._find_media_rows("群名", {3})
|
||||
# 类型码 / type codes:1文本 3图片 34语音 43视频 47动画表情 49文件
|
||||
# 1 text, 3 image, 34 voice, 43 video, 47 emoji, 49 file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 实时消息监听 / Real-time Message Listening
|
||||
|
||||
两种方式:**db.Listener**(推荐,纯数据库轮询)和 **WeChat.AddListenChat**(wxauto 风格封装)。
|
||||
Two ways: **db.Listener** (recommended, pure DB polling) and **WeChat.AddListenChat** (wxauto-style).
|
||||
|
||||
### 4.1 db.Listener(推荐 / recommended)
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0) # 每秒轮询一次 / poll every second
|
||||
|
||||
def on_msg(msg, lst):
|
||||
print(f"[{msg['type']}] {msg['sender_id']}: {msg['content']}")
|
||||
# 可在此扩展业务:关键词回复、媒体下载、通知推送等 / extend here
|
||||
|
||||
lst.add_listener("filehelper", on_msg) # 参数是会话 username
|
||||
lst.start() # 启动(后台线程)/ background thread
|
||||
# ... 你的主程序逻辑 / your main logic ...
|
||||
lst.stop() # 停止 / stop
|
||||
```
|
||||
|
||||
**并发模型 / Concurrency model**:
|
||||
|
||||
- 轮询线程只读库 + 分派,不会被慢回调阻塞 / the poller never blocks on slow callbacks
|
||||
- 每个会话一条独立工作线程:**同会话保序、跨会话并行** / per-chat worker: in-order per chat, parallel across chats
|
||||
- 慢回调(AI 调用、图片识别)不影响整体监听 / slow callbacks don't affect polling
|
||||
|
||||
**监听无聊天记录的联系人 / Contact with no history**:消息表按需创建,对方发第一条消息后下次轮询即可捕获,只需 `add_listener("wxid_xxx", cb)`。
|
||||
|
||||
**watermark 持久化 / Watermark persistence**:监听器记录已消费的 `sort_seq`,下次启动可传入避免重复推送。
|
||||
|
||||
### 4.2 WeChat.AddListenChat(wxauto 风格 / wxauto-style)
|
||||
|
||||
```python
|
||||
from wechatauto import WeChat
|
||||
from wechatauto.msgs import TextMessage, ImageMessage
|
||||
|
||||
wc = WeChat()
|
||||
|
||||
def on_msg(msg, chat):
|
||||
print(f"[{msg.type}] {chat.who}: {msg.content}")
|
||||
if isinstance(msg, ImageMessage):
|
||||
md = MediaDownloader(chat._db)
|
||||
out = md.download_image(chat._wxid, msg.local_id)
|
||||
|
||||
wc.AddListenChat(nickname="群名", callback=on_msg) # 传昵称即可,内部解析
|
||||
wc.GetListenMessage() # 阻塞监听循环(Ctrl+C 退出)/ blocking listen loop
|
||||
# 或 / or wc.KeepRunning()
|
||||
```
|
||||
|
||||
`WeChat` 还提供 / also offers:
|
||||
|
||||
```python
|
||||
wc.GetSession() # 会话列表 / session list [SessionItem]
|
||||
wc.ChatWith("filehelper") # 切换当前会话 / switch current chat
|
||||
wc.GetAllSubWindow() # 所有会话窗口 / all chat windows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 发送消息 / Sending Messages
|
||||
|
||||
### 5.1 快速函数 / Quick functions (guia)
|
||||
|
||||
```python
|
||||
from wechatauto.guia import (
|
||||
quick_send, quick_send_file, quick_send_image, quick_reply,
|
||||
)
|
||||
|
||||
quick_send("你好", "filehelper", verify=True) # 文本,verify=True 从库回读确认
|
||||
quick_send_file(r"D:\report.pdf", "filehelper") # 文件 / file
|
||||
quick_send_image(r"D:\photo.png", "filehelper") # 图片 / image
|
||||
quick_reply("回复内容", "filehelper", 123) # 回复某条消息 / reply
|
||||
```
|
||||
|
||||
### 5.2 WeChat / Chat 对象(wxauto 风格 / wxauto-style)
|
||||
|
||||
```python
|
||||
from wechatauto import WeChat
|
||||
|
||||
wc = WeChat()
|
||||
chat = wc.ChatWith("filehelper") # 或 / or Chat("filehelper", wc._gui, wc._db)
|
||||
|
||||
resp = chat.SendMsg("你好") # 发送到当前会话 / send to current chat
|
||||
resp = chat.SendMsg("大家好", "群名", at=["@张三", "@李四"]) # 群聊 @ 成员 / group @members
|
||||
resp = chat.SendFiles([r"D:\a.pdf", r"D:\b.docx"]) # 多个文件 / multiple files
|
||||
```
|
||||
|
||||
### 5.3 消息对象操作 / Message objects
|
||||
|
||||
```python
|
||||
msgs = chat.GetAllMessage() # 全部消息 / all messages
|
||||
new = chat.GetNewMessage() # 新消息 / new messages
|
||||
last = chat.GetLastMessage() # 最后一条 / last message
|
||||
|
||||
for m in msgs:
|
||||
print(m.type, m.content, m.sender, m.create_time)
|
||||
```
|
||||
|
||||
### 5.4 语音通话 / 拍一拍 / 撤回 / Voice call / Poke / Recall
|
||||
|
||||
```python
|
||||
chat.VoiceCall() # 语音通话 / voice call
|
||||
chat.VoiceCall(video=True) # 视频通话 / video call
|
||||
chat.Poke() # 拍一拍 / poke
|
||||
chat.RecallLastMessage() # 撤回最近一条自己发的消息 / recall latest own message
|
||||
```
|
||||
|
||||
### 5.5 转发语音 / Forward voice
|
||||
|
||||
```python
|
||||
chat.ForwardVoiceMessage(target="群名") # 从当前会话提取语音转成文件发送
|
||||
```
|
||||
|
||||
### 5.6 发送的验证机制(防误发)/ Anti-misdelivery verification
|
||||
|
||||
`send_msg` 链路带**目标对象三重校验**(UIA 路径)/ triple target verification (UIA path):
|
||||
|
||||
1. `open_chat` 打开后从 UIA 树读回输入框名称比对 / reads back input-box name after opening
|
||||
2. 发送前确认 `current_chat() == 目标` / confirms current chat is the target
|
||||
3. `verify=True` 时从数据库回读确认消息落库 / reads back from the DB to confirm
|
||||
|
||||
**目标不在好友/会话列表时安全失败**,不会误发给当前打开的会话(区别于旧版 wxauto3)。
|
||||
**If the target isn't in your list, sending fails safely** — never falls back to the current chat.
|
||||
|
||||
---
|
||||
|
||||
## 6. 媒体下载 / Media Download
|
||||
|
||||
### 6.1 初始化与密钥 / Init & keys
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MediaDownloader
|
||||
|
||||
db = WeChatDB()
|
||||
md = MediaDownloader(db) # 默认保存到 ~/Documents/wechatauto_media
|
||||
# md = MediaDownloader(db, save_dir=r"D:\media") # 指定保存目录 / specify save dir
|
||||
```
|
||||
|
||||
图片 AES 密钥处理 / Image AES key handling:
|
||||
|
||||
```python
|
||||
md.detect_image_key() # 扫描进程内存提取密钥(首次需要,之后持久化)
|
||||
# md = MediaDownloader(db, image_key="16位密钥") # 或手动注入 / inject manually
|
||||
```
|
||||
|
||||
> ⚠️ 图片 AES 密钥仅在**微信中点开图片查看**时驻留内存约 5 分钟。首次运行请先在
|
||||
> 微信里点开任意一张图;`detect_image_key(monitor=True)` 可自动轮询等待;找到后
|
||||
> 持久化到 `image_keys.json`,之后无需再扫。
|
||||
> The image AES key is only resident while **viewing an image in WeChat** (~5 min).
|
||||
|
||||
### 6.2 下载 API / Download API
|
||||
|
||||
```python
|
||||
# 按类型自动分发(3图片 34语音 43视频 49文件)/ auto-dispatch by type
|
||||
out = md.download_media("filehelper", 123, save_dir=r"D:\media")
|
||||
|
||||
out = md.download_image("filehelper", 123) # jpg/png/gif
|
||||
out = md.download_voice("filehelper", 123) # .silk
|
||||
out = md.download_video("filehelper", 123) # .mp4
|
||||
out = md.download_file("filehelper", 123) # 原文件 / original file
|
||||
```
|
||||
|
||||
返回落盘路径,失败返回 `None`。 / Returns the saved path, or `None` on failure.
|
||||
|
||||
### 6.3 群聊图片 / Group chat images
|
||||
|
||||
- 群聊图片原图**只有点开查看过才落盘**;否则只有缩略图 / originals only stored after being opened
|
||||
- `download_image` 会自动回退缩略图,文件名带 `_thumb` 标记 / auto-falls back to thumbnail (`_thumb`)
|
||||
- 无 ffmpeg 时 wxgf 格式存为 `.wxgf` 原始数据兜底 / without ffmpeg, wxgf saved as `.wxgf`
|
||||
|
||||
### 6.4 批量下载全部图片 / Batch download all images
|
||||
|
||||
```python
|
||||
ids = db._find_media_rows("群名", {3}) # 全部图片 ID,不管会话消息总量多大
|
||||
for lid in ids:
|
||||
out = md.download_image("群名", lid)
|
||||
if out:
|
||||
print("downloaded:", out)
|
||||
```
|
||||
|
||||
命令行也有现成脚本 / There is also a CLI demo:
|
||||
|
||||
```bash
|
||||
python demo_media.py 群名 --images 100 # 下载该群最近 100 张图片
|
||||
python demo_media.py 群名 --images 100000 # 超过总数即全部 / all if > total
|
||||
python demo_media.py 文件传输助手 --filter 图片,文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 朋友圈 / Moments
|
||||
|
||||
```python
|
||||
from wechatauto import MomentDB
|
||||
|
||||
moments = MomentDB(db) # 基于 sns.db 直读 / direct sns.db reads
|
||||
for feed in moments.get_moments(limit=10):
|
||||
print(feed["nickname"], feed["text"])
|
||||
print(" images:", [i["md5"] for i in feed["images"]])
|
||||
print(" likes:", [l["nickname"] for l in feed["likes"]])
|
||||
print(" comments:", [(c["nickname"], c["content"]) for c in feed["comments"]])
|
||||
```
|
||||
|
||||
GUI 交互版(点赞/评论读取)使用 `Moment` 对象,见 `demo` 脚本。
|
||||
|
||||
---
|
||||
|
||||
## 8. 多账号 / Multi-account
|
||||
|
||||
```python
|
||||
from wechatauto import list_accounts, WeChatDB
|
||||
|
||||
accts = list_accounts() # 列出本机所有微信账号 / list all accounts
|
||||
for a in accts:
|
||||
print(a)
|
||||
|
||||
db = WeChatDB(account="wxid_xxx") # 指定账号 / pick an account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 导出聊天记录 / Exporting Chat History
|
||||
|
||||
```python
|
||||
db.export_history(
|
||||
out_dir=r"D:\export",
|
||||
out_format="json", # json / sqlite
|
||||
include_media=True,
|
||||
)
|
||||
|
||||
for chat in db.list_message_chats(): # 有消息的会话 / chats that have messages
|
||||
print(chat)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 群聊操作专题 / Group Chat Operations
|
||||
|
||||
### 10.1 获取群信息 / Group info
|
||||
|
||||
```python
|
||||
info = chat.ChatInfo() # 群成员、群主等 / members, owner, etc.
|
||||
```
|
||||
|
||||
### 10.2 群聊发消息并 @ 成员 / Send & @ members
|
||||
|
||||
```python
|
||||
chat.SendMsg("大家看这个", at=["张三", "李四"])
|
||||
# 或指定群 / or
|
||||
wc.ChatWith("群名")
|
||||
wc.SendMsg("开会了", at=["全体成员"])
|
||||
```
|
||||
|
||||
### 10.3 群聊监听 / Listen to a group
|
||||
|
||||
```python
|
||||
lst.add_listener("44054166277@chatroom", on_msg) # 用群 username
|
||||
```
|
||||
|
||||
### 10.4 群聊图片 / 语音 / Group images & voice
|
||||
|
||||
```python
|
||||
md.download_image("群名", local_id) # 自动缩略图回退 / auto thumbnail fallback
|
||||
md.download_voice("群名", local_id) # 自动搜索所有 media_*.db / searches all media_*.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 常见问题与排错 / FAQ & Troubleshooting
|
||||
|
||||
### Q1: `RuntimeError: 数据库无可用密钥` / no usable DB key
|
||||
|
||||
- 确认微信**已登录**(密钥在进程内存)/ make sure WeChat is **logged in**
|
||||
- 确认运行账号有权限读取微信进程(同用户运行)/ run as the same user
|
||||
- 微信版本差异可能影响内存扫描,升级微信或查看 issue / some versions differ in memory layout
|
||||
|
||||
### Q2: 图片下载失败 / 无法获取 AES 密钥 / image key not found
|
||||
|
||||
- 先在微信里**点开一张图片看大图**,立即重试 / open any image in WeChat first
|
||||
- 或 `md.detect_image_key(monitor=True)` 持续等待
|
||||
- 或手动传 `image_key="16位"` 给 `MediaDownloader`
|
||||
|
||||
### Q3: 群聊图片只有几张 / 很多下不了 / group chat only a few images
|
||||
|
||||
- 群聊图片原图未点开查看时只有缩略图,`download_image` 会自动回退
|
||||
- 若要全部,用 `_find_media_rows` + 遍历(6.4),或用 `--images` 参数
|
||||
|
||||
### Q4: 发送失败 / sending fails
|
||||
|
||||
- 微信窗口需可见(不能锁屏/最小化到托盘)/ window must be visible
|
||||
- `desktop_available()` 为 False 时发送会安全失败
|
||||
- 换用 `verify=True` 获得回读确认
|
||||
|
||||
### Q5: 语音下载不到 / voice not downloading
|
||||
|
||||
- 1.1.4+ 已支持搜索所有 `media_*.db`(微信分片存储)/ 1.1.4+ searches all media_*.db
|
||||
- 确认升级到最新版本 / make sure you're on the latest version
|
||||
|
||||
### Q6: 监听无聊天记录的联系人 / contact with no history
|
||||
|
||||
- 消息表按需创建,对方发第一条消息后轮询即捕获
|
||||
- 需要知道对方 wxid(用 `search_contact`)
|
||||
|
||||
### Q7: `WeChatAuto` 导入报错 / ImportError
|
||||
|
||||
- 本库入口类是 **`WeChat`**,不存在 `WeChatAuto`
|
||||
- 教程代码若用旧类名,把 `WeChatAuto()` 换成 `WeChat()`
|
||||
|
||||
---
|
||||
|
||||
## 12. API 速查表 / API Quick Reference
|
||||
|
||||
### WeChatDB(数据读取 / data)
|
||||
|
||||
| 方法 / Method | 说明 / Description |
|
||||
|---|---|
|
||||
| `get_self_info()` | 当前账号信息 / current account info |
|
||||
| `get_sessions(limit)` | 会话列表 / session list |
|
||||
| `search_contact(kw)` | 搜索联系人 / search contacts |
|
||||
| `get_nickname(user)` | 反查昵称 / reverse nickname lookup |
|
||||
| `get_messages(user, limit, offset)` | 最近消息 / recent messages |
|
||||
| `get_message_row(user, local_id)` | 单条原始行 / single raw row |
|
||||
| `get_new_messages(user, since_seq)` | 增量消息 / incremental messages |
|
||||
| `_find_media_rows(user, types)` | 按类型取全部媒体 ID / all media IDs by type |
|
||||
| `list_message_chats()` | 有消息的会话 / chats that have messages |
|
||||
| `export_history(...)` | 导出聊天记录 / export history |
|
||||
| `list_accounts()` | 列出账号(模块级)/ list accounts (module-level) |
|
||||
|
||||
### Listener(实时监听 / realtime)
|
||||
|
||||
| 方法 / Method | 说明 / Description |
|
||||
|---|---|
|
||||
| `add_listener(user, cb)` | 注册回调 / register callback |
|
||||
| `remove_listener(user, cb)` | 移除回调 / remove callback |
|
||||
| `start()` / `stop()` | 启停 / start / stop |
|
||||
| `watermark` | 已消费序号 / consumed seq |
|
||||
|
||||
### MediaDownloader(媒体 / media)
|
||||
|
||||
| 方法 / Method | 说明 / Description |
|
||||
|---|---|
|
||||
| `detect_image_key(monitor)` | 提取图片密钥 / extract image key |
|
||||
| `download_image(user, lid)` | 图片(含缩略图/wxgf 回退)/ image |
|
||||
| `download_voice(user, lid)` | 语音 .silk / voice |
|
||||
| `download_video(user, lid)` | 视频 .mp4 / video |
|
||||
| `download_file(user, lid)` | 原文件 / original file |
|
||||
| `download_media(user, lid)` | 按类型自动分发 / auto-dispatch by type |
|
||||
|
||||
### WeChat / Chat(发送,wxauto 风格 / sending)
|
||||
|
||||
| 方法 / Method | 说明 / Description |
|
||||
|---|---|
|
||||
| `ChatWith(who)` | 切换会话 / switch chat |
|
||||
| `SendMsg(msg, who, at)` | 发文本(支持群 @)/ send text |
|
||||
| `SendFiles(paths, who)` | 发文件 / send files |
|
||||
| `GetAllMessage()` / `GetNewMessage()` | 读消息 / read messages |
|
||||
| `VoiceCall(video)` | 语音/视频通话 / voice/video call |
|
||||
| `Poke()` | 拍一拍 / poke |
|
||||
| `RecallLastMessage()` | 撤回最近消息 / recall latest message |
|
||||
| `ForwardVoiceMessage(target)` | 转发语音 / forward voice |
|
||||
| `AddListenChat(nickname, cb)` | 监听(WeChat)/ listen |
|
||||
| `KeepRunning()` | 阻塞保持运行 / block & stay alive |
|
||||
|
||||
### guia(快捷函数 / convenience)
|
||||
|
||||
| 函数 / Function | 说明 / Description |
|
||||
|---|---|
|
||||
| `quick_send(text, who, verify)` | 发文本 / send text |
|
||||
| `quick_send_file(path, who)` | 发文件 / send file |
|
||||
| `quick_send_image(path, who)` | 发图片 / send image |
|
||||
| `quick_reply(text, who, msg_id)` | 回复消息 / reply to a message |
|
||||
|
||||
### 消息对象 / Message objects (msgs)
|
||||
|
||||
`TextMessage` `ImageMessage` `VoiceMessage` `VideoMessage` `FileMessage`
|
||||
`QuoteMessage` `LinkMessage` `LocationMessage` `SystemMessage` `FriendMessage` `SelfMessage`
|
||||
|
||||
常用属性 / Common attributes:`.type` `.content` `.sender` `.create_time` `.local_id`
|
||||
|
||||
---
|
||||
|
||||
## 参考 / References
|
||||
|
||||
- [README(英文 / English)](README.md)
|
||||
- [README(中文 / 中文)](README.zh-CN.md)
|
||||
- `wechatauto/demo_*.py` —— 各功能的可运行示例 / runnable demos
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,224 @@
|
||||
[**English**](README.md) | [**中文**](README.zh-CN.md)
|
||||
|
||||
> [!NOTE]
|
||||
> **📢 维护状态 / Maintenance Notice**
|
||||
> 本人因今年升高一,明天(8月23日)报到。开学后几乎没有时间继续更新本项目(如果有时间,争取周日更新)。遇到问题请自行在 Issues 区讨论,或询问 AI 协助解决。感谢支持!
|
||||
>
|
||||
> I'm starting senior high school and will register tomorrow (Aug 23). After school starts I'll have almost no time to keep updating (Sundays if possible). Please discuss issues in the Issues section or ask an AI. Thanks for your support!
|
||||
|
||||
|
||||
# wechatauto-replica — WeChat 4.x Windows Automation (wxauto-compatible)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Automate the **WeChat 4.x Windows desktop client** (not the web version): read messages, listen in real time, download media, export full history, read Moments (朋友圈), and send messages — by driving the local client directly.
|
||||
|
||||
> **Current version:** 1.1.7 · Windows 10/11 · Python 3.9+ (verified on 3.12) · WeChat **4.1.12+**
|
||||
>
|
||||
> **Why this project exists:** the classic [wxauto](https://github.com/cluic/wxauto) relies on the UI Automation tree, which WeChat 4.x broke with self-drawn rendering (no accessibility nodes). wechatauto-replica is a drop-in-style replacement: messages are read through **local database decryption** (SQLCipher 4), and sending uses a **UIA + OCR hybrid** driver that auto-falls back between engines.
|
||||
|
||||

|
||||
|
||||
*Reading the encrypted `contact.db` / `message_*.db` / `sns.db` files directly from `xwechat_files/.../db_storage/` — no web API, all local.*
|
||||
|
||||
## ✨ Features
|
||||
|
||||
| Capability | Status | How |
|
||||
|---|---|---|
|
||||
| Read messages | ✅ verified | Local SQLCipher 4 DB decryption (`wechatauto/db.py`) |
|
||||
| Real-time message listening | ✅ verified | `Listener` incremental polling, per-chat worker threads |
|
||||
| Emoji message capture | ✅ verified | Screen capture + direction-aware bubble auto-cropping |
|
||||
| Full history export | ✅ verified | JSON / SQLite |
|
||||
| Media download (image / voice / file) | ✅ verified | `MediaDownloader`: image v2 AES decryption, SILK voice, files |
|
||||
| Moments (朋友圈) read | ✅ verified | Direct `sns.db` reads (3382 feeds verified) |
|
||||
| Multi-account | ✅ verified | `list_accounts()` + `account=` |
|
||||
| Send text / file / image / reply / @member | ✅ verified | UIA-first, coordinate + OCR fallback |
|
||||
| Voice call / Poke (拍一拍) | ✅ verified | UIA buttons + OCR menus |
|
||||
| UIAutomation tree | ✅ after hot-activation | Writes the Qt accessibility gate inside Weixin.dll |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
> 📖 **Full usage guide**: [GUIDE.md](GUIDE.md) (中英对照 / bilingual)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
# extra deps for the OCR sending path:
|
||||
pip install winsdk pypinyin
|
||||
```
|
||||
|
||||
### Read messages
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
|
||||
db = WeChatDB() # auto-detects account & data dir (WeChat must be logged in)
|
||||
|
||||
info = db.get_self_info() # current account
|
||||
for s in db.get_sessions(limit=10): # session list
|
||||
print(db.get_nickname(s["username"]), s["unread"])
|
||||
|
||||
hits = db.search_contact("Ayi") # search contacts
|
||||
for m in db.get_messages("filehelper", limit=10): # recent messages
|
||||
print(m["create_time"], m["sender_id"], m["type"], m["content"])
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```python
|
||||
from wechatauto.guia import quick_send, quick_send_file
|
||||
|
||||
quick_send("Hello", "filehelper", verify=True) # verify=True reads back from DB
|
||||
quick_send_file(r"D:\report.pdf", "filehelper")
|
||||
```
|
||||
|
||||
### Real-time listening
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0)
|
||||
lst.add_listener("filehelper", lambda msg, lst: print("new:", msg["content"]))
|
||||
lst.start()
|
||||
# ... your code ...
|
||||
lst.stop()
|
||||
```
|
||||
|
||||
Callbacks run on dedicated per-chat worker threads: messages in one chat are processed in order, different chats in parallel; slow callbacks (AI calls, image recognition) never block the poller.
|
||||
|
||||
### Media & Moments
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MediaDownloader, MomentDB
|
||||
|
||||
db = WeChatDB()
|
||||
md = MediaDownloader(db)
|
||||
md.detect_image_key() # scan process memory for the image AES key (persisted after first hit)
|
||||
for m in db.get_messages("filehelper", limit=50):
|
||||
out = md.download_media("filehelper", m["local_id"])
|
||||
if out:
|
||||
print("downloaded:", out)
|
||||
|
||||
moments = MomentDB(db)
|
||||
for feed in moments.get_moments(limit=10):
|
||||
print(feed["nickname"], feed["text"])
|
||||
print(" images:", [i["md5"] for i in feed["images"]])
|
||||
print(" likes:", [l["nickname"] for l in feed["likes"]])
|
||||
print(" comments:", [(c["nickname"], c["content"]) for c in feed["comments"]])
|
||||
```
|
||||
|
||||
## 🧠 How It Works
|
||||
|
||||
- **Reading** — WeChat 4.x stores everything in SQLCipher 4 encrypted SQLite databases under `xwechat_files/<wxid>/db_storage/` (`contact.db`, `message_*.db`, `media_0.db`, `sns.db`, …). Each DB has its own 32-byte key living in the Weixin.exe process memory (`com.Tencent.WCDB.Config.Cipher` config objects). The library locates them with a **read-only memory scan**, validates candidates with SQLCipher HMAC rules, decrypts pages to a temp dir and caches the result (first decrypt ~6s, then instant). WAL incremental merging with frame-salt filtering prevents `database disk image is malformed` corruption.
|
||||
- **Sending** — WeChat 4.x chat UI is self-drawn (no accessibility nodes), so sending uses a hybrid driver: hot-activate the **Qt accessibility gate** inside Weixin.dll (RVA scan, writes the screen-reader flag) to materialize the `mmui::*` UIA tree — search box, `chat_input_field`, etc. Sending is **UIA-first, coordinate + OCR fallback**: auto-calibrating layout (`~/.wechatauto/layout-<machine>.json`), zoomed OCR (3x) with multi-round voting for rare Chinese characters, clipboard + Ctrl+V input to dodge IME interception.
|
||||
- **Media** — image `.dat` files are `[6B sig][4B aes_size][4B xor_size] + AES-ECB + plaintext + xor` chunks. The account-level AES key is transient (only resident in memory while viewing an image); `MediaDownloader` scans for it, validates via JPEG/PNG magic, and **persists it to `image_keys.json`** so later runs need no scanning (or pass `image_key=` explicitly). Voice is plain SILK read from `media_0.db`; files are read from `msg/file/` with original names resolved from `message_resource.db`.
|
||||
|
||||
## ⚖️ vs wxauto
|
||||
|
||||
| | wxauto | wechatauto-replica |
|
||||
|---|---|---|
|
||||
| WeChat 4.x | ❌ UIA tree gone → broken | ✅ DB decryption + UIA hot-activation |
|
||||
| Message reading | via UI tree | via local DB (full history, faster) |
|
||||
| Sending | UIA clicks | UIA-first + OCR fallback |
|
||||
| Media | limited | image AES decrypt, SILK voice, files |
|
||||
| Moments | read | read (posting dropped: self-drawn UI) |
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
1. **WeChat must be logged in** — DB keys live in process memory; cached after first extraction, re-extracted automatically after re-login.
|
||||
2. **Image AES key is transient** — only resident while viewing an image; persisted to `image_keys.json` once found, or inject via `image_key=`.
|
||||
3. **Sending is a GUI operation** — fails cleanly when the desktop is locked (`desktop_available()` returns False).
|
||||
4. **Videos** are downloadable only when the mp4 already exists on disk (`msg/video/`).
|
||||
5. **Group-chat image originals** are stored locally only after being opened (viewed) in WeChat; until then only the thumbnail (`_t.dat`) exists — `download_image` falls back to the thumbnail (marked `_thumb` in the filename).
|
||||
6. **Moments posting is dropped** (4.x self-drawn UI, unreliable); reading/likes/comments are supported.
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- Calibrate and verify file/image/reply/@ sending on unlocked desktops
|
||||
- Video message download (4.x storage location TBD)
|
||||
- Performance: parallel export / first-scan, incremental memory-scan cache
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### v1.1.7 (2026-08-22)
|
||||
- **Master-key based key extraction (PR #10, thanks [NothingFumo](https://github.com/NothingFumo))**: instead of scanning process memory for per-DB `Config.Cipher` literals (which fails on WeChat 4.1.12.26+), we now extract the **single master key** from the `cfg` structure (`cfg+0x2B8` cipher XORed with 4×movabs constants from the DLL) and **derive each DB key offline** via `PBKDF2-HMAC-SHA512(master_key, db_salt, 256000)` — 27/27 SQLCipher4 DBs verified. This fixes key extraction on 4.1.12.26+ (issues #3 / #7).
|
||||
- **Unified image-key pipeline**: template collection (`*_t.dat`, top 16 by mtime) → tail-byte majority XOR (replaces the old single-file probe that could wrongly fall back to `0x88`) → `cfgDword` derivation (deterministic, offline) preferred, with injected/cached/memory-scan AES fallbacks. Probe-verified on 3000/3000 real ciphertexts.
|
||||
- **Account fields from cfg**: `WeChatDB` now also returns `name` / `number` / `phone` alongside the master key, matching the output format of mainstream key tools.
|
||||
- New optional params `master_key` / `cfg_dword` are fully backward compatible — if not passed, the original path is used. Core decryption functions unchanged.
|
||||
|
||||
### v1.1.6.3 (2026-08-21)
|
||||
- **Fix import hang**: `import wechatauto` no longer blocks permanently on systems where `uiautomation` / COM initialization hangs (e.g. WeChat or other Qt apps occupying COM). The `uiautomation` and `comtypes` imports are now deferred — loaded lazily on first UIA access, not at `import wechatauto` time.
|
||||
- **Fix OCR hang**: `ScreenOCR.recognize` now wraps the WinRT async call in `asyncio.wait_for(..., 8s)` — a hung `Windows.Media.Ocr` async (e.g. Chinese-locale systems) previously blocked `quick_send` forever; it now times out and degrades to empty OCR results.
|
||||
- **Fix first-run calibrate_layout hang**: `calibrate_layout` now runs each OCR detection step in a daemon thread with a 5-second timeout. Previously, if WinRT OCR hung on a first-run (no layout config), the entire `WeChatGUI.__init__` would block forever; now it times out and falls back to default layout ratios.
|
||||
- **Support WeChat builds with plaintext-header (key+salt) DBs**: key extraction now accepts SQLCipher 4 "Raw Key with Explicit Salt" form (`x'<96hex>'` = 32B key + 16B explicit salt, used with `cipher_plaintext_header_size`). A 48-byte key (32B key + 16B salt) is verified and decrypted in plaintext-header layout (page-1 keeps its plaintext header); a 32-byte key keeps the standard file-header-salt path. This unblocks DB decryption on builds where the old offsets point at the class-name table instead of the Cipher instance (e.g. a 4.1.12.26 environment).
|
||||
- **Fix `wxid_*` hardcoding**: account discovery no longer assumes directories start with `wxid_` — any subdirectory of `db_dir` containing `db_storage/` is recognized. This supports custom WeChat IDs (e.g. user-chosen usernames that don't use the `wxid_` prefix).
|
||||
- **Listen to all messages**: `WeChat.AddListenAll(callback)` now monitors ALL sessions (friends, groups, file transfer, etc.) with a single call, including auto-discovery of new sessions. `WeChat.RemoveListenAll()` stops it. The callback receives `(Message, Chat)` where `Chat.who` is the session username.
|
||||
|
||||
### v1.1.6.1 (2026-08-20)
|
||||
- **PyPI description fix**: v1.1.6 was uploaded without the synced `README_pypi.md` (description still showed 1.1.5.1); this patch restores the full v1.1.6 changelog and bumps the version marker.
|
||||
|
||||
### v1.1.6 (2026-08-20)
|
||||
- **Auto-diagnosis on missing key**: `数据库无可用密钥` now runs a built-in check before raising — Python bitness (32-bit can't read 64-bit Weixin memory), per-PID `OpenProcess`/`ReadProcessMemory` permission, and multi-account mismatch (all `wxid_*` dirs vs. picked account, suggesting `WeChatDB(account=...)`). No need to run `diagnose_keys` first.
|
||||
- **New diagnostic tool**: `wechatauto/diagnose_keys.py` (`python -m wechatauto.diagnose_keys`, WeChat logged in) dumps lib version, Python bitness, Weixin PIDs with per-process read-permission checks, all accounts vs. picked account, cached keys, fresh in-memory extraction, and key verification — paste the output when reporting key-extraction failures.
|
||||
- **Skip `migrate\unspportmsg.db`**: WeChat's reserved "unsupported message" DB has no in-memory key and is never queried; it was forcing a full process-memory scan on every init.
|
||||
|
||||
### v1.1.5.1 (2026-08-18) — beta
|
||||
- **Fix real-time listening**: `WeChatDB.get_new_messages()` referenced an undefined `found` (NameError swallowed by `Listener._poll_once`), so **no** message callbacks ever fired — including first messages from contacts you had never chatted with.
|
||||
- **Dynamic message shards**: `_message_dbs()` now re-scans the disk so shards WeChat creates at runtime (e.g. `message_5.db`) are picked up and their keys extracted automatically.
|
||||
|
||||
### v1.1.5 (2026-08-18)
|
||||
- **Version cleanup**: normalized the patch version (1.1.4.2 → 1.1.5) after the `media_*.db` voice fix.
|
||||
|
||||
### v1.1.4.2 (2026-08-18)
|
||||
- **PyPI description cleanup**: removed the demo default-group changelog line from the PyPI description.
|
||||
|
||||
### v1.1.4.1 (2026-08-18)
|
||||
- **PyPI readme bilingual**: merged the Chinese (`README.zh-CN.md`) and English (`README.md`) into one PyPI description so the Chinese version is visible on the package page.
|
||||
|
||||
### v1.1.4 (2026-08-18)
|
||||
- **Voice download across all media databases**: `download_voice()` now searches every `media_*.db` (not just `media_0.db`) — WeChat shards voice data across multiple media DBs; previously voices stored in `media_1.db` etc. could not be found (thanks uiharukazari0105).
|
||||
- **`demo_media.py --images N`**: download the latest N images of a chat directly from the DB (by local_type), bypassing the total-message `--limit` — no more "only a few images listed" when a group has thousands of messages.
|
||||
- **`WeChatDB._find_media_rows(user, types)`**: new helper returning all media local_ids of a chat for a set of local_types (batch download).
|
||||
- **Group-chat image thumbnail fallback**: original images in group chats are only downloaded after being opened in WeChat; `download_image` now falls back to the thumbnail (`_t.dat`) when the original is missing, saving it with a `_thumb` suffix.
|
||||
|
||||
### v1.1.3 (2026-08-17)
|
||||
|
||||
### v1.1.2 (2026-08-16)
|
||||
- **UIA driver thread-safety**: `WeChatUIA` now initializes COM on the current thread (`CoInitializeEx`, idempotent) — fixes crashes when instantiated from background threads / host apps (e.g. WeChatBot) with "CoInitialize not called / cannot load UIAutomationCore.dll" errors.
|
||||
- **Main-window filtering**: only windows whose process loaded `Weixin.dll` are considered — auxiliary processes without the DLL (whose hot-activation always fails) no longer produce noise warnings.
|
||||
- **Forward-voice fix**: `Chat.ForwardVoiceMessage` uses `self` when no target is given (the previous `_cur()` could resolve the wrong chat).
|
||||
- **Re-entrant UI lock**: `LockManager` is now re-entrant per thread — `@uilock` functions calling each other (e.g. `ForwardVoiceMessage` → `VoiceMessage.forward_to`) no longer deadlock.
|
||||
|
||||
### v1.1.1 (2026-08-16)
|
||||
- **Recall last message** (`Chat.RecallLastMessage` / `uia_driver.recall_last_message`): right-click the latest own message → UIA-first menu-item click (`mmui::XMenuView` found inside the main-window subtree), OCR fallback; fails cleanly when the 2-minute recall window has passed (menu only shows "Delete").
|
||||
- UIA robustness: menu-item lookup scoped to the main-window subtree (avoids the Windows UIA root-traversal hang), removed the fragile `WindowControl(ClassName=...)` fallback.
|
||||
- Media fix: video id bytes→str decoding in `MediaDownloader`.
|
||||
- `demo_media.py --photos` default 3 → 10.
|
||||
|
||||
### v1.1.0 (2026-08-15)
|
||||
- **Image AES key auto-capture** (`media.py`): the V2 image key is only resident in memory while viewing an image (~5 min). `_scan_aes_key()` gained a `monitor` mode — polls continuously and persists the key to `image_keys.json` once found; users just open one image to finish setup.
|
||||
- Fixed the process-ordering scan bug (removed the memory-usage sort that pushed the main process last).
|
||||
- **Forward voice messages**: SILK extraction from `media_0.db` + file-message send (`demo_forward_voice.py`).
|
||||
- New demos: `demo_group_messages.py` (group + red-packet ZSTD parsing), `demo_robust.py`.
|
||||
|
||||
## 🤝 Acknowledgments
|
||||
|
||||
Thanks to [vesio](https://github.com/vesio) for sharing the WeChat 4.1.12 UIA control-tree approach and debugging ideas in [issue #1](https://github.com/fanyuantaier/wechatauto-replica/issues/1) — it made the UIA hybrid driver (v1.0.8) possible.
|
||||
|
||||
Thanks to [nanshanjack](https://github.com/nanshanjack) for finding the UI-lock re-entrancy problem (fixed in v1.1.2).
|
||||
|
||||
Thanks to [maozhitao12450](https://github.com/maozhitao12450) for reporting the WXAM (wxgf) image download issue (fixed in v1.1.3).
|
||||
|
||||
Thanks to [uiharukazari0105](https://github.com/uiharukazari0105) for finding that voice data stored in `media_1.db` (and later) was never searched (fixed in v1.1.4).
|
||||
Thanks to [NothingFumo](https://github.com/NothingFumo) for the master-key extraction design (cfg + PBKDF2-derived per-DB keys, v1.1.7).
|
||||
|
||||
## 📄 License & Disclaimer
|
||||
|
||||
Apache-2.0. This project is for personal learning and automation research only — please respect the WeChat software license agreement and applicable laws.
|
||||
|
||||
Contact: fanyuantaier@163.com
|
||||
+655
@@ -0,0 +1,655 @@
|
||||
[**English**](README.md) | [**中文**](README.zh-CN.md)
|
||||
|
||||
> [!NOTE]
|
||||
> **📢 维护状态 / Maintenance Notice**
|
||||
> 本人因今年升高一,明天(8月23日)报到。开学后几乎没有时间继续更新本项目(如果有时间,争取周日更新)。遇到问题请自行在 Issues 区讨论,或询问 AI 协助解决。感谢支持!
|
||||
>
|
||||
> I'm starting senior high school and will register tomorrow (Aug 23). After school starts I'll have almost no time to keep updating (Sundays if possible). Please discuss issues in the Issues section or ask an AI. Thanks for your support!
|
||||
|
||||
|
||||
# wechatauto —— 微信 4.x Windows 客户端自动化(wxauto 复刻版)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
本项目复刻上游 wxauto 项目,目标是实现对当前微信 4.x Windows 客户端的自动化
|
||||
(读取消息、发送消息、媒体下载、朋友圈),非网页版,直接操作本机客户端。
|
||||
|
||||
> 当前版本:1.1.7
|
||||
>
|
||||
> **兼容范围**:Windows 10/11 | Python 3.9+(已在 3.12 验证)| 微信 **4.1.12+**
|
||||
> (数据库读取路线对微信版本不敏感;坐标+OCR 发送路线依赖 4.1.12+ 自绘渲染
|
||||
> 布局,其它 4.x 小版本可能需校准 `guia.py` 布局常量)。
|
||||
|
||||

|
||||
|
||||
*直接解密读取 `xwechat_files/.../db_storage/` 下的 `contact.db` / `message_*.db` / `sns.db` 加密库——纯本地,无 Web API。*
|
||||
|
||||
---
|
||||
|
||||
## 🤝 致谢
|
||||
|
||||
> 感谢 [vesio](https://github.com/vesio) 在 [issue #1](https://github.com/fanyuantaier/wechatauto-replica/issues/1) 提供微信 4.1.12 的 UIA 控件树代码与思路,促成了 v1.0.8 的 UIA 混合驱动。
|
||||
>
|
||||
> 感谢 [nanshanjack](https://github.com/nanshanjack) 发现 UI 锁的可重入问题(v1.1.2 修复)。
|
||||
>
|
||||
> 感谢 [maozhitao12450](https://github.com/maozhitao12450) 报告 WXAM (wxgf) 图片下载问题(v1.1.3 修复)。
|
||||
>
|
||||
> 感谢 [uiharukazari0105](https://github.com/uiharukazari0105) 发现语音数据分片存储(`media_1.db` 等)从未被搜索的问题(v1.1.4 修复)。
|
||||
感谢 [NothingFumo](https://github.com/NothingFumo) 提出主密钥提取方案(cfg + PBKDF2 派生逐库密钥,v1.1.7)。
|
||||
|
||||
---
|
||||
|
||||
## 版本记录
|
||||
|
||||
### v1.1.7(2026-08-22)
|
||||
|
||||
- **基于主密钥的密钥提取(PR #10,感谢 [NothingFumo](https://github.com/NothingFumo))**:不再依赖进程内存中逐个库的 `Config.Cipher` 字面量扫描(微信 4.1.12.26+ 已失效),改为从 `cfg` 结构提取**单一主密钥**(`cfg+0x2B8` 密文 ⊕ DLL 中 4×movabs 常量),再通过 `PBKDF2-HMAC-SHA512(主密钥, 库salt, 256000)` **离线派生各库独立密钥**——27/27 个 SQLCipher4 数据库实测验证通过。解决 4.1.12.26+ 密钥提取失效(issue #3 / #7)。
|
||||
- **图片密钥统一流程**:模板收集(`*_t.dat`,按 mtime 取前 16)→ 尾字节众数统计 XOR(替代原单文件探测误回退 `0x88` 的缺陷)→ 优先 `cfgDword` 派生(确定性、离线),注入/缓存/内存扫描 AES 兜底。3000/3000 真实密文探针验证通过。
|
||||
- **cfg 同步返回账号字段**:`WeChatDB` 现在随主密钥一起返回 `name` / `number` / `phone`,与主流密钥工具输出格式逐字段对齐。
|
||||
- 新参数 `master_key` / `cfg_dword` 全部可选、完全向后兼容——不传则走原路径。核心解密函数零改动。
|
||||
|
||||
### v1.1.6.3(2026-08-21)
|
||||
|
||||
- **修复导入卡死**:`import wechatauto` 不再在部分系统上永久阻塞(如微信或其他 Qt 应用占用 COM 导致 `uiautomation` / COM 初始化挂起)。`uiautomation` 和 `comtypes` 现在延迟导入——首次访问 UIA 功能时才加载,不在 `import wechatauto` 时触发。
|
||||
- **修复 OCR 挂起**:`ScreenOCR.recognize` 的 WinRT 异步调用现在用 `asyncio.wait_for(..., 8s)` 包住——`Windows.Media.Ocr` 异步永不完成(如中文系统环境)时,此前会让 `quick_send` 永久挂起;现在 8 秒超时后降级为空 OCR 结果。
|
||||
- **修复首次校准卡死**:`calibrate_layout` 的每个 OCR 检测步骤现在用守护线程 + 5 秒超时。此前首次运行(无布局配置文件)时 WinRT OCR 若挂起,整个 `WeChatGUI.__init__` 会永久阻塞;现在超时后回退使用默认布局比例。
|
||||
- **支持明文头(key+salt)微信构建的库解密**:密钥提取现在接受 SQLCipher 4 的 "Raw Key with Explicit Salt" 形式(`x'<96hex>'` = 32 字节 key + 16 字节显式 salt,配合 `cipher_plaintext_header_size` 使用)。48 字节密钥(32B key + 16B salt)按明文头布局验证与解密(页 1 保留其明文头);32 字节密钥保持标准文件头 salt 路径。这解除了部分构建上因旧偏移指向类名表而非 Cipher 实例(如 4.1.12.26 环境)而无法解密库的问题。
|
||||
- **修复 `wxid_*` 硬编码**:账号发现不再假设目录以 `wxid_` 开头——`db_dir` 下任何含 `db_storage/` 子目录的子目录都会被识别。这支持自定义微信号(如用户手动修改、不含 `wxid_` 前缀的账号)。
|
||||
- **全局消息监听**:`WeChat.AddListenAll(callback)` 现在一次监听所有会话(好友、群聊、文件传输助手等),支持自动发现新会话。`WeChat.RemoveListenAll()` 停止全局监听。回调签名为 `(Message, Chat)`,其中 `Chat.who` 为会话原始 username。
|
||||
|
||||
### v1.1.6.1(2026-08-20)
|
||||
|
||||
- **PyPI 描述修复**:1.1.6 发布时漏同步 `README_pypi.md`(描述停留在 1.1.5.1),本补丁版补全 v1.1.6 更新记录并同步版本号。
|
||||
|
||||
### v1.1.6(2026-08-20)
|
||||
|
||||
- **缺密钥报错自动诊断**:`数据库无可用密钥` 报错前会自动检测三项最常见根因——Python 位数(32 位读不了 64 位微信内存)、逐个微信进程的 `OpenProcess`/`ReadProcessMemory` 读取权限、多账号目录与所选账号对比(提示用 `WeChatDB(account=...)` 显式指定),无需先手动运行 `diagnose_keys`。
|
||||
- **新增诊断工具**:`wechatauto/diagnose_keys.py`(微信登录后运行 `python -m wechatauto.diagnose_keys`)输出库版本、Python 位数、微信进程 PID 及逐个进程的读取权限检测、磁盘全部账号与所选账号对比、已缓存密钥、进程内存重新提取结果与密钥校验情况——报密钥提取问题时把输出完整发给维护者即可定位。
|
||||
- **跳过 `migrate\unspportmsg.db`**:该库是微信保留的「未支持消息」库,进程内存中无对应密钥、代码也从不会访问;此前它会让每次初始化都触发一次全进程内存扫描。
|
||||
|
||||
### v1.1.5.1(2026-08-18)— 测试版 / beta
|
||||
|
||||
- **修复实时监听不触发**:`WeChatDB.get_new_messages()` 引用了未定义的 `found`(NameError 被 `Listener._poll_once` 吞掉),导致消息回调从未触发——包括从未聊过天的联系人的首条消息。
|
||||
- **动态消息分片**:`_message_dbs()` 现在会重新扫描磁盘,微信运行中新建的分片(如 `message_5.db`)会被自动发现并提取密钥。
|
||||
|
||||
### v1.1.5(2026-08-18)
|
||||
|
||||
- **版本号规范化**:语音跨库下载修复后整理补丁版本号(1.1.4.2 → 1.1.5)。
|
||||
|
||||
### v1.1.4.2(2026-08-18)
|
||||
|
||||
- **PyPI 描述清理**:移除 v1.1.4 版本记录中关于 demo 默认群改动的条目。
|
||||
|
||||
### v1.1.4.1(2026-08-18)
|
||||
|
||||
- **PyPI 页面中英双语**:PyPI 描述合并中文(`README.zh-CN.md`)与英文(`README.md`)两个版本,中文版在包页面可见。
|
||||
|
||||
### v1.1.4(2026-08-18)
|
||||
|
||||
- **跨全部媒体库下载语音**:`download_voice()` 现在搜索所有 `media_*.db`(不再只查 `media_0.db`)——微信把语音分片存到多个媒体库;此前存在 `media_1.db` 等的语音无法找到(感谢 uiharukazari0105)。
|
||||
- **群聊图片缩略图回退**:群聊的图片原图只有被点开(查看)后才会落盘本地;原图未点开不下发时,`download_image` 自动回退到缩略图(`_t.dat`),保存为带 `_thumb` 后缀的文件。
|
||||
- **`WeChatDB._find_media_rows(user, types)`**:新增批量查媒体接口——返回某会话指定 `local_type` 集合的全部媒体 `local_id`(用于批量下载)。
|
||||
- **`demo_media.py --images N`**:按 `local_type` 直接从数据库下载某会话最近 N 张图片,绕过总消息数 `--limit` 的限制——群聊消息上万条时不再「只列出几张图」。
|
||||
|
||||
### v1.1.3(2026-08-17)
|
||||
|
||||
- **WXAM (wxgf) 图片解码**:微信 4.x 现在把**普通图片**(不仅是动图贴纸)也存进 WXAM 容器
|
||||
(内部为 HEVC 比特流)。`MediaDownloader.download_image` 新增 wxgf 处理:提取 HEVC
|
||||
Annex-B 流,用 ffmpeg 转码为 JPG(优先用 `imageio-ffmpeg` 内置二进制,其次 PATH 上的
|
||||
ffmpeg);ffmpeg 不可用时不再丢弃数据,改为保存原始解密数据为 `.wxgf` 兜底。
|
||||
- 新增依赖:`imageio-ffmpeg>=0.4.9`。
|
||||
|
||||
### v1.1.2(2026-08-16)
|
||||
|
||||
- **UIA 驱动线程安全**:`WeChatUIA` 实例化时在当前线程初始化 COM(`CoInitializeEx`,幂等)——修复后台线程/宿主进程(如 WeChatBot)实例化报「尚未调用 CoInitialize / 无法加载 UIAutomationCore.dll」。
|
||||
- **主窗口过滤**:只认加载了 `Weixin.dll` 的主进程窗口,过滤无 DLL 的辅助进程窗口(其热激活必然失败,不再刷噪音警告)。
|
||||
- **转发语音修复**:`Chat.ForwardVoiceMessage` 未指定目标时用 `self`(原 `_cur()` 可能误取会话)。
|
||||
- **UI 锁可重入**:`LockManager` 同线程可重入——`@uilock` 函数互相调用(如 `ForwardVoiceMessage` → `VoiceMessage.forward_to`)不再死锁。
|
||||
|
||||
### v1.1.1(2026-08-16)
|
||||
|
||||
- **撤回消息**(`Chat.RecallLastMessage` / `uia_driver.recall_last_message`):右键最新一条自己发的消息 → UIA 优先
|
||||
(主窗口树内 `mmui::XMenuView` 菜单项定位「撤回」,Invoke/Select 或鼠标点击),OCR 兜底(全屏识别「撤回」
|
||||
文字定位点击);菜单只剩「删除」(超过 2 分钟撤回时限)时返回失败。
|
||||
- **UIA 健壮性**:菜单项查找限定在主窗口子树内(避免触发 Windows UIA 根遍历的系统挂起 bug);移除脆弱的
|
||||
`WindowControl(ClassName=...)` 兜底定位。
|
||||
- **媒体修复**:视频 id bytes→str 解码(`MediaDownloader`),修复视频文件定位。
|
||||
- `demo_media.py` `--photos` 默认 3 → 10。
|
||||
|
||||
### v1.1.0(2026-08-15)
|
||||
|
||||
- **图片 AES 密钥自动监控捕获**(`media.py`):微信 4.x 的 V2 图片 AES 密钥仅在
|
||||
查看图片大图时短暂驻留进程内存(实测约 5 分钟后释放)。`_scan_aes_key()` 新增
|
||||
`monitor` 模式——首次扫描未命中时自动持续轮询并提示去微信点开一张图片看大图,
|
||||
密钥进入内存后自动捕获并持久化到 `image_keys.json`,之后免扫描直接解密。
|
||||
首次用户无需手工找密钥,看图一次即可完成配置。
|
||||
- **修复进程排序扫描 bug**:移除 `_scan_aes_key` 中按内存占用排序进程的逻辑
|
||||
(`GetProcessMemoryInfo` 结构体大小传错导致工作集全为 0,`reverse` 排序反而把
|
||||
主进程排到最后,错过密钥驻留窗口),恢复按微信进程原顺序扫描(主进程优先命中)。
|
||||
- **语音/视频/文件不受影响**:仅图片 `.dat` 为 V2 AES 加密需密钥;语音(SILK)、
|
||||
视频(MP4)、文件均为明文直接读取。
|
||||
|
||||
### v1.0.9(2026-08-14)
|
||||
|
||||
- **open_chat 账号/微信号搜索修复**(`uia_driver.py`):微信搜索框不认 wxid
|
||||
(系统账号),`open_chat` 传入 username 时自动通过本地 DB 映射为昵称/备注/
|
||||
微信号再搜索(`_resolve_search_keyword`),并清空搜索框残留重试;
|
||||
实测 `open_chat('wxid_sb9or2x9zxj012')` 成功。
|
||||
- **UIA 表情包精确读取**(`msgs/mtype.py` + `uia_driver.py`):热激活后消息
|
||||
列表暴露 `mmui::RecyclerListView`,新增 `find_in_message_list()` 用鼠标滚轮
|
||||
驱动虚拟化列表滚动,按 ClassName/Name 定位表情行并取 BoundingRectangle
|
||||
精确坐标;`EmojiMessage.capture()` 优先走 UIA 定位 + 方向感知气泡裁剪
|
||||
(`_crop_bubble_from_row`),实测 1.1s 裁出 271×271 表情,替代原先
|
||||
「截图全消息区 + 连通域猜气泡」的脆弱方案;失败自动回退原连通域逻辑。
|
||||
- **语音通话**(`uia_driver.voice_call` + `Chat.VoiceCall`):标题栏暴露
|
||||
`mmui::ChatVoIPView.voip_button`(Name=语音通话),控件树动态重建需重试
|
||||
定位;video=True 尝试找视频通话按钮(当前版本未暴露,通常失败)。
|
||||
- **拍一拍**(`uia_driver.poke` + `Chat.Poke`):微信 4.x 拍一拍只能通过
|
||||
右键对方头像触发,菜单为自绘不暴露 UIA;实现为「内容重心定位 friend
|
||||
消息行 → 右键头像 → 全屏 OCR 定位「拍一拍」→ 点击」,实测 3.2s 发出
|
||||
(网络正常时对方收到,网络异常时微信显示失败提示,链路本身正确)。
|
||||
- `EmojiMessage.capture()` / `voice_call` / `poke` 失败均自动回退或返回
|
||||
WxResponse 失败,不影响既有 OCR 发送路径。
|
||||
|
||||
### v1.0.8(2026-08-13)
|
||||
- 🎉 **特别感谢 [vesio](https://github.com/vesio)**:在 issue #1 中提供了微信 4.1.12 可出 UIA 控件树的代码与调试思路,本版 UIA 混合驱动由此而来;
|
||||
- **UIA 混合驱动**(`uia_driver.py`,微信 4.1.12.26 实测):
|
||||
- 新增 `WeChatUIA` 引擎:冷启动时 `Qt51514QWindowIcon` 只是空壳(Qt
|
||||
无障碍门未激活),通过写 Weixin.dll 内的 Qt accessibility gate
|
||||
(RVA 扫描定位)**热激活**后,锚点变为 `mmui::MainWindow`,搜索框
|
||||
`mmui::XValidatorTextEdit` / 搜索下拉 `search_list` / 输入框
|
||||
`chat_input_field` 全部可用;
|
||||
- 发送链路全部走 UIA:搜索下拉选人(`search_item_*`)打开会话 →
|
||||
`chat_input_field` 直接输入 + 回车发送,`current_chat` 校验防误配,
|
||||
无 OCR 抖动;Windows 冷状态热激活后 UIA 树保持可用;
|
||||
- `guia.py` 集成混合路径:`_get_uia()` 惰性启用,`open_chat` /
|
||||
`send_msg` **UIA 优先、OCR 兜底**——UIA 树不可用(版本变更新增 RVA)
|
||||
或失败时自动降级到坐标 + 放大 OCR 方案,首次失败本次会话内不再重试。
|
||||
- 实测:`send_msg('文件传输助手')` 10.2s、`send_msg('卢立竺')` 13.2s
|
||||
均走 UIA 并数据库确认成功(含 verify);UIA 对生僻字会话名不再依赖
|
||||
OCR 识别。
|
||||
- 新增依赖:`uiautomation`(UIA 客户端库)。
|
||||
|
||||
### v1.0.7(2026-08-13)
|
||||
|
||||
- **OCR 识别可靠性提升**(`guia.py`,针对生僻字/小字号会话名识别失败):
|
||||
- 新增 `ocr_zoomed()`:对区域放大 N 倍后再 OCR,坐标按 1/N 还原;实测
|
||||
微信小字号中文在放大 3 倍时识别率最高(放大 6 倍图像过大反而整块
|
||||
返回空),超过 5 倍即回落;
|
||||
- `_chat_is_open` 标题检测改用放大 3 倍 + y 范围扩到 0-185(微信 4.x
|
||||
标题实际渲染在 y≈80-180,原 15-100 的区间会漏检已打开的会话);
|
||||
- `_search_chat` 搜索回退排除「群聊」节标题以下行、含「包含」的群成员
|
||||
预览行(如「00,包含:卢立竺」)与群名结尾行,只点联系人,修复
|
||||
「搜索选中群聊而非联系人」的问题;
|
||||
- `_chat_open_confirmed` 改为**优先标题命中**,标题读不到才退而用面板
|
||||
非空白作为已打开判据,修复「点错会话也误判成功」;
|
||||
- `open_chat` 首查 `_chat_is_open && _pane_has_content`,右侧面板已打开
|
||||
目标会话时直接成功(不再滚动/搜索),已打开场景耗时 45s → 2.7s。
|
||||
- **OCR 多轮投票**(`find_session._scan_vote`):WinRT OCR 对生僻字存在
|
||||
抖动(同一行不同轮次可能读出「卢立竺」或「亠人五」)。对侧栏放大 3x
|
||||
扫描 4 轮,命中行按 y 聚类(≤30px 视为同行),票数 ≥2 才返回,显著
|
||||
降低误配;普通会话仍走单轮快速路径,无性能损失。
|
||||
- 实测:`find_session('卢立竺')` 连续 5 轮 4/4 票一致、稳定命中;
|
||||
`open_chat` + `send_msg` 全链路成功。
|
||||
|
||||
### v1.0.6(2026-08-11)
|
||||
|
||||
- **元数据与门面优化**:README 增加徽章(PyPI 版本/下载量/Python 版本/License/Stars)、PyPI description/keywords/classifiers SEO 优化、Homepage 修正为项目 GitHub 地址。
|
||||
|
||||
### v1.0.5(2026-08-10)
|
||||
|
||||
- **表情截图跨机器修复**:`EmojiMessage.capture()` 表情气泡自动裁剪全面重构:
|
||||
- 主路径改用**连通域分析**(`_crop_last_bubble`),按消息方向(左=对方/右=自己)
|
||||
精确定位最后一条消息气泡,自动过滤细长竖条(滚动条/面板边框)、剔除头像类
|
||||
小元素,从根源解决右缘滚动条/边框被当成内容导致的右侧大片空白;
|
||||
- 圆形表情顶部/底部在缩放采样时因 LANCZOS 模糊丢失边缘像素:加大裁剪边距
|
||||
(`pad = max(10, scale*5)`)并在全分辨率下**逐像素边缘扩展**找回丢失内容,
|
||||
且扩展遇**连续空白行**(消息间分隔)即停,避免吃进相邻消息;
|
||||
- 时间戳等居中小文字(水平居中约 50% 宽度)不再被误当成消息:方向判定加
|
||||
阈值(左侧 <45% 宽度、右侧 >55%),居中元素两边都不匹配;
|
||||
- 最终尺寸校验:`min(crop) < 50` 视为时间戳/文字误判,自动回退到
|
||||
「消息分隔空白」「头像锚点」等备用定位,仍过小则判定失败返回 None;
|
||||
- 本机与高 DPI 机器均已实测通过(完整表情、无空白、无切顶、不截时间戳)。
|
||||
|
||||
### v1.0.4(2026-08-10)
|
||||
|
||||
- **多特征兜底窗口定位**:主窗口定位不再只依赖类名 `Qt51514QWindowIcon`
|
||||
(类名降级为软条件),联合 进程名 `weixin.exe` / 窗口可见 / 大尺寸
|
||||
(≥800px)/ 标题关键词(微信/Weixin/WeChat)评分定位——Qt 升级改名
|
||||
(`Qt51514` → `Qt6xxx`)也不失效;渲染子窗口按前缀 `MMUIRenderSubWindow`
|
||||
匹配(兼容 `MMUIRenderSubWindowHW` / `MMUIRenderSubWindow` 等变体),
|
||||
找不到时退回用主窗口矩形计算坐标。
|
||||
- **布局自动校准**:首次运行自动校准——OCR 检测「搜索」「发送」锚点实测
|
||||
布局比例,保存到 `~/.wechatauto/layout-<机器标识>.json`,之后自动加载;
|
||||
布局漂移(DPI/窗口尺寸/缩放变化)时自动重新校准。
|
||||
- **最大化状态保持**:激活窗口时先 `GetWindowPlacement` 记录状态,原为
|
||||
最大化则用 `SW_SHOWMAXIMIZED` 恢复(原 `SW_RESTORE` 会把最大化窗口
|
||||
缩成普通大小),最小化恢复不再破坏用户窗口布局。
|
||||
- **发送模块窗口兜底**:`find_main_window` 类名查找失败后按标题「微信」
|
||||
兜底,适配类名不同的机器。
|
||||
|
||||
### v1.0.3(2026-08-08)
|
||||
|
||||
- **文本消息还原**:微信 4.x 部分文本消息 content 为「容器头 + UTF-8 明文 +
|
||||
尾部填充」结构,此前显示为 `[文本]`/空。新增 `_extract_text_from_blob`
|
||||
还原明文,数据库读取与 bot 均可见真实内容(含群消息 `wxid_xxx:` 前缀)。
|
||||
- **表情截图方向感知与兼容性**:`_db_row_to_message` 写入 `msg.attr`
|
||||
(`self`/`friend`),`EmojiMessage.capture()` 按方向定位气泡(自己发的用
|
||||
消息分隔空白、对方发的用头像锚点),避免截图前自己又发了一条消息时误截到
|
||||
自己的气泡;裁剪阈值自适应截图尺寸,跨分辨率/DPI 可用。微信 4.x 主窗口为
|
||||
Qt 自绘渲染,不暴露 UIA 子树,故截图定位全部基于屏幕像素分析。
|
||||
- **发送会话复用**:`send_msg` 记录 `_current_chat`,目标会话已打开时跳过
|
||||
`open_chat`(重扫侧栏+点击),逐条连续发送不再反复点击对话框,效率提升。
|
||||
- **搜索联系人选第一条**:`_search_chat` 按视觉顺序排序并过滤「搜索网络结果/
|
||||
搜一搜」节标题,点选第一条联系人而非网络搜索。
|
||||
- **动画表情不再落盘伪 `.gif`**:`download_image` 识别到 `wxgf` 容器(微信
|
||||
动画表情私有格式)时返回 `None`,不再生成打不开的假图片。
|
||||
- **`Listener.stop()` 崩溃修复**:`db.py` 补 `import sys`(`_run/_poll_once`
|
||||
使用 `sys.stderr` 却未导入)。
|
||||
|
||||
### v1.0.2(2026-08-08)
|
||||
|
||||
- **表情消息支持**:新增 `EmojiMessage` 消息类型(`type='emotion'`),
|
||||
"动画表情"不再被归为 `OtherMessage`,并按收发方向提供
|
||||
`FriendEmojiMessage` / `SelfEmojiMessage`。微信 4.x 表情消息在本地数据库中的
|
||||
content 为加密数据,无法直接还原成图片,因此新增 `EmojiMessage.capture()`:
|
||||
采用「打开会话 → 滚动到底 → 截取消息区 → 自动裁剪最后一条消息气泡」
|
||||
的屏幕截图方案,返回图片路径,可直接供 AI 视觉识别使用
|
||||
(示例见 `demo_emoji_capture.py`)。
|
||||
- **监听器并发工作线程**:`Listener` 回调移到独立工作线程执行,每个被监听
|
||||
会话对应一条**串行**工作线程——同一会话内消息按序处理、不同会话间并行;
|
||||
轮询线程只负责读取数据库并分派任务,不再被慢回调(AI 调用 / 图片识别等)
|
||||
阻塞,`stop()` 优雅关闭所有工作线程。
|
||||
- **数据库消息兼容增强**:`_db_row_to_message` 支持 bytes 类型 content
|
||||
(自动解码还原文本)、`local_type` 缺失时自动推导消息类型,
|
||||
`_extract_group_sender` 兼容 bytes 内容。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目状态
|
||||
|
||||
| 能力 | 状态 | 实现方式 |
|
||||
| ---- | ---- | -------- |
|
||||
| 读取消息 | ✅ 已完成并验证 | 本地数据库解密(`wechatauto/db.py`) |
|
||||
| 消息监听(轮询) | ✅ 已完成并验证 | `Listener` + `get_new_messages` 增量回调 |
|
||||
| 表情消息识别与截图 | ✅ 已完成并验证(v1.0.3 方向感知) | `EmojiMessage` + `capture()`(屏幕截图自动裁剪) |
|
||||
| WAL 增量合并 | ✅ 已修复并验证 | 帧盐校验合并 `-wal`(见 §2.4) |
|
||||
| 历史消息全量导出 | ✅ 已完成并验证 | `export_history`(JSON / SQLite) |
|
||||
| 媒体下载(图片/语音/文件) | ✅ 已完成并验证 | `wechatauto/media.py`(图片 V2 解密) |
|
||||
| 朋友圈读取 | ✅ 已完成并验证 | `MomentDB` 直接读 `sns.db` |
|
||||
| 多账号管理 | ✅ 已完成并验证 | `list_accounts()` + `account=` 参数 |
|
||||
| 读取会话列表 | ✅ 已完成并验证 | 同上 |
|
||||
| 搜索联系人 | ✅ 已完成并验证 | 同上 |
|
||||
| 发送消息 | ✅ 已完成并验证 | UIA + 坐标+OCR 混合(`wechatauto/guia.py`) |
|
||||
| 发送文件/图片/回复/艾特 | ✅ 已完成并验证 | 剪贴板 CF_HDROP + OCR |
|
||||
| 语音通话 / 拍一拍 | ✅ 已完成并验证 | UIA 按钮 + OCR 菜单(`Chat.VoiceCall` / `Chat.Poke`) |
|
||||
| UI 自动化(UIAutomation) | ✅ 热激活后可用 | 写 Weixin.dll Qt accessibility gate,物化 `mmui::*` 树 |
|
||||
|
||||
**结论**:微信 4.1.x 聊天界面使用自绘渲染(`MMUIRenderSubWindow*`),冷启动
|
||||
对 UIAutomation 只暴露 `Qt51514QWindowIcon` 空壳(原 wxauto 的 UI 方案因此
|
||||
失效)。本项目通过**热激活 Qt accessibility gate**(写 Weixin.dll 内读屏
|
||||
标志位,从 `qt.accessibility.core` 引用扫描 RVA)物化 `mmui::*` UIA 树,
|
||||
实现发送/语音通话/拍一拍等操作(UIA 优先、坐标+OCR 兜底);消息读取仍走
|
||||
「**本地数据库解密**」(已全链路验证)。
|
||||
|
||||
---
|
||||
|
||||
## 二、读取原理
|
||||
|
||||
微信 4.x 的数据存放在本地 SQLCipher 4 加密的 SQLite 数据库中:
|
||||
|
||||
```
|
||||
D:\微信文件\xwechat_files\<wxid>_xxxx\db_storage\
|
||||
├── contact\contact.db 联系人(昵称、备注)
|
||||
├── session\session.db 会话列表(未读数、摘要)
|
||||
├── message\message_0..4.db 聊天消息(按会话分表 Msg_<md5>,跨分库分片)
|
||||
├── message\media_0.db 语音(VoiceInfo.voice_data,SILK 二进制)
|
||||
├── message\message_resource.db 文件原名(MessageResourceDetail.packed_info)
|
||||
├── sns\sns.db 朋友圈(SnsTimeLine,SnsDataItem XML)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2.1 密钥提取(进程内存只读扫描)
|
||||
|
||||
每个数据库有**独立的 32 字节密钥**,保存在微信进程内存中的
|
||||
`com.Tencent.WCDB.Config.Cipher` 配置对象里:
|
||||
|
||||
1. 在 Weixin.exe 所有可读内存区域中查找该字符串;
|
||||
2. 由字符串地址定位配置对象(`[ptr][len]` 结构回溯);
|
||||
3. 数据块与固定掩码异或后得到 `x'<64位hex密钥><32位hex盐>'` 明文配置;
|
||||
4. 用 SQLCipher 4 HMAC 校验规则验证每个候选密钥;
|
||||
5. 验证通过的密钥保存到 `%TEMP%\wechatauto_db\<账号>\keys.json` 缓存。
|
||||
|
||||
### 2.2 数据库解密
|
||||
|
||||
- SQLCipher 4,页大小 4096,`PBKDF2-HMAC-SHA512`(加密密钥 256000 次迭代);
|
||||
- 解密结果按页写入临时目录,校验源 mtime/size 复用缓存;
|
||||
- 首次解密 contact.db 约 6s,之后全部秒级。
|
||||
|
||||
### 2.3 消息查询
|
||||
|
||||
- 会话名 → `Md5(会话微信号)` → 表名 `Msg_<md5>`(同一会话可能分片在多个
|
||||
`message_*.db`,按 `sort_seq` 合并排序);
|
||||
- 关键列:`local_type`、`real_sender_id`(2=自己,其他为数字 id,可通过
|
||||
`message_resource.SenderName2Id` 反查微信号)、`server_id`、
|
||||
`packed_info_data`(图片/视频 md5)、`sort_seq`。
|
||||
|
||||
### 2.4 WAL 增量合并(已修复)
|
||||
|
||||
微信 `-wal` 是预分配文件:checkpoint 时 WAL 头 salt+1 并清零写游标,但
|
||||
**旧世代帧仍留在文件中**。若合并时不过滤帧盐,会把过期页覆盖进主库导致
|
||||
`database disk image is malformed`。修复方案:
|
||||
|
||||
- `_merge_wal` 读取 WAL 头后**仅合并 salt 与当前 WAL 头一致的帧**,
|
||||
旧世代帧直接跳过;
|
||||
- 缓存 stamp 加入版本号 `STAMP_VERSION=2`,旧损坏缓存自动强制全量重建;
|
||||
- 合并结果用 `PRAGMA integrity_check` 校验,失败自动重试全量重建。
|
||||
|
||||
验证:contact.db 合并后 integrity OK,2354 个联系人全部可查。
|
||||
|
||||
### 2.5 媒体存储与解密(图片 v2 格式)
|
||||
|
||||
- 图片:`msg\attach\<会话md5>\<YYYY-MM>\Img\<md5>.dat`(加密);
|
||||
- 语音:`media_0.db` → `VoiceInfo.voice_data`(SILK 明文 BLOB);
|
||||
- 文件:`msg\file\<YYYY-MM>\<原文件名>`(原名来自 message_resource);
|
||||
- 视频:`msg\video\<YYYY-MM>\<id>.mp4`(未落盘时返回 None)。
|
||||
|
||||
图片 `.dat` 为 **v2 格式**:`[6B sig 070856320807][4B aes_size LE][4B xor_size LE]`
|
||||
+ AES-ECB 密文 + 明文段 + 异或段:
|
||||
|
||||
- **AES 密钥**:16 字节 ASCII,账户级稳定密钥,但仅在微信查看图片时驻留
|
||||
进程内存。`MediaDownloader` 通过内存扫描反测(AES 解首块后校验 JPEG/PNG
|
||||
魔数)获取,**命中后持久化到 `image_keys.json`**;也支持 `image_key=` 参数
|
||||
显式注入。本机实测:单一密钥稳定解密 35/40 张随机图片(其余为微信动画
|
||||
表情容器 `wxgf`)。
|
||||
- **XOR 密钥**:单字节,从同图缩略图 `<md5>_t.dat` 尾部 JPEG 结束标记
|
||||
`FF D9` 反推(`key = tail[0] ^ 0xFF`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、快速开始
|
||||
|
||||
> 📖 **完整使用指南**:[GUIDE.md](GUIDE.md)(中英对照 / bilingual)
|
||||
|
||||
### 3.1 安装
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
# 坐标+OCR 发送路线额外依赖:
|
||||
pip install winsdk pypinyin
|
||||
```
|
||||
|
||||
### 3.2 示例程序
|
||||
|
||||
```bash
|
||||
python demo_db.py
|
||||
```
|
||||
|
||||
### 3.3 代码示例
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
|
||||
db = WeChatDB() # 自动检测账号与数据目录(微信需已登录)
|
||||
|
||||
info = db.get_self_info() # 当前账号昵称
|
||||
for s in db.get_sessions(limit=10): # 会话列表
|
||||
print(db.get_nickname(s["username"]), s["unread"])
|
||||
|
||||
hits = db.search_contact("Ayi") # 搜索联系人
|
||||
who = hits[0]["username"]
|
||||
for m in db.get_messages(who, limit=10): # 最近消息
|
||||
print(m["create_time"], m["sender_id"], m["type"], m["content"])
|
||||
```
|
||||
|
||||
### 3.4 媒体下载
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MediaDownloader
|
||||
|
||||
db = WeChatDB()
|
||||
md = MediaDownloader(db) # 可传 image_key="..." 注入图片密钥
|
||||
key = md.detect_image_key() # 内存扫描/缓存取 AES+XOR 密钥
|
||||
print(key)
|
||||
|
||||
for m in db.get_messages("filehelper", limit=50):
|
||||
out = md.download_media("filehelper", m["local_id"]) # 按类型自动分发
|
||||
if out:
|
||||
print("已下载:", out)
|
||||
```
|
||||
|
||||
### 3.5 朋友圈读取
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MomentDB
|
||||
|
||||
md = MomentDB(WeChatDB())
|
||||
for feed in md.get_moments(limit=10): # 时间线(3382 条全量可读)
|
||||
print(feed["nickname"], feed["text"])
|
||||
print(" 图片:", [i["md5"] for i in feed["images"]])
|
||||
print(" 赞:", [l["nickname"] for l in feed["likes"]])
|
||||
print(" 评论:", [(c["nickname"], c["content"]) for c in feed["comments"]])
|
||||
md.download_media(feed["images"][0]) # 本地缓存或 URL 拉取
|
||||
```
|
||||
|
||||
### 3.6 消息监听
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0)
|
||||
lst.add_listener("filehelper", lambda msg, lst: print("新消息:", msg["content"]))
|
||||
lst.start()
|
||||
# ... 业务代码 ...
|
||||
lst.stop()
|
||||
```
|
||||
|
||||
- 回调在**独立工作线程**中执行(v1.0.2):每个被监听会话对应一条串行
|
||||
工作线程,同一会话内消息按序处理、不同会话间并行;轮询线程只负责读取
|
||||
数据库并分派任务,不会被慢回调(AI 调用 / 图片识别等)阻塞。
|
||||
|
||||
### 3.7 历史导出
|
||||
|
||||
```python
|
||||
db.export_history(r"D:\backup\chat.json", fmt="json") # 全部会话
|
||||
db.export_history(r"D:\backup\chat.db", fmt="sqlite")
|
||||
db.export_history(r"D:\backup\one.json", fmt="json",
|
||||
users=["filehelper"], limit_per_chat=1000)
|
||||
```
|
||||
|
||||
### 3.8 多账号
|
||||
|
||||
```python
|
||||
from wechatauto import list_accounts, WeChatDB
|
||||
for a in list_accounts():
|
||||
print(a["account"], a["wxid"])
|
||||
db2 = WeChatDB(account="wxid_xxx_abcd") # 显式指定账号(缓存按账号隔离)
|
||||
```
|
||||
|
||||
### 3.9 表情消息与截图
|
||||
|
||||
微信 4.x 的"动画表情"消息在本地数据库中 content 为加密数据,无法直接还原成
|
||||
图片。v1.0.2 起监听回调中的表情消息为独立的 `EmojiMessage` 类型
|
||||
(`type='emotion'`,`FriendEmojiMessage` / `SelfEmojiMessage` 按收发方向区分),
|
||||
并支持对屏幕上的表情气泡自动截图:
|
||||
|
||||
```python
|
||||
# 在 Listener 回调内,把消息 dict 转成消息对象后再截图:
|
||||
def on_msg(msg, listener):
|
||||
if msg["type"] == "动画表情":
|
||||
from wechatauto.wx import _db_row_to_message
|
||||
m = _db_row_to_message(msg, chat) # chat: 当前会话
|
||||
path = m.capture() # 返回 PNG 路径,供 AI 视觉识别
|
||||
```
|
||||
|
||||
`capture(save_dir=None)` 流程:打开会话(已打开则跳过,避免刷新消息列表导致
|
||||
控件失效)→ 滚动到底 → 截取消息区 → 按消息方向定位最后一条消息气泡:
|
||||
|
||||
- **自己发的消息**(`attr='self'`,右侧无头像):用「消息分隔空白」定位
|
||||
消息顶部,空白阈值按截图高度自适应(约消息区高度的 2.5%),
|
||||
跨分辨率/DPI 保持一致;
|
||||
- **对方发的消息**(`attr='friend'`,左侧有头像):优先检测头像圆形彩色块
|
||||
的顶部作为消息顶部(特征跨分辨率稳定),失败时回退消息分隔空白。
|
||||
|
||||
返回图片路径(失败返回 None)。独立示例:`python demo_emoji_capture.py`。
|
||||
调试时可保留 `~/pane_diag_raw.png`(每次截图保存的消息区原图)与
|
||||
`[CAP]` 日志行(截图尺寸、消息方向、裁剪路径、结果尺寸)用于排查。
|
||||
|
||||
---
|
||||
|
||||
## 四、API 参考
|
||||
|
||||
### `WeChatDB(db_dir=None, keys_file=None, workdir=None, account=None)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `get_self_info() -> dict` | 当前账号(username / nick_name / remark) |
|
||||
| `get_sessions(limit=100)` | 会话列表:username / unread / summary / last_time |
|
||||
| `search_contact(keyword)` | 按昵称/备注/微信号搜索 |
|
||||
| `get_messages(user, limit, offset)` | 读取指定会话消息 |
|
||||
| `get_message_row(user, local_id)` | 单条原始消息(含 server_id / packed_info,媒体用) |
|
||||
| `get_new_messages(user, since_seq)` | `sort_seq > since_seq` 的增量消息(升序) |
|
||||
| `get_nickname(user)` | 微信号 → 显示昵称 |
|
||||
| `list_message_chats()` | 所有含消息的会话(md5 / 昵称 / 消息数) |
|
||||
| `export_history(out_path, fmt, ...)` | 全量导出 JSON / SQLite |
|
||||
| `extract_keys()` | 手动触发密钥提取 |
|
||||
| `wxid` / `account` / `account_dir` | 当前账号信息 |
|
||||
| `list_accounts()`(模块级) | 扫描本机所有微信账号 |
|
||||
| `auto_detect_db_dir()`(模块级) | 自动定位数据目录(配置文件 → 注册表 → 常见默认目录) |
|
||||
|
||||
### `MediaDownloader(db, save_dir=None, image_key=None)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `detect_image_key(refresh)` | 取 (AES 密钥, XOR 密钥),命中后持久化 |
|
||||
| `decrypt_image(dat_path)` | 解密单个 `.dat`(自动识别 v1/v2) |
|
||||
| `download_media(user, local_id)` | 按类型分发下载 |
|
||||
| `download_image / _voice / _video / _file` | 各类媒体下载 |
|
||||
| `copy_files_to_clipboard(paths)` | CF_HDROP 写剪贴板(发送附件用) |
|
||||
|
||||
### `MomentDB(db)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `get_moments(limit, offset, username)` | 朋友圈时间线(最新在前) |
|
||||
| `get_moment(tid)` / `get_my_moments(limit)` | 单条 / 我的动态 |
|
||||
| `find_local_media(md5, kind)` | 本地缓存查找(Sns\Img / Sns\Video) |
|
||||
| `download_media(media, save_dir)` | 缓存优先,否则 URL 拉取 |
|
||||
|
||||
### `Listener(db, interval, watermark)`
|
||||
|
||||
`add_listener(user, cb)` / `remove_listener` / `start` / `stop` / `watermark`。
|
||||
|
||||
### `WeChatGUI`(发送,锁屏不可用)
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `send_msg(text, who, verify)` | 文本发送(OCR 定位 + 剪贴板粘贴) |
|
||||
| `send_file(path, who, verify)` | 文件(CF_HDROP 粘贴 + 回车) |
|
||||
| `send_image(path, who, verify)` | 图片(同上) |
|
||||
| `reply_msg(text, who, verify)` | 回复最近消息(悬停 + OCR 回复入口) |
|
||||
| `at_member(member, text, who, verify)` | 群聊 @ 成员 |
|
||||
| `open_chat / focus_input / bring_to_front` | 基础操作 |
|
||||
|
||||
一行式:`quick_send` / `quick_send_file` / `quick_send_image` / `quick_reply`。
|
||||
|
||||
---
|
||||
|
||||
## 五、已知限制
|
||||
|
||||
1. **需要微信登录**:数据库密钥存于进程内存,首次使用需微信运行中
|
||||
(提取后本地缓存);重新登录后密钥变化需重新提取(自动校验失败重扫);
|
||||
2. **图片 AES 密钥瞬态**:仅在微信查看图片时驻留内存;`MediaDownloader`
|
||||
扫描命中后会持久化(`image_keys.json`),也可用 `image_key=` 显式传入;
|
||||
3. **发送为 GUI 操作**:锁屏/会话断开时 `desktop_available()` 返回 False,
|
||||
发送接口返回明确失败;文件/图片/回复/艾特代码已完成但需桌面解锁后实测;
|
||||
4. **视频文件未落盘时不可下载**:视频 mp4 仅在本地存在(`msg/video`)时
|
||||
返回,否则返回 None;
|
||||
5. **发朋友圈功能已舍弃**:4.x 的发表为自绘界面操作,不可靠自动化;
|
||||
本库仅保留朋友圈读取/点赞/评论能力。
|
||||
|
||||
---
|
||||
|
||||
## 六、发送消息(坐标 + OCR)
|
||||
|
||||
微信 4.1.12+ 聊天界面自绘渲染、无无障碍节点,发送走
|
||||
「屏幕坐标 + 本地 OCR」(`wechatauto/guia.py`):
|
||||
|
||||
1. **多特征兜底定位**主窗口(类名 `Qt51514QWindowIcon` 只是「软条件」,
|
||||
联合标题 / 进程名 `weixin.exe` / 可见 / 大尺寸评分,Qt 升级改名也不
|
||||
失效),再按前缀 `MMUIRenderSubWindow` 找渲染子窗口(兼容
|
||||
`MMUIRenderSubWindowHW` / `MMUIRenderSubWindow` 等不同版本类名;
|
||||
找不到时回退用主窗口矩形计算坐标);
|
||||
2. 布局用渲染子窗口相对坐标描述,运行时换算为屏幕绝对坐标;首次运行自动
|
||||
校准(OCR 检测「搜索/发送」锚点实测比例),保存到
|
||||
`~/.wechatauto/layout-<机器>.json`,之后自动加载、布局漂移自动重校准;
|
||||
3. OCR 识别会话列表点击目标(失败走搜索框;生僻字/小字号会话名自动放大
|
||||
3 倍 + 多轮投票重扫,搜索回退只点联系人、自动排除群聊与群成员预览行);
|
||||
4. 扫描输入框白色区定位并聚焦;
|
||||
5. 文字以「剪贴板 + Ctrl+V」输入(避免中文输入法拦截),失败回退拼音组合;
|
||||
6. OCR 定位「发送」按钮(找不到回退回车键);
|
||||
7. `verify=True` 时用 `WeChatDB` 读回确认。
|
||||
|
||||
文件/图片通过 **CF_HDROP 剪贴板 + Ctrl+V** 插入草稿再回车发送,绕开自绘
|
||||
「+ 菜单」定位;回复/艾特分别走悬停 OCR 工具栏与成员弹层 OCR。
|
||||
|
||||
```python
|
||||
from wechatauto.guia import quick_send, quick_send_file
|
||||
quick_send('你好', '文件传输助手', verify=True)
|
||||
quick_send_file(r'D:\资料\报告.pdf', '文件传输助手')
|
||||
```
|
||||
|
||||
> 注意:OCR 需要系统语言包含中文(`Windows.Media.Ocr`)。
|
||||
|
||||
---
|
||||
|
||||
## 七、后续路线
|
||||
|
||||
1. **发送功能实测**:桌面解锁后校准 guia 各坐标常量,验证文件/图片/回复/艾特;
|
||||
2. **视频消息下载增强**:微信 4.x 聊天视频存储位置仍需确认(本机无样本);
|
||||
3. **性能优化**:导出/首扫并行化,内存扫描增量缓存。
|
||||
|
||||
---
|
||||
|
||||
## 八、目录结构
|
||||
|
||||
```
|
||||
├── wechatauto/
|
||||
│ ├── wx.py UIA 自动化入口(4.x 受限)
|
||||
│ ├── guia.py ★ 坐标+OCR 发送模块(文本/文件/图片/回复/艾特)
|
||||
│ ├── db.py ★ 数据库读取(密钥提取 + 解密 + WAL 合并 + 导出 + 监听)
|
||||
│ ├── media.py ★ 媒体下载(图片 v2 解密 / 语音 / 视频 / 文件)
|
||||
│ ├── moment.py ★ 朋友圈(MomentDB 数据库路线 + 旧 UIA 兼容)
|
||||
│ ├── ui/ UI 控件层
|
||||
│ ├── msgs/ 消息模型
|
||||
│ └── ...
|
||||
├── demo.py UI 自动化示例(微信 4.1 上受限)
|
||||
├── demo_db.py ★ 数据库读取示例(推荐)
|
||||
├── demo_guia.py ★ 坐标+OCR 发送示例
|
||||
├── demo_listen.py ★ 实时消息监听示例
|
||||
├── demo_reply_at.py ★ 回复/@ 成员实测示例
|
||||
├── demo_emoji_capture.py ★ 表情消息截图示例
|
||||
├── docs/技术文档.md ★ 完整技术文档(架构/原理/API/扩展)
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
## 九、免责声明
|
||||
|
||||
本项目仅用于个人学习与自动化研究,请遵守微信软件许可协议及当地法律法规,
|
||||
勿用于任何违反规定的用途。
|
||||
|
||||
|
||||
注:本库完全由AI(opencode+deepseek-v4-flash)生成
|
||||
|
||||
---
|
||||
|
||||
## 十、联系方式
|
||||
|
||||
- 邮箱:fanyuantaier@163.com
|
||||
+868
@@ -0,0 +1,868 @@
|
||||
> [!NOTE]
|
||||
> **📢 维护状态 / Maintenance Notice**
|
||||
> 本人因今年升高一,明天(8月23日)报到。开学后几乎没有时间继续更新本项目(如果有时间,争取周日更新)。遇到问题请自行在 Issues 区讨论,或询问 AI 协助解决。感谢支持!
|
||||
>
|
||||
> I'm starting senior high school and will register tomorrow (Aug 23). After school starts I'll have almost no time to keep updating (Sundays if possible). Please discuss issues in the Issues section or ask an AI. Thanks for your support!
|
||||
|
||||
# wechatauto-replica — 微信 4.x Windows 自动化 / WeChat 4.x Automation
|
||||
|
||||
> **中文版** 在下方 · **English version below**
|
||||
|
||||
---
|
||||
|
||||
## 🇨🇳 中文版
|
||||
|
||||
### wechatauto —— 微信 4.x Windows 客户端自动化(wxauto 复刻版)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
本项目复刻上游 wxauto 项目,目标是实现对当前微信 4.x Windows 客户端的自动化
|
||||
(读取消息、发送消息、媒体下载、朋友圈),非网页版,直接操作本机客户端。
|
||||
|
||||
> 当前版本:1.1.7
|
||||
>
|
||||
> **兼容范围**:Windows 10/11 | Python 3.9+(已在 3.12 验证)| 微信 **4.1.12+**
|
||||
> (数据库读取路线对微信版本不敏感;坐标+OCR 发送路线依赖 4.1.12+ 自绘渲染
|
||||
> 布局,其它 4.x 小版本可能需校准 `guia.py` 布局常量)。
|
||||
|
||||

|
||||
|
||||
*直接解密读取 `xwechat_files/.../db_storage/` 下的 `contact.db` / `message_*.db` / `sns.db` 加密库——纯本地,无 Web API。*
|
||||
|
||||
---
|
||||
|
||||
## 🤝 致谢
|
||||
|
||||
> 感谢 [vesio](https://github.com/vesio) 在 [issue #1](https://github.com/fanyuantaier/wechatauto-replica/issues/1) 提供微信 4.1.12 的 UIA 控件树代码与思路,促成了 v1.0.8 的 UIA 混合驱动。
|
||||
>
|
||||
> 感谢 [nanshanjack](https://github.com/nanshanjack) 发现 UI 锁的可重入问题(v1.1.2 修复)。
|
||||
>
|
||||
> 感谢 [maozhitao12450](https://github.com/maozhitao12450) 报告 WXAM (wxgf) 图片下载问题(v1.1.3 修复)。
|
||||
>
|
||||
> 感谢 [uiharukazari0105](https://github.com/uiharukazari0105) 发现语音数据分片存储(`media_1.db` 等)从未被搜索的问题(v1.1.4 修复)。
|
||||
Thanks to [NothingFumo](https://github.com/NothingFumo) for the master-key extraction design (cfg + PBKDF2-derived per-DB keys, v1.1.7).
|
||||
感谢 [NothingFumo](https://github.com/NothingFumo) 提出主密钥提取方案(cfg + PBKDF2 派生逐库密钥,v1.1.7)。
|
||||
|
||||
---
|
||||
|
||||
## 版本记录
|
||||
|
||||
### v1.1.7(2026-08-22)
|
||||
|
||||
- **基于主密钥的密钥提取(PR #10,感谢 [NothingFumo](https://github.com/NothingFumo))**:不再依赖进程内存中逐个库的 `Config.Cipher` 字面量扫描(微信 4.1.12.26+ 已失效),改为从 `cfg` 结构提取**单一主密钥**(`cfg+0x2B8` 密文 ⊕ DLL 中 4×movabs 常量),再通过 `PBKDF2-HMAC-SHA512(主密钥, 库salt, 256000)` **离线派生各库独立密钥**——27/27 个 SQLCipher4 数据库实测验证通过。解决 4.1.12.26+ 密钥提取失效(issue #3 / #7)。
|
||||
- **图片密钥统一流程**:模板收集(`*_t.dat`,按 mtime 取前 16)→ 尾字节众数统计 XOR(替代原单文件探测误回退 `0x88` 的缺陷)→ 优先 `cfgDword` 派生(确定性、离线),注入/缓存/内存扫描 AES 兜底。3000/3000 真实密文探针验证通过。
|
||||
- **cfg 同步返回账号字段**:`WeChatDB` 现在随主密钥一起返回 `name` / `number` / `phone`,与主流密钥工具输出格式逐字段对齐。
|
||||
- 新参数 `master_key` / `cfg_dword` 全部可选、完全向后兼容——不传则走原路径。核心解密函数零改动。
|
||||
|
||||
### v1.1.6.3(2026-08-21)
|
||||
|
||||
- **修复导入卡死**:`import wechatauto` 不再在部分系统上永久阻塞(如微信或其他 Qt 应用占用 COM 导致 `uiautomation` / COM 初始化挂起)。`uiautomation` 和 `comtypes` 现在延迟导入——首次访问 UIA 功能时才加载,不在 `import wechatauto` 时触发。
|
||||
- **修复 OCR 挂起**:`ScreenOCR.recognize` 现在用 `asyncio.wait_for(..., 8s)` 包裹 WinRT 异步调用——在中文等部分系统上 `Windows.Media.Ocr` 异步可能永不完成,此前会导致 `quick_send` 永久阻塞;现在会超时并退化为返回空 OCR 结果。
|
||||
- **修复首次校准卡死**:`calibrate_layout` 的每个 OCR 检测步骤现在用守护线程 + 5 秒超时。此前首次运行(无布局配置文件)时 WinRT OCR 若挂起,整个 `WeChatGUI.__init__` 会永久阻塞;现在超时后回退使用默认布局比例。
|
||||
- **支持明文头(key+salt)库解密**:密钥提取现支持 SQLCipher 4「Raw Key with Explicit Salt」形式(`x'<96hex>'` = 32B key + 16B 显式 salt,配合 `cipher_plaintext_header_size`)。48 字节 key(32B key + 16B salt)按明文头布局验证/解密(第 1 页保留明文头);32 字节 key 保持标准文件头 salt 路径。可解锁部分新版本微信(如 4.1.12.26 环境)下旧偏移指向类名表而非 Cipher 实例导致的解密失败。
|
||||
- **修复 `wxid_*` 硬编码**:账号发现不再假设目录以 `wxid_` 开头——只要 `db_dir` 下某子目录含 `db_storage/` 子目录即识别为账号,支持自定义微信号(用户自选的非 `wxid_` 前缀用户名)。
|
||||
- **全局消息监听**:`WeChat.AddListenAll(callback)` 现在一次监听所有会话(好友、群聊、文件传输助手等),支持自动发现新会话。`WeChat.RemoveListenAll()` 停止全局监听。回调签名为 `(Message, Chat)`,其中 `Chat.who` 为会话原始 username。
|
||||
|
||||
### v1.1.6.1(2026-08-20)
|
||||
|
||||
- **PyPI 描述修复**:1.1.6 发布时漏同步 `README_pypi.md`(描述停留在 1.1.5.1),本补丁版补全 v1.1.6 更新记录并同步版本号。
|
||||
|
||||
### v1.1.6(2026-08-20)
|
||||
|
||||
- **缺密钥报错自动诊断**:`数据库无可用密钥` 报错前会自动检测三项最常见根因——Python 位数(32 位读不了 64 位微信内存)、逐个微信进程的 `OpenProcess`/`ReadProcessMemory` 读取权限、多账号目录与所选账号对比(提示用 `WeChatDB(account=...)` 显式指定),无需先手动运行 `diagnose_keys`。
|
||||
- **新增诊断工具**:`wechatauto/diagnose_keys.py`(微信登录后运行 `python -m wechatauto.diagnose_keys`)输出库版本、Python 位数、微信进程 PID 及逐个进程的读取权限检测、磁盘全部账号与所选账号对比、已缓存密钥、进程内存重新提取结果与密钥校验情况——报密钥提取问题时把输出完整发给维护者即可定位。
|
||||
- **跳过 `migrate\unspportmsg.db`**:该库是微信保留的「未支持消息」库,进程内存中无对应密钥、代码也从不会访问;此前它会让每次初始化都触发一次全进程内存扫描。
|
||||
|
||||
### v1.1.5.1(2026-08-18)— 测试版 / beta
|
||||
|
||||
- **修复实时监听不触发**:`WeChatDB.get_new_messages()` 引用了未定义的 `found`(NameError 被 `Listener._poll_once` 吞掉),导致消息回调从未触发——包括从未聊过天的联系人的首条消息。
|
||||
- **动态消息分片**:`_message_dbs()` 现在会重新扫描磁盘,微信运行中新建的分片(如 `message_5.db`)会被自动发现并提取密钥。
|
||||
|
||||
### v1.1.5(2026-08-18)
|
||||
|
||||
- **版本号规范化**:语音跨库下载修复后整理补丁版本号(1.1.4.2 → 1.1.5)。
|
||||
|
||||
### v1.1.4.2(2026-08-18)
|
||||
|
||||
- **PyPI 描述清理**:移除 v1.1.4 版本记录中关于 demo 默认群改动的条目。
|
||||
|
||||
### v1.1.4.1(2026-08-18)
|
||||
|
||||
- **PyPI 页面中英双语**:PyPI 描述合并中文(`README.zh-CN.md`)与英文(`README.md`)两个版本,中文版在包页面可见。
|
||||
|
||||
### v1.1.4(2026-08-18)
|
||||
|
||||
- **跨全部媒体库下载语音**:`download_voice()` 现在搜索所有 `media_*.db`(不再只查 `media_0.db`)——微信把语音分片存到多个媒体库;此前存在 `media_1.db` 等的语音无法找到(感谢 uiharukazari0105)。
|
||||
- **群聊图片缩略图回退**:群聊的图片原图只有被点开(查看)后才会落盘本地;原图未点开不下发时,`download_image` 自动回退到缩略图(`_t.dat`),保存为带 `_thumb` 后缀的文件。
|
||||
- **`WeChatDB._find_media_rows(user, types)`**:新增批量查媒体接口——返回某会话指定 `local_type` 集合的全部媒体 `local_id`(用于批量下载)。
|
||||
- **`demo_media.py --images N`**:按 `local_type` 直接从数据库下载某会话最近 N 张图片,绕过总消息数 `--limit` 的限制——群聊消息上万条时不再「只列出几张图」。
|
||||
|
||||
### v1.1.3(2026-08-17)
|
||||
|
||||
- **WXAM (wxgf) 图片解码**:微信 4.x 现在把**普通图片**(不仅是动图贴纸)也存进 WXAM 容器
|
||||
(内部为 HEVC 比特流)。`MediaDownloader.download_image` 新增 wxgf 处理:提取 HEVC
|
||||
Annex-B 流,用 ffmpeg 转码为 JPG(优先用 `imageio-ffmpeg` 内置二进制,其次 PATH 上的
|
||||
ffmpeg);ffmpeg 不可用时不再丢弃数据,改为保存原始解密数据为 `.wxgf` 兜底。
|
||||
- 新增依赖:`imageio-ffmpeg>=0.4.9`。
|
||||
|
||||
### v1.1.2(2026-08-16)
|
||||
|
||||
- **UIA 驱动线程安全**:`WeChatUIA` 实例化时在当前线程初始化 COM(`CoInitializeEx`,幂等)——修复后台线程/宿主进程(如 WeChatBot)实例化报「尚未调用 CoInitialize / 无法加载 UIAutomationCore.dll」。
|
||||
- **主窗口过滤**:只认加载了 `Weixin.dll` 的主进程窗口,过滤无 DLL 的辅助进程窗口(其热激活必然失败,不再刷噪音警告)。
|
||||
- **转发语音修复**:`Chat.ForwardVoiceMessage` 未指定目标时用 `self`(原 `_cur()` 可能误取会话)。
|
||||
- **UI 锁可重入**:`LockManager` 同线程可重入——`@uilock` 函数互相调用(如 `ForwardVoiceMessage` → `VoiceMessage.forward_to`)不再死锁。
|
||||
|
||||
### v1.1.1(2026-08-16)
|
||||
|
||||
- **撤回消息**(`Chat.RecallLastMessage` / `uia_driver.recall_last_message`):右键最新一条自己发的消息 → UIA 优先
|
||||
(主窗口树内 `mmui::XMenuView` 菜单项定位「撤回」,Invoke/Select 或鼠标点击),OCR 兜底(全屏识别「撤回」
|
||||
文字定位点击);菜单只剩「删除」(超过 2 分钟撤回时限)时返回失败。
|
||||
- **UIA 健壮性**:菜单项查找限定在主窗口子树内(避免触发 Windows UIA 根遍历的系统挂起 bug);移除脆弱的
|
||||
`WindowControl(ClassName=...)` 兜底定位。
|
||||
- **媒体修复**:视频 id bytes→str 解码(`MediaDownloader`),修复视频文件定位。
|
||||
- `demo_media.py` `--photos` 默认 3 → 10。
|
||||
|
||||
### v1.1.0(2026-08-15)
|
||||
|
||||
- **图片 AES 密钥自动监控捕获**(`media.py`):微信 4.x 的 V2 图片 AES 密钥仅在
|
||||
查看图片大图时短暂驻留进程内存(实测约 5 分钟后释放)。`_scan_aes_key()` 新增
|
||||
`monitor` 模式——首次扫描未命中时自动持续轮询并提示去微信点开一张图片看大图,
|
||||
密钥进入内存后自动捕获并持久化到 `image_keys.json`,之后免扫描直接解密。
|
||||
首次用户无需手工找密钥,看图一次即可完成配置。
|
||||
- **修复进程排序扫描 bug**:移除 `_scan_aes_key` 中按内存占用排序进程的逻辑
|
||||
(`GetProcessMemoryInfo` 结构体大小传错导致工作集全为 0,`reverse` 排序反而把
|
||||
主进程排到最后,错过密钥驻留窗口),恢复按微信进程原顺序扫描(主进程优先命中)。
|
||||
- **语音/视频/文件不受影响**:仅图片 `.dat` 为 V2 AES 加密需密钥;语音(SILK)、
|
||||
视频(MP4)、文件均为明文直接读取。
|
||||
|
||||
### v1.0.9(2026-08-14)
|
||||
|
||||
- **open_chat 账号/微信号搜索修复**(`uia_driver.py`):微信搜索框不认 wxid
|
||||
(系统账号),`open_chat` 传入 username 时自动通过本地 DB 映射为昵称/备注/
|
||||
微信号再搜索(`_resolve_search_keyword`),并清空搜索框残留重试;
|
||||
实测 `open_chat('wxid_sb9or2x9zxj012')` 成功。
|
||||
- **UIA 表情包精确读取**(`msgs/mtype.py` + `uia_driver.py`):热激活后消息
|
||||
列表暴露 `mmui::RecyclerListView`,新增 `find_in_message_list()` 用鼠标滚轮
|
||||
驱动虚拟化列表滚动,按 ClassName/Name 定位表情行并取 BoundingRectangle
|
||||
精确坐标;`EmojiMessage.capture()` 优先走 UIA 定位 + 方向感知气泡裁剪
|
||||
(`_crop_bubble_from_row`),实测 1.1s 裁出 271×271 表情,替代原先
|
||||
「截图全消息区 + 连通域猜气泡」的脆弱方案;失败自动回退原连通域逻辑。
|
||||
- **语音通话**(`uia_driver.voice_call` + `Chat.VoiceCall`):标题栏暴露
|
||||
`mmui::ChatVoIPView.voip_button`(Name=语音通话),控件树动态重建需重试
|
||||
定位;video=True 尝试找视频通话按钮(当前版本未暴露,通常失败)。
|
||||
- **拍一拍**(`uia_driver.poke` + `Chat.Poke`):微信 4.x 拍一拍只能通过
|
||||
右键对方头像触发,菜单为自绘不暴露 UIA;实现为「内容重心定位 friend
|
||||
消息行 → 右键头像 → 全屏 OCR 定位「拍一拍」→ 点击」,实测 3.2s 发出
|
||||
(网络正常时对方收到,网络异常时微信显示失败提示,链路本身正确)。
|
||||
- `EmojiMessage.capture()` / `voice_call` / `poke` 失败均自动回退或返回
|
||||
WxResponse 失败,不影响既有 OCR 发送路径。
|
||||
|
||||
### v1.0.8(2026-08-13)
|
||||
- 🎉 **特别感谢 [vesio](https://github.com/vesio)**:在 issue #1 中提供了微信 4.1.12 可出 UIA 控件树的代码与调试思路,本版 UIA 混合驱动由此而来;
|
||||
- **UIA 混合驱动**(`uia_driver.py`,微信 4.1.12.26 实测):
|
||||
- 新增 `WeChatUIA` 引擎:冷启动时 `Qt51514QWindowIcon` 只是空壳(Qt
|
||||
无障碍门未激活),通过写 Weixin.dll 内的 Qt accessibility gate
|
||||
(RVA 扫描定位)**热激活**后,锚点变为 `mmui::MainWindow`,搜索框
|
||||
`mmui::XValidatorTextEdit` / 搜索下拉 `search_list` / 输入框
|
||||
`chat_input_field` 全部可用;
|
||||
- 发送链路全部走 UIA:搜索下拉选人(`search_item_*`)打开会话 →
|
||||
`chat_input_field` 直接输入 + 回车发送,`current_chat` 校验防误配,
|
||||
无 OCR 抖动;Windows 冷状态热激活后 UIA 树保持可用;
|
||||
- `guia.py` 集成混合路径:`_get_uia()` 惰性启用,`open_chat` /
|
||||
`send_msg` **UIA 优先、OCR 兜底**——UIA 树不可用(版本变更新增 RVA)
|
||||
或失败时自动降级到坐标 + 放大 OCR 方案,首次失败本次会话内不再重试。
|
||||
- 实测:`send_msg('文件传输助手')` 10.2s、`send_msg('卢立竺')` 13.2s
|
||||
均走 UIA 并数据库确认成功(含 verify);UIA 对生僻字会话名不再依赖
|
||||
OCR 识别。
|
||||
- 新增依赖:`uiautomation`(UIA 客户端库)。
|
||||
|
||||
### v1.0.7(2026-08-13)
|
||||
|
||||
- **OCR 识别可靠性提升**(`guia.py`,针对生僻字/小字号会话名识别失败):
|
||||
- 新增 `ocr_zoomed()`:对区域放大 N 倍后再 OCR,坐标按 1/N 还原;实测
|
||||
微信小字号中文在放大 3 倍时识别率最高(放大 6 倍图像过大反而整块
|
||||
返回空),超过 5 倍即回落;
|
||||
- `_chat_is_open` 标题检测改用放大 3 倍 + y 范围扩到 0-185(微信 4.x
|
||||
标题实际渲染在 y≈80-180,原 15-100 的区间会漏检已打开的会话);
|
||||
- `_search_chat` 搜索回退排除「群聊」节标题以下行、含「包含」的群成员
|
||||
预览行(如「00,包含:卢立竺」)与群名结尾行,只点联系人,修复
|
||||
「搜索选中群聊而非联系人」的问题;
|
||||
- `_chat_open_confirmed` 改为**优先标题命中**,标题读不到才退而用面板
|
||||
非空白作为已打开判据,修复「点错会话也误判成功」;
|
||||
- `open_chat` 首查 `_chat_is_open && _pane_has_content`,右侧面板已打开
|
||||
目标会话时直接成功(不再滚动/搜索),已打开场景耗时 45s → 2.7s。
|
||||
- **OCR 多轮投票**(`find_session._scan_vote`):WinRT OCR 对生僻字存在
|
||||
抖动(同一行不同轮次可能读出「卢立竺」或「亠人五」)。对侧栏放大 3x
|
||||
扫描 4 轮,命中行按 y 聚类(≤30px 视为同行),票数 ≥2 才返回,显著
|
||||
降低误配;普通会话仍走单轮快速路径,无性能损失。
|
||||
- 实测:`find_session('卢立竺')` 连续 5 轮 4/4 票一致、稳定命中;
|
||||
`open_chat` + `send_msg` 全链路成功。
|
||||
|
||||
### v1.0.6(2026-08-11)
|
||||
|
||||
- **元数据与门面优化**:README 增加徽章(PyPI 版本/下载量/Python 版本/License/Stars)、PyPI description/keywords/classifiers SEO 优化、Homepage 修正为项目 GitHub 地址。
|
||||
|
||||
### v1.0.5(2026-08-10)
|
||||
|
||||
- **表情截图跨机器修复**:`EmojiMessage.capture()` 表情气泡自动裁剪全面重构:
|
||||
- 主路径改用**连通域分析**(`_crop_last_bubble`),按消息方向(左=对方/右=自己)
|
||||
精确定位最后一条消息气泡,自动过滤细长竖条(滚动条/面板边框)、剔除头像类
|
||||
小元素,从根源解决右缘滚动条/边框被当成内容导致的右侧大片空白;
|
||||
- 圆形表情顶部/底部在缩放采样时因 LANCZOS 模糊丢失边缘像素:加大裁剪边距
|
||||
(`pad = max(10, scale*5)`)并在全分辨率下**逐像素边缘扩展**找回丢失内容,
|
||||
且扩展遇**连续空白行**(消息间分隔)即停,避免吃进相邻消息;
|
||||
- 时间戳等居中小文字(水平居中约 50% 宽度)不再被误当成消息:方向判定加
|
||||
阈值(左侧 <45% 宽度、右侧 >55%),居中元素两边都不匹配;
|
||||
- 最终尺寸校验:`min(crop) < 50` 视为时间戳/文字误判,自动回退到
|
||||
「消息分隔空白」「头像锚点」等备用定位,仍过小则判定失败返回 None;
|
||||
- 本机与高 DPI 机器均已实测通过(完整表情、无空白、无切顶、不截时间戳)。
|
||||
|
||||
### v1.0.4(2026-08-10)
|
||||
|
||||
- **多特征兜底窗口定位**:主窗口定位不再只依赖类名 `Qt51514QWindowIcon`
|
||||
(类名降级为软条件),联合 进程名 `weixin.exe` / 窗口可见 / 大尺寸
|
||||
(≥800px)/ 标题关键词(微信/Weixin/WeChat)评分定位——Qt 升级改名
|
||||
(`Qt51514` → `Qt6xxx`)也不失效;渲染子窗口按前缀 `MMUIRenderSubWindow`
|
||||
匹配(兼容 `MMUIRenderSubWindowHW` / `MMUIRenderSubWindow` 等变体),
|
||||
找不到时退回用主窗口矩形计算坐标。
|
||||
- **布局自动校准**:首次运行自动校准——OCR 检测「搜索」「发送」锚点实测
|
||||
布局比例,保存到 `~/.wechatauto/layout-<机器标识>.json`,之后自动加载;
|
||||
布局漂移(DPI/窗口尺寸/缩放变化)时自动重新校准。
|
||||
- **最大化状态保持**:激活窗口时先 `GetWindowPlacement` 记录状态,原为
|
||||
最大化则用 `SW_SHOWMAXIMIZED` 恢复(原 `SW_RESTORE` 会把最大化窗口
|
||||
缩成普通大小),最小化恢复不再破坏用户窗口布局。
|
||||
- **发送模块窗口兜底**:`find_main_window` 类名查找失败后按标题「微信」
|
||||
兜底,适配类名不同的机器。
|
||||
|
||||
### v1.0.3(2026-08-08)
|
||||
|
||||
- **文本消息还原**:微信 4.x 部分文本消息 content 为「容器头 + UTF-8 明文 +
|
||||
尾部填充」结构,此前显示为 `[文本]`/空。新增 `_extract_text_from_blob`
|
||||
还原明文,数据库读取与 bot 均可见真实内容(含群消息 `wxid_xxx:` 前缀)。
|
||||
- **表情截图方向感知与兼容性**:`_db_row_to_message` 写入 `msg.attr`
|
||||
(`self`/`friend`),`EmojiMessage.capture()` 按方向定位气泡(自己发的用
|
||||
消息分隔空白、对方发的用头像锚点),避免截图前自己又发了一条消息时误截到
|
||||
自己的气泡;裁剪阈值自适应截图尺寸,跨分辨率/DPI 可用。微信 4.x 主窗口为
|
||||
Qt 自绘渲染,不暴露 UIA 子树,故截图定位全部基于屏幕像素分析。
|
||||
- **发送会话复用**:`send_msg` 记录 `_current_chat`,目标会话已打开时跳过
|
||||
`open_chat`(重扫侧栏+点击),逐条连续发送不再反复点击对话框,效率提升。
|
||||
- **搜索联系人选第一条**:`_search_chat` 按视觉顺序排序并过滤「搜索网络结果/
|
||||
搜一搜」节标题,点选第一条联系人而非网络搜索。
|
||||
- **动画表情不再落盘伪 `.gif`**:`download_image` 识别到 `wxgf` 容器(微信
|
||||
动画表情私有格式)时返回 `None`,不再生成打不开的假图片。
|
||||
- **`Listener.stop()` 崩溃修复**:`db.py` 补 `import sys`(`_run/_poll_once`
|
||||
使用 `sys.stderr` 却未导入)。
|
||||
|
||||
### v1.0.2(2026-08-08)
|
||||
|
||||
- **表情消息支持**:新增 `EmojiMessage` 消息类型(`type='emotion'`),
|
||||
"动画表情"不再被归为 `OtherMessage`,并按收发方向提供
|
||||
`FriendEmojiMessage` / `SelfEmojiMessage`。微信 4.x 表情消息在本地数据库中的
|
||||
content 为加密数据,无法直接还原成图片,因此新增 `EmojiMessage.capture()`:
|
||||
采用「打开会话 → 滚动到底 → 截取消息区 → 自动裁剪最后一条消息气泡」
|
||||
的屏幕截图方案,返回图片路径,可直接供 AI 视觉识别使用
|
||||
(示例见 `demo_emoji_capture.py`)。
|
||||
- **监听器并发工作线程**:`Listener` 回调移到独立工作线程执行,每个被监听
|
||||
会话对应一条**串行**工作线程——同一会话内消息按序处理、不同会话间并行;
|
||||
轮询线程只负责读取数据库并分派任务,不再被慢回调(AI 调用 / 图片识别等)
|
||||
阻塞,`stop()` 优雅关闭所有工作线程。
|
||||
- **数据库消息兼容增强**:`_db_row_to_message` 支持 bytes 类型 content
|
||||
(自动解码还原文本)、`local_type` 缺失时自动推导消息类型,
|
||||
`_extract_group_sender` 兼容 bytes 内容。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目状态
|
||||
|
||||
| 能力 | 状态 | 实现方式 |
|
||||
| ---- | ---- | -------- |
|
||||
| 读取消息 | ✅ 已完成并验证 | 本地数据库解密(`wechatauto/db.py`) |
|
||||
| 消息监听(轮询) | ✅ 已完成并验证 | `Listener` + `get_new_messages` 增量回调 |
|
||||
| 表情消息识别与截图 | ✅ 已完成并验证(v1.0.3 方向感知) | `EmojiMessage` + `capture()`(屏幕截图自动裁剪) |
|
||||
| WAL 增量合并 | ✅ 已修复并验证 | 帧盐校验合并 `-wal`(见 §2.4) |
|
||||
| 历史消息全量导出 | ✅ 已完成并验证 | `export_history`(JSON / SQLite) |
|
||||
| 媒体下载(图片/语音/文件) | ✅ 已完成并验证 | `wechatauto/media.py`(图片 V2 解密) |
|
||||
| 朋友圈读取 | ✅ 已完成并验证 | `MomentDB` 直接读 `sns.db` |
|
||||
| 多账号管理 | ✅ 已完成并验证 | `list_accounts()` + `account=` 参数 |
|
||||
| 读取会话列表 | ✅ 已完成并验证 | 同上 |
|
||||
| 搜索联系人 | ✅ 已完成并验证 | 同上 |
|
||||
| 发送消息 | ✅ 已完成并验证 | UIA + 坐标+OCR 混合(`wechatauto/guia.py`) |
|
||||
| 发送文件/图片/回复/艾特 | ✅ 已完成并验证 | 剪贴板 CF_HDROP + OCR |
|
||||
| 语音通话 / 拍一拍 | ✅ 已完成并验证 | UIA 按钮 + OCR 菜单(`Chat.VoiceCall` / `Chat.Poke`) |
|
||||
| UI 自动化(UIAutomation) | ✅ 热激活后可用 | 写 Weixin.dll Qt accessibility gate,物化 `mmui::*` 树 |
|
||||
|
||||
**结论**:微信 4.1.x 聊天界面使用自绘渲染(`MMUIRenderSubWindow*`),冷启动
|
||||
对 UIAutomation 只暴露 `Qt51514QWindowIcon` 空壳(原 wxauto 的 UI 方案因此
|
||||
失效)。本项目通过**热激活 Qt accessibility gate**(写 Weixin.dll 内读屏
|
||||
标志位,从 `qt.accessibility.core` 引用扫描 RVA)物化 `mmui::*` UIA 树,
|
||||
实现发送/语音通话/拍一拍等操作(UIA 优先、坐标+OCR 兜底);消息读取仍走
|
||||
「**本地数据库解密**」(已全链路验证)。
|
||||
|
||||
---
|
||||
|
||||
## 二、读取原理
|
||||
|
||||
微信 4.x 的数据存放在本地 SQLCipher 4 加密的 SQLite 数据库中:
|
||||
|
||||
```
|
||||
D:\微信文件\xwechat_files\<wxid>_xxxx\db_storage\
|
||||
├── contact\contact.db 联系人(昵称、备注)
|
||||
├── session\session.db 会话列表(未读数、摘要)
|
||||
├── message\message_0..4.db 聊天消息(按会话分表 Msg_<md5>,跨分库分片)
|
||||
├── message\media_0.db 语音(VoiceInfo.voice_data,SILK 二进制)
|
||||
├── message\message_resource.db 文件原名(MessageResourceDetail.packed_info)
|
||||
├── sns\sns.db 朋友圈(SnsTimeLine,SnsDataItem XML)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2.1 密钥提取(进程内存只读扫描)
|
||||
|
||||
每个数据库有**独立的 32 字节密钥**,保存在微信进程内存中的
|
||||
`com.Tencent.WCDB.Config.Cipher` 配置对象里:
|
||||
|
||||
1. 在 Weixin.exe 所有可读内存区域中查找该字符串;
|
||||
2. 由字符串地址定位配置对象(`[ptr][len]` 结构回溯);
|
||||
3. 数据块与固定掩码异或后得到 `x'<64位hex密钥><32位hex盐>'` 明文配置;
|
||||
4. 用 SQLCipher 4 HMAC 校验规则验证每个候选密钥;
|
||||
5. 验证通过的密钥保存到 `%TEMP%\wechatauto_db\<账号>\keys.json` 缓存。
|
||||
|
||||
### 2.2 数据库解密
|
||||
|
||||
- SQLCipher 4,页大小 4096,`PBKDF2-HMAC-SHA512`(加密密钥 256000 次迭代);
|
||||
- 解密结果按页写入临时目录,校验源 mtime/size 复用缓存;
|
||||
- 首次解密 contact.db 约 6s,之后全部秒级。
|
||||
|
||||
### 2.3 消息查询
|
||||
|
||||
- 会话名 → `Md5(会话微信号)` → 表名 `Msg_<md5>`(同一会话可能分片在多个
|
||||
`message_*.db`,按 `sort_seq` 合并排序);
|
||||
- 关键列:`local_type`、`real_sender_id`(2=自己,其他为数字 id,可通过
|
||||
`message_resource.SenderName2Id` 反查微信号)、`server_id`、
|
||||
`packed_info_data`(图片/视频 md5)、`sort_seq`。
|
||||
|
||||
### 2.4 WAL 增量合并(已修复)
|
||||
|
||||
微信 `-wal` 是预分配文件:checkpoint 时 WAL 头 salt+1 并清零写游标,但
|
||||
**旧世代帧仍留在文件中**。若合并时不过滤帧盐,会把过期页覆盖进主库导致
|
||||
`database disk image is malformed`。修复方案:
|
||||
|
||||
- `_merge_wal` 读取 WAL 头后**仅合并 salt 与当前 WAL 头一致的帧**,
|
||||
旧世代帧直接跳过;
|
||||
- 缓存 stamp 加入版本号 `STAMP_VERSION=2`,旧损坏缓存自动强制全量重建;
|
||||
- 合并结果用 `PRAGMA integrity_check` 校验,失败自动重试全量重建。
|
||||
|
||||
验证:contact.db 合并后 integrity OK,2354 个联系人全部可查。
|
||||
|
||||
### 2.5 媒体存储与解密(图片 v2 格式)
|
||||
|
||||
- 图片:`msg\attach\<会话md5>\<YYYY-MM>\Img\<md5>.dat`(加密);
|
||||
- 语音:`media_0.db` → `VoiceInfo.voice_data`(SILK 明文 BLOB);
|
||||
- 文件:`msg\file\<YYYY-MM>\<原文件名>`(原名来自 message_resource);
|
||||
- 视频:`msg\video\<YYYY-MM>\<id>.mp4`(未落盘时返回 None)。
|
||||
|
||||
图片 `.dat` 为 **v2 格式**:`[6B sig 070856320807][4B aes_size LE][4B xor_size LE]`
|
||||
+ AES-ECB 密文 + 明文段 + 异或段:
|
||||
|
||||
- **AES 密钥**:16 字节 ASCII,账户级稳定密钥,但仅在微信查看图片时驻留
|
||||
进程内存。`MediaDownloader` 通过内存扫描反测(AES 解首块后校验 JPEG/PNG
|
||||
魔数)获取,**命中后持久化到 `image_keys.json`**;也支持 `image_key=` 参数
|
||||
显式注入。本机实测:单一密钥稳定解密 35/40 张随机图片(其余为微信动画
|
||||
表情容器 `wxgf`)。
|
||||
- **XOR 密钥**:单字节,从同图缩略图 `<md5>_t.dat` 尾部 JPEG 结束标记
|
||||
`FF D9` 反推(`key = tail[0] ^ 0xFF`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、快速开始
|
||||
|
||||
### 3.1 安装
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
# 坐标+OCR 发送路线额外依赖:
|
||||
pip install winsdk pypinyin
|
||||
```
|
||||
|
||||
### 3.2 示例程序
|
||||
|
||||
```bash
|
||||
python demo_db.py
|
||||
```
|
||||
|
||||
### 3.3 代码示例
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
|
||||
db = WeChatDB() # 自动检测账号与数据目录(微信需已登录)
|
||||
|
||||
info = db.get_self_info() # 当前账号昵称
|
||||
for s in db.get_sessions(limit=10): # 会话列表
|
||||
print(db.get_nickname(s["username"]), s["unread"])
|
||||
|
||||
hits = db.search_contact("Ayi") # 搜索联系人
|
||||
who = hits[0]["username"]
|
||||
for m in db.get_messages(who, limit=10): # 最近消息
|
||||
print(m["create_time"], m["sender_id"], m["type"], m["content"])
|
||||
```
|
||||
|
||||
### 3.4 媒体下载
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MediaDownloader
|
||||
|
||||
db = WeChatDB()
|
||||
md = MediaDownloader(db) # 可传 image_key="..." 注入图片密钥
|
||||
key = md.detect_image_key() # 内存扫描/缓存取 AES+XOR 密钥
|
||||
print(key)
|
||||
|
||||
for m in db.get_messages("filehelper", limit=50):
|
||||
out = md.download_media("filehelper", m["local_id"]) # 按类型自动分发
|
||||
if out:
|
||||
print("已下载:", out)
|
||||
```
|
||||
|
||||
### 3.5 朋友圈读取
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MomentDB
|
||||
|
||||
md = MomentDB(WeChatDB())
|
||||
for feed in md.get_moments(limit=10): # 时间线(3382 条全量可读)
|
||||
print(feed["nickname"], feed["text"])
|
||||
print(" 图片:", [i["md5"] for i in feed["images"]])
|
||||
print(" 赞:", [l["nickname"] for l in feed["likes"]])
|
||||
print(" 评论:", [(c["nickname"], c["content"]) for c in feed["comments"]])
|
||||
md.download_media(feed["images"][0]) # 本地缓存或 URL 拉取
|
||||
```
|
||||
|
||||
### 3.6 消息监听
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0)
|
||||
lst.add_listener("filehelper", lambda msg, lst: print("新消息:", msg["content"]))
|
||||
lst.start()
|
||||
# ... 业务代码 ...
|
||||
lst.stop()
|
||||
```
|
||||
|
||||
- 回调在**独立工作线程**中执行(v1.0.2):每个被监听会话对应一条串行
|
||||
工作线程,同一会话内消息按序处理、不同会话间并行;轮询线程只负责读取
|
||||
数据库并分派任务,不会被慢回调(AI 调用 / 图片识别等)阻塞。
|
||||
|
||||
### 3.7 历史导出
|
||||
|
||||
```python
|
||||
db.export_history(r"D:\backup\chat.json", fmt="json") # 全部会话
|
||||
db.export_history(r"D:\backup\chat.db", fmt="sqlite")
|
||||
db.export_history(r"D:\backup\one.json", fmt="json",
|
||||
users=["filehelper"], limit_per_chat=1000)
|
||||
```
|
||||
|
||||
### 3.8 多账号
|
||||
|
||||
```python
|
||||
from wechatauto import list_accounts, WeChatDB
|
||||
for a in list_accounts():
|
||||
print(a["account"], a["wxid"])
|
||||
db2 = WeChatDB(account="wxid_xxx_abcd") # 显式指定账号(缓存按账号隔离)
|
||||
```
|
||||
|
||||
### 3.9 表情消息与截图
|
||||
|
||||
微信 4.x 的"动画表情"消息在本地数据库中 content 为加密数据,无法直接还原成
|
||||
图片。v1.0.2 起监听回调中的表情消息为独立的 `EmojiMessage` 类型
|
||||
(`type='emotion'`,`FriendEmojiMessage` / `SelfEmojiMessage` 按收发方向区分),
|
||||
并支持对屏幕上的表情气泡自动截图:
|
||||
|
||||
```python
|
||||
# 在 Listener 回调内,把消息 dict 转成消息对象后再截图:
|
||||
def on_msg(msg, listener):
|
||||
if msg["type"] == "动画表情":
|
||||
from wechatauto.wx import _db_row_to_message
|
||||
m = _db_row_to_message(msg, chat) # chat: 当前会话
|
||||
path = m.capture() # 返回 PNG 路径,供 AI 视觉识别
|
||||
```
|
||||
|
||||
`capture(save_dir=None)` 流程:打开会话(已打开则跳过,避免刷新消息列表导致
|
||||
控件失效)→ 滚动到底 → 截取消息区 → 按消息方向定位最后一条消息气泡:
|
||||
|
||||
- **自己发的消息**(`attr='self'`,右侧无头像):用「消息分隔空白」定位
|
||||
消息顶部,空白阈值按截图高度自适应(约消息区高度的 2.5%),
|
||||
跨分辨率/DPI 保持一致;
|
||||
- **对方发的消息**(`attr='friend'`,左侧有头像):优先检测头像圆形彩色块
|
||||
的顶部作为消息顶部(特征跨分辨率稳定),失败时回退消息分隔空白。
|
||||
|
||||
返回图片路径(失败返回 None)。独立示例:`python demo_emoji_capture.py`。
|
||||
调试时可保留 `~/pane_diag_raw.png`(每次截图保存的消息区原图)与
|
||||
`[CAP]` 日志行(截图尺寸、消息方向、裁剪路径、结果尺寸)用于排查。
|
||||
|
||||
---
|
||||
|
||||
## 四、API 参考
|
||||
|
||||
### `WeChatDB(db_dir=None, keys_file=None, workdir=None, account=None)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `get_self_info() -> dict` | 当前账号(username / nick_name / remark) |
|
||||
| `get_sessions(limit=100)` | 会话列表:username / unread / summary / last_time |
|
||||
| `search_contact(keyword)` | 按昵称/备注/微信号搜索 |
|
||||
| `get_messages(user, limit, offset)` | 读取指定会话消息 |
|
||||
| `get_message_row(user, local_id)` | 单条原始消息(含 server_id / packed_info,媒体用) |
|
||||
| `get_new_messages(user, since_seq)` | `sort_seq > since_seq` 的增量消息(升序) |
|
||||
| `get_nickname(user)` | 微信号 → 显示昵称 |
|
||||
| `list_message_chats()` | 所有含消息的会话(md5 / 昵称 / 消息数) |
|
||||
| `export_history(out_path, fmt, ...)` | 全量导出 JSON / SQLite |
|
||||
| `extract_keys()` | 手动触发密钥提取 |
|
||||
| `wxid` / `account` / `account_dir` | 当前账号信息 |
|
||||
| `list_accounts()`(模块级) | 扫描本机所有微信账号 |
|
||||
| `auto_detect_db_dir()`(模块级) | 自动定位数据目录(配置文件 → 注册表 → 常见默认目录) |
|
||||
|
||||
### `MediaDownloader(db, save_dir=None, image_key=None)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `detect_image_key(refresh)` | 取 (AES 密钥, XOR 密钥),命中后持久化 |
|
||||
| `decrypt_image(dat_path)` | 解密单个 `.dat`(自动识别 v1/v2) |
|
||||
| `download_media(user, local_id)` | 按类型分发下载 |
|
||||
| `download_image / _voice / _video / _file` | 各类媒体下载 |
|
||||
| `copy_files_to_clipboard(paths)` | CF_HDROP 写剪贴板(发送附件用) |
|
||||
|
||||
### `MomentDB(db)`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `get_moments(limit, offset, username)` | 朋友圈时间线(最新在前) |
|
||||
| `get_moment(tid)` / `get_my_moments(limit)` | 单条 / 我的动态 |
|
||||
| `find_local_media(md5, kind)` | 本地缓存查找(Sns\Img / Sns\Video) |
|
||||
| `download_media(media, save_dir)` | 缓存优先,否则 URL 拉取 |
|
||||
|
||||
### `Listener(db, interval, watermark)`
|
||||
|
||||
`add_listener(user, cb)` / `remove_listener` / `start` / `stop` / `watermark`。
|
||||
|
||||
### `WeChatGUI`(发送,锁屏不可用)
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `send_msg(text, who, verify)` | 文本发送(OCR 定位 + 剪贴板粘贴) |
|
||||
| `send_file(path, who, verify)` | 文件(CF_HDROP 粘贴 + 回车) |
|
||||
| `send_image(path, who, verify)` | 图片(同上) |
|
||||
| `reply_msg(text, who, verify)` | 回复最近消息(悬停 + OCR 回复入口) |
|
||||
| `at_member(member, text, who, verify)` | 群聊 @ 成员 |
|
||||
| `open_chat / focus_input / bring_to_front` | 基础操作 |
|
||||
|
||||
一行式:`quick_send` / `quick_send_file` / `quick_send_image` / `quick_reply`。
|
||||
|
||||
---
|
||||
|
||||
## 五、已知限制
|
||||
|
||||
1. **需要微信登录**:数据库密钥存于进程内存,首次使用需微信运行中
|
||||
(提取后本地缓存);重新登录后密钥变化需重新提取(自动校验失败重扫);
|
||||
2. **图片 AES 密钥瞬态**:仅在微信查看图片时驻留内存;`MediaDownloader`
|
||||
扫描命中后会持久化(`image_keys.json`),也可用 `image_key=` 显式传入;
|
||||
3. **发送为 GUI 操作**:锁屏/会话断开时 `desktop_available()` 返回 False,
|
||||
发送接口返回明确失败;文件/图片/回复/艾特代码已完成但需桌面解锁后实测;
|
||||
4. **视频文件未落盘时不可下载**:视频 mp4 仅在本地存在(`msg/video`)时
|
||||
返回,否则返回 None;
|
||||
5. **发朋友圈功能已舍弃**:4.x 的发表为自绘界面操作,不可靠自动化;
|
||||
本库仅保留朋友圈读取/点赞/评论能力。
|
||||
|
||||
---
|
||||
|
||||
## 六、发送消息(坐标 + OCR)
|
||||
|
||||
微信 4.1.12+ 聊天界面自绘渲染、无无障碍节点,发送走
|
||||
「屏幕坐标 + 本地 OCR」(`wechatauto/guia.py`):
|
||||
|
||||
1. **多特征兜底定位**主窗口(类名 `Qt51514QWindowIcon` 只是「软条件」,
|
||||
联合标题 / 进程名 `weixin.exe` / 可见 / 大尺寸评分,Qt 升级改名也不
|
||||
失效),再按前缀 `MMUIRenderSubWindow` 找渲染子窗口(兼容
|
||||
`MMUIRenderSubWindowHW` / `MMUIRenderSubWindow` 等不同版本类名;
|
||||
找不到时回退用主窗口矩形计算坐标);
|
||||
2. 布局用渲染子窗口相对坐标描述,运行时换算为屏幕绝对坐标;首次运行自动
|
||||
校准(OCR 检测「搜索/发送」锚点实测比例),保存到
|
||||
`~/.wechatauto/layout-<机器>.json`,之后自动加载、布局漂移自动重校准;
|
||||
3. OCR 识别会话列表点击目标(失败走搜索框;生僻字/小字号会话名自动放大
|
||||
3 倍 + 多轮投票重扫,搜索回退只点联系人、自动排除群聊与群成员预览行);
|
||||
4. 扫描输入框白色区定位并聚焦;
|
||||
5. 文字以「剪贴板 + Ctrl+V」输入(避免中文输入法拦截),失败回退拼音组合;
|
||||
6. OCR 定位「发送」按钮(找不到回退回车键);
|
||||
7. `verify=True` 时用 `WeChatDB` 读回确认。
|
||||
|
||||
文件/图片通过 **CF_HDROP 剪贴板 + Ctrl+V** 插入草稿再回车发送,绕开自绘
|
||||
「+ 菜单」定位;回复/艾特分别走悬停 OCR 工具栏与成员弹层 OCR。
|
||||
|
||||
```python
|
||||
from wechatauto.guia import quick_send, quick_send_file
|
||||
quick_send('你好', '文件传输助手', verify=True)
|
||||
quick_send_file(r'D:\资料\报告.pdf', '文件传输助手')
|
||||
```
|
||||
|
||||
> 注意:OCR 需要系统语言包含中文(`Windows.Media.Ocr`)。
|
||||
|
||||
---
|
||||
|
||||
## 七、后续路线
|
||||
|
||||
1. **发送功能实测**:桌面解锁后校准 guia 各坐标常量,验证文件/图片/回复/艾特;
|
||||
2. **视频消息下载增强**:微信 4.x 聊天视频存储位置仍需确认(本机无样本);
|
||||
3. **性能优化**:导出/首扫并行化,内存扫描增量缓存。
|
||||
|
||||
---
|
||||
|
||||
## 八、目录结构
|
||||
|
||||
```
|
||||
├── wechatauto/
|
||||
│ ├── wx.py UIA 自动化入口(4.x 受限)
|
||||
│ ├── guia.py ★ 坐标+OCR 发送模块(文本/文件/图片/回复/艾特)
|
||||
│ ├── db.py ★ 数据库读取(密钥提取 + 解密 + WAL 合并 + 导出 + 监听)
|
||||
│ ├── media.py ★ 媒体下载(图片 v2 解密 / 语音 / 视频 / 文件)
|
||||
│ ├── moment.py ★ 朋友圈(MomentDB 数据库路线 + 旧 UIA 兼容)
|
||||
│ ├── ui/ UI 控件层
|
||||
│ ├── msgs/ 消息模型
|
||||
│ └── ...
|
||||
├── demo.py UI 自动化示例(微信 4.1 上受限)
|
||||
├── demo_db.py ★ 数据库读取示例(推荐)
|
||||
├── demo_guia.py ★ 坐标+OCR 发送示例
|
||||
├── demo_listen.py ★ 实时消息监听示例
|
||||
├── demo_reply_at.py ★ 回复/@ 成员实测示例
|
||||
├── demo_emoji_capture.py ★ 表情消息截图示例
|
||||
├── docs/技术文档.md ★ 完整技术文档(架构/原理/API/扩展)
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
## 九、免责声明
|
||||
|
||||
本项目仅用于个人学习与自动化研究,请遵守微信软件许可协议及当地法律法规,
|
||||
勿用于任何违反规定的用途。
|
||||
|
||||
|
||||
注:本库完全由AI(opencode+deepseek-v4-flash)生成
|
||||
|
||||
---
|
||||
|
||||
## 十、联系方式
|
||||
|
||||
- 邮箱:fanyuantaier@163.com
|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 English
|
||||
|
||||
### wechatauto-replica — WeChat 4.x Windows Automation (wxauto-compatible)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Automate the **WeChat 4.x Windows desktop client** (not the web version): read messages, listen in real time, download media, export full history, read Moments (朋友圈), and send messages — by driving the local client directly.
|
||||
|
||||
> **Current version:** 1.1.7 · Windows 10/11 · Python 3.9+ (verified on 3.12) · WeChat **4.1.12+**
|
||||
>
|
||||
> **Why this project exists:** the classic [wxauto](https://github.com/cluic/wxauto) relies on the UI Automation tree, which WeChat 4.x broke with self-drawn rendering (no accessibility nodes). wechatauto-replica is a drop-in-style replacement: messages are read through **local database decryption** (SQLCipher 4), and sending uses a **UIA + OCR hybrid** driver that auto-falls back between engines.
|
||||
|
||||

|
||||
|
||||
*Reading the encrypted `contact.db` / `message_*.db` / `sns.db` files directly from `xwechat_files/.../db_storage/` — no web API, all local.*
|
||||
|
||||
## ✨ Features
|
||||
|
||||
| Capability | Status | How |
|
||||
|---|---|---|
|
||||
| Read messages | ✅ verified | Local SQLCipher 4 DB decryption (`wechatauto/db.py`) |
|
||||
| Real-time message listening | ✅ verified | `Listener` incremental polling, per-chat worker threads |
|
||||
| Emoji message capture | ✅ verified | Screen capture + direction-aware bubble auto-cropping |
|
||||
| Full history export | ✅ verified | JSON / SQLite |
|
||||
| Media download (image / voice / file) | ✅ verified | `MediaDownloader`: image v2 AES decryption, SILK voice, files |
|
||||
| Moments (朋友圈) read | ✅ verified | Direct `sns.db` reads (3382 feeds verified) |
|
||||
| Multi-account | ✅ verified | `list_accounts()` + `account=` |
|
||||
| Send text / file / image / reply / @member | ✅ verified | UIA-first, coordinate + OCR fallback |
|
||||
| Voice call / Poke (拍一拍) | ✅ verified | UIA buttons + OCR menus |
|
||||
| UIAutomation tree | ✅ after hot-activation | Writes the Qt accessibility gate inside Weixin.dll |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
# extra deps for the OCR sending path:
|
||||
pip install winsdk pypinyin
|
||||
```
|
||||
|
||||
### Read messages
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
|
||||
db = WeChatDB() # auto-detects account & data dir (WeChat must be logged in)
|
||||
|
||||
info = db.get_self_info() # current account
|
||||
for s in db.get_sessions(limit=10): # session list
|
||||
print(db.get_nickname(s["username"]), s["unread"])
|
||||
|
||||
hits = db.search_contact("Ayi") # search contacts
|
||||
for m in db.get_messages("filehelper", limit=10): # recent messages
|
||||
print(m["create_time"], m["sender_id"], m["type"], m["content"])
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```python
|
||||
from wechatauto.guia import quick_send, quick_send_file
|
||||
|
||||
quick_send("Hello", "filehelper", verify=True) # verify=True reads back from DB
|
||||
quick_send_file(r"D:\report.pdf", "filehelper")
|
||||
```
|
||||
|
||||
### Real-time listening
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0)
|
||||
lst.add_listener("filehelper", lambda msg, lst: print("new:", msg["content"]))
|
||||
lst.start()
|
||||
# ... your code ...
|
||||
lst.stop()
|
||||
```
|
||||
|
||||
Callbacks run on dedicated per-chat worker threads: messages in one chat are processed in order, different chats in parallel; slow callbacks (AI calls, image recognition) never block the poller.
|
||||
|
||||
### Media & Moments
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB, MediaDownloader, MomentDB
|
||||
|
||||
db = WeChatDB()
|
||||
md = MediaDownloader(db)
|
||||
md.detect_image_key() # scan process memory for the image AES key (persisted after first hit)
|
||||
for m in db.get_messages("filehelper", limit=50):
|
||||
out = md.download_media("filehelper", m["local_id"])
|
||||
if out:
|
||||
print("downloaded:", out)
|
||||
|
||||
moments = MomentDB(db)
|
||||
for feed in moments.get_moments(limit=10):
|
||||
print(feed["nickname"], feed["text"])
|
||||
print(" images:", [i["md5"] for i in feed["images"]])
|
||||
print(" likes:", [l["nickname"] for l in feed["likes"]])
|
||||
print(" comments:", [(c["nickname"], c["content"]) for c in feed["comments"]])
|
||||
```
|
||||
|
||||
## 🧠 How It Works
|
||||
|
||||
- **Reading** — WeChat 4.x stores everything in SQLCipher 4 encrypted SQLite databases under `xwechat_files/<wxid>/db_storage/` (`contact.db`, `message_*.db`, `media_0.db`, `sns.db`, …). Each DB has its own 32-byte key living in the Weixin.exe process memory (`com.Tencent.WCDB.Config.Cipher` config objects). The library locates them with a **read-only memory scan**, validates candidates with SQLCipher HMAC rules, decrypts pages to a temp dir and caches the result (first decrypt ~6s, then instant). WAL incremental merging with frame-salt filtering prevents `database disk image is malformed` corruption.
|
||||
- **Sending** — WeChat 4.x chat UI is self-drawn (no accessibility nodes), so sending uses a hybrid driver: hot-activate the **Qt accessibility gate** inside Weixin.dll (RVA scan, writes the screen-reader flag) to materialize the `mmui::*` UIA tree — search box, `chat_input_field`, etc. Sending is **UIA-first, coordinate + OCR fallback**: auto-calibrating layout (`~/.wechatauto/layout-<machine>.json`), zoomed OCR (3x) with multi-round voting for rare Chinese characters, clipboard + Ctrl+V input to dodge IME interception.
|
||||
- **Media** — image `.dat` files are `[6B sig][4B aes_size][4B xor_size] + AES-ECB + plaintext + xor` chunks. The account-level AES key is transient (only resident in memory while viewing an image); `MediaDownloader` scans for it, validates via JPEG/PNG magic, and **persists it to `image_keys.json`** so later runs need no scanning (or pass `image_key=` explicitly). Voice is plain SILK read from `media_0.db`; files are read from `msg/file/` with original names resolved from `message_resource.db`.
|
||||
|
||||
## ⚖️ vs wxauto
|
||||
|
||||
| | wxauto | wechatauto-replica |
|
||||
|---|---|---|
|
||||
| WeChat 4.x | ❌ UIA tree gone → broken | ✅ DB decryption + UIA hot-activation |
|
||||
| Message reading | via UI tree | via local DB (full history, faster) |
|
||||
| Sending | UIA clicks | UIA-first + OCR fallback |
|
||||
| Media | limited | image AES decrypt, SILK voice, files |
|
||||
| Moments | read | read (posting dropped: self-drawn UI) |
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
1. **WeChat must be logged in** — DB keys live in process memory; cached after first extraction, re-extracted automatically after re-login.
|
||||
2. **Image AES key is transient** — only resident while viewing an image; persisted to `image_keys.json` once found, or inject via `image_key=`.
|
||||
3. **Sending is a GUI operation** — fails cleanly when the desktop is locked (`desktop_available()` returns False).
|
||||
4. **Videos** are downloadable only when the mp4 already exists on disk (`msg/video/`).
|
||||
5. **Group-chat image originals** are stored locally only after being opened (viewed) in WeChat; until then only the thumbnail (`_t.dat`) exists — `download_image` falls back to the thumbnail (marked `_thumb` in the filename).
|
||||
6. **Moments posting is dropped** (4.x self-drawn UI, unreliable); reading/likes/comments are supported.
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- Calibrate and verify file/image/reply/@ sending on unlocked desktops
|
||||
- Video message download (4.x storage location TBD)
|
||||
- Performance: parallel export / first-scan, incremental memory-scan cache
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### v1.1.7 (2026-08-22)
|
||||
- **Master-key based key extraction (PR #10, thanks [NothingFumo](https://github.com/NothingFumo))**: instead of scanning process memory for per-DB `Config.Cipher` literals (which fails on WeChat 4.1.12.26+), we now extract the **single master key** from the `cfg` structure (`cfg+0x2B8` cipher XORed with 4×movabs constants from the DLL) and **derive each DB key offline** via `PBKDF2-HMAC-SHA512(master_key, db_salt, 256000)` — 27/27 SQLCipher4 DBs verified. This fixes key extraction on 4.1.12.26+ (issues #3 / #7).
|
||||
- **Unified image-key pipeline**: template collection (`*_t.dat`, top 16 by mtime) → tail-byte majority XOR (replaces the old single-file probe that could wrongly fall back to `0x88`) → `cfgDword` derivation (deterministic, offline) preferred, with injected/cached/memory-scan AES fallbacks. Probe-verified on 3000/3000 real ciphertexts.
|
||||
- **Account fields from cfg**: `WeChatDB` now also returns `name` / `number` / `phone` alongside the master key, matching the output format of mainstream key tools.
|
||||
- New optional params `master_key` / `cfg_dword` are fully backward compatible — if not passed, the original path is used. Core decryption functions unchanged.
|
||||
|
||||
### v1.1.6.1 (2026-08-20)
|
||||
- **PyPI description fix**: v1.1.6 was uploaded without the synced `README_pypi.md` (description still showed 1.1.5.1); this patch restores the full v1.1.6 changelog and bumps the version marker.
|
||||
|
||||
### v1.1.6 (2026-08-20)
|
||||
- **Auto-diagnosis on missing key**: `数据库无可用密钥` now runs a built-in check before raising — Python bitness (32-bit can't read 64-bit Weixin memory), per-PID `OpenProcess`/`ReadProcessMemory` permission, and multi-account mismatch (all `wxid_*` dirs vs. picked account, suggesting `WeChatDB(account=...)`). No need to run `diagnose_keys` first.
|
||||
- **New diagnostic tool**: `wechatauto/diagnose_keys.py` (`python -m wechatauto.diagnose_keys`, WeChat logged in) dumps lib version, Python bitness, Weixin PIDs with per-process read-permission checks, all accounts vs. picked account, cached keys, fresh in-memory extraction, and key verification — paste the output when reporting key-extraction failures.
|
||||
- **Skip `migrate\unspportmsg.db`**: WeChat's reserved "unsupported message" DB has no in-memory key and is never queried; it was forcing a full process-memory scan on every init.
|
||||
|
||||
### v1.1.5.1 (2026-08-18) — beta
|
||||
- **Fix real-time listening**: `WeChatDB.get_new_messages()` referenced an undefined `found` (NameError swallowed by `Listener._poll_once`), so **no** message callbacks ever fired — including first messages from contacts you had never chatted with.
|
||||
- **Dynamic message shards**: `_message_dbs()` now re-scans the disk so shards WeChat creates at runtime (e.g. `message_5.db`) are picked up and their keys extracted automatically.
|
||||
|
||||
### v1.1.5 (2026-08-18)
|
||||
- **Version cleanup**: normalized the patch version (1.1.4.2 → 1.1.5) after the `media_*.db` voice fix.
|
||||
|
||||
### v1.1.4.2 (2026-08-18)
|
||||
- **PyPI description cleanup**: removed the demo default-group changelog line from the PyPI description.
|
||||
|
||||
### v1.1.4.1 (2026-08-18)
|
||||
- **PyPI readme bilingual**: merged the Chinese (`README.zh-CN.md`) and English (`README.md`) into one PyPI description so the Chinese version is visible on the package page.
|
||||
|
||||
### v1.1.4 (2026-08-18)
|
||||
- **Voice download across all media databases**: `download_voice()` now searches every `media_*.db` (not just `media_0.db`) — WeChat shards voice data across multiple media DBs; previously voices stored in `media_1.db` etc. could not be found (thanks uiharukazari0105).
|
||||
- **`demo_media.py --images N`**: download the latest N images of a chat directly from the DB (by local_type), bypassing the total-message `--limit` — no more "only a few images listed" when a group has thousands of messages.
|
||||
- **`WeChatDB._find_media_rows(user, types)`**: new helper returning all media local_ids of a chat for a set of local_types (batch download).
|
||||
- **Group-chat image thumbnail fallback**: original images in group chats are only downloaded after being opened in WeChat; `download_image` now falls back to the thumbnail (`_t.dat`) when the original is missing, saving it with a `_thumb` suffix.
|
||||
|
||||
### v1.1.3 (2026-08-17)
|
||||
|
||||
### v1.1.2 (2026-08-16)
|
||||
- **UIA driver thread-safety**: `WeChatUIA` now initializes COM on the current thread (`CoInitializeEx`, idempotent) — fixes crashes when instantiated from background threads / host apps (e.g. WeChatBot) with "CoInitialize not called / cannot load UIAutomationCore.dll" errors.
|
||||
- **Main-window filtering**: only windows whose process loaded `Weixin.dll` are considered — auxiliary processes without the DLL (whose hot-activation always fails) no longer produce noise warnings.
|
||||
- **Forward-voice fix**: `Chat.ForwardVoiceMessage` uses `self` when no target is given (the previous `_cur()` could resolve the wrong chat).
|
||||
- **Re-entrant UI lock**: `LockManager` is now re-entrant per thread — `@uilock` functions calling each other (e.g. `ForwardVoiceMessage` → `VoiceMessage.forward_to`) no longer deadlock.
|
||||
|
||||
### v1.1.1 (2026-08-16)
|
||||
- **Recall last message** (`Chat.RecallLastMessage` / `uia_driver.recall_last_message`): right-click the latest own message → UIA-first menu-item click (`mmui::XMenuView` found inside the main-window subtree), OCR fallback; fails cleanly when the 2-minute recall window has passed (menu only shows "Delete").
|
||||
- UIA robustness: menu-item lookup scoped to the main-window subtree (avoids the Windows UIA root-traversal hang), removed the fragile `WindowControl(ClassName=...)` fallback.
|
||||
- Media fix: video id bytes→str decoding in `MediaDownloader`.
|
||||
- `demo_media.py --photos` default 3 → 10.
|
||||
|
||||
### v1.1.0 (2026-08-15)
|
||||
- **Image AES key auto-capture** (`media.py`): the V2 image key is only resident in memory while viewing an image (~5 min). `_scan_aes_key()` gained a `monitor` mode — polls continuously and persists the key to `image_keys.json` once found; users just open one image to finish setup.
|
||||
- Fixed the process-ordering scan bug (removed the memory-usage sort that pushed the main process last).
|
||||
- **Forward voice messages**: SILK extraction from `media_0.db` + file-message send (`demo_forward_voice.py`).
|
||||
- New demos: `demo_group_messages.py` (group + red-packet ZSTD parsing), `demo_robust.py`.
|
||||
|
||||
## 🤝 Acknowledgments
|
||||
|
||||
Thanks to [vesio](https://github.com/vesio) for sharing the WeChat 4.1.12 UIA control-tree approach and debugging ideas in [issue #1](https://github.com/fanyuantaier/wechatauto-replica/issues/1) — it made the UIA hybrid driver (v1.0.8) possible.
|
||||
|
||||
Thanks to [nanshanjack](https://github.com/nanshanjack) for finding the UI-lock re-entrancy problem (fixed in v1.1.2).
|
||||
|
||||
Thanks to [maozhitao12450](https://github.com/maozhitao12450) for reporting the WXAM (wxgf) image download issue (fixed in v1.1.3).
|
||||
|
||||
Thanks to [uiharukazari0105](https://github.com/uiharukazari0105) for finding that voice data stored in `media_1.db` (and later) was never searched (fixed in v1.1.4).
|
||||
|
||||
## 📄 License & Disclaimer
|
||||
|
||||
Apache-2.0. This project is for personal learning and automation research only — please respect the WeChat software license agreement and applicable laws.
|
||||
|
||||
Contact: fanyuantaier@163.com
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""诊断:自己的 wxid、会话类型、最近消息 sender_id 结构"""
|
||||
import sys, os, time
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
os.system("chcp 65001 >nul 2>&1")
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from wechatauto.db import WeChatDB
|
||||
|
||||
db = WeChatDB()
|
||||
info = db.get_self_info()
|
||||
print("=== 自己 ===")
|
||||
print("username:", info.get("username"))
|
||||
print("nick_name:", info.get("nick_name"))
|
||||
|
||||
print("\n=== 会话列表(前 40 个,标注类型)===")
|
||||
sessions = db.get_sessions(limit=40)
|
||||
for s in sessions:
|
||||
u = s["username"]
|
||||
kind = "群聊" if u.endswith("@chatroom") else ("文件助手" if u == "filehelper" else "好友/其他")
|
||||
print(f"[{kind}] {u} 未读={s['unread']} 摘要={ (s['summary'] or '')[:20]}")
|
||||
|
||||
print("\n=== 找 1 个好友会话,看最近 5 条消息的 sender_id 结构 ===")
|
||||
for s in sessions:
|
||||
u = s["username"]
|
||||
if u.endswith("@chatroom") or u == "filehelper":
|
||||
continue
|
||||
msgs = db.get_messages(u, limit=5)
|
||||
if not msgs:
|
||||
continue
|
||||
nick = db.get_nickname(u)
|
||||
print(f"\n好友会话: {u} (昵称/备注={nick})")
|
||||
for m in reversed(msgs):
|
||||
sid = m["sender_id"]
|
||||
me = "★自己" if (str(sid) == "2" or str(sid) == str(info.get("username"))) else "对方"
|
||||
print(f" sender_id={sid!r} {me} | {m['type']} | {str(m['content'])[:30]}")
|
||||
break
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""实测 sender_id:get_messages 与 Listener 回调两条路径的真实值"""
|
||||
import sys, os, tempfile, time, threading
|
||||
BASE = r"C:\Users\Administrator\wechatauto-replica"
|
||||
os.chdir(BASE)
|
||||
sys.path.insert(0, BASE)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
from wechatauto.db import WeChatDB, Listener
|
||||
|
||||
wd = tempfile.mkdtemp(prefix="diag_sid_")
|
||||
db = WeChatDB(workdir=wd)
|
||||
info = db.get_self_info()
|
||||
self_wxid = info.get("username")
|
||||
print("SELF wxid:", self_wxid, "| nick:", info.get("nick_name"))
|
||||
|
||||
# 路径1:get_messages 的 sender_id
|
||||
who = "wxid_yxnyrj5sul1g22"
|
||||
try:
|
||||
msgs = db.get_messages(who, limit=12)
|
||||
print("\n[get_messages] 好友", who, "最近", len(msgs), "条:")
|
||||
for m in reversed(msgs):
|
||||
sid = str(m.get("sender_id"))
|
||||
me = "★自己" if (sid == "1" or sid == self_wxid) else "对方"
|
||||
print(f" sender_id={sid!r} {me} | {m.get('type')} | {str(m.get('content'))[:28]}")
|
||||
except Exception as e:
|
||||
print("get_messages ERR:", repr(e))
|
||||
|
||||
# 路径2:Listener 回调的 sender_id(真实监听一条,看回调字段)
|
||||
print("\n[Listener] 用 add_listener 监听该好友,等 3s 观察 watermark 初始化…")
|
||||
seen = {}
|
||||
lst = Listener(db, interval=1.0)
|
||||
|
||||
def on_msg(m, l):
|
||||
sid = str(m.get("sender_id"))
|
||||
print(" Listener 回调 sender_id=%r | content=%r" % (sid, str(m.get("content"))[:30]))
|
||||
|
||||
lst.add_listener(who, on_msg)
|
||||
lst.start()
|
||||
time.sleep(3)
|
||||
lst.stop()
|
||||
print("[Listener] 结束(若无新消息则无回调输出,属正常)")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 534 KiB |
+514
@@ -0,0 +1,514 @@
|
||||
# wechatauto 技术文档
|
||||
|
||||
> 版本:1.0.6 | 适用:微信 4.x Windows 客户端 | 语言:Python 3.9+
|
||||
> 本文档描述 wechatauto 的设计原理、内部实现与扩展方式,面向二次开发与排障。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
wechatauto 是上游 [wxauto](https://wxauto.org) 的复刻版,面向**微信 4.x
|
||||
Windows 客户端**的自动化库。它**不是网页版**,直接操作本机客户端。
|
||||
|
||||
与老版本微信(3.x,暴露 UIAutomation 无障碍树)不同,微信 4.1.12+ 的聊天
|
||||
区域采用**自绘渲染**(`MMUIRenderSubWindow*`,不同版本后缀不同,如
|
||||
`MMUIRenderSubWindowHW` / `MMUIRenderSubWindow`),对 UIAutomation / MSAA 完全
|
||||
不暴露内容。因此本项目拆成两条技术路线:
|
||||
|
||||
| 路线 | 模块 | 用途 |
|
||||
| ---- | ---- | ---- |
|
||||
| 本地数据库解密 | `wechatauto/db.py` | 读取消息、监听、历史导出、会话/联系人 |
|
||||
| 坐标 + OCR | `wechatauto/guia.py` | 发送文本/文件/图片、回复、@成员 |
|
||||
|
||||
外加两个独立子系统:媒体下载(`media.py`)、朋友圈读取(`moment.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ wechatauto │
|
||||
├──────────────┬─────────────┬───────────────┤
|
||||
读取/监听/导出 │ wechatauto │ wechatauto │ wechatauto │ 发送/回复/@
|
||||
┌──────────────┐ │ db.py │ media.py │ guia.py │ ┌────────────┐
|
||||
│ Listener │ │ (SQLCipher4) │ (媒体解密) │ (坐标+OCR) │ │ WeChatGUI │
|
||||
│ MomentDB │ └──────────────┴─────────────┴───────────────┘ └────────────┘
|
||||
│ msgs/ ui/ │ │ │ │
|
||||
└──────┬───────┘ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
contact/session/message_*.db msg\attach\*.dat 屏幕截取 + Win32 微信主窗口
|
||||
sns.db(SQLCipher 4) media_0.db 用户输入(SendInput) (MMUIRender…)
|
||||
```
|
||||
|
||||
### 2.1 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| ---- | ---- |
|
||||
| `db.py` | 微信数据库密钥提取、SQLCipher 4 解密、WAL 增量合并、消息查询/监听/导出、会话/联系人 |
|
||||
| `guia.py` | 坐标 + OCR 发送:窗口定位、会话点击、输入框探测、剪贴板粘贴、发送按钮、发送后 DB 验证 |
|
||||
| `media.py` | 图片 v2 解密(AES+XOR)、语音/视频/文件下载、剪贴板 CF_HDROP |
|
||||
| `moment.py` | 朋友圈时间线读取、媒体下载(`MomentDB` 数据库路线) |
|
||||
| `wx.py` | 高层 API 入口(`WeChat`/`Chat`/`Listener`),内部按能力分派到 DB 或 GUI |
|
||||
| `msgs/` | 消息对象模型(文本/图片/语音/引用/系统消息等) |
|
||||
| `ui/` | 老版 UIAutomation 控件层(微信 3.x 遗留,4.x 受限,保留兼容) |
|
||||
| `utils/` | `win32.py`(窗口/剪贴板/进程)、`lock.py`(全局 UI 锁)、`tools.py` |
|
||||
| `param.py` | `WxParam`/`WxResponse`/`PROJECT_NAME` 等公共参数与返回类型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库读取子系统(`wechatauto/db.py`)
|
||||
|
||||
### 3.1 数据存储布局
|
||||
|
||||
微信 4.x 数据位于登录目录下。**不同电脑/账号的目录位置不同**,由
|
||||
`auto_detect_db_dir()` 自动定位,探测链(`db.py`):
|
||||
|
||||
1. **微信配置文件**:扫描 `%APPDATA%`/`%LOCALAPPDATA%` 下
|
||||
`Tencent\xwechat`、`Tencent\WeChat` 等配置目录内的**所有文件**,内容支持
|
||||
JSON(`dataDir`/`fileSavePath` 等字段)、纯路径、或任意文本中提取第一个
|
||||
Windows 路径(`_extract_path_from_config`);
|
||||
2. **注册表**:读 `HKCU\Software\Tencent\xwechat` 等键下含
|
||||
path/dir/save 的值(用户自定义保存位置时的补充来源);
|
||||
3. **常见默认目录**:`Documents`、用户主目录兜底。
|
||||
|
||||
定位结果统一经 `_locate_account_root()` 归一化:兼容
|
||||
`<root>/xwechat_files/<wxid>_xxxx/db_storage` 与
|
||||
`<root>/<wxid>_xxxx/db_storage` 两种布局,返回包含 `wxid_*` 账号目录的
|
||||
父目录(即 `WeChatDB.db_dir`)。仍找不到时可手动传入 `db_dir=` 指定。
|
||||
|
||||
典型布局:
|
||||
|
||||
```
|
||||
D:\微信文件\xwechat_files\<wxid>_xxxx\db_storage\
|
||||
├── contact\contact.db 联系人(昵称、备注、别名)
|
||||
├── session\session.db 会话列表(未读数、摘要、排序时间戳)
|
||||
├── message\message_0..4.db 聊天消息(按会话分表 Msg_<md5>,跨库分片)
|
||||
├── message\media_0.db 语音(VoiceInfo.voice_data,SILK 二进制)
|
||||
├── message\message_resource.db 文件原名(MessageResourceDetail.packed_info)
|
||||
├── sns\sns.db 朋友圈(SnsTimeLine、SnsDataItem XML)
|
||||
└── ...
|
||||
```
|
||||
|
||||
每个数据库都是 **SQLCipher 4** 加密的 SQLite:页大小 4096、
|
||||
`PBKDF2-HMAC-SHA512`(加密密钥迭代 256000 次),每页 IV(16) + HMAC(80)。
|
||||
|
||||
### 3.2 密钥提取(进程内存只读扫描)
|
||||
|
||||
每个数据库有**独立的 32 字节密钥**,运行时保存在微信进程
|
||||
`com.Tencent.WCDB.Config.Cipher` 配置对象中。`extract_keys()` 流程:
|
||||
|
||||
1. 用 `psutil` 枚举 `Weixin.exe` 进程,取可读内存区域;
|
||||
2. 定位字符串 `com.Tencent.WCDB.Config.Cipher` 的内存地址;
|
||||
3. 从字符串地址回溯配置对象(`[ptr][len]` 结构),读取数据块;
|
||||
4. 数据块与固定掩码(`CONFIG_XOR_MASK`)逐字节异或,得到明文配置:
|
||||
`x'<64位hex密钥><32位hex盐>'`;
|
||||
5. 用 SQLCipher 4 的 HMAC 校验规则验证候选密钥(取某库首页试解);
|
||||
6. 通过后写入 `%TEMP%\wechatauto_db\<账号>\keys.json` 缓存,
|
||||
之后进程内复用(`_cached_db`),无需每次重新扫描。
|
||||
|
||||
### 3.3 数据库解密
|
||||
|
||||
- SQLCipher 4,`PAGE_SZ=4096`,页尾 `RESERVE_SZ=80` 保留字节
|
||||
(IV 16 + HMAC 64);
|
||||
- 解密结果按页写入临时工作目录;通过比对源文件 mtime/size 的 stamp
|
||||
(`STAMP_VERSION=2`,含缓存格式版本号)判定是否可复用缓存;
|
||||
- 首次解密 contact.db 约 6s,之后全部秒级;
|
||||
- 每次打开连接前用 `PRAGMA integrity_check` 兜底,损坏时自动全量重建。
|
||||
|
||||
### 3.4 WAL 增量合并(帧盐校验)
|
||||
|
||||
微信 `-wal` 是**预分配文件**:checkpoint 时 WAL 头 salt+1 并清零写游标,
|
||||
但**旧世代帧仍留在文件中**。若合并时不过滤帧盐,会把过期页覆盖进主库,
|
||||
导致 `database disk image is malformed`。修复方案(`_merge_wal`):
|
||||
|
||||
1. 读取 WAL 头 `salt1/salt2`;
|
||||
2. 仅合并 **salt 与当前 WAL 头一致**的帧,旧世代帧直接跳过;
|
||||
3. 合并结果 `PRAGMA integrity_check` 校验,失败自动回退全量重建;
|
||||
4. 缓存 stamp 版本升级到 2,旧格式缓存强制重建。
|
||||
|
||||
### 3.5 消息分片与查询
|
||||
|
||||
- 会话名 → `Md5(会话微信号)` → 表名 `Msg_<md5>`;
|
||||
- 同一会话可能分片在多个 `message_*.db`,查询时按 `sort_seq` 合并排序;
|
||||
- 关键列:
|
||||
- `local_type` → 消息类型(见 `MSG_TYPE_NAMES`);
|
||||
- `real_sender_id`:`2` 表示自己,其他为数字 id,可经
|
||||
`message_resource.SenderName2Id` 反查微信号;
|
||||
- `server_id`:服务端消息 id;
|
||||
- `packed_info_data`:图片/视频 md5 等二进制载荷;
|
||||
- `sort_seq`:会话内单调递增序号(增量游标用);
|
||||
- `get_messages(user, limit, offset)` 倒序取最近消息;
|
||||
- `get_message_row(user, local_id)` 取含 `packed_info` 的原始行(媒体用);
|
||||
- `get_new_messages(user, since_seq)` 返回 `sort_seq > since_seq` 的增量,
|
||||
升序排列。
|
||||
|
||||
### 3.6 增量消息与水印
|
||||
|
||||
`Listener` 每个被监听会话维护一个 `watermark`(最近一次 `sort_seq`)。
|
||||
轮询时以水印为游标取增量,成功后把水印推进到 `msgs[-1]["sort_seq"]`,
|
||||
保证不重不漏。回调签名 `callback(msg: dict, listener)`。
|
||||
|
||||
### 3.7 会话 / 联系人 / 导出
|
||||
|
||||
- `get_sessions(limit)`:读 `session.db` 的 `SessionTable`
|
||||
(`username/unread/summary/last_time/last_sender`);
|
||||
- `search_contact(keyword)`:对 contact.db 按 昵称/备注/微信号/别名 模糊匹配;
|
||||
- `get_nickname(user)`:微信号 → 显示昵称(内部维护昵称/发送者索引);
|
||||
- `export_history(out, fmt, users, limit_per_chat)`:全量导出
|
||||
JSON / SQLite,支持指定会话与条数上限。
|
||||
|
||||
---
|
||||
|
||||
## 4. 媒体下载子系统(`wechatauto/media.py`)
|
||||
|
||||
### 4.1 媒体存储
|
||||
|
||||
| 媒体 | 位置 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| 图片 | `msg\attach\<会话md5>\<YYYY-MM>\Img\<md5>.dat` | v2 加密 |
|
||||
| 语音 | `media_0.db` → `VoiceInfo.voice_data` | SILK 明文 BLOB |
|
||||
| 文件 | `msg\file\<YYYY-MM>\<原文件名>` | 原名来自 message_resource |
|
||||
| 视频 | `msg\video\<YYYY-MM>\<id>.mp4` | 仅本地落盘时可用 |
|
||||
|
||||
### 4.2 图片 v2 格式
|
||||
|
||||
```
|
||||
[6B 签名 07 08 56 32 08 07] [4B aes_size LE] [4B xor_size LE]
|
||||
+ AES-ECB 密文段 + 明文段 + 异或段
|
||||
```
|
||||
|
||||
### 4.3 AES 密钥内存反测
|
||||
|
||||
图片 AES 密钥是 16 字节 ASCII、账户级稳定,但**仅在微信查看图片时驻留进程
|
||||
内存**。`detect_image_key()` 的流程:
|
||||
|
||||
1. 优先读缓存 `image_keys.json`;
|
||||
2. 否则对 `Weixin.exe` 内存做定向扫描,对每个候选 16 字节串用 AES-ECB
|
||||
解密图片首块,校验 JPEG/PNG 魔数,命中即认为有效;
|
||||
3. 命中后持久化;也可用 `image_key=` 显式注入。
|
||||
|
||||
### 4.4 XOR 密钥推导
|
||||
|
||||
异或段密钥是单字节,从同图缩略图 `<md5>_t.dat` 尾部 JPEG 结束标记
|
||||
`FF D9` 反推:`key = tail[0] ^ 0xFF`。
|
||||
|
||||
### 4.5 下载分发
|
||||
|
||||
`download_media(user, local_id)` 按 `local_type` 自动分发到
|
||||
`download_image/_voice/_video/_file`,输出到 `DEFAULT_SAVE_PATH`
|
||||
(`~/Documents/wechatauto_media`)或自定义 `save_dir`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 朋友圈子系统(`wechatauto/moment.py`)
|
||||
|
||||
`MomentDB` 直接读 `sns.db`:
|
||||
|
||||
- `get_moments(limit, offset, username)`:时间线(最新在前,3382 条全量可读);
|
||||
- `get_moment(tid)` / `get_my_moments(limit)`:单条 / 我的动态;
|
||||
- 文本、图片 md5、点赞、评论均由 `SnsDataItem` XML 解析得到;
|
||||
- `find_local_media(md5, kind)` / `download_media(media, save_dir)`:
|
||||
本地缓存(`Sns\Img` / `Sns\Video`)优先,否则 URL 拉取。
|
||||
|
||||
> 发表朋友圈已舍弃(4.x 自绘界面不可靠);点赞/评论仅在旧 UIA 类中
|
||||
> 保留,4.x 下不可用。
|
||||
|
||||
---
|
||||
|
||||
## 6. 发送子系统(`wechatauto/guia.py`)
|
||||
|
||||
发送走「屏幕坐标 + 本地 OCR」,全部坐标基于**渲染子窗口相对坐标**,
|
||||
运行时换算为屏幕绝对坐标,保证跨 DPI / 分辨率 / 窗口尺寸一致。
|
||||
|
||||
### 6.1 窗口定位与布局
|
||||
|
||||
- 主窗口:**多特征兜底定位**(防 Qt 升级改名)。类名前缀
|
||||
`Qt51514QWindowIcon` 只是「软条件」之一,`_find_main_window()` 对全部
|
||||
可见顶层窗口按「标题含微信 / 类名前缀 / 进程名 `weixin.exe` / 可见 /
|
||||
尺寸 ≥ 800px」加权评分,取最高分;完全找不到时退回按标题精确查找。
|
||||
- 渲染子窗口:类名前缀 `MMUIRenderSubWindow`(自绘内容所在;不同版本
|
||||
后缀不同,如 `MMUIRenderSubWindowHW` / `MMUIRenderSubWindow`,
|
||||
`_find_render_window()` 按前缀匹配并取面积最大者);找不到渲染子窗口
|
||||
时(Qt 改版等)直接**回退用主窗口矩形计算坐标**,不中断使用。
|
||||
- `_update_render_rect()` 读 `GetWindowRect` 得到渲染区,`_update_layout()`
|
||||
按比例换算各布局常量:
|
||||
- `SIDEBAR_RATIO=0.22` → 侧栏宽度 / 渲染区宽度(默认基线);
|
||||
- `SEARCH_BOX_RATIO` → 左侧搜索框;
|
||||
- `SEND_BUTTON_RATIO` → 右下角发送按钮检索区;
|
||||
- 所有像素坐标均改为比例计算,避免硬编码失效。
|
||||
|
||||
### 6.1.1 布局动态校准(防 DPI/布局漂移)
|
||||
|
||||
不同机器(DPI 缩放 / 窗口尺寸 / 微信版本)下,固定比例常量可能漂移。
|
||||
`WeChatGUI` 提供「跑一次永久兼容」的校准机制:
|
||||
|
||||
1. **锚点实测**:`calibrate_layout()` 用 OCR 检测「搜索」占位文本
|
||||
(文本中心 ≈ 侧栏宽 0.28)反推侧栏宽度;在右下角 OCR 找「发送」
|
||||
按钮反推其检索区比例。结果都钳制在合理范围(侧栏比例限
|
||||
`[0.14, 0.30]`),**检测失败一律回退默认常量**,宁可不动不误配;
|
||||
2. **按机器缓存**:结果写入 `~/.wechatauto/layout-<主机名>_<分辨率>.json`
|
||||
(含当时的 `render_w/h`);再次运行时 `_load_layout()` 自动加载,
|
||||
与当前窗口尺寸差异 >15% 视为不匹配,自动重新校准;
|
||||
3. **异常自动重校准**:`get_input_box()` 探测连续 6 次失败时,自动触发
|
||||
`calibrate_layout()` 并再次探测(每会话一次),应对运行中途布局漂移;
|
||||
4. **显式强制**:`WeChatGUI(calibrate=True)` 强制重新校准;也可在任何
|
||||
时候调用 `wx.calibrate_layout()`。
|
||||
|
||||
> 说明:微信 4.x 侧栏背景与消息区同为纯白,**像素色差检测不可靠**
|
||||
> (实测边界两侧均为 255,唯一稳定锚点是搜索框 OCR 文本)。
|
||||
|
||||
### 6.2 会话定位与点击(防 toggle 关闭)
|
||||
|
||||
`open_chat()` 优先点侧栏(最可靠),失败走搜索框回退。流程:
|
||||
|
||||
1. `find_session(name)`:OCR 侧栏,按名称匹配返回会话行位置;
|
||||
2. **活动行检测** `_row_is_active(rel_y)`:微信活动会话行背景为绿色
|
||||
`(21,172,112)`,普通行浅灰 `(238,238,240)`。采样行内横向条带统计
|
||||
"g 显著大于 r/b" 的绿色像素数,超过阈值判定为已打开;
|
||||
3. 若行已是活动行(会话已打开),**跳过点击**——因为再点一次会
|
||||
toggle 关闭会话(这是二次发送失败的历史根因);
|
||||
4. 否则点击并 `_chat_open_confirmed()`:轮询标题 OCR 命中,或
|
||||
`_pane_has_content()` 判定消息区已渲染非空白;
|
||||
5. 侧栏确认失败 → `_search_chat(name)` 搜索框兜底,命中独立聊天窗则
|
||||
`use_window` 切换 GUI 目标。
|
||||
|
||||
### 6.3 输入框定位与文本输入
|
||||
|
||||
- `get_input_box()`:在输入区按比例扫描白色矩形带(`_probe_input_box`);
|
||||
- `focus_input()`:点击输入框聚焦;
|
||||
- `input_text()`:文字以 **剪贴板 + Ctrl+V** 注入(绕过中文输入法拦截),
|
||||
失败时回退拼音组合(`pypinyin` + SendInput 大写字母键)。
|
||||
|
||||
### 6.4 发送与验证
|
||||
|
||||
- `click_send()`:OCR 定位发送按钮(`发送`/`Enter` 图标)点击,
|
||||
找不到则直接回车;
|
||||
- `verify=True` 时用 `WeChatDB` 读回确认:`send_msg` 轮询
|
||||
`_verify_sent()`(新消息类型 + 内容匹配),`send_file/send_image` 用
|
||||
`_verify_attachment_sent()`(`sort_seq` 基线 + 类型/文件名/`packed_info`
|
||||
匹配,图片按类型+时序)。
|
||||
- DB 实例经 `_get_db()` 缓存复用,避免每次发送验证都重新解析数据库
|
||||
(这是历史慢 2.5 分钟问题的根因之一)。
|
||||
|
||||
### 6.5 文件 / 图片发送(CF_HDROP)
|
||||
|
||||
文件/图片通过 **剪贴板 CF_HDROP + Ctrl+V** 插入草稿再回车发送,绕开自绘
|
||||
「+ 菜单」定位。`copy_files_to_clipboard(paths)` 写入文件列表剪贴板,
|
||||
粘贴后 `click_send()`。
|
||||
|
||||
### 6.6 回复与艾特
|
||||
|
||||
- `reply_msg(text, who, target_text, verify)`:悬停最近一条消息
|
||||
(`_last_message_y` 估算)→ OCR 悬停工具栏找「回复」→ 点击 →
|
||||
`input_text` → 发送;找不到工具栏回退右键菜单;
|
||||
- `at_member(member, text, who, verify)`:聚焦输入框 → 键入 `@` →
|
||||
OCR 成员选择弹层定位成员名 → 点击 → 输入正文 → 发送;
|
||||
- 两者均可用 `verify=True` 走 DB 确认。
|
||||
|
||||
### 6.7 关键工程修复记录
|
||||
|
||||
| 问题 | 根因 | 修复 |
|
||||
| ---- | ---- | ---- |
|
||||
| 二次发送失败/2.5min 卡死 | 点击已打开会话被 toggle 关闭 | `_row_is_active` 活动行高亮检测,跳过点击 |
|
||||
| 发送验证每次重新解析 DB | `WeChatDB()` 每次新建、重复解库 | `_get_db()` 缓存实例 |
|
||||
| 窗口尺寸变化布局失效 | 硬编码像素坐标 | 全量改按比例布局 |
|
||||
| `Listener.stop()` 崩溃(v1.0.3) | `_run/_poll_once` 用 `sys.stderr` 却未 `import sys` | db.py 补 `import sys` |
|
||||
| 部分文本消息显示为 `[文本]`/空(v1.0.3) | 微信 4.x 文本消息 content 为「容器头+明文+填充」结构,`_friendly_content` 无法解码 | 新增 `_extract_text_from_blob` 还原明文,数据库与 bot 均可见真实内容 |
|
||||
| 动画表情被落盘为打不开的伪 `.gif`(v1.0.3) | 解密后为 `wxgf` 容器(微信动画表情私有格式),按魔数误判为 GIF | `download_image` 对 `wxgf` 直接返回 `None`,不落盘 |
|
||||
| 搜索联系人点错「网络搜索」(v1.0.3) | OCR 未按视觉顺序排序,且未过滤「搜索网络结果」节标题 | `_search_chat` 按 y 排序并跳过网络搜索节标题,取第一条联系人 |
|
||||
| 表情截图截到自己发的消息(v1.0.3) | `capture()` 固定取「最底部一条」,截图前自己发消息时底部是自己的气泡 | 按消息方向(`msg.attr`)裁剪:自己消息用消息分隔空白定位、对方消息用头像锚点定位,阈值自适应截图尺寸 |
|
||||
| 逐条发送都要重新点击对话框(v1.0.3) | `send_msg` 每次对目标会话都走完整 `open_chat`(重扫侧栏+点击) | 记录 `_current_chat`,目标会话已打开时跳过 `open_chat` 直接输入发送 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 消息监听(`Listener`)
|
||||
|
||||
```python
|
||||
from wechatauto import WeChatDB
|
||||
from wechatauto.db import Listener
|
||||
|
||||
db = WeChatDB()
|
||||
lst = Listener(db, interval=1.0) # 轮询间隔(秒)
|
||||
lst.add_listener("filehelper", cb) # cb(msg: dict, listener)
|
||||
lst.start() # 后台守护线程
|
||||
lst.stop() # 优雅停止
|
||||
```
|
||||
|
||||
- 每个会话独立水印,`watermark` 属性可读写(支持断点续跑);
|
||||
- 单次轮询/回调异常不终止监听,写 `sys.stderr` 后继续;
|
||||
- 数据来源是解密后的本地库,与客户端在线状态无关;
|
||||
- **回调并发模型(v1.0.2)**:回调在独立工作线程中执行。每个被监听会话
|
||||
对应一条**串行**工作线程:同一会话内消息按序处理、不同会话间并行;
|
||||
轮询线程只负责读取数据库并分派任务,不会被慢回调(AI 调用 / 图片识别等)
|
||||
阻塞。`stop()` 会向工作队列投递停止信号并等待线程退出。
|
||||
|
||||
---
|
||||
|
||||
## 8. 消息模型与 UIA 兼容层
|
||||
|
||||
- `msgs/`:`BaseMessage` 及子类(`TextMessage/ImageMessage/VideoMessage/
|
||||
VoiceMessage/FileMessage/QuoteMessage/LinkMessage/LocationMessage/
|
||||
PersonalCardMessage/EmojiMessage/SystemMessage`),`parse_msg()` 统一解析;
|
||||
- `EmojiMessage`(`type='emotion'`,v1.0.2 新增):"动画表情"消息专属类型,
|
||||
`FriendEmojiMessage` / `SelfEmojiMessage` 按收发方向区分。微信 4.x 表情
|
||||
content 在数据库中为加密数据,`capture()` 采用「打开会话 → 滚动到底 →
|
||||
截取消息区 → 自动裁剪最后一条消息气泡」的屏幕截图方案返回图片路径,
|
||||
供上层 AI 视觉识别使用(示例见 `demo_emoji_capture.py`)。
|
||||
|
||||
定位实现(`msgs/mtype.py`):
|
||||
- 微信 4.x 主窗口为 Qt 自绘渲染(`Qt51514QWindowIcon`),不暴露 UIA 子树,
|
||||
且 DB 模式消息的 control 是伪控件(`_DBMessageControl.BoundingRectangle`
|
||||
恒为 0),无法用控件坐标定位,因此**全部基于屏幕像素分析**;
|
||||
- `_pane_capture_img()` 截取消息区全宽画面(渲染顶部到输入框);
|
||||
- 自己发的消息(`attr='self'`):`_crop_bottom_message()` 用「消息分隔
|
||||
空白」定位消息顶部,空白阈值自适应截图高度(约消息区高度的 2.5%),
|
||||
跨分辨率/DPI 保持一致;
|
||||
- 对方发的消息(`attr='friend'`):`_crop_by_avatar()` 优先用左侧头像
|
||||
圆形彩色块的顶部锚定消息顶部(特征跨分辨率稳定),失败回退
|
||||
`_crop_bottom_message()`;
|
||||
- 结果再经 `_content_bbox_crop()` 裁掉空白边缘后保存;
|
||||
- 会话已打开时跳过 `open_chat`(避免刷新消息列表/切换窗口),并记录
|
||||
`[CAP]` 诊断日志与 `~/pane_diag_raw.png` 原图便于跨电脑排查;
|
||||
- `msgs/mtype.py`:`WxParam.MSG_TYPE_*` 常量到类型类的映射;
|
||||
- `ui/`:`BaseUISubWnd/Component/Main/NavigationBox/SessionBox/ChatBox`,
|
||||
老版 UIA 控件层,微信 4.x 下受限,保留兼容;
|
||||
- `wx.py`:`WeChat/Chat/Listener` 高层入口,内部按能力分派:
|
||||
聊天区读取/监听走 DB,发送走 GUI(`guia.WeChatGUI`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 工具层(`utils/`)
|
||||
|
||||
- `win32.py`:`find_all_windows_from_root`(遍历子窗口)、
|
||||
`GetPathByHwnd`(进程路径)、`SetClipboardText`;
|
||||
- `lock.py`:`LockManager` + `uilock` 全局互斥锁,避免多线程并发操作
|
||||
同一微信窗口;
|
||||
- `tools.py`:杂项工具函数。
|
||||
|
||||
---
|
||||
|
||||
## 10. API 参考
|
||||
|
||||
### `wechatauto/__init__.py` 顶层导出
|
||||
|
||||
`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`、异常类、消息模型、`parse_msg`、`PROJECT_NAME`。
|
||||
|
||||
### `WeChatDB`
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `get_self_info()` | 当前账号(username / nick_name / remark) |
|
||||
| `get_sessions(limit=100)` | 会话列表 |
|
||||
| `search_contact(keyword)` | 搜索联系人 |
|
||||
| `get_messages(user, limit, offset)` | 会话消息 |
|
||||
| `get_message_row(user, local_id)` | 原始行(媒体用) |
|
||||
| `get_new_messages(user, since_seq)` | 增量消息 |
|
||||
| `get_nickname(user)` | 微信号 → 昵称 |
|
||||
| `list_message_chats()` | 含消息的会话 |
|
||||
| `export_history(out, fmt, ...)` | 导出 JSON/SQLite |
|
||||
| `extract_keys()` | 手动密钥提取 |
|
||||
|
||||
### `MediaDownloader`
|
||||
|
||||
`detect_image_key(refresh)`、`decrypt_image(dat_path)`、
|
||||
`download_media(user, local_id)`、`download_image/_voice/_video/_file`、
|
||||
`copy_files_to_clipboard(paths)`。
|
||||
|
||||
### `MomentDB`
|
||||
|
||||
`get_moments(limit, offset, username)`、`get_moment(tid)`、
|
||||
`get_my_moments(limit)`、`find_local_media(md5, kind)`、
|
||||
`download_media(media, save_dir)`。
|
||||
|
||||
### `WeChatGUI`
|
||||
|
||||
`send_msg`、`send_file`、`send_image`、`reply_msg`、`at_member`、
|
||||
`open_chat`、`focus_input`、`bring_to_front`、`get_sessions`、
|
||||
`get_input_box`;一行式 `quick_send` 等。
|
||||
|
||||
---
|
||||
|
||||
## 11. 目录结构
|
||||
|
||||
```
|
||||
wechatauto/
|
||||
├── wechatauto/
|
||||
│ ├── __init__.py 公共导出
|
||||
│ ├── wx.py 高层 API(UIA 受限,分派 DB/GUI)
|
||||
│ ├── guia.py ★ 坐标+OCR 发送模块
|
||||
│ ├── db.py ★ 数据库解密/监听/导出
|
||||
│ ├── media.py ★ 媒体下载(图片 v2 解密)
|
||||
│ ├── moment.py ★ 朋友圈(MomentDB)
|
||||
│ ├── param.py / logger.py / languages.py / exceptions.py
|
||||
│ ├── ui/ msgs/ utils/ uia/
|
||||
│ └── py.typed
|
||||
├── demo.py UI 自动化示例(4.x 受限)
|
||||
├── demo_db.py ★ 数据库读取示例
|
||||
├── demo_guia.py ★ 坐标+OCR 发送示例
|
||||
├── demo_listen.py ★ 实时消息监听示例
|
||||
├── demo_reply_at.py ★ 回复/@ 成员实测示例
|
||||
├── demo_emoji_capture.py ★ 表情消息截图示例
|
||||
├── README.md
|
||||
├── docs/技术文档.md 本文档
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知限制
|
||||
|
||||
1. **需要微信登录**:数据库密钥存于进程内存,首次需微信运行中(提取后缓存);
|
||||
重新登录密钥变化需重新提取(自动校验失败重扫);
|
||||
2. **图片 AES 密钥瞬态**:仅在微信查看图片时驻留内存,命中后持久化
|
||||
`image_keys.json`,也可 `image_key=` 显式注入;
|
||||
3. **发送依赖桌面**:锁屏/会话断开时 `desktop_available()` 返回 False;
|
||||
回复/艾特依赖 OCR 工具栏/弹层,弱网或弹层排版变化时可能失败;
|
||||
4. **视频仅本地可用**:视频 mp4 未落盘时返回 None;
|
||||
5. **发朋友圈已舍弃**;点赞/评论在 4.x 下不可用;
|
||||
6. **UI 自动化受限**:4.1.12+ 聊天区自绘,UIA 路线不可用。
|
||||
|
||||
---
|
||||
|
||||
## 13. 扩展开发指南
|
||||
|
||||
### 新增一个发送类型
|
||||
|
||||
1. 在 `guia.py` 仿照 `send_file`/`send_image` 实现原语(定位 → 注入 →
|
||||
发送 → 可选验证);
|
||||
2. 需要验证时,用 `_target_seq()` 取基线、`_verify_attachment_sent()`
|
||||
轮询 DB;
|
||||
3. 在 `__init__.py` 导出,并加一行式 `quick_*` 封装。
|
||||
|
||||
### 新增一种媒体格式
|
||||
|
||||
1. 在 `media.py` 参照 `_decrypt_v2` 实现解码原语;
|
||||
2. 在 `download_media()` 分发处按 `local_type` 接入;
|
||||
3. 按需在 `_img_md5`/`_find_dat` 补充定位逻辑。
|
||||
|
||||
### 新增数据库表
|
||||
|
||||
在 `db.py` 的 `_collect_db_files()` 注册新库,用 `_open(rel)` 打开连接,
|
||||
复用 `_check_merged` 的 WAL 合并与 integrity 兜底。
|
||||
|
||||
### 布局常量校准
|
||||
|
||||
微信更新导致 OCR 定位失效时,优先检查 `guia.py` 顶部 `*_RATIO` 常量与
|
||||
`_update_layout()`,而非改硬编码坐标。跨机器/DPI 的布局漂移走自动校准:
|
||||
删掉 `~/.wechatauto/layout-<机器>.json` 后重启(或
|
||||
`WeChatGUI(calibrate=True)` / 调用 `calibrate_layout()`)会重新实测并
|
||||
缓存「搜索/发送」锚点比例。
|
||||
|
||||
---
|
||||
|
||||
## 14. 路线图
|
||||
|
||||
1. 回复/艾特的跨版本稳定性校准(当前依赖 OCR 工具栏/弹层位置);
|
||||
2. 视频消息下载增强(确认 4.x 视频落盘位置);
|
||||
3. 导出/首扫并行化,密钥内存扫描增量缓存;
|
||||
4. 支持更多消息类型(小程序、转账、位置等)友好显示(表情消息已在 v1.0.2
|
||||
支持)。
|
||||
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""v10:纯图发布 + 长等待,正确相机坐标 (1244,84),验证图片上传。"""
|
||||
import sys, os, time, json, ctypes
|
||||
from ctypes import wintypes
|
||||
sys.path.insert(0, r"C:\Users\Administrator\wechatauto-replica")
|
||||
os.chdir(r"C:\Users\Administrator\wechatauto-replica")
|
||||
|
||||
from wechatauto.guia import (LAYOUT_CONFIG_DIR, SIDEBAR_RATIO, SEND_BUTTON_RATIO, _machine_id, WeChatGUI, ScreenOCR)
|
||||
from PIL import ImageGrab
|
||||
|
||||
p = os.path.join(LAYOUT_CONFIG_DIR, f"layout-{_machine_id()}.json")
|
||||
if not os.path.isfile(p):
|
||||
os.makedirs(LAYOUT_CONFIG_DIR, exist_ok=True)
|
||||
json.dump({"machine": _machine_id(), "sidebar_ratio": SIDEBAR_RATIO,
|
||||
"send_button_ratio": list(SEND_BUTTON_RATIO), "render_w": 1298, "render_h": 950,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S")}, open(p, "w", encoding="utf-8"))
|
||||
|
||||
wx = WeChatGUI()
|
||||
u = ctypes.windll.user32
|
||||
|
||||
def upd(): wx._update_render_rect()
|
||||
|
||||
def find_dialog():
|
||||
found = []
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def cb(h, lp):
|
||||
if u.IsWindowVisible(h):
|
||||
b = ctypes.create_unicode_buffer(256)
|
||||
u.GetClassNameW(h, b, 256)
|
||||
if b.value == "#32770":
|
||||
r = wintypes.RECT()
|
||||
u.GetWindowRect(h, ctypes.byref(r))
|
||||
found.append((r.left, r.top, r.right, r.bottom))
|
||||
return True
|
||||
u.EnumWindows(cb, 0)
|
||||
return found[0] if found else None
|
||||
|
||||
def click_abs(x, y, wait=1.0):
|
||||
u.SetCursorPos(int(x), int(y)); time.sleep(0.12)
|
||||
u.mouse_event(0x0002,0,0,0,0); u.mouse_event(0x0004,0,0,0,0); time.sleep(wait)
|
||||
|
||||
def paste(t):
|
||||
import pyperclip
|
||||
pyperclip.copy(t); time.sleep(0.25)
|
||||
u.keybd_event(0x11,0,0,0); u.keybd_event(0x56,0,0,0)
|
||||
u.keybd_event(0x56,0,2,0); u.keybd_event(0x11,0,2,0); time.sleep(0.3)
|
||||
|
||||
def ctrl_a():
|
||||
u.keybd_event(0x11,0,0,0); u.keybd_event(0x41,0,0,0)
|
||||
u.keybd_event(0x41,0,2,0); u.keybd_event(0x11,0,2,0); time.sleep(0.15)
|
||||
|
||||
upd()
|
||||
RW, RH = wx.render_w, wx.render_h
|
||||
ox, oy = wx.origin_x, wx.origin_y
|
||||
img_path = r"C:\Users\Administrator\wechatauto-replica\_test_moment.jpg"
|
||||
|
||||
# 1. 点相机(实测坐标 1244,84)
|
||||
wx.wx_click(ox+1244, oy+84); time.sleep(2.5)
|
||||
dlg = find_dialog()
|
||||
if not dlg:
|
||||
print("RESULT: NO_DIALOG"); sys.exit()
|
||||
dx0, dy0, dx1, dy1 = dlg
|
||||
DW, DH = dx1-dx0, dy1-dy0
|
||||
print(f"[v10] 对话框 {DW}x{DH}")
|
||||
|
||||
# 2. 选图
|
||||
click_abs(dx0+int(DW*0.33), dy0+int(DH*0.90), wait=0.8)
|
||||
ctrl_a(); paste(img_path); time.sleep(0.5)
|
||||
click_abs(dx0+int(DW*0.84), dy0+int(DH*0.94), wait=2.0)
|
||||
print("[v10] 已选图")
|
||||
|
||||
# 3. 长等待 30s,观察图片状态
|
||||
for i in range(6):
|
||||
time.sleep(5)
|
||||
upd()
|
||||
img = ImageGrab.grab(bbox=(ox, oy, ox+RW, oy+RH))
|
||||
lines = ScreenOCR.recognize(img)
|
||||
has_pic = any("Unai" in t or "unai" in t.lower() for t,_,_,_,_ in lines)
|
||||
has_cancel = any("取消" in t for t,_,_,_,_ in lines)
|
||||
print(f" {i*5+5}s 图片在编辑框={has_pic} 取消按钮={has_cancel}")
|
||||
|
||||
# 4. 绿色扫描发表按钮
|
||||
upd()
|
||||
box = wx._rel_to_screen((int(RW*0.20), int(RH*0.40), RW, RH))
|
||||
im = ImageGrab.grab(bbox=box).convert("RGB")
|
||||
px = im.load(); pts = []
|
||||
for y in range(0, im.size[1], 3):
|
||||
for x in range(0, im.size[0], 3):
|
||||
r,g,b = px[x,y][:3]
|
||||
if abs(r)<45 and abs(g-195)<45 and abs(b-117)<45:
|
||||
pts.append((x,y))
|
||||
if len(pts) < 6:
|
||||
print("RESULT: NO_GREEN_BUTTON"); sys.exit()
|
||||
xs=[q[0] for q in pts]; ys=[q[1] for q in pts]
|
||||
bx = int(RW*0.20)+(min(xs)+max(xs))//2
|
||||
by = int(RH*0.40)+(min(ys)+max(ys))//2
|
||||
print(f"[v10] 发表按钮 ({bx},{by}) 绿色点{len(pts)}")
|
||||
|
||||
# 5. 点发表
|
||||
wx.wx_click(ox+bx, oy+by); time.sleep(6.0)
|
||||
|
||||
# 6. 验证
|
||||
from wechatauto.db import WeChatDB
|
||||
from wechatauto.moment import MomentDB
|
||||
md = MomentDB(WeChatDB())
|
||||
now = int(time.time())
|
||||
for f in md.get_my_moments(limit=3):
|
||||
ct = f.get("create_time") or 0
|
||||
imgs = f.get("images") or []
|
||||
if abs(now-int(ct)) < 300:
|
||||
print(f"[v10] 最新 ct={ct} 图={len(imgs)} 正文={f.get('text','')[:30]}")
|
||||
print("RESULT: SUCCESS_WITH_IMAGE" if imgs else "RESULT: NO_IMAGE")
|
||||
break
|
||||
@@ -0,0 +1,51 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wechatauto-replica"
|
||||
version = "1.1.7"
|
||||
description = "微信 4.x Windows 客户端自动化库(wxauto 复刻版):SQLCipher 数据库解密读取 + UIA/坐标-OCR 混合发送,支持消息监听、媒体下载、朋友圈读取"
|
||||
readme = "README_pypi.md"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "Apache-2.0" }
|
||||
authors = [{ name = "wechatauto-replica" }]
|
||||
keywords = ["wechat", "weixin", "wechat4", "wxauto", "wechatauto", "uiautomation", "sqlcipher", "ocr", "rpa", "automation", "wechat-automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Win32 (MS Windows)",
|
||||
"Natural Language :: Chinese (Simplified)",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Communications :: Chat",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"uiautomation>=2.0.18",
|
||||
"pywin32>=305",
|
||||
"pyperclip>=1.8.2",
|
||||
"Pillow>=9.0.0",
|
||||
"psutil>=5.9.0",
|
||||
"colorama>=0.4.6",
|
||||
"cryptography>=41.0.0",
|
||||
"winsdk>=1.0.0b10",
|
||||
"imageio-ffmpeg>=0.4.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
guia = ["winsdk>=1.0.0b10", "pypinyin>=0.48.0"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/fanyuantaier/wechatauto-replica"
|
||||
Repository = "https://github.com/fanyuantaier/wechatauto-replica"
|
||||
Issues = "https://github.com/fanyuantaier/wechatauto-replica/issues" # 上游 wxauto 项目主页(wechatauto 为其复刻版)
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["wechatauto*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
wechatauto = ["py.typed"]
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
rem 微信智能客服引擎启动脚本(schtasks 常驻用)
|
||||
rem 用 unai-core 的 cpython(真 python,不 spawn 孪生进程),避免 launcher 双开竞争
|
||||
cd /d "C:\Users\Administrator\wechatauto-replica"
|
||||
set PYTHONPATH=
|
||||
set VIRTUAL_ENV=
|
||||
"C:\Users\Administrator\AppData\Local\unai-core-os\unai-core-os\python\cpython-3.12.13-windows-x86_64-none\python.exe" wechat_ai_reply.py >> ai_reply.log 2>&1
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "C:\Users\Administrator\wechatauto-replica"
|
||||
"C:\Users\Administrator\wechatauto-replica\.venv\Scripts\python.exe" wechat_ai_reply.py >> "C:\Users\Administrator\wechatauto-replica\engine.log" 2>&1
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
C:\wechatauto-replica\third_party\ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""端到端验证:大模型 + 知识库生成回复(不真发,不写真实好友历史)。"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
import wechat_ai_reply as eng
|
||||
|
||||
questions = [
|
||||
"你好,你们能做抖音数据采集吗?",
|
||||
"你们公司是做什么业务的?",
|
||||
"帮我看看有没有浏览器自动化的工具",
|
||||
]
|
||||
|
||||
for q in questions:
|
||||
kb = eng._kb.search(q)
|
||||
print("\n===== 客户问: %s =====" % q)
|
||||
print("知识库命中: %s" % ("是" if kb else "否"))
|
||||
if kb:
|
||||
head = kb.strip().split("\n", 1)[0][:60]
|
||||
print(" 命中笔记: %s ..." % head)
|
||||
if eng._llm.available:
|
||||
msgs, kb_hit = eng._build_messages("wxid_verify_demo", q)
|
||||
reply = eng._llm.chat(msgs)
|
||||
print("LLM 回复: %s" % reply)
|
||||
print("-" * 60)
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微信语音转文字模块(silk 解码 + faster-whisper ASR)。
|
||||
|
||||
链路:.silk → silk_v3_decoder.exe 解码为 .pcm → 加 wav 头 → faster-whisper 转文字。
|
||||
|
||||
用法:
|
||||
from voice2text import VoiceToText
|
||||
v2t = VoiceToText()
|
||||
text = v2t.transcribe_silk("/path/to/xxx.silk") # 返回文字,失败返回 None
|
||||
text = v2t.transcribe_voice(username, local_id, db) # 直接从微信库提取并转文字
|
||||
|
||||
说明:
|
||||
- whisper 模型懒加载(首次调用才加载,约几十秒;之后常驻内存)。
|
||||
- 中文识别,small 模型在 CPU 上平衡速度与准确率。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import wave
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DECODER_EXE = os.path.join(
|
||||
BASE_DIR, "third_party", "silk-v3-decoder", "silk_v3_decoder.exe"
|
||||
)
|
||||
WHISPER_MODEL = os.environ.get("V2T_MODEL", "small")
|
||||
SAMPLE_RATE = 24000 # silk_v3_decoder 默认输出采样率
|
||||
|
||||
_model = None
|
||||
_model_lock = threading.Lock()
|
||||
_hf_ready = False
|
||||
|
||||
|
||||
def _ensure_hf_env():
|
||||
"""国内环境用 hf-mirror 下载模型,并禁用 xet(避免 401)。"""
|
||||
global _hf_ready
|
||||
if _hf_ready:
|
||||
return
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
||||
_hf_ready = True
|
||||
|
||||
|
||||
def _get_model():
|
||||
global _model
|
||||
with _model_lock:
|
||||
if _model is None:
|
||||
_ensure_hf_env()
|
||||
from faster_whisper import WhisperModel
|
||||
_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||
return _model
|
||||
|
||||
|
||||
def _silk_to_pcm(silk_path: str, pcm_path: str) -> bool:
|
||||
if not os.path.exists(DECODER_EXE):
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[DECODER_EXE, silk_path, pcm_path, "-quiet"],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
return r.returncode == 0 and os.path.exists(pcm_path) and os.path.getsize(pcm_path) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pcm_to_wav(pcm_path: str, wav_path: str, sample_rate: int = SAMPLE_RATE):
|
||||
with open(pcm_path, "rb") as f:
|
||||
data = f.read()
|
||||
with wave.open(wav_path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sample_rate)
|
||||
w.writeframes(data)
|
||||
|
||||
|
||||
def transcribe_silk(silk_path: str, sample_rate: int = SAMPLE_RATE):
|
||||
"""把 .silk 语音文件转成文字;失败返回 None。"""
|
||||
if not silk_path or not os.path.exists(silk_path):
|
||||
return None
|
||||
pcm_path = silk_path + ".pcm"
|
||||
wav_path = silk_path + ".wav"
|
||||
try:
|
||||
if not _silk_to_pcm(silk_path, pcm_path):
|
||||
return None
|
||||
_pcm_to_wav(pcm_path, wav_path, sample_rate)
|
||||
segments, _info = _get_model().transcribe(
|
||||
wav_path, language="zh", beam_size=5,
|
||||
)
|
||||
text = "".join(s.text for s in segments).strip()
|
||||
return text or None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
for p in (pcm_path, wav_path):
|
||||
try:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def transcribe_voice(username: str, local_id: int, db, save_dir: str = None):
|
||||
"""从微信库提取语音并转文字:db 为 WeChatDB 实例。"""
|
||||
try:
|
||||
from wechatauto.media import MediaDownloader
|
||||
md = MediaDownloader(db, save_dir=save_dir)
|
||||
silk = md.download_voice(username, local_id, save_dir=save_dir)
|
||||
if not silk:
|
||||
return None
|
||||
return transcribe_silk(silk)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class VoiceToText:
|
||||
"""可复用的语音转文字器(模型懒加载、线程安全)。"""
|
||||
|
||||
def __init__(self, model: str = None):
|
||||
global WHISPER_MODEL
|
||||
if model:
|
||||
WHISPER_MODEL = model
|
||||
|
||||
def transcribe_silk(self, silk_path: str):
|
||||
return transcribe_silk(silk_path)
|
||||
|
||||
def transcribe_voice(self, username: str, local_id: int, db, save_dir: str = None):
|
||||
return transcribe_voice(username, local_id, db, save_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python voice2text.py <xxx.silk>")
|
||||
sys.exit(1)
|
||||
print(transcribe_silk(sys.argv[1]))
|
||||
@@ -0,0 +1,491 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微信智能客服引擎 v3(wechatauto-replica)
|
||||
|
||||
在 v2 固定话术自动回复基础上升级为「大模型上下文对话 + 本机知识库」:
|
||||
|
||||
1. 每个客户(username)维护独立会话历史,持久化到磁盘,跨重启不丢,
|
||||
按「最近 N 轮 / 最近 N 秒」窗口取上下文喂给大模型。
|
||||
2. 收到消息时检索本机 Obsidian 知识库(字符 n-gram 相似度),把最相关的
|
||||
笔记片段注入 system prompt,让回复「结合知识库」。
|
||||
3. 通过 OpenAI 兼容接口调用大模型生成自然回复(默认 DeepSeek,可换
|
||||
智谱/Kimi/通义等,只要填 base_url + model + api_key)。
|
||||
4. 无 api_key 时自动降级:关键词规则 → 知识库片段拼接 → 兜底话术,
|
||||
保证引擎不因缺 key 而停摆。
|
||||
|
||||
用法(项目目录下,独立 venv):
|
||||
.venv\\Scripts\\python.exe wechat_ai_reply.py
|
||||
|
||||
前置:微信 4.x 已登录、桌面未锁屏、管理员权限(提密钥)。
|
||||
配置:改 ai_config.json(重点是 llm.api_key)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import msvcrt
|
||||
|
||||
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
|
||||
from wechatauto.guia import WeChatGUI
|
||||
import voice2text
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, "ai_config.json")
|
||||
WATERMARK_PATH = os.path.join(BASE_DIR, "data", "watermark.json")
|
||||
|
||||
|
||||
def load_watermark() -> dict:
|
||||
"""加载监听水位(username -> 已推送的最大 sort_seq),重启不重复推送、不重复回复。"""
|
||||
try:
|
||||
if os.path.exists(WATERMARK_PATH):
|
||||
data = json.loads(open(WATERMARK_PATH, "r", encoding="utf-8").read())
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_watermark(wm: dict) -> None:
|
||||
"""把监听水位落盘。"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(WATERMARK_PATH), exist_ok=True)
|
||||
open(WATERMARK_PATH, "w", encoding="utf-8").write(
|
||||
json.dumps(wm, ensure_ascii=False)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
CFG = load_config()
|
||||
|
||||
# ==================== 知识库检索(本地 n-gram 相似度) ====================
|
||||
class KnowledgeBase:
|
||||
"""读本机 Obsidian vault 的 .md 笔记,按字符 n-gram 重叠度检索。
|
||||
|
||||
与 Unai 本机 vault 的 hashed n-gram 检索思路一致:靠字符重叠而非语义。
|
||||
排除 .obsidian 等元数据目录,只读已发布的分类笔记。
|
||||
"""
|
||||
|
||||
def __init__(self, vault_dir: str):
|
||||
self.vault_dir = vault_dir
|
||||
self._notes = [] # [(title, path, text)]
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if not self.vault_dir or not os.path.isdir(self.vault_dir):
|
||||
print(f"[KB] 知识库目录不存在: {self.vault_dir}", flush=True)
|
||||
return
|
||||
for root, dirs, files in os.walk(self.vault_dir):
|
||||
dirs[:] = [d for d in dirs if d not in (".obsidian", ".git")]
|
||||
for name in files:
|
||||
if not name.endswith(".md"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
text = open(path, "r", encoding="utf-8", errors="ignore").read()
|
||||
except OSError:
|
||||
continue
|
||||
if not text.strip():
|
||||
continue
|
||||
self._notes.append((name[:-3], path, text))
|
||||
print(f"[KB] 已载入 {len(self._notes)} 篇笔记", flush=True)
|
||||
|
||||
@staticmethod
|
||||
def _ngrams(s: str, n: int = 3):
|
||||
s = re.sub(r"\s+", "", s)
|
||||
return {s[i:i + n] for i in range(len(s) - n + 1)} if len(s) >= n else {s}
|
||||
|
||||
def search(self, query: str, top_k: int = 3, max_chars: int = 1500) -> str:
|
||||
"""返回拼接后的相关笔记片段;无结果返回空串。"""
|
||||
if not self._notes or not query:
|
||||
return ""
|
||||
q_grams = self._ngrams(query)
|
||||
scored = []
|
||||
for title, path, text in self._notes:
|
||||
t_grams = self._ngrams(text, 3)
|
||||
inter = len(q_grams & t_grams)
|
||||
if inter == 0:
|
||||
continue
|
||||
# 重叠数 + 少量标题命中加权
|
||||
score = inter
|
||||
if title and self._ngrams(title) & q_grams:
|
||||
score += 10
|
||||
scored.append((score, title, text))
|
||||
if not scored:
|
||||
return ""
|
||||
scored.sort(reverse=True, key=lambda x: x[0])
|
||||
parts = []
|
||||
for score, title, text in scored[:top_k]:
|
||||
excerpt = text.strip()
|
||||
if len(excerpt) > max_chars:
|
||||
excerpt = excerpt[:max_chars] + "…"
|
||||
parts.append(f"【{title}】\n{excerpt}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ==================== 每客户会话记忆 ====================
|
||||
class ConversationMemory:
|
||||
"""username -> 独立对话历史,内存缓存 + 磁盘持久化。"""
|
||||
|
||||
def __init__(self, base_dir: str, max_turns: int):
|
||||
self.base_dir = base_dir
|
||||
self.max_turns = max_turns
|
||||
self._cache = {}
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
||||
def _safe_name(self, username: str) -> str:
|
||||
return re.sub(r"[^0-9A-Za-z_@-]", "_", username) + ".json"
|
||||
|
||||
def _path(self, username: str) -> str:
|
||||
return os.path.join(self.base_dir, self._safe_name(username))
|
||||
|
||||
def load(self, username: str) -> list:
|
||||
if username in self._cache:
|
||||
return self._cache[username]
|
||||
path = self._path(username)
|
||||
hist = []
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
hist = json.loads(open(path, "r", encoding="utf-8").read())
|
||||
except (OSError, ValueError):
|
||||
hist = []
|
||||
self._cache[username] = hist
|
||||
return hist
|
||||
|
||||
def append(self, username: str, role: str, content: str):
|
||||
hist = self.load(username)
|
||||
hist.append({"role": role, "content": content, "time": int(time.time())})
|
||||
# 只保留最近 max_turns 轮(一轮 = 用户一条 + 助手一条)
|
||||
hist = hist[-(self.max_turns * 2):]
|
||||
self._cache[username] = hist
|
||||
try:
|
||||
open(self._path(username), "w", encoding="utf-8").write(
|
||||
json.dumps(hist, ensure_ascii=False, indent=1)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 大模型客户端(OpenAI 兼容,零第三方依赖) ====================
|
||||
class LLMClient:
|
||||
def __init__(self, cfg: dict):
|
||||
self.api_key = (cfg.get("api_key") or "").strip()
|
||||
self.base_url = (cfg.get("base_url") or "").rstrip("/")
|
||||
self.model = cfg.get("model") or "deepseek-chat"
|
||||
self.timeout = cfg.get("timeout_sec") or 60
|
||||
self.max_retry = cfg.get("max_retry") or 2
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def chat(self, messages: list) -> str:
|
||||
"""调用 chat/completions,返回助手文本;失败抛异常。"""
|
||||
url = self.base_url + "/chat/completions"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"stream": False,
|
||||
}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
last_err = None
|
||||
for attempt in range(self.max_retry + 1):
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Authorization", "Bearer " + self.api_key)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
obj = json.loads(body)
|
||||
return obj["choices"][0]["message"]["content"].strip()
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, KeyError,
|
||||
IndexError, ValueError, OSError) as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retry:
|
||||
time.sleep(1 + attempt)
|
||||
raise RuntimeError(f"LLM 调用失败: {last_err!r}")
|
||||
|
||||
|
||||
# ==================== 引擎主逻辑 ====================
|
||||
_gui = None
|
||||
_gui_lock = threading.Lock()
|
||||
_send_lock = threading.Lock()
|
||||
_last_reply = {} # username -> 上次回复时间戳
|
||||
_last_reply["_db"] = None
|
||||
_self_wxid = None # 当前登录账号的 wxid,用于跳过自己发的消息
|
||||
_handled_seq = {} # username -> 已处理的最大 sort_seq(消息级去重,双保险)
|
||||
_sent_recent = {} # username -> [(content, ts)]:引擎刚发出的回复回声,跳过"自己的消息被读回"
|
||||
_SELF_SENDER_IDS = {"1"} # real_sender_id 里"自己"的取值(微信 4.1.13 实测 self=1)
|
||||
_pending = {} # username -> {"texts": [..], "timer": Timer|None}:冷却期内累积的消息,到期合并回复
|
||||
_pending_lock = threading.Lock()
|
||||
|
||||
|
||||
def _recent_sent(username: str, now: float, window: float = 120.0):
|
||||
"""返回该用户最近 window 秒内发出的回复内容列表,用于回声匹配。"""
|
||||
items = [(c, t) for c, t in _sent_recent.get(username, []) if now - t <= window]
|
||||
_sent_recent[username] = items
|
||||
return [c for c, _ in items]
|
||||
|
||||
_kb = KnowledgeBase(CFG["knowledge"].get("vault_dir", ""))
|
||||
_llm = LLMClient(CFG["llm"])
|
||||
_mem = ConversationMemory(
|
||||
CFG["storage"].get("conversation_dir", "data/conversations"),
|
||||
CFG["reply"].get("history_turns", 10),
|
||||
)
|
||||
|
||||
|
||||
def get_gui():
|
||||
global _gui
|
||||
with _gui_lock:
|
||||
if _gui is None:
|
||||
_gui = WeChatGUI()
|
||||
return _gui
|
||||
|
||||
|
||||
_BLOCK_PREFIXES = ("gh_",) # 公众号,不回复
|
||||
_SYSTEM_USERS = {
|
||||
"filehelper", "brandsessionholder", "brandservicesessionholder",
|
||||
"notifymessage", "fmessage", "floatbottle", "medianote",
|
||||
}
|
||||
|
||||
|
||||
def should_reply(username: str) -> bool:
|
||||
r = CFG["reply"]
|
||||
if not r.get("enabled", True):
|
||||
return False
|
||||
allowlist = r.get("allowlist") or []
|
||||
if allowlist:
|
||||
# 白名单模式:只回复名单内的好友(测试期锁定单一联系人用)
|
||||
return username in allowlist
|
||||
if username in r.get("blocklist", []):
|
||||
return False
|
||||
if username in _SYSTEM_USERS:
|
||||
return False
|
||||
if "@" in username: # 群聊 @chatroom / 服务号 @openim / @weclaw / @placeholder 等
|
||||
return False
|
||||
if username.startswith(_BLOCK_PREFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_messages(username: str, incoming: str) -> tuple:
|
||||
"""构造发给大模型的 messages 列表,返回 (messages, kb_hit)。"""
|
||||
sys_prompt = CFG["persona"].get("system_prompt", "")
|
||||
kb_hit = ""
|
||||
kb = _kb.search(incoming, top_k=CFG["knowledge"].get("top_k", 3),
|
||||
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
|
||||
if kb:
|
||||
kb_hit = kb
|
||||
sys_prompt += "\n\n【知识库资料】\n" + kb
|
||||
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
|
||||
# 取该客户历史(按时间窗口过滤)
|
||||
hist = _mem.load(username)
|
||||
window = CFG["reply"].get("history_window_sec", 86400)
|
||||
now = time.time()
|
||||
recent = [h for h in hist if now - h.get("time", 0) <= window]
|
||||
for h in recent[-(CFG["reply"].get("history_turns", 10) * 2):]:
|
||||
if h.get("content"):
|
||||
messages.append({"role": h["role"], "content": h["content"]})
|
||||
|
||||
messages.append({"role": "user", "content": incoming})
|
||||
return messages, kb_hit
|
||||
|
||||
|
||||
def _fallback_reply(content: str, kb_hit: str) -> str:
|
||||
"""无 api_key 时的降级:关键词规则 → 知识库片段 → 兜底话术。"""
|
||||
rules = CFG["reply"].get("keyword_rules") or {}
|
||||
for kw, reply in rules.items():
|
||||
if kw and kw in content:
|
||||
return reply
|
||||
if kb_hit:
|
||||
# 知识库命中但无大模型,只回片段开头的摘录 + 引导
|
||||
head = kb_hit.strip().split("\n", 1)[0].replace("【", "").replace("】", "")
|
||||
return f"关于这个问题,您可以先参考这份资料({head}):需要我展开哪一点,我帮您细看。"
|
||||
return CFG["reply"].get("fallback_reply",
|
||||
"【自动回复】已收到您的消息,稍后回复您。")
|
||||
|
||||
|
||||
def _do_reply(username: str, content: str, db):
|
||||
"""生成并发送一次回复(大模型优先,无 key 降级)。"""
|
||||
nick = db.get_nickname(username) if db else username
|
||||
try:
|
||||
if _llm.available:
|
||||
messages, kb_hit = _build_messages(username, content)
|
||||
reply = _llm.chat(messages)
|
||||
mode = "LLM" + ("+KB" if kb_hit else "")
|
||||
else:
|
||||
kb_hit = _kb.search(content, top_k=CFG["knowledge"].get("top_k", 3),
|
||||
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
|
||||
reply = _fallback_reply(content, kb_hit)
|
||||
mode = "降级(无key)" + ("+KB" if kb_hit else "")
|
||||
except Exception as e: # noqa: BLE001
|
||||
reply = CFG["reply"].get("fallback_reply", "【自动回复】已收到您的消息,稍后回复您。")
|
||||
mode = f"异常回退:{e!r}"
|
||||
|
||||
# 记录历史(用户 + 助手),再发送(加锁,避免 timer 线程与 worker 线程并发写)
|
||||
with _send_lock:
|
||||
_mem.append(username, "user", content)
|
||||
_mem.append(username, "assistant", reply)
|
||||
_sent_recent.setdefault(username, []).append((reply, time.time()))
|
||||
try:
|
||||
r = get_gui().send_msg(reply, who=username, verify=True)
|
||||
print(f"[智能回复|{mode}] {username} ({nick}) <- {reply} | ok={r.is_success}",
|
||||
flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[发送失败] {username}: {e!r}", flush=True)
|
||||
|
||||
|
||||
def _flush_pending(username: str):
|
||||
"""冷却期结束后,把累积的多条消息合并成一次回复发出。"""
|
||||
with _pending_lock:
|
||||
p = _pending.get(username)
|
||||
if not p or not p["texts"]:
|
||||
return
|
||||
texts = p["texts"]
|
||||
p["texts"] = []
|
||||
p["timer"] = None
|
||||
content = " ".join(texts).strip()
|
||||
if not content:
|
||||
return
|
||||
db = _last_reply["_db"]
|
||||
_last_reply[username] = time.time()
|
||||
_do_reply(username, content, db)
|
||||
|
||||
|
||||
def on_message(msg: dict, lst: Listener):
|
||||
username = msg.get("username", "")
|
||||
sender_id = str(msg.get("sender_id", ""))
|
||||
content = str(msg.get("content", "")).strip()
|
||||
sort_seq = msg.get("sort_seq", 0)
|
||||
mtype = msg.get("type", "")
|
||||
local_id = msg.get("local_id")
|
||||
|
||||
# 跳过自己发的(避免回复自己的回复 → 死循环)。
|
||||
now = time.time()
|
||||
|
||||
# ---- 跳过"自己"的消息(三层防护,杜绝自回复死循环)----
|
||||
# 1) sender_id 是真实 wxid 字符串时
|
||||
if _self_wxid and sender_id == str(_self_wxid):
|
||||
return
|
||||
# 2) real_sender_id 数值 == 自己(微信 4.1.13 实测 self=1)
|
||||
if sender_id in _SELF_SENDER_IDS:
|
||||
return
|
||||
# 3) 内容回声:刚发出去的回复又被监听读回,直接丢弃(不依赖 sender_id 语义,最稳一层)
|
||||
if content and content in _recent_sent(username, now):
|
||||
return
|
||||
|
||||
if not should_reply(username) or not content:
|
||||
return
|
||||
|
||||
# 消息级去重:sort_seq 不前进则说明是同一条消息被重复回调,直接丢弃
|
||||
last_seq = _handled_seq.get(username, -1)
|
||||
if sort_seq and sort_seq <= last_seq:
|
||||
return
|
||||
_handled_seq[username] = sort_seq
|
||||
|
||||
db = _last_reply["_db"]
|
||||
|
||||
# 语音消息:先转文字再回复(语音 content 是 "[语音]" 占位,真实内容在音频里)
|
||||
if mtype == "语音" and local_id:
|
||||
text = voice2text.transcribe_voice(username, local_id, db)
|
||||
if text:
|
||||
content = text
|
||||
print(f"[语音转文字] {username} -> {text}", flush=True)
|
||||
else:
|
||||
print(f"[语音转文字] {username} 转写失败,跳过", flush=True)
|
||||
return
|
||||
|
||||
# 冷却合并:冷却期内的新消息不丢弃,累积后统一回复(修复"只回第一句、后面不回")。
|
||||
cooldown = CFG["reply"].get("cooldown_sec", 5)
|
||||
if now - _last_reply.get(username, 0) < cooldown:
|
||||
with _pending_lock:
|
||||
p = _pending.setdefault(username, {"texts": [], "timer": None})
|
||||
p["texts"].append(content)
|
||||
if p["timer"] is not None:
|
||||
p["timer"].cancel()
|
||||
p["timer"] = threading.Timer(cooldown, _flush_pending, args=(username,))
|
||||
p["timer"].daemon = True
|
||||
p["timer"].start()
|
||||
return
|
||||
|
||||
_last_reply[username] = now
|
||||
_do_reply(username, content, db)
|
||||
|
||||
|
||||
def main():
|
||||
global _self_wxid
|
||||
# 单实例锁:防止多个引擎进程并发读写同一个解密缓存导致 database malformed
|
||||
_lock_path = os.path.join(BASE_DIR, "engine.lock")
|
||||
_lock_fh = open(_lock_path, "wb")
|
||||
try:
|
||||
msvcrt.locking(_lock_fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError:
|
||||
print("已有引擎实例在运行,本进程退出(避免并发写坏数据库缓存)。", flush=True)
|
||||
_lock_fh.close()
|
||||
sys.exit(0)
|
||||
|
||||
db = WeChatDB()
|
||||
info = db.get_self_info()
|
||||
nick = info.get("nick_name") or info.get("username")
|
||||
_last_reply["_db"] = db
|
||||
_self_wxid = info.get("username") or getattr(db, "wxid", None)
|
||||
print(f"已接管微信: {nick} ({info.get('username')})", flush=True)
|
||||
print(f"自身 wxid: {_self_wxid}(跳过自己发的消息,防自回复循环)", flush=True)
|
||||
print(f"大模型: {'已配置 ' + _llm.model if _llm.available else '未配置(降级模式)'} "
|
||||
f"| base_url={_llm.base_url}", flush=True)
|
||||
print(f"知识库: {CFG['knowledge'].get('vault_dir','')} (已载入 {len(_kb._notes)} 篇)",
|
||||
flush=True)
|
||||
_allow = CFG['reply'].get('allowlist') or []
|
||||
_scope = f"白名单 {len(_allow)} 人" if _allow else "所有好友(排除公众号/群聊/服务号)"
|
||||
print(f"自动回复: {'开' if CFG['reply'].get('enabled') else '关'} | "
|
||||
f"回复范围: {_scope} | "
|
||||
f"防抖 {CFG['reply'].get('cooldown_sec')}s", flush=True)
|
||||
|
||||
lst = Listener(db, interval=3.0, watermark=load_watermark())
|
||||
# 监听所有会话(add_all + discover 自动发现新会话)。should_reply 里排除
|
||||
# 公众号/群聊/服务号/系统账号;若配置 allowlist 则只回名单内好友。
|
||||
lst.add_all(on_message, discover=True)
|
||||
print(f"智能客服监听已启动({_scope}),Ctrl+C 退出...",
|
||||
flush=True)
|
||||
|
||||
lst.start()
|
||||
_save_every = 0
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
_save_every += 5
|
||||
if _save_every >= 30:
|
||||
_save_every = 0
|
||||
save_watermark(lst.watermark)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
save_watermark(lst.watermark)
|
||||
lst.stop()
|
||||
print("已停止监听。", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微信自动回复接管脚本 v2(wechatauto-replica)
|
||||
|
||||
功能:监听微信新消息,当【真实好友】发来私聊消息时,自动回复。
|
||||
规则:
|
||||
- 只回复 wxid_ 开头的真实好友(公众号 gh_、群聊 @chatroom、
|
||||
文件传输助手 filehelper、特殊号 @openim/@placeholder/brandsessionholder 一律不碰)
|
||||
- 跳过自己发出去的消息(sender_id == 2)
|
||||
- 同一好友 COOLDOWN_SEC 秒内只回一次(防刷屏)
|
||||
- GUI 发送全局串行(多个好友同时来消息不会抢窗口)
|
||||
|
||||
用法(项目目录下,独立 venv):
|
||||
.venv\\Scripts\\python.exe wechat_daemon.py
|
||||
|
||||
前置:微信 4.x 已登录、桌面未锁屏、管理员权限(提密钥)。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
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
|
||||
from wechatauto.guia import WeChatGUI
|
||||
|
||||
# ==================== 自动回复配置(改这里) ====================
|
||||
AUTO_REPLY = True # 总开关
|
||||
DEFAULT_REPLY = "【自动回复】已收到您的消息,稍后回复您。"
|
||||
|
||||
# 关键词规则:消息命中关键词时用对应话术回复;未命中则用 DEFAULT_REPLY
|
||||
KEYWORD_RULES = {
|
||||
# "你好": "你好呀,请问有什么可以帮您?",
|
||||
# "价格": "具体价格您可以看下我发给您的报价单。",
|
||||
}
|
||||
|
||||
# 只回复这些前缀的会话(真实好友)。留空 = 不限制前缀
|
||||
REPLY_ONLY_PREFIX = ("wxid_",)
|
||||
|
||||
# 防抖:同一会话内多少秒内不重复自动回复
|
||||
COOLDOWN_SEC = 30
|
||||
|
||||
# 黑名单会话(精确匹配 username,绝不自动回复)
|
||||
BLOCKLIST = {"filehelper"}
|
||||
# ==============================================================
|
||||
|
||||
_gui = None
|
||||
_gui_lock = threading.Lock()
|
||||
_last_reply = {} # username -> 上次回复时间戳
|
||||
_send_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gui():
|
||||
global _gui
|
||||
with _gui_lock:
|
||||
if _gui is None:
|
||||
_gui = WeChatGUI()
|
||||
return _gui
|
||||
|
||||
|
||||
def should_reply(username: str) -> bool:
|
||||
"""是否对某会话启用自动回复。"""
|
||||
if not AUTO_REPLY:
|
||||
return False
|
||||
if username in BLOCKLIST:
|
||||
return False
|
||||
if username.endswith("@chatroom"):
|
||||
return False
|
||||
if not REPLY_ONLY_PREFIX:
|
||||
return True
|
||||
return username.startswith(REPLY_ONLY_PREFIX)
|
||||
|
||||
|
||||
def pick_reply(content: str) -> str:
|
||||
for kw, reply in KEYWORD_RULES.items():
|
||||
if kw in content:
|
||||
return reply
|
||||
return DEFAULT_REPLY
|
||||
|
||||
|
||||
def on_message(msg: dict, lst: Listener):
|
||||
username = msg.get("username", "")
|
||||
sender_id = msg.get("sender_id", "")
|
||||
content = str(msg.get("content", ""))
|
||||
|
||||
# 跳过自己发出去的消息(避免回复自己的回复 → 死循环)
|
||||
if str(sender_id) == "2":
|
||||
return
|
||||
|
||||
if not should_reply(username):
|
||||
return
|
||||
|
||||
# 防抖
|
||||
now = time.time()
|
||||
last = _last_reply.get(username, 0)
|
||||
if now - last < COOLDOWN_SEC:
|
||||
return
|
||||
_last_reply[username] = now
|
||||
|
||||
reply = pick_reply(content)
|
||||
nick = _last_reply.get("_db") and _last_reply["_db"].get_nickname(username) or username
|
||||
# 全局串行发送,避免多个会话并发抢同一微信窗口
|
||||
with _send_lock:
|
||||
try:
|
||||
r = get_gui().send_msg(reply, who=username, verify=True)
|
||||
print(f"[自动回复] {username} ({nick}) <- {reply} | ok={r.is_success} {r['message']}",
|
||||
flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[自动回复失败] {username}: {e!r}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
db = WeChatDB()
|
||||
info = db.get_self_info()
|
||||
nick = info.get("nick_name") or info.get("username")
|
||||
_last_reply["_db"] = db
|
||||
print(f"已接管微信: {nick} ({info.get('username')})", flush=True)
|
||||
print(f"自动回复: {'开' if AUTO_REPLY else '关'} | 默认话术: {DEFAULT_REPLY} | "
|
||||
f"只回前缀: {REPLY_ONLY_PREFIX} | 防抖 {COOLDOWN_SEC}s", flush=True)
|
||||
|
||||
lst = Listener(db, interval=1.0)
|
||||
# add_all:监听所有已有会话 + 自动发现新会话
|
||||
lst.add_all(on_message)
|
||||
print("自动回复监听已启动(真实好友发消息即自动回复,公众号/群聊不碰),Ctrl+C 退出...",
|
||||
flush=True)
|
||||
|
||||
lst.start()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
lst.stop()
|
||||
print("已停止监听。", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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