feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布

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