fanyuantaier/wechatauto-replica
fanyuantaier/wechatauto-replica has 648 GitHub stars and 278 forks. GitHot measured +7 stars in 24 hours and +37 in 7 days. Last checked 2026-11-11 04:18 UTC. Last push 2026-10-31.
Windows WeChat client automation (not web-based), a wxauto replica that supports WeChat 4.x.
What you can do
- 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
Measured star growth
- in 24h
- +7
- in 7 days
- +37
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Originalwechatauto-replica — WeChat 4.x Windows Automation (wxauto-compatible)
[!NOTE] 📢 维护状态 / Maintenance Notice 本人因今年升高一,开学后几乎没有时间继续更新本项目(如果有时间,争取周日更新)。遇到问题请自行在 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!
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.2.9 · Windows 10/11 · Python 3.9+ (verified on 3.12) · WeChat 4.1.12+ (verified on 4.1.15.53)
Why this project exists: the classic wxauto relies on the UI Automation tree, which WeChat 4.x broke with self-drawn rendering (no accessibility nodes). wechatauto-replica is a drop-in-style replacement: messages are read through local database decryption (SQLCipher 4), and sending uses a UIA + OCR hybrid driver that auto-falls back between engines.

Reading the encrypted contact.db / message_*.db / sns.db files directly from xwechat_files/.../db_storage/ — no web API, all local.
✨ Features
| Capability | Status | How |
|---|---|---|
| Read messages | ✅ verified | Local SQLCipher 4 DB decryption (wechatauto/db.py) |
| Real-time message listening | ✅ verified | Listener incremental polling, per-chat worker threads |
| Emoji message capture | ✅ verified | Screen capture + direction-aware bubble auto-cropping |
| Full history export | ✅ verified | JSON / SQLite |
| Media download (image / voice / file) | ✅ verified | MediaDownloader: image v2 AES decryption, SILK voice, files |
| Image tiers on this machine (original / compressed / thumbnail) | ✅ verified | download_image(..., tier='original'/'best'/'mid'/'thumb'); image_status() / list_image_status() give the reason (4271 rows measured) |
| Trigger WeChat to fetch the original | ✅ implemented; the live UI path was not re-verified this round | download_image_original(): UI click triggers the download, then polls _h.dat until it stops growing (skipped entirely when a qualified original is already on disk) |
| "UIA tree is blocked" now names the cause | ✅ verified | uia_driver warns per scenario (32-bit interpreter / privileges / not running), once per process |
| 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 |
| Contact labels / forward a message | ✅ implemented; the live batch send is not verified | ListLabels / CreateLabel / AddLabelMembers / SendToLabel(dry_run=) / ForwardMessage, CLI labels / forward |
| Read-back gate before Enter + master OCR switch | ✅ verified (89 offline checks + verbatim live read-back) | WxParam.SEND_CONTENT_RATIO (0.6), WxParam.ENABLE_OCR (on) |
🚀 Quick Start
📖 Full usage guide: GUIDE.md (中英对照 / bilingual)
pip install -e .
# extra deps for the OCR sending path:
pip install winsdk pypinyin
One command (CLI)
No Python required — after pip install, just run commands (asked for in issue #31;
the existing Python API is unchanged, these are plain callers of it):
Easiest one: type wechatauto (or python -m wechatauto) with nothing else — it runs a
health check, then shows a numbered menu (read messages / export / download images / send /
listen / moments), so you never have to remember a flag.
wechatauto # follow the prompts (menu only when stdin is a terminal)
wechatauto doctor # account / DB keys / image key / UIA tree at a glance
wechatauto sessions # session list
wechatauto messages 文件传输助手 # recent messages (--type image, --json)
wechatauto export 文件传输助手 # dump to a UTF-8 text file
wechatauto images GroupName --out D:\img
wechatauto send "hello" --to 文件传输助手 --verify # drives the real client
wechatauto listen GroupName # print new messages until Ctrl+C
wechatauto moments --me
Subcommands work in Chinese or English (消息 = messages, 导出 = export,
图片 = images, 发 = send, 听 = listen, 体检 = doctor). Any handle works for a
chat — nickname, remark, wxid, group id or display name: the command resolves the display
name into the username the DB layer needs (typing a wxid into WeChat's search box is the
classic beginner trap: no hit, ~60 s of retries, then a bare None). images checks the
image AES key first and tells you what to do when it is missing instead of returning a wall
of None. Only --original drives the real client (it moves your WeChat window).
Read messages
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
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
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
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"]])
# download this feed's pictures & videos (local cache first, then CDN url)
saved = moments.download_moment_media(feed, save_dir=r"D:\moments")
print(" saved:", saved)
See wechatauto/demo_moments_download.py for a runnable download demo
(python -m wechatauto.demo_moments_download [N] --out 目录).
Like & comment (UIA controls) — Moments like/comment are server-side
actions done through the client UI, so they use the UIA-tree route (not the
local DB). WeChat hot-activates the mmui UIA tree and clicks the
朋友圈 nav button, then likes/comments a feed via its UIA controls:
from wechatauto import WeChat
wx = WeChat()
moments = wx.Moment # None if the UIA tree is unavailable
if moments is None:
raise SystemExit("UIA tree unavailable — can't like/comment")
wx.SwitchToMoments() # click 朋友圈 in the nav bar
items = moments.GetMoments() # list feed items as UIA controls
first = items[0]
moments.Like(first) # thumb up
moments.Like(first, cancel=True) # undo
moments.Comment(first, "Nice!") # comment
moments.Comment(first, "Thanks!", reply_to="张三") # reply to a comment
Runnable demo: python -m wechatauto.demo_moments_interact [--like N | --unlike N | --comment N 文字]
(plain run lists the latest feeds without touching the UI).
⚠️ Comment/reply automation is experimental — testing only. The reply-to-a-comment feature (
ReplyComment) locates the comment row on screen via OCR (WeChat's comment area is self-drawn) and then drives the UI to click / paste / send. Layout varies across versions and it is not production-grade — use it only on a test account to validate the pipeline.
Explicit standalone windows
WeChatGUI(hwnd=chat_hwnd) and WeChatUIA(hwnd=chat_hwnd) also accept a
materialized mmui::ChatSingleWindow. Its title identifies the current chat;
open_chat() accepts that exact title or the native username exposed by the
window's AutomationId. A different target fails without searching another window
or using OCR. Text sending uses UIA; attachment input geometry comes from the
same window's chat_input_field.
A bound mmui::SearchMsgUniqueChatWindow is a read-only chat-history window:
UIA binding, layout diagnostics and message-list lookup work, but text and
attachment input fail. Its search box is never treated as a send input.
Automatic UIA discovery still selects only main windows. Explicit bindings keep
the existing HWND/PID checks; closing the target does not select another account.
🧠 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.Cipherconfig 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 preventsdatabase disk image is malformedcorruption. - 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
.datfiles are[6B sig][4B aes_size][4B xor_size] + AES-ECB + plaintext + xorchunks. The account-level AES key is transient (only resident in memory while viewing an image);MediaDownloaderscans for it, validates via JPEG/PNG magic, and persists it toimage_keys.jsonso later runs need no scanning (or passimage_key=explicitly). Voice is plain SILK read frommedia_0.db; files are read frommsg/file/with original names resolved frommessage_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 + like/comment (UIA) | read + like/comment (UIA), full history via DB |
⚠️ Known Limitations
- WeChat must be logged in — DB keys live in process memory; cached after first extraction, re-extracted automatically after re-login.
- Image AES key is transient — only resident while viewing an image; persisted to
image_keys.jsononce found, or inject viaimage_key=. - Sending is a GUI operation — fails cleanly when the window is locked/unresponsive (operations return a clear failure).
- Videos are downloadable only when the mp4 already exists on disk (
msg/video/). - A group-chat image exists in up to three tiers —
<md5>_t.datthumbnail,<md5>.datthe copy WeChat pushes by default,<md5>_h.datthe real original, which is only written after the image has been opened with 「查看原图 / 图片原始大小」 in WeChat. Ask before you retry:MediaDownloader.image_status(user, local_id)/list_image_status(user)report which tiers exist and why (ok/mid_only/only_thumbnail/original_partial/ …), anddownload_image(user, local_id, tier='original'|'best'|'mid'|'thumb')picks a tier explicitly —tier='original'returnsNoneinstead of quietly handing back the compressed copy, and the tier is written into the filename (_h/ none /_thumb). Not passingtierkeeps the pre-1.2.4.2 behaviour verbatim. Measured over 4271 image messages in 211 sessions here: 2210mid_only, 1428only_thumbnail, 626 with a real original on disk — roughly 1 in 7, so "cannot download the original" is usually WeChat's storage policy, not a decryption failure.download_image_original()still exists to trigger the fetch through the UI. - Moments likes/comments go through the UI (server-side actions) and need the hot-activated
mmuiUIA tree plus an unlocked desktop; they fail cleanly when the tree is unavailable. Moments posting stays dropped (4.x self-drawn UI, unreliable). - Quote-message sending (BETA) goes through a coordinate + OCR +
SendInputpipeline that depends on WeChat 4.1.x self-drawn layout; positioning may drift with window size / DPI / chat content — test flow on a throwaway account only. - Voice messages can be missing their audio locally — WeChat only writes
voice_dataintomedia_*.dbafter a voice has been played/received on that machine, sodownload_voice()returnsNonefor the rest, with no way to tell "not on disk" from "library broke".MediaDownloader.list_voice_status()/voice_status()now return the reason:audio_not_downloaded(play it once in WeChat) vsaudio_missing_from_media_db(worth an issue). Measured over 958 voices in 20 sessions: 898 available, 54 flaggeddownload_status=0and indeed absent, 6 with no media index entry —download_status != 0matched "audio on disk" with no exceptions. - "The UIA tree is blocked" is usually a readable cause now, not a mystery — the
mmuitree only exists after one byte insideWeixin.dllis flipped, and that byte resets every time WeChat restarts, updates or you re-login. The library hot-writes it, but when it cannot, it used to return an empty tree in silence. It now names the reason once per process (no visible window / window found but its modules unreadable — with 32-bit Python called out separately, since reading a 64-bit process's module list from 32-bit always fails — pywin32 missing), so the fix is obvious instead of guesswork.
🗺️ 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.2.9 (2026-10-31)
- ⚠️ Important fix (PR #46, reported by dhz1145, issue #45): image-key validation now uses the target ciphertext, so valid keys are no longer rejected. The old probe picked the first
.datfound in the attach cache; when that file was not a suitable V2 sample, the correct key failed to decrypt the wrong sample, got marked invalid, anddownload_image()fell into a minutes-long memory scan._decrypt_v2now passes the target's own first ciphertext block as the probe through the whole key-resolution chain (config derivation / persisted cache / scan fallback share one target), without overwriting the shared probe (concurrent decryptions can no longer mislead each other); target-less pre-probes skip non-V2 / truncated files. Live comparison on 4.1.15.56: stock 1.2.8.1 entered scanning three times (180/22/22 s), the fix succeeded three times (extraction 76/78/70 ms). Addstests/test_media_key_probe.py(10 cases). - New: wxauto4-style standalone chat window —
wx.ChatWindow("Alice").SendMsg("hi"). Double-clicks the contact's row in the session list (on the message-preview zone; the avatar zone does not register), opens the standalone chat window, rebinds to it, and returns aChatthat sends inside that window; an already-open window is reused (double-clicking again closes it). Messages go through the same rhythm throttle as main-window sends. Companionwx.CloseChatWindow(who): closes via Alt+F4 only after verifying the foreground actually switched to the target window — it retries three times and then fails explicitly, never firing Alt+F4 into whatever the user is doing. - New: WeChat 4.2.0.11 compatibility (verified item by item on a real 4.2.0.11 client, 2026-10-30/31):
- Attachment paste: 4.2.0 input boxes no longer accept a hardware-keyboard Ctrl+V (after SetFocus the keyboard focus never lands on the control), so pasting now goes through UIA SendKeys; image-draft detection reads the input box's Value for the U+FFFC object-replacement character instead of counting colored pixels (the 4.2.0 pixel scan was a constant false positive: 1046 colored pixels with no draft vs. a threshold of 30).
- Moments: 4.2.0 uses new cell class names for image posts and ads (
mmui::TimelineGridImageCell/TimelineAd*Cell); the content parser whitelist covers them, andSwitchToMomentsre-anchors the UIA root after page switches (stale references used to cause false failures). - Forwarding: 4.2.0 message rows span the full window width with the bubble on one side, so right-clicking the row center lands on empty space — right-click position now uses the row's content centroid (
_row_content_point, column-projection of non-background pixels). - Session locating: after 4.2.0's narrower sidebar, the OCR scan band overlapped the chat area, letting chat titles/timestamps pose as session rows —
get_sessions/find_sessionnow filter by the name-column right edge (sidebar_right*0.55) and drop pure-timestamp fragments. - Preview window: 4.2.0's preview mmui subtree self-demotes to a bare Qt shell seconds after materialization —
_preview_windowsdetects the shell and re-activates automatically;_save_onceusesaccDoDefaultActionas its primary trigger (Click()is a silent no-op on 4.2.0) and presses Enter through the overwrite-confirmation dialog.
- New: DPAPI encryption for the key cache + main-window reacquisition.
keys.json(work cache and stable copy) is encrypted withCryptProtectDatabound to the current Windows user (dpapi1:prefix); un-prefixed legacy plaintext caches still read fine and upgrade on next save; saving without pywin32 falls back to plaintext (no new failure mode). After WeChat quits or restarts mid-session,ensure_visiblere-finds the main window and rebinds automatically (previously a long-lived process stayed dead withis_alive()==Falseforever). - Fix: EmojiMessage scrolls the message list back to the bottom after screenshots. Emoji locating could scroll the list into history; without scrolling back, every message the bot sends afterwards is invisible to the user ("the page scrolled up"). Rolls back even on failure;
max_scrolls60→8. - Verdict: 37 offline unit tests on the merged tree (10 key-probe from PR #46 + 3 DPAPI + the existing 24 in
test_pure); all five offline suites green (labels 98 / forward 59 / send_verify 21 / chat_identity 21 / ocr_send_gate 26);tools/selftest.py561/0; verified item by item on a real 4.2.0.11 client (text/image/file send, forwarding, Moments, standalone window, voice, labels — every write path confirmed by an independent DB read-back).
v1.2.8.1 (2026-10-25)
- ⚠️ Important fixes (six, from PR #37, reported by dhz1145, issues #38–#43): targeting and multi-instance safety on the send path. Every item came with three live reproductions, an independent database read-back, and before/after comparisons.
- Input-box location (#38): when probing failed,
get_input_box()fell back to a window-height-ratio estimate that could sit up to 73 px above the real input —send_file's focus click landed in the message area and returned "sent" while nothing was sent. The real UIA edit-control rectangle is now preferred, the ratio fallback is gone, and a failed probe is reported as failure. Draft detection only looks inside the edit control, so toolbar icons / history messages no longer count as "input has text". - Duplicate sends (#38 comment): if the post-Enter "input cleared" check failed, the old code re-pasted and submitted again — the real-world outcome was two identical messages from one call. Enter is now submitted exactly once;
_send_attempteddistinguishes "rejected before send" (retryable) from "submitted, unconfirmed" (explicit failure, never auto-resent). - Quote / reply targeting (#39, #41): OCR drops ASCII spaces while
target_textkept them, so quotes could never find their target;reply_msg'starget_textwas never read at all (and a loop variable shadowed the body, sending the menu word instead). Target text and OCR now use the same space handling, and zero or multiple visible candidates both fail explicitly;reply_msgreusesquote_msg's locator. - Remark written to the wrong contact (#40): with the target off-screen and a similar name (containing the target's) on-screen, the old code opened the similar contact and rewrote their remark, and
verify=Trueonly failed after the mis-write. Three gates now run before writing: the full display name must resolve to exactly one database contact, on-screen rows only match exactly, and the editor's current value must be the target itself. - Wrong window with multiple accounts (#42): with two accounts logged in,
WeChatGUI(hwnd=B)'s internal UIA engine still picked windows globally by area — bound to B, operating on A.WeChatUIA(hwnd=…)now binds explicitly; lookups, hot-activation and login detection stay within that HWND + original PID, and a closed/reused target fails instead of silently switching accounts. - Verification database on the wrong account (#43): send verification picked the account by newest directory mtime, so a B-bound window could read A's database and report "unconfirmed" for a message that was sent. The account is now derived from the
db_storagepaths the window's process actually holds; if it cannot be confirmed uniquely, it fails rather than auto-picking another account.
- Input-box location (#38): when probing failed,
- New: standalone-window UIA (PR #44):
WeChatGUI(hwnd=…)/WeChatUIA(hwnd=…)explicitly accept a materializedmmui::ChatSingleWindow(standalone chat window — text/attachment sending works, input geometry from that window's realchat_input_field), whilemmui::SearchMsgUniqueChatWindow(chat-history window) is read-only: binding, diagnostics and message-list lookup work, sends and input are refused, and its search box is never treated as a send input. A mismatched target fails without searching other windows or falling back to OCR. Newwindow_kind()plusstandalone-chat/historylayouts indescribe_layout(). Auto-discovery without an HWND still selects main windows only — unchanged. - Internal fixes (
6a3d63e): connection leak / silent listen "success" / key extraction abort / OCR race.db.pyget_self_info()now closes its sqlite connection intry/finally— this is a high-frequency path during listening, and the leaked handles kept the decryption cache directory locked.db.pypage-1 reads during key extraction tolerateOSError: WeChat may briefly replace/lock a file during checkpointing; one unreadable file used to abort the whole extraction round and is now skipped.wx.pyAddListenChat()'s failure test was always False (_resolve_wxidechoes unknown names back, a truthy value), so a mistyped nickname silently registered a listener that could never receive messages while reporting success. Registration now does an exact contact lookup (nickname/remark) first and fails when nothing matches; the return annotation becomesUnion[Chat, WxResponse].guia.pyScreenOCR's fixed temp filename becomes a uniquemkstempname with cleanup — concurrent OCR (send verification × Moments observer) used to overwrite each other's temp image.wx.pyShutDown()'sos.system('taskkill')becomessubprocess.run(no cmd.exe, real exit code).- New
tests/test_pure.pywith 24 pure-function unit tests (key verification / content formatting / aid matching / md5 extraction) that never touch WeChat:python -m unittest discover -s tests.
- Verified: 76 new offline regressions on the merged tree (input box 8, quote/reply 13, remark 7, window binding 9, send retry 7, database binding 12, standalone windows 20) all green;
tests/test_pure.py24/24; every existing suite unregressed;tools/selftest.py560/0.
v1.2.8.0 (2026-10-23)
- New: add a group member as a friend —
wx.AddFriendFromGroup("Group", "Bob")/wechatauto 加好友 群名 成员名. UIA path: open the group → “Chat info” (top-right) → the “Search group members” box → open the result card → “Add to contacts” → “Send”.- Search results are chosen by screen position, not tree order. A name can hit several rows, and Qt's recycler list exposes its children in a different order from what you see; the previous “first match while walking the tree” opened the wrong (bottom) card. The topmost row is now selected (
mmui::XTableCellsorted byBoundingRectangle.top). - “Add to contacts” also serves as the friend test. A non-friend's card only has that button; an existing friend's card has “Message / Voice / Video” instead, so nobody already in your contacts receives a request.
- The friend-request dialog is located generically (the new top-level window after the click, then its submit button), so a renamed window class does not break the flow. Live-verified on 4.1.15.53 (2026-10-23, with the user's authorization): the dialog is
mmui::VerifyFriendWindow(“申请添加朋友”) and its submit button is “确定”, not “发送” — both labels are accepted. The outgoing request was confirmed independently as a newgeneral.db:FMessageTablerow (is_sender_=1,scene_=14, recipient = the member's wxid).dry_run=True/--dry-runstill stops at the profile card and clicks nothing.
- Search results are chosen by screen position, not tree order. A name can hit several rows, and Qt's recycler list exposes its children in a different order from what you see; the previous “first match while walking the tree” opened the wrong (bottom) card. The topmost row is now selected (
- Fix:
open_chatcould type into the wrong “Search” box. 4.1.15 exposes up to threeName='搜索'edits (session search, in-chat search, group-member search) and the picker took the first in the tree, so with a chat or the info panel open the query landed in the in-chat search and the session never opened. The session box is now preferred by ancestor class, and transientCOMErrors while walking the tree no longer abortopen_chat. When the client sits on the Moments page (seen after a restart), the normal nav-bar click can be swallowed by the transparent render layer; an extra click on the “微信” tab is used as a last resort — still a rhythm-randomized point with a curved cursor path. - Fix: image bubbles became invisible to
MediaDownloaderon WeChat 4.1.15.56+. The client renders image bubbles withmmui::ChatImageItemViewinstead of the oldmmui::ChatBubbleReferItemView, so row-alignment and original-image downloads reported “no image bubble in view”. Both class names are accepted, keeping older clients working; regression covered intools/selftest.py. - Verified: 30 new offline checks (topmost-row selection, already-friend, dry-run, missing dialog/message box/submit button), all existing suites green; live: one complete request sent to a non-friend group member (run on a real client with the account owner's authorization) and confirmed in
FMessageTable, with the earlier probes reaching real profile cards and switching groups.
v1.2.7.0 (2026-10-17)
- New: set a contact's remark —
wx.RenameMember("Bob", "high school")/wechatauto 备注 Bob --to "high school". This is the first feature that writes to your account data, so three things are deliberate:- The path is Contacts → open a contact → profile "More" → "Remark & Tags", reusing the same window/click scaffolding as the contact-label feature (
wechatauto/remark.py,RemarkOpsextendsLabelOps) — lifted windows, click-ownership checks and independent-popup enumeration all shared. - The result is confirmed by reading the
remarkcolumn back for that contact's username out ofcontact.db; a failed read-back is reported as failure, never as success. - Clearing a remark needs an explicit
--clear/allow_empty=True, so a forgotten argument cannot wipe someone's remark.wechatauto 备注 Bob --probewalks the same path, opens the editor and closes it without writing anything — use it to find which step broke when WeChat's UI shifts.
- The path is Contacts → open a contact → profile "More" → "Remark & Tags", reusing the same window/click scaffolding as the contact-label feature (
- New: the post-send database read-back interval is configurable.
WxParam.DB_VERIFY_INTERVAL(default 1.0 s) andWxParam.DB_VERIFY_TIMEOUT(default 8.0 s; 0 = single read), with--verify-interval/--verify-timeoutonsend,labels sendandforward. The defaults are exactly the two numbers that used to be hard-coded, so behaviour is unchanged. The failure text now states how long it waited (数据库未确认(等了 8s)). - New:
tools/wake_uia.py— activate or drop the UIA control tree without depending on this library.--statusonly reports,--offflips the byte back,--rva 0x…skips the scan. It shares the same RVA cache file, so the scan cost is paid once. - Internal: the Qt accessibility gate is its own module (
wechatauto/accessibility.py), with same-name forwarding fromuia_driverkept so no caller changes. Two defects that were silently breaking hot activation are fixed:_HAS_WIN32and theuiautomationalias were left behind during the move and swallowed by an outerexcept, showing up as "found a WeChat window but could not locate Weixin.dll" and "N candidates did not materialize the mmui tree" — i.e. every activation rolled its own byte back.- The screen-reader system flag used the wrong SPI constants (
0x0046/0x0047are the keyboard-preference pair) and passed the value in the wrong argument; Windows rejected it withFALSE+ err=87, so the flag had never actually been set. A six-arm fresh-process A/B showed the tree follows the gate byte alone, so the flag is now touched only when the byte path fails.
- New: WeChat 4.1.15.53 support — gate RVA
0x0B3959D8added to the version table (scanned and verified on a live client: writing it flips the tree tommui::MainWindow, clearing it drops back toQt51514QWindowIcon). - Verified: 561 offline checks + 98 label + 59 forward green; the remark path completed two full live round-trips (set → confirmed in the DB → clear → confirmed empty).
v1.2.6.1 (2026-10-10)
⚠️ Important fix: group messages credited the sender to an unrelated contact —
real_sender_idmust be resolved through theName2Idtable of the shard the row lives in. (issue #34)- Symptom (found by a user in real use): sender names come out wrong, showing an unrelated contact or a group name.
- Cause 1 (wrong table for the id space):
real_sender_idis a rowid into theName2Idtable of themessage_N.dbshard that holds the row, while the code resolved it againstSenderName2Idinmessage_resource.db. The two numbering spaces are unrelated, so the same number is a different person. - Cause 2 (the hard-coded "id 2 means me" is false): on this machine the local account sits at rowid 2/4/1 in
message_0/1/2.dbrespectively, so== 2only holds in one of them — and the "fall back to 1 when nothing resolves" rule added in 1.2.4 is wrong on the other two. - Oracle (depends on no id table): WeChat writes the sender itself as a
sender:prefix inside group text rows. Across 12 groups and 3662 comparable rows: shard-based resolution matched 3662/3662, the old global table matched 0 (3656 attributed to someone else, 6 unresolved). All 856 文件传输助手 rows were sent from this machine: the new path returns the local wxid for 855 of them (the remaining row really is sent byfilehelperitself), while the old path attributed 639 of them to a frequent contact or to a group. - Scope: every release from v1.0.0 through v1.2.6 (
_sender_id_index(), which reads the wrong table, is present in the initial commit; the earliest tagged 1.0.3 already has it). What it touched widened over time:export_history'ssender_namealways used it,get_messages/ listenersender_usernamesince v1.1.8, and "who sent this non-text group message" since v1.2.4.1. - Fix: the per-shard
Name2Idis now fetched together with the rows (_name2id_map()/_shard_maps()), and_msg_row_to_dict,get_message_rowand the export-side_resolve_senderlook the id up in the map of that row'sshard;_sender_id_index()is deleted; new publicWeChatDB.sender_username(shard, sender_id);wx._db_row_to_messageandMediaDownloader._sent_by_selfnow decide "is this mine" by comparing the resolved username with the local wxid, with nosender_id == 2and no "fall back to 1". When nothing resolves the field stays an empty string (unknown) instead of guessing from another database — exactly what the report asked for. - Behaviour change:
sender_usernameused to be populated for almost every row (often wrongly); now about 0.2% (129 of 60243 rows, concentrated in one shard) are empty; the samesender_idmay legitimately resolve to a different person in another shard; and when it cannot be resolved_sent_by_selftreats the row as not-mine and logs a debug line. - Tests: new
tools/test_sender_namespace.py, 36 checks — the two-shard fixture deliberately maps the same number to different people in each shard and collides the row ids, so the assertions can only pass if resolution follows the row's shard; includes a negative control (reverting to "both shards share one global table" turnssid=2into the same stranger in both) and a live section requiring 文件传输助手 senders ⊆ {local account,filehelper} with ≥95% local, plus zero disagreements against the body-prefix oracle in group chats.
Everything else carries over from v1.2.6 (addressable cross-shard rows via
shard, and shard read failures no longer treated as "this shard has no messages").
v1.2.6 (2026-10-09)
⚠️ Important fix: within a sharded chat,
(chat, local_id)is not unique, so looking a row up by id could return a message from a different shard. (issue #20)- Background: WeChat splits one chat's history horizontally across several
message_N.dbshards, andlocal_idrestarts at 1 in every shard. Once reads were merged (v1.2.2),(user, local_id)stopped being a unique key. - Measured locally: of 200 session tables, 54 span multiple shards; 20008
(chat, local_id)pairs hit more than one shard, and 10477 of those share the samelocal_typewhile carrying different content (a sample of image rows alone contained 35 groups with the same id but different md5). - Impact:
get_message_row()used to pick the highestsort_seqsilently. Voice, image, video and file downloads all go through it, so the observable result was "asked for A's id, downloaded B's media". - Fix: every message row now carries
shard(the shard's database file name) — in the merged view(local_id, shard)is the unique key.get_message_row(user, local_id, local_type=…, shard=…)pins one shard; when several shards match and noshardwas given it warns first and then falls back to the previous behaviour; a wrongshardname returnsNonewith a warning instead of guessing. - Spread through the API:
list_voice_status()/list_image_status()rows carryshardtoo, andimage_status/download_image/download_image_original/download_voice/download_video/download_fileall acceptshard;download_media()passes the candidate row's ownshardwhen it dispatches. - Compatibility: only additive — a new
shardkey in returned dicts and new optional keyword arguments; every existing call shape is unchanged. - Tests: new
tools/test_db_shard.py, 46 checks — a two-shard temporary-database fixture covers merged reads,shardaddressing, that the new? AS shardplaceholder cannot steal a WHERE parameter, and shard-failure visibility; the live section re-reads a real same-id, same-type, cross-shard row per shard (withoutshardyou get the newest shard, with it each shard returns its own row) and asserts thatget_messages()returns exactly the sum of the per-shardcount(*)across all of the chat's shards.
- Background: WeChat splits one chat's history horizontally across several
Fix: a shard whose query raised
database disk image is malformedwas treated as "this shard has no messages". Corruption now propagates to_run_msg_query, which rebuilds the decrypted cache and retries; other errors still skip that shard but name it in a warning (logged once per key, so a poll loop doesn't flood). This was the second silent source of "incomplete reads" — returning success with half the rows missing.Removed: the
_find_msg_table()/_msg_conn()single-shard stubs (no callers, docstring saying "returns only the first matching shard"). The line-by-line analysis in issue #20 was written against them — that code has been unreachable since v1.2.2, but the tombstones were still in the file.Everything else carries over from v1.2.5.1 (the drive-root data-directory fix, the read-back gate before Enter, the
WxParam.ENABLE_OCRmaster switch, contact labels + right-click forwarding).
v1.2.5.1 (2026-10-07)
- ⚠️ Important fix: when WeChat's storage location is a drive root (the config holds
d:\), auto-detection started depending on the working directory. (issue #32)- Symptom: same Python, same dependencies, same WeChat config — just a different working directory turns "works" into
RuntimeError: 未找到微信数据库目录,请通过 db_dir 参数手动指定. - Cause:
_locate_account_root()stripped the trailing separator withrstrip("\/"), turningd:\intod:. On Windowsd:is a **drive-relative** path, soos.path.join('d:', 'xwechat_files')yieldsd:xwechat_files, resolved against that drive's own current directory — not the process working directory. - Scope: every release from v1.0.0 through v1.2.5 (that line has been unchanged since the initial commit; this is not a regression).
- Why most people never hit it: two conditions must hold together — ① the storage location is a bare drive root (a value like
E:\xwechat_filesis untouched by the strip), and ② work happens somewhere on that same drive (keep the data on E: while always running Python from C: and E:'s per-drive current directory stays atE:\, so the malformed path happens to resolve correctly). That is why it looks like "nothing changed, but yesterday it worked". - Fix: new
_abs_root()keeps root semantics viaos.path.normpathand also repairs a drive letter written without a separator (d:→d:\); all three sites of this pattern in_locate_account_root()and_extract_path_from_config()now use it, so candidates are always absolute. UNC roots are unaffected. - Workaround without upgrading: pass the account directory's parent explicitly as
WeChatDB(db_dir=...), or don't put WeChat's storage at a bare drive root. - Tests: new
tools/test_db_path_root.py, 40 checks — under a fake filesystem ad:\root must resolve tod:\xwechat_files(**with a negative control**: reverting the implementation to the oldrstripmakes the same check returnNone, proving the assertions actually bite); each of drive letters A–F and Z checked individually; the per-drive-current-directory drift is demonstrated live on the real drives; the live database is detected identically from 4 working directories.
- Symptom: same Python, same dependencies, same WeChat config — just a different working directory turns "works" into
- Everything else is as in v1.2.5 (contact labels + forwarding, the read-back gate before Enter, the
WxParam.ENABLE_OCRmaster switch, the chat-name placeholder tail, and three silently-broken window behaviours).
v1.2.5 (2026-10-07)
- New: contact labels + forwarding a message, implemented against the anchors measured on the live 4.1.15.13 client (Contacts → Contact management → Labels).
- 12 new
WeChatmethods:ListLabels(prefer='db'/'ui')/CreateLabel/RenameLabel/AddLabelMembers/RemoveLabelMembers/DeleteLabel/LabelMembers/SendToLabel/ForwardMessage/ProbeLabels; wxids are resolved to display names automatically andRenameLabelchanges only the name (members andlabel_iduntouched). - Rehearse before you send:
SendToLabel(text, label, dry_run=True)only resolves recipients and never touches the window,limit=1tries one person first;WeChatDB().list_labels()is a pure DB read.ProbeLabelsnavigates without writing and reports which step breaks. - The CLI gains
labels/forwardsubcommands plus two menu entries, accepting Chinese and English aliases alike. Guide sections §10.5 and §10.6 added.
- 12 new
- New: read-back gate before Enter —
WxParam.SEND_CONTENT_RATIO(default 0.6,<=0disables).Ctrl+Vcan miss entirely (WeChat not in the foreground, focus not on the input field), and the keystroke used to go through anyway, sending an empty message or the previous draft — while the exception was swallowed and success was reported. Both the UIA path and the coordinate/OCR path now read the input box back before pressing Enter; on a mismatch they clear the box, return failure for the caller to retry, and do not consume therhythmwrite budget.- Why 0.6 rather than a higher number: measured on the live client, an ordinary message containing an emoji codes scores 0.884, and one wrong character in an 8-character Chinese sentence scores 0.875 — 0.9 would block good sends. An empty read-back is 0.0 and a stale draft 0.11, both caught.
- Emoji codes (measured):
[微笑]becomes a singleU+FFFCinside the input box, while the stored message keeps the literal text (WeChat converts on send). Both sides are folded before comparing, giving 1.0. Trade-off: after folding,[微笑]and[发怒]are indistinguishable, so a wrong emoji code is not caught by this gate.
- New:
WxParam.ENABLE_OCR(defaultTrue, behaviour unchanged). One switch to stop OCR when recognition is unreliable. It sits at the engine entryScreenOCR.recognize()(plus short-circuits inocr()/ocr_zoomed()that skip the screenshot itself), because 20 call sites are spread overguia,momentanduia_driver— a static assertion now pins thatguia.pyis the only module referencingOcrEngine. With it off, anything that can go through the UIA tree does; operations with no UIA equivalent fail explicitly instead of clicking on guessed coordinates: sidebar session lookup, search dropdown, the "send" button fallback, Moments elements, forward/multi-select menus. Pixel probes (input-box location, non-blank pane, highlighted row) are not OCR and keep working. - New: foreground cleanup API.
ensure_visible(keep_topmost=…)is now a parameter;_minimize_blockers()records the foreign windows it minimized, with newrestore_blockers()(restores only what it actually minimized, without stealing focus) andrelease_foreground()(un-topmost + restore blockers). - Fixed:
current_chat()used to carry the input box's placeholder tail. WeChat puts "session name + placeholder" in the control'sName(measured:文件传输助手→文件传输助手按住鼠标 语音输入文字), so everycurrent_chat() == whocomparison in the library could never be true: an already-open chat still madeopen_chatreturn False, sends fell to the slow, error-prone coordinate path, and the consecutive-send fast path never triggered. Known tails are now stripped; unknown ones return the name unchanged (behaviour as before). - Fixed: topmost never actually worked on 64-bit Python.
SetWindowPos(hwnd, -1, …)passed-1as a 32-bit integer into anHWNDparameter, so the call returned 0 (last_error 1400); it now usesctypes.c_void_p(-1)/c_void_p(-2). ⚠️ Behaviour change: topmost now really applies —ensure_visible()defaults tokeep_topmost=True, so WeChat sits above every other window during a batch; callrelease_foreground()when the batch is done. - Fixed: the foreground lock was set to 0 and never restored.
SPI_SETFOREGROUNDLOCKTIMEOUTis a machine-wide setting; leaving it at 0 weakens foreground-stealing protection for all processes, not just WeChat. The original value is now read back and restored on return — and it turned out the value only takes effect inpvParam(passing it inuiParamreturns FALSE and changes nothing). - Fixed: a clipboard held by another process raised straight through
send_msgas a traceback (PyperclipWindowsException: Error calling OpenClipboard, hit live once).set_clipboard()now returns abooland contains the exception; callers treat a failure as "this input attempt failed" and retry or fail cleanly. - Improved:
_chat_is_openprefers UIA and falls back to OCR — when UIA answers, no magnified OCR pass and no extra screenshot are spent. - Compatibility: no signature changes;
set_clipboard()returnsboolinstead ofNone, and call sites testis Falserather thannot …, so overrides that still returnNoneare not misread as failures. No new dependencies. - Regressions:
tools/selftest.py496 checks / 0 failures. Four new offline suites —test_send_verify21,test_ocr_send_gate26,test_ocr_switch21,test_chat_identity21; labels/forward keeptest_labels98 andtest_forward59. Live: one send through the UIA path and one through UIA-only (ENABLE_OCR=False) both read back verbatim from the database; the simulated "paste into thin air" scored 0.000 and pressed no Enter; the topmost set/release cycle was verified on the live window. - Not verified (stated plainly): placeholder tails on other WeChat builds (unknown ones fall back to old behaviour, no crash); the actual degraded behaviour of Moments/forward with
ENABLE_OCR=False(offline only proves OCR is not called);SendToLabelin a real batch (--go) because it writes to other people;send_msgstill spends 3 attempts (measured 56.9 s) before failing when the gate keeps rejecting; the foreground lock is not restored ifbring_to_frontraises mid-loop.
v1.2.4.4 (2026-10-03)
- New: one command —
wechatauto/python -m wechatauto(issue #31: "can you make calling it simpler? it's too much, e.g. one command"). The existing Python API is unchanged — the CLI is just a caller ofWeChatDB/MediaDownloader/guia.quick_send/MomentDB; anyone who prefers Python keeps doing exactly that.- Remember one thing only: after installing, type
wechatautowith no arguments. It runs a health check first (account / DB keys / image key / UIA tree — whichever one is zero is stated with the concrete next step), then shows a numbered menu: read messages / export / download images / send / listen / moments / list sessions / health check. Picking a chat lists your 10 most recent sessions (nickname + unread count) to choose by number, so you never need to know what ausernamelooks like. The menu only opens when stdin is a terminal; in scripts and pipespython -m wechatautostill prints help and exits 2, so it can never hang a CI oninput(). - Subcommands work in Chinese or English:
消息=messages,导出=export,图片=images,发=send,听=listen,体检=doctor,会话=sessions,朋友圈=moments. That is a one-line lookup table — the argument definitions still live in exactly one place (argparse). - Three traps it absorbs for you, which is what "too complicated" actually was: ① the DB layer needs
usernamewhile the UI needs the display name —resolve_chat()accepts nickname / remark / wxid / group id, and when two contacts share a name it asks for a wxid instead of silently picking one (typing a wxid into WeChat's search box is the number-one cause of "no hit, ~60 s of retries, then a bareNone"); ② the image AES key is checked before downloading images, and a missing key is reported as what to do next rather than a wall ofNonethat looks like a broken library; ③ the Windows console is GBK and Chinese bodies raiseUnicodeEncodeError— the entry point reconfigures stdout/stderr to UTF-8 first, andexportalways writes UTF-8. - Two display fixes: video/file/quote bodies are whole XML documents, now collapsed to one line such as
[非文本正文] playlength=151 length=4211669(use--jsonfor the raw text);--typeaccepts both English and the Chinese type names stored in the DB (Chinese-only would make people conclude "this chat has no images"). pyproject.tomlgained[project.scripts], so installing gives you a realwechatautocommand withoutpython -m. Sending defaults to--verify(the send is read back from the database).
- Remember one thing only: after installing, type
- Not verified (stated plainly): the menu's real keystroke path — automation can only feed scripted input, so nobody pressed keys yet; and
sendagainst the live client (it drives the window and needs the real client behind therhythmgate). Everything else has offline regressions:tools/selftest.py490 checks / 0 failures, including a newcligroup of 41 (one of them pins "cli must not import the UI stack at module level" — verified in reverse by hoistingguiaup, which turns it red).
v1.2.4.3 (2026-10-01)
- Fixed: "it says my own images have no original" / "it keeps clicking around and then reports failure" / "what I downloaded is still the preview" — one batch of reports, four independent causes, none of them decryption:
- "Did I send this?" used to be hard-coded as
real_sender_id == 2.real_sender_idis a rowid intoSenderName2Idinmessage_resource.db, and which rowid your own account sits on differs per machine — the2came from someone else's box back in 1.1.8. Measured here: self = 1 (all 400 messages in File Transfer Helper), while2is a frequent contact (512 image messages). The cost of the constant is direct: your own images were treated as incoming ones, the code clicked the left side first, and never hit the bubble. The check is now "does the resolved username equal my own wxid", with a fallback only when the index cannot be read. download_image_original()now targets the real original_h.datand drives the UI whenever it is missing. The middle tier.datis no longer accepted as a result: it is "whatever WeChat pushed", which may be a full image or itself a preview version — live example: a.datof 44,002 bytes next to a 2,961-byte preview, and that.datwas still a preview. No tier on disk can prove "this is not a preview version", so we also stopped pretending there is an answer for it (has_fullis gone;has_midsays only whether a.datexists). Callers who want "whatever is on this machine" usedownload_image(tier='full'/'best'), which never touches the UI.- WeChat writes the file later than our judgement. "It reported failure, then the next run said it was already local" and "it kept clicking bubbles and finally failed" are the same cause. Every decision point (on entry, before clicking the next bubble, after the tier wait times out, before declaring failure) now re-checks the disk and stops the remaining UI actions when the file is there.
- The download button in the preview window opens a standard Windows Save dialog (titled 「保存」, not 「另存为」), and the first save may write out exactly the preview currently shown (user report: saving from the UI still produced the preview; re-downloading produced the real image). The saved artifact is now size-checked against the local tiers; when it looks like a preview, the code clicks 「图片原始大小」 and saves again, keeping the largest of the attempts. Button selection verifies
ControlTypeNamereally is a button (the file-format combo box is also named 「保存」 in UIA — hit live once), the dialog must actually close for the click to count, and every failure path clicks 「取消」 so no modal is left on screen.
- "Did I send this?" used to be hard-coded as
- Added: scroll to the target image when it is off-screen (
download_image_original(..., scroll=True, max_scrolls=6)). "Which row is this image" is resolved by sliding-window alignment between the visible UIA rows and the database text/image sequence, with three gates: at least two text anchors and score ≥0.6 (a one-row viewport yields a fake 1.00 for every offset), tied best offsets must agree on a single answer, and the aligned row must actually be an image row in UIA. Rows-per-notch is measured after each scroll rather than hard-coded for a DPI. The hard precondition for scrolling is knowing the WeChat pid — the wheel is delivered at the cursor position at dequeue time, so an unverifiable landing point can hit whatever sits over WeChat; without a pid the code degrades to "current screen only, never scroll" and warns.scroll-stuck/scroll-limitare reported separately, and the fallback is counting newer images below, then trying bubbles one by one — it never scrolls or clicks forever. - Two narrow-window (portrait) click fixes:
- One click on the 「微信」 nav tab does not leave the conversation. Portrait is a single-pane layout: while a conversation is open,
session_listand the search box are absent from the tree entirely, so the first click only brings the main window back to the chat page and a second click is needed to exit the conversation. The old single click made theopen_chat()search-box lookup that follows it fail by construction — reported out as "cannot open the chat".back_to_chat_tab()now clicks → checks whether the chat page rendered → clicks again if not (max_clicks=2), and clicks nothing when it is already there (that click would yank the conversation the user is looking at). - Image bubbles could not be hit. The bubble position does not scale with row width (the avatar column plus margin is a fixed pixel count): on a 2598 px row the bubble sits at +44..+632, so "12% of row width" = +311 lands inside; on a 720 px portrait row the bubble sits at +141..+459 while the same formula gives +86 — background. Candidates are now "absolute anchor 300 px from the sender's edge → the old 12%", which collapse to the same point in a wide window, so behaviour is byte-identical to the released version there.
- Two shape facts on the same path, now handled: the preview window comes in two shapes (either the desktop's direct child is
mmui::PreviewWindow, or the top level isQt51514QWindowIcontitled 「图片和视频」 withmmui::PreviewWindowone level inside — filtering only top-level children never finds the second), and the zoom button'sNametoggles between 「图片原始大小」 and 「图片适应窗口大小」, so searching one hard-coded name bailed early and the 「保存」 fallback never ran.
- One click on the 「微信」 nav tab does not leave the conversation. Portrait is a single-pane layout: while a conversation is open,
- Fixed:
demo_original_image.pystill carried the deleted> 100KBrule as its "already downloaded" pre-check, which would report a compressed copy as an original. - Not re-verified on the live client this round (stated plainly): the click-bubble → preview-window → button →
_h.datstretch needs the real client behind therhythmgate, and the portrait bubble band for self-sent images is still a mirrored inference. Everything above it (tier judgement, row alignment, scroll decisions, dialog clicking, click geometry) has offline regressions. - Regression coverage:
tools/selftest.py449 checks / 0 failures (image196,tree25). Mutation testing: 69 arms onmedia.pyplus 6 onuia_driver.py; the 15 arms run this round all turned checks red (relaxing_harvestto accept.dat→ 5 red; self-sent early return back → 1;has_midalways true → 1; waiting on.dat→ 1; dropping the entry disk-check → 1; bubble click back to pure percentage → 2; removing the row-width clamp → 1, an extremely narrow row would click x=0; ignoring the sender side → 5; no candidate de-duplication → 1; the six nav-tab arms 1–5 red each).
v1.2.4.2 (2026-09-30)
- Fixed: "I can only download the thumbnail, never the original" (reported with a bot log: 「只要原图,先不用缩略图」 followed by retries that never converged). Three separate causes, none of them decryption:
download_image()never looked at_h.dat— the real original — and when it returned the compressed copy the filename was byte-for-byte the one an original would have (<user>_<local_id>.jpg), so a caller that only wants originals could only guess from the file size and retry forever. There is now an explicittier=argument:'original'(returnsNonewhen the machine has no original — it will not quietly degrade),'best'(original → compressed → thumbnail),'mid','thumb', and the tier is written into the filename (_h/ none /_thumb). Omittingtierreproduces the old behaviour verbatim.download_image_original()had an absolute size gate,getsize(_h.dat) > 102400, which is how a compressed copy was judged "not downloaded yet". Measured over the 705_h.datfiles on this machine: min 575 B, p10 4.3 KB, p50 91.5 KB, p75 563 KB, max 18 MB — i.e. 51.3% of real originals were being rejected as incomplete. The judgement is now relative (non-empty, ≥1 KB, and not more than 10% smaller than the compressed copy), and it is exposed asMediaDownloader.original_ready()so callers can apply the same rule. The same function'stimeoutargument was decorative: it clicked and then slept a fixed 3 s, once. It now polls until the file is both usable and no longer growing, or untiltimeout— and if a qualified_h.datalready exists, the file is decrypted straight from disk without touching the UI at all.- The tier report was lying because the query was missing a column. An image's local filename comes from a 32-hex digest inside the message body, and the generic message
SELECTdoes not carrypacked_info_data, so every row came back "not an image".WeChatDB.get_image_rows()is a narrow query forlocal_type=3that does, with field names aligned toget_message_row(). - New
image_status(user, local_id)/list_image_status(user, limit)— same shape as the 1.2.4.1 voice report: per image, the byte size of each tier present, the best tier available, and areason(ok,mid_only,only_thumbnail,original_partial,no_local_copy,no_md5,no_message_row), one message-table query and one directory walk per digest. Measured here over 4271 image messages across 211 sessions: 2210mid_only, 1428only_thumbnail, 626ok, 5original_partial, 2no_local_copy— about 1 in 7 has an original on disk, so most of these reports are WeChat's storage policy, not a library bug, and asking first removes the retry storm. - The UI path is now honest about which step failed: no 「图片原始大小」 button in the preview window (the image already is the original, or WeChat offers no entry) is a distinct warning pointing at
tier='best', bubbles that are not in the visible area are reported instead of guessed at, and when several image bubbles are visible the one most likely to be the target is tried first — computed as "how many newer images does this one have below it in the session" — with the digest re-checked after every click, so a wrong bubble cannot pass as the right one. - Not verified live this round: the click → preview-window → button path needs the real client and the
rhythmgate, so it stayed untested; everything above it (tier judgement, routing, waiting, ordering, the DB query) is covered offline.
- Fixed: an empty UIA tree was silent (reported as 「控件树被屏蔽」).
uia_driverfilters out windows whose process has noWeixin.dllmodule,ensure_materialized()returnedFalseon the empty list, andensure_window()quietly switched to the "set the screen-reader flag and wake" branch — without logging a single line. "Every window got filtered out" is precisely the signature of a 32-bit interpreter enumerating a 64-bit process's modules (TH32CS_SNAPMODULEreturnsERROR_PARTIAL_COPY), but the user had no clue and could only say "blocked". Addedgate_block_hint(diag, bits=None)(pure function, so it is testable offline) and_warn_gate_blocked(), which pick from four actionable wordings: no visible window at all (not started / not logged in / minimised to tray), window found but modules unreadable (split by bitness — 32-bit is told to switch to 64-bit Python, 64-bit is pointed at privileges and the security-software allow-list), pywin32 missing, and — when at least one window survives — no output at all. Each reason warns once per process, because a long-running listener scans every round and spam is worse than silence. Filtering logic and return values are unchanged, so existing callers are unaffected. - Regression coverage:
tools/selftest.pygained animagegroup (39 checks, pure offline: temp attach trees + fake DBs — the 90% boundary on both sides, all sevenreasonvalues, all fivetierroutes and their filenames, no-silent-degrade, bubble ordering, the poll-until-stable wait,get_image_rowscolumn alignment, and the one-walk-per-digest cache) and atreegroup (16 checks with fakewin32gui+ fake pid/module enumeration: scan counts, four wordings, zero output in the healthy case, de-duplication, and that both real call sites actually consult the diagnostic). Whole suite 282 checks / 0 fail. Mutations: restoring the>102400gate → 5 red; deleting the relative-size rule → 2 red; not marking_hin the filename → 2 red; ignoring "how many newer images" when ordering bubbles → 3 red; lettingtier='original'silently fall back → 1 red; scanning the same digest twice → 1 red; dropping the diagnostic call inensure_materialized→ 2 red.
v1.2.4.1 (2026-09-25)
- Fixed: a group message could not tell you who sent it unless it was text (issue #20 territory, reported as "who is talking").
real_sender_idis a numeric rowid;wechatauto/db.pyalready resolved it throughmessage_resource.db'sSenderName2Idinto a real wxid and put it insender_username— butwx.py:_db_row_to_messagenever passed that on,msg.wxidstored the number, and the only working path was scraping thewxid_xxx:\nprefix out of text bodies, so images / voice / files / stickers were anonymous. Nowmsg.sender_wxidcarries the real wxid (falling back to the body prefix, then to the old numeric value so nothing that worked breaks),msg.sender/sender_remarkgive the remark-or-nickname, andmsg.wxidfor your own messages is your real wxid instead of the constant2. NewChat.GetGroupMembers()({username, nick_name, remark, is_owner}) andWeChatDB.nickname_map()(cached wxid→name) cover people who are not in your contact list. Measured over 8 real groups / 1249 stored messages: 95.8% carry a sender identity, and non-text messages went from 0% to 99.6% (451/453). The remaining 4.2% have no identity anywhere in WeChat's own data —SenderName2Idhas 655 rows and does not contain those ids, and sampling 20 of them found no username tag insourceeither. Nothing is invented:sender_wxidstays empty. A stale fallback that fed the numeric rowid intocontact.username(which can never match, and madeget_nicknameecho the number back as if it were a username) is gone. - Fixed:
download_voice()returningNonetold you nothing (issue #20, "26 voice messages but only 19 came through"). Reproduced, and the cause is not a broken lookup: an independent re-implementation agreed with the library on 958/958 voices, and the "svr_id exists but under a differentchat_name_id" case was 0. WeChat only writesvoice_dataintomedia_*.dbafter a voice has been played or received on that machine, so most misses mean the audio was never on disk — and the silentNonemade that indistinguishable from a library bug. The oracle was already there: the message table'sdownload_status. Over 975 voices the correlation is exact —download_status != 0⇔ "audio on disk" (515 rowsds=1+ 400ds=5, 0 exceptions), and all 60 rows withds=0were genuinely absent. NewMediaDownloader.list_voice_status(user)/voice_status(user, local_id)returnavailable/bytes/download_status/self_sent/reason, wherereasonisok,audio_not_downloaded(nothing on disk — play it once in WeChat),audio_missing_from_media_db(the flag says it should be there and it is not — that one is worth an issue),session_not_in_media_index,no_server_idorno_voice_row.download_voice()keeps its signature and behaviour; on failure it now logs the reason. Measured availability here: 93.7% (898 ok / 54 not downloaded / 6 with no media index entry). The new narrow query also guards a real trap:download_statusis deliberately not in the shared messageSELECT, and where a message table lacks the column the query degrades and returnsNonefor it — the shared path'sexcept: continuewould have silently dropped every row of that shard, turning "cannot read" into "there are no voices", which is worse than the bug being fixed. - Behaviour change to know about: in groups,
msg.senderused to be the rawwxid_xxxscraped from the body; it is now the remark/nickname. Readmsg.sender_wxidfor the raw id. - Regression coverage:
tools/selftest.pygained asendergroup (22 checks: the numeric-rowid masquerade, the text-prefix fallback, your own messages, no-db and nickname-query-failure paths, cache semantics, three group-member failure modes, and both listener paths actually passingdbthrough) and avoicegroup (28 checks, built on real in-memory / temp-file SQLite message and media databases, including the missing-column degradation that must not drop rows). Whole suite 226 checks / 0 fail. Mutations confirm both groups bite: reverting the sender-identity resolution → 5 red; putting the fake numeric fallback back → source check red; making the voice query use the sharedcontinueon a missing column → "does not lose rows" goes red (0 rows came back) and the group crashes.
v1.2.4 (2026-09-24)
- Adapted to WeChat 4.1.15.13 (the client auto-updated mid-round;
Weixin.dll198,060,584 → 201,552,944 bytes). Three separate things had to change, and each one looked like the others from the outside:- The search entry is now collapsed by default. What used to be an edit box is a
mmui::XButtonnamed 搜索 (measured[314,84,370,140]); themmui::XValidatorTextEditonly appears after that button is clicked._search_box(expand=True)clicks it once and waits for the box;search_box_rect()still resolves on the collapsed button (it returns the button rect as a read-only anchor, deliberately without clicking — an anchor must not have side effects) whileopen_chat()asks for the expanding variant. On builds where the box is already resident, nothing extra is clicked. 4.1.13.x keeps working; which branch is taken is asserted offline. - The accessibility gate RVA moved to
0xb135c38and is in the version table, so the vectorised scan from 1.2.3 is not needed on the common path (it still is for unlisted versions, and results are cached in~/.wechatauto/gate_cache.json). - Entering Moments needed an explicit wake-up. When the
mmuitree had not materialised yet,_switch_to_moments_new_stylefailed with "找不到导航按钮" — which reads exactly like "this WeChat version is unsupported" and sent me down the wrong path first. It now checks tree readiness, callsensure_materialized(timeout=6.0), and re-anchors the root control afterwards (the old handle points at the empty shell from before the wake-up).
- The search entry is now collapsed by default. What used to be an edit box is a
- Fixed: Moments scrolling never actually scrolled —
find_momentonly appeared to work._send_scroll()posted aMOVEand aWHEELSendInputback to back with zero pause between them. Wheel events are delivered to whatever window is under the cursor when they are dequeued, so the wheel landed on the old position, i.e. on the wrong window entirely; and the mmui timeline ignores the wheel unless WeChat is the foreground window. Now cursor placement is verified (SetCursorPos→GetCursorPospoll, ≤8 tries ≈0.4 s) before a 0.3 s settle, and_scroll()brings the window to the foreground first. Measured on the live client:find_momenthit 2/2 runs (51.4 s / 14.2 s), later 17.2 s — before this it was a coin flip that looked like a timing fluke. - Moments like / comment now have one implementation. There were two parallel routes; the one actually exercised by the demos (locate the 「…」 button, then click the 赞 / 评论 in the float layer) was not what
LikeMoment/CommentMomentused.LikeandCommentnow take the float route first and fall back to the legacy right-click menu only when it fails;LikeMoment/CommentMomentdelegate instead of carrying their own copies. The legacy comment window (MomentCommentDialog.send: click box →Ctrl+A→ paste → 发送) also bypassed the 1.2.3 throttle entirely — it is gated now, placed after the preconditions so a rejected comment does not consume a write slot. Verified on the live client: like and comment both landed, and the comment is present when the post is read back from the local Moments database. Lesson kept: two implementations of one feature means the audited one is not the shipped one. - Fixed:
find_momentcould hand back a cell that nothing can be clicked on. UIA recycles a cell handle once its row scrolls out of the viewport, and the recycled node reportsBoundingRectangle == (0,0,0,0); every coordinate action on it then raisesCan not move cursor, which surfaced as "未能打开朋友圈操作菜单" after a successful locate. A hit is now checked before it is returned:_rect_usable()(unreadable / degenerate rect),_reattach_item()re-claims the same post among the currently visible cells by nickname + content prefix + time and swaps the handle in place, so the object the caller is holding stays valid, and_settle_item()scrolls it back into view once if no live twin is on screen. Re-claiming is deliberately conservative: a different post by the same publisher is never adopted (that would like the wrong thing), and a post with neither text nor timestamp — bare nickname — is refused outright. Content is compared by "first 10 characters contain each other" rather than by exact signature, because the UIA summary truncates the body while the DB-corrected body is longer; an exact signature would fail to recognise its own post. Bothfind_momenthit sites,_locate_more_click(per retry) and_invoke_action_menu(before it touches anything) go through this. - Fixed: a real concurrency crash while watching all conversations. Two threads reaching the same database at once wrote to the same
dst.tmpscratch file:FileNotFoundErrorfromos.replace, thrown out of the listener.db._open()now takes a per-database build lock, names its scratch files uniquely (dst.<pid>.<thread-id>.tmp) and sweeps scratch left behind by a killed process (older than 600 s). - Fixed:
get_messages()was not fail-closed on bad paging arguments.limit=0returned a full page andoffset=-1returned the last row (Python slicing, not a database error), which silently produced wrong answers instead of an empty list — found while the live listener was paging a short session.limit <= 0oroffset < 0now return[]. - Added:
AddListenAll()really watches every session. The global callback used to be registered per session, so any conversation that already had its ownAddListenChathandler was skipped and never saw the global one; de-duplication is now by callback, newly discovered sessions are attached during polling, the pseudo-chat handed to the callback is usable (itsnicknameresolves,chatis created lazily,SendMsgworks), the global callback is re-attached when the listener restarts, andRemoveListenAll()detaches for real. Documented inGUIDE.md§4.3. Known cost (not fixed this round): on an account with ~200 sessions, registering takes ~11 s and each poll round ~10 s, because every session is opened and read; that also makes it a suspect for the high-I/O report in issue #25. - Live verification (real client,
calmrhythm profile, actions spaced out): send to File Transfer Helper with verbatim database read-back twice (newsort_seqmatched exactly);back_to_chat_tab()+open_chat()end to end (7.2% / 8.3% whole-window pixel change as the objective "the page did switch" oracle); Moments locate → like → comment confirmed on screen and in the database. - Regression coverage:
tools/selftest.pygained amomentgroup (30 checks: fake controls and realMomentIteminstances, all offline — no WeChat, no clicking), plus theclick(8) andlisten(7) groups added during this round; the whole suite is 175 checks / 0 fail (147 immediately before this fix; the sessions/messages groups report a few more or fewer depending on account data). Two mutations confirm the new group bites: make_rect_usablealways returnTrue→ 15 checks red; make post re-claiming compare only the nickname → 5 red, and they are the "must not adopt a neighbour" ones.
v1.2.3 (2026-09-22)
- Added: a human-pacing layer,
wechatauto/rhythm.py— on by default, and it changes default visible behaviour. This account tripped WeChat risk control once (2026-09-21, forced re-login mid-session), so from now on every action that drives the real client has to move like a person, and it had to be enforced in the library rather than in a demo script. What risk control sees is the time distribution of actions, not coordinates: constant intervals, a cursor that teleports, clicks that always land dead centre, perfectly uniform typing, several writes per second. Nownap()jitters every wait (multiplier floor pinned at 1.0 — it only ever lengthens the fixed sleeps that hold render stability),move_to()walks the cursor along a quadratic bezier andpoint()picks a random interior target after insetting all four edges (no moreBoundingRectanglecentre hits),type_gap()/key_hold()break uniform keystrokes, andgate()throttles outward-visible writes only (send / send-file / like / comment / recall / poke / voice call) with a minimum interval plus a rolling-window burst cap. Read paths (database, screenshots, OCR, control lookup) are never throttled. Profilesnatural(default) /calm/fast/off;offreproduces pre-rhythm values exactly and is for control experiments only. Override withWECHATAUTO_RHYTHM,WECHATAUTO_WRITE_GAP,WECHATAUTO_WRITE_BURST. Throttle state is persisted to~/.wechatauto/rhythm.jsonso it also holds across processes — every demo script is a fresh Python process, which makes in-memory rate limiting worthless. On the default profile two sends are at least 2.5–6 s apart and the 7th write inside 120 s enters a 30–75 s cool-off. Cross-process is documented last-writer-wins; no named mutex was added on purpose. - Fixed: the UIA gate scan could stall
quick_sendfor ~8 s and could abort it outright. A user reported hanging inside_rip_xrefs_to_rva's byte loop (198 MBWeixin.dll; measured 6.6–9.8 s here, which looks like a deadlock on slower machines), and nothing on that path caught errors — any exception propagated out ofensure_windowand killed the wholequick_sendcall. The scan is now numpy-vectorised: identical candidates on the real DLL (0xae2b0c8) at 0.56–0.77 s (~12x), with the old byte loop kept as the no-numpy fallback (numpyarrives viaopencv-python, so a normal install never takes it; if it is missing the scan is only slower, never absent). Scan results are cached on disk in~/.wechatauto/gate_cache.jsonkeyed by DLL identity (version directory + size + mtime), including negative results ("scanned, no candidates" — unsupported versions were paying the full rescan every launch); a second scan in the same process is 0.000 s, and a verified gate RVA is restored from disk and put first in the candidate list. Unreadable file / not-a-PE now return an empty sequence instead ofNone(callers iterate directly) and are not written to disk. Exceptions during hot activation now degrade to "skip this round, fall back to OCR/coordinates" instead of breaking the send;KeyboardInterruptis aBaseExceptionand still propagates, so Ctrl+C keeps working. - Fixed: the older Moments comment path bypassed the throttle entirely. Comments have two parallel implementations; the previous round gated only the newer template-matching one (
_click_comment_send), whileMoment.Commentstill went through the old UIA comment window (MomentCommentDialog.send): click the edit box →Ctrl+A→ paste → click 发送, with not one step throttled. That is now gated, placed after all precondition checks so a failed precheck does not consume a write slot. The lesson: audit outward writes by the action they perform, never by function name.tools/selftest.pynow scans for the action itself (SendKeys('{Enter}')/press_enter/keybd_event/ clicking 发送) and fails if such a function has norhythm.gate, with an explicit whitelist for functions that merely reference the send button or produce nothing outward (button lookup, pinyin candidate selection, OCR matching, Enter-to-open-chat). Deleting this one gate turns 3 checks red and namesmoment.py:MomentCommentDialog.send. Every remaining write entry point was traced to a gated sink:quick_send→send_msg→click_send,quick_quote/at_member→click_send,send_text_to→send_text,ReplyCommentMoment→ReplyComment→_click_comment_send,wx.SendMsg→send_msg,sender.send_to→send. - Live re-verification (2026-09-22, real client,
calmprofile, actions spaced out): a send to File Transfer Helper passed with verbatim database read-back (newsort_seqmatched exactly, throttle ledger recorded one write, hybrid-path log confirms the UIA driver was active);back_to_chat_tab()+open_chat('文件传输助手')passed end to end. - Known issue (not fixed this round, and it corrects a v1.2.2.6 claim): v1.2.2.6 stated that clearing
WS_EX_TRANSPARENTaround the event makes UIA clicks land. That effect did not reproduce on this machine. Measured: the style is genuinely cleared (ex=0x80000), yetWindowFromPointstill returns the plainQt51514QWindowIconmain window instead of the render sub-window at the nav-tab point, and the click changes nothing (0.0% whole-window pixel diff);Control.Click()andInvoke()are equally inert. When that style was left cleared across processes, the very same point did switch pages (15.3%) — so the hit-test decision involves more than theWS_EX_TRANSPARENTbit (MMUIRenderSubWindowHWis a layered window, where per-pixel alpha plausibly matters). Search-box /ValuePatternroutes are unaffected, so sending, searching and opening chats all work; coordinate clicks on the nav bar remain unresolved. This round changed no code in that area, and the "fixed" claim has been removed from the record rather than left standing. - Regression coverage:
tools/selftest.pygained agategroup of 35 checks (synthetic PE fragments + temporary cache files + fake window handles, fully offline, never touches WeChat) and therhythmgroup grew to 42 with the action-based throttle audit; whole gate 131 checks. Three mutation checks confirm the tests bite: delete the no-numpy fallback → the group crashes; drop the displacement sign-extension → 6 fail; delete the comment gate → 3 fail and name the function.
v1.2.2.6 (2026-09-21)
- Fixed:
calibrate_layout()always raisedNameErrorin the published 1.2.2.5. The timeout wrapper_run_with_timeoutinguia.pyusesthreading.Thread, but the module never importedthreading(checked against the 1.2.2.5 wheel:threading.Threadpresent,import threadingabsent). Layout calibration is the entry path of the UIA driver, so every pip-installed user hit it on the first calibration; local checkouts were synced separately and hid it. The two layout profiles failed differently, which is why one report was not enough: onwidecalibration returnedFalseand wrote no layout file at all, while onportraitthe send-button probe swallowed the same error and calibration returnedTrueon default ratios. Both shapes were reproduced by deleting the module attribute from a synced copy. A probe that errors inside the timeout wrapper now leaves a log line, and the outer handler separates code defects (NameError/UnboundLocalError/AttributeError/TypeError/ImportError→wxlog.error, reaches the console) from recoverable misses (OCR anchor simply not found → debug, falls back to defaults by design). Adding the import does not make OCR find the anchor — that stays a fallback, not a failure. - Fixed: send verification could confirm a message that was never actually sent (
_verify_sent). Two independent holes: it matched on substring, so a draft left in the chat input (the body that actually went out read校准wechatauto 部署自检 OK) still verified a call forwechatauto 部署自检 OK; and with no watermark, an older self-message containing the target text among the last rows validated even when this send produced no row at all — the UI returning success only leads to polling, never to a resend, so a stale row is accepted on the first check. Plain-text sends now require a verbatim body match (strip()is not verbatim); reply / quote /at_memberkeep substring matching because WeChat wraps those bodies, and that rule is now explicit per call site. Every verified send first takes a pre-send watermark of the target chat: the maxsort_seqplus the(sort_seq, local_id)identity set of the top rows, because realsort_seqvalues tie heavily (up to 8 rows in one chat) and a bare>would reject a genuine send. Verification also resolves the display name to ausernamebefore reading: message tables are keyed byusernameand a wrong one returns[]silently, so group-chat verification had been failing closed for the wrong reason. When no watermark can be taken (fresh chat, DB unavailable) verification falls back to the unwatermarked check rather than reporting failure. - Regression coverage:
tools/selftest.pygained an offlineverifygroup (14 checks against a fake DB) and an offlinecalibrate_layoutgroup covering both profiles and the anchor-hit path (8 checks against a fake window) — 36 offline checks, full gate 55 pass / 0 fail. The verifier's rules were additionally replayed read-only against the live decrypted database (no message was sent); the end-to-end send path still needs a real take. - Fixed: a UIA click could land on whichever window sits behind WeChat. WeChat's content window (
MMUIRenderSubWindowHW) carriesWS_EX_TRANSPARENT(measuredexStyle=00080020), so a rawmouse_eventat its coordinates is skipped by hit-testing and delivered to the plainQt51514QWindowIconwindow behind it — this is why UIA clicks looked ignored on 4.1.13+.uia_drivernow clears the extended style around the event and restores it immediately (_click_at/_click_ctrl), the wayguia.wx_clickalready did. The mouse wheel is the exception: it works on that window unchanged. - Fixed:
open_chatcould not recover when WeChat was parked on the Moments page. The session list does not exist there, so the search-based path never resolves — measured as an 8+ minute spin at high CPU, and separately as a ~90 s give-up that returnsNonewith nothing on screen explaining it (it cost a whole recording take).WeChatUIA.back_to_chat_tab()clicks themmui::MainTabBar「微信」 item. It cannot ask which page is showing: on this buildXTabBarItemexposes no selection state (plainButtonControl, noSelectionItempattern,LegacyIAccessible.Statealways 0) and the per-page controls stay in the tree after a switch, so the tab is clicked unconditionally — clicking the already-selected tab only scrolls the session list back to the top. - Fixed: the search-box calibration ratio could paste a chat name into a live conversation's input box. The stale
SEARCH_BOX_RATIOresolved to a click point off the real box (computed center 212,120 vs a measured box at 250,152–412,192), so the clipboard paste went to whichever chat was open._search_chatnow prefers the UIAsearch_box_rect()and self-checks afterwards: if the text did land in the chat input it is cleared throughValuePattern.SetValue('')and the search fallback is abandoned — nothing is sent. - Added to the UIA driver:
search_box_rect()and_set_text().ValuePattern.SetValuedrives WeChat's live search with no keystrokes and no clipboard, but it does not give the Qt widget focus, so it is used for the search box only — the send path still needs a focused input and{Enter}. - Privacy fix:
media.pyno longer prints 12 bare[DBG]lines to stdout, one of which carried the absolute.datpath containing the account wxid. They arewxlog.debugnow (the console handler is INFO by default), and the two silentreturn Nonebail-outs became warnings. - Still unverified on a live client this round: whether
_click_ctrlandback_to_chat_tabactually land the click on WeChat's content area (the relogin interrupted that check). TheWS_EX_TRANSPARENTmeasurement and the search-box rect numbers are real; the end-to-end effect of the two new click paths is not yet demonstrated. - Thanks to wenjiavv for reporting both of the above with reproductions, a per-profile symptom split and fix proposals (#28, #29).
- Fixed:
RecallGuardcould not see a single revoke — two independent faults.- Revoke rows were never delivered. WeChat rewrites the original row in place (across 208 local sessions, 64
revokemsgrows:revoketime - create_timelands in 1-30 s for 10 of them, 31-300 s for 54, and zero for none), andlocal_id/create_timestay those of the original message.Listenerincrements bysort_seq > watermark, which never changes on a rewrite, so no revoke event is emitted.watch()now runs awxrecall-scandaemon thread that re-reads the lastscan_limit(default 30) rows per session everyscan_interval(default 2.0 s) and diffslocal_idagainst the mirror: normal in the mirror,revokemsgin the live DB = one revoke.scan_now()is exposed for scripts. - Even when delivered, the original was never found.
_find_originalusedcreate_time < revoke_time, whererevoke_timeis the revoke row's owncreate_time(= the original's timestamp) — the strict<excluded the only matching row. Lookup is now exact by(chat, local_id)first (the rewritten row keeps itslocal_id, so the mirror row with that id is necessarily the original), with the time window only as a fallback and relaxed to<=. on_msgno longer mirrors revoke rows — storing one would overwrite the original it is meant to rescue.- The revoke timestamp now parses
<revoketime>(it used to print the original send time);(chat, revoke_time)is the dedup key, so the Listener and polling paths cannot double-report and a restart cannot re-report history. close()now stops the polling thread. Measured: offline 3/3 real revoke rows (local_id 62/64/88) restored, a secondscan_now()returns 0 rows (dedup works), onlyMainThreadleft afterclose(); the live path (send → mirror 20→21 → revoke → restore) passes too.
- Revoke rows were never delivered. WeChat rewrites the original row in place (across 208 local sessions, 64
- Security fix:
demo_media.py --listprinted media XML verbatim, exposingaeskey,cdnthumbaeskey,cdnthumburlandmd5. It now prints only dimensions, byte size, duration and file name. - Added: OCR fallback for Moments likes/comments under WeChat 4.1.13's merged layout (
Moment._read_comment_cell_ocr). Likes and comments now live in a siblingmmui::TimelineCommentCellthat never enters the UIA tree, so parsing the text cell always came back empty; an empty UIA result now falls back to "comment-box region screenshot + OCR". The comment-box search area also moved to 320 px above the viewport bottom (the oldbottom+8band landed on the taskbar and never matched on 4.1.13). - Cleanup:
demo_send.py'spick_default_imagedocstring is now a raw string, silencing a\Wescape warning.
v1.2.2.5 (2026-09-19)
- Fixed: cached Moments pictures decrypted into files nothing could decode. Two independent causes on the same
.datv2 path:- Wrong single-byte XOR key for cache containers. That key is the low byte of the account's config dword, but the code derived it per file from the plaintext's last two bytes (
tail ^ 0xFF == FF D9). WeChat appends a 24-byte footer after the image end marker in Sns cache containers (189/295 measured here), so the check failed and it silently fell back to a wrong key — the whole tail segment came out garbled. Resolution order is now config dword (authoritative) -> thumbnail statistics -> fallback, resolved once per account. - The footer was kept as image data. Decrypted output is now trimmed at the JPEG/PNG end marker, so a strict decoder no longer rejects an otherwise valid picture over trailing bytes.
Measured: Sns cache containers passing
MediaDownloader.decrypt_image()118/295 -> 295/295; the Moments cache index's decrypt failures 177 -> 0; a 15,577-file chat-image sample 429 JPEG + 171 wxgf, 0 failures (chat media unaffected, wxgf/WXAM containers untouched).
- Wrong single-byte XOR key for cache containers. That key is the low byte of the account's config dword, but the code derived it per file from the plaintext's last two bytes (
- Fixed:
MomentDB.find_local_media's size guard never ran on its most common path. The "reject an impostor by size deviation" check only existed on the multi-candidate branch; with exactly one same-dimensions candidate the code returned without comparing anything, so a 66 KB mismatch passed silently. It now logs the deviation and deliberately still does not reject: the declaredtotalSizeis the CDN original while the cache holds WeChat's re-encoded copy, so a large delta is normal and is not evidence of a wrong image (the multi-candidate rule is unchanged).
v1.2.2.4 (2026-09-18)
- Fixed: a wrong key form could make an entire message shard unreadable. A cached key could be stored as 48 bytes (32B key + 16B explicit salt), but decryption picks its branch by key length — 48 bytes takes the “plaintext header” branch and produces a file whose header is not SQLite (
file is not a database), making that shard (a 96 MBmessage_0.dbin practice) completely unreadable. Three guards now: verify the standard form first when storing (store a bare 32-byte key unless the DB really uses a plaintext header), normalize on read, and auto-correct legacy entries when loading the cache. - Fixed: “database merge failed” was raised outright while WeChat keeps writing. The old code wrote decrypt results straight onto the cache file and raised on failure, destroying the last usable copy. Now: build a self-consistent main-DB snapshot as a floor (verified with
quick_check, re-read up to 4 times) → then try merging WAL frames on a copy (fall back to the main snapshot with a warning) → all intermediate files are written to a temp path and atomically replaced only on success, so a failure never destroys the previous usable copy. - Fixed: leftover cache entries for databases that no longer exist crashed construction (
KeyError) — now fully tolerated. - Layout: added a phone-style portrait profile (dual profiles
wide/portrait), auto-selected by window aspect ratio, each calibrated and stored independently (old flat files migrate automatically). Also fixed session lookup in portrait mode (the name-column filter discarded every session name as an “avatar area”, sofind_sessionalways returned None). - Cleanup: removed 10 unused imports; added debug logs to 8 silently-swallowing handlers (a probe failure must not masquerade as a normal result);
demo_send.pyno longer hardcodes another user’s path or a real wxid (default image auto-discovers RWTemp); real wxids in READMEs replaced with placeholders. - New
tools/selftest.py: read-only self-check (layout / keys / sessions / messages), run in one command.
v1.2.2.3 (2026-09-16)
- Fixed: constant ~50 MB/s disk read + write while the library runs. The decrypt-cache stamp compared mtimes with exact float equality while writing them with
%f(6 decimals) against Windows' 7-decimal mtimes — so every poll (~1s) looked like a changed database and re-decrypted everything (WAL merge + cache rewrite included). NowSTAMP_VERSION 3with%r(exact round-trip): one rebuild after upgrading, then stable. - Message reads now LIMIT inside each shard before merging (5.5× on a 48k-message group: 1.053s → 0.191s;
get_new_messages≈6×). Huge chats no longer materialize every shard's rows in Python. Public APIs (get_messages,get_new_messages,get_message_row(..., local_type=),get_message_rows_for_media) keep identical signatures and results (verified across 6 chats × 71 cases). - Fixed “cannot get keys” under UTF-8 mode: four
tasklistcalls decoded GBK output with the default codec; underpython -X utf8/PYTHONUTF8=1the decode failed, leftstdoutas None and raisedAttributeError, killing key extraction. All four now useencoding="gbk", errors="replace"with a None guard. WeChatUIA.is_running()is now multi-criterion: it used to be one probe wrapped inexcept → False, so any error silently became “WeChat is not running”. It now checks tasklist / main-window title / psutil, and only writes an explicit stderr note when every probe errors.- Real contact/group names removed from demos and docs (replaced with 「文件传输助手」; 「兔仔仔」/「送你挖银子」 kept as sample defaults).
v1.2.2.2 (2026-09-13)
- Key handling hardened: no more recurring failure after every WeChat update. Three layers:
- The cache can no longer be wiped:
_save_keys()never persists an empty result (atomic write +.bakkept). Previously a transient extraction failure (wrong account / permission) overwrote a good cache with an empty file, so every later start reported "0 keys" — that is exactly thekeys cached: 0seen in the field. - Durable key copy: a copy is kept at
%LOCALAPPDATA%\wechatauto_keys\<account>.json(override the directory with theWECHATAUTO_KEYS_DIRenv var, e.g. your project workspace), surviving TEMP cleanup and WeChat updates. On startup the caches are merged from several locations (durable copy → work cache →.bak→ other accounts' caches) and every entry is verified against page-1 HMAC, keeping only working keys. - Account selection is now decided by key verification, not by "most recently modified .db" (a WeChat update rewrites every .db, shifting mtimes and picking the wrong account → 0 keys). One memory scan now collects candidate key material and scores every account directory by page-1 HMAC, switching to the one that unlocks (log:
已按密钥校验选定账号目录: …).
- The cache can no longer be wiped:
- cfg master-key warning: on WeChat 4.1.13+ the cfg path returns an untrustworthy master key (demoted to a fallback since v1.1.9); it now logs an explicit warning when it cannot reproduce any database key instead of silently succeeding.
- Better diagnostics (
diagnose_keys): now prints the WeChat client FileVersion, per-account "cache / derived" availability and a master-key consistency check (which tells you which account the keys belong to); the_openerror text now lists the three classic causes (32-bit Python / permission mismatch / wrong account among several) plus theaccount=hint.
v1.2.2.1 (2026-09-12)
- Compatibility with the new WeChat UI (verified on 4.1.13.65): the new build changed
AutomationIdfrom short names into dotted paths (oldsession_list/chat_input_field→ newMainView.main_tabbar,MainView….main_window_sub_splitter_view…), which broke exact-equality matching. AutomationIds are now matched as exact / dotted-segment / suffix (_aid_hit()), so both the old short names and the new paths resolve. - Relaxed window-title matching: the new main window title is
Weixin, and becomes微信(3)when there are unread counts;_title_is_main()now matches by containment and still rejects unrelated titles such asWeChat. - Anchor candidate lists + structural fallbacks: the main window / login window / search box now match against candidate tuples (single-value constants kept for backward compatibility); the search box, chat input and search-result list each gained a structural fallback (an EditControl whose Name contains 搜索, an EditControl inside the chat area, attribute-based search from the root), so a renamed class or AID in a future build no longer breaks the whole path.
- New layout self-check
WeChatUIA.describe_layout(): one call returns the main class name, window title, layout kind (merged/legacy/chat) and the resolution result of every anchor (main_window, search_box, session_list, chat_input, main_tabbar, sns_list). Run it first when a new WeChat build changes the UI. - Note: the Moments anchors were already dual-layout (standalone
mmui::SNSWindow/ mergedmmui::SNSContentView); 4.1.13.65 keeps those class names, so no change was needed there.
v1.2.2 (2026-09-12)
- Fix cross-shard message reads (missing messages / voice): a conversation's
Msg_<md5>table actually spans severalmessage_*.dbshards, butget_messagesonly hit the first one — e.g. a chat with 8,904 real messages (24 voice notes) reported just 1. New_find_msg_tables()/_msg_conns()/_shard_rows()merge reads across all shards and sort bysort_seq;get_messages,get_new_messagesand_find_media_rowsnow use the merged view.get_message_rowgained alocal_typefilter (alocal_idis not unique across shards) and newget_message_rows_for_media()returns every shard row; media downloaders pass their type code so the right shard row is selected. - Reliable listener delivery (behavior change): the watermark now advances only after callbacks succeed (a new
_inflightboundary prevents re-dispatching unconfirmed messages), callbacks are retried (max_retries, default 3) before being logged as dropped, and the watermark is persisted tolistener_watermark.json. Messages that arrive while your process is down are delivered on the next start instead of being skipped. Passwatermark_file=""to disable persistence. - Text restore no longer requires CJK: pure English / digits / URLs / emoji container-format messages are decoded instead of degrading to
[文本]. Chat.GetNewMessage()no longer drops backlog: batches are pulled until caught up (>200 messages) and the watermark only moves to the last message actually returned, instead of jumping to the newest DB position.- UIA materialization self-heal (no child controls after a WeChat restart/upgrade): after a WeChat restart or upgrade the Qt accessibility gate byte resets to 0 and the
mmui::tree degrades to an empty Qt shell (Qt51514QWindowIcon+ 2 nodes). The driver now hot-writes the gate, verifies thatmmui::controls actually materialized, and retries other candidate RVAs on failure (the RVA that worked is cached per DLL identity)._get_uia()self-heals on a 30s throttle — no more "one failed wake and OCR forever", and no manualrefresh=True. Fallback table gained4.1.13.65 → 0x0AE2B0C8. - Moments scroll-positioning fixes: bounded reversals (at most one per run, then downward-only) and stall detection (the top-cell fingerprint now includes geometry — merged-layout ListItems can share the same Name, which previously looked like a stall and aborted mid-scroll); skip "scroll to top" when the DB ruler says the target is below; the stop criterion is now "the next moment's UIA control appeared"; direction/distance fixes (clipped pixels → wheel notches) plus a bottom margin so the "…" button is reachable.
- Message type table: 4.x composite
local_typeis decomposed by its low 32 bits; added50 音视频通话(VoIP bubble),11000 动画表情,8594229559345 红包(the library previously mislabeled it as an appmsg/file card via the low-byte mapping); empty bodies (stickers) now show[动画表情]instead of a blank line;demo_group_messagesdecodes zstd for every type and prints one-line summaries. demo_listen.py --allnow auto-discovers new sessions (previously limited to the 30 most recent at startup).- New anti-recall listener
RecallGuard(BETA): afterwatch(listener)every new message is mirrored into a local sqlite DB and attachments (image/voice/video/file) are backed up tomedia/; on arevokemsgit prints[撤回] <revoker> → <original text>and records it inrecall_events. Not fully field-tested — shipped as BETA. - New
MomentObserver(BETA): observe-and-freeze snapshots of Moments cache keys viasnapshot()/diff()(cache keys have no derivable mapping to feed md5 and the cache is evictable, so observing is the only way to keep them). Not fully field-tested — shipped as BETA.
v1.2.1 (2026-09-06)
- New "quote & send" message feature (BETA):
WeChatGUI.quote_msg(text, who, target_text=None, verify=False)right-clicks the target message → picks「引用」from the popup menu → types the content → sends; omittingtarget_textquotes the most recent message.quick_quote()is a one-liner entry point, demo scriptwechatauto/demo_quote.py.- BETA disclaimer: the feature uses a coordinate + OCR +
SendInputpipeline that depends on WeChat 4.1.x self-drawn layout; positioning may drift with window size / DPI / chat content. The right-click usesSendInputinjection (the render window ignoresmouse_eventright-clicks), and the cursor is first moved withSetCursorPosbefore injecting the click to avoid "moves but doesn't click / clicks but doesn't move" drift.
- BETA disclaimer: the feature uses a coordinate + OCR +
- Removed the
desktop_available()white-pixel screen check: control targeting is fully UIA-based now, so the full-window screenshot white-ratio sampling was dropped — it could falsely report "window not visible" while WeChat was fine.ensure_visible()now treats a live window handle as visible and keeps its "minimize blockers + bring-to-front" actions.
v1.2.0.1 (2026-08-31)
- Fix WAL-merged database cache corruption causing infinite loop:
_check_mergedpreviously usedSELECT count(*) FROM sqlite_masterwhich only checks the schema tree — corrupted data pages still passed validation, causing the cache stamp to mark the bad cache as "up-to-date" and every subsequent poll to reuse it, throwingdatabase disk image is malformedon a dead loop. Now usesPRAGMA quick_checkfor full database validation (data + index pages). New_invalidate_cache()clears all decrypted.db/.stampfiles. New_run_msg_query()unified entry point auto-retries once onmalformed(clear cache → rebuild → retry)._msg_connnow closes shard connections immediately to avoid Windows file-lock issues during cache cleanup.
v1.2.0 (2026-08-30)
Note: this release merges all changes made after 1.1.10.2 that were not yet published (1.1.10.3 → 1.1.10.7).
- Smart Moments positioning + auto like:
Moment.find_moment(publisher, keyword, ...)uses a hybrid of the DB route (computing the target offset) + UIA route (scrolling by offset) — it derives how many feeds the target is from the current view using the localsns.dbruler, then scrolls adaptively in the correct direction to land on the moment by author/keyword, eliminating blind downward scrolling and false "not found" results. - "…" overlay recognition:
Moment._locate_more_click/_find_more_buttonlocate the "…" button (bottom-right of a feed) via template matching (light/dark templates shipped inassets/) and click it; if not found it keeps nudging the scroll and retrying to pop up the like/comment overlay. - One-shot Like:
Moment.LikeMoment(publisher, keyword, ...)does "locate → tap "…" → like in the overlay"; the "赞/Comment" buttons in the overlay are found by a global deep traversal from the UIA root (matching by name) and clicked at their center. - Moments like/comment via UIA controls:
WeChatnow exposes aMomentproperty andSwitchToMoments()that hot-activate themmuiUIA tree and click the 朋友圈 nav button.Moment.Like(item, cancel=False)andMoment.Comment(item, content, reply_to=None)operate on UIA feed items — likes/comments are server-side actions, so they need the UI (the DB route stays read-only).WeChat.MomentisNonewhen the UIA tree is unavailable. Demowechatauto/demo_moments_interact.py. - Moments media download: new
MomentDB.download_media(media, save_dir, kind)copies a single picture/video from the local cache first (byte-for-byte, offline) and falls back to the CDN url;MomentDB.download_moment_media(feed, save_dir, ...)fetches all pictures/videos of one feed into a folder.find_local_media(md5, kind, size)locates the cache file by md5 and, for videos, bytotalSizeacross the wholeSns/Videotree (the video cache name is a content-hash unrelated to the feed md5, so size matching recovers real MP4s).parse_feednow distinguishes pictures vs videos viavideomd5/videoDuration/typeand records each media'ssize. Demowechatauto/demo_moments_download.py. - Moments read API (DB route):
MomentDB.get_moments()now supportssince/until(Unix-seconds time filter) andkeyword(text filter), pluslimit=0to return every row. New incremental-sync helperslatest_tid()/get_moments_since()make it easy to poll for new moments. New interaction notifierget_interactions()/interactions_unread_count()read the "likes/comments on my moments" table (SnsMessage_tmp3). Newcomment_tree()/comment_reply_to()organize a feed's comments into reply chains (built fromcomment_id/ref_comment_id). - Add group name ↔ ID lookup:
get_groups()now returns each group's realname(fromcontacttable, falling back to its wxid). Newgroup_name_to_id(name)(exact match first, then substring/fuzzy) andgroup_id_to_name(chatroom_wxid)let you resolve a group's wxid from its display name and vice versa — handy for combining withget_group_members()andat_member(). - Add group member enumeration & change watch (read-only, no UI): New
WeChatDB.get_groups()/get_group_members(chatroom_wxid)readchat_room+chatroom_member+contactfromcontact.dbto return each group's members (username / nick_name / remark / is_owner). NewGroupMemberWatcher(viaget_group_member_watcher) snapshots membership andpoll()diffs against the baseline to reportjoined/leftmembers, enabling polling-based membership-change monitoring. Useful together with the existing UI-automationat_member(). - New runnable demos
wechatauto/demo_moment_find.py,demo_moment_more.py,demo_moment_like.py; new depspyautogui,opencv-python.
v1.1.10.2 (2026-08-30)
- Fix long text still showing
[文本]on fresh installs: add requiredzstandarddependency: WeChat 4.x stores long-textmessage_contentas a zstd-compressed frame, decoded in_friendly_contentviaimport zstandard. That import silently failed whenzstandardwas absent (it was not inpyproject.tomlrequired deps), so long text degraded to the[文本]placeholder while listening worked normally.zstandardis now a required dependency;_friendly_contentalso gained lazy dual-package import (zstandard/zstd) via new_get_zstd_module()/_zstd_decompress()helpers.
v1.1.10.1 (2026-08-29)
- Fix
AttributeError: 'sqlite3.Row' object has no attribute 'get'in message reading:_msg_row_to_dictcalled.get("compress_content")on asqlite3.Row, which only supports[]access. Messages whose content stays a placeholder (e.g. emoji/special types) hit this code path and crashed the real-timeListenerpolling loop. Now uses[]access with a fallback, fixingget_messages/get_new_messages/get_message_row.
v1.1.10 (2026-08-27)
- Add original image download via UI automation: New
MediaDownloader.download_image_original()method triggers WeChat to download original images by simulating UI clicks on image messages. This solves the limitation where group chat images only have thumbnails available. - Fix long text message content extraction: Added zstd decompression support,
compress_contentfallback, and fixed newline character handling.
v1.1.9 (2026-08-27)
- Fix key extraction for WeChat 4.1.13+: Prioritized
Config.Ciphermemory scan overextract_master_key_from_cfgfor key extraction. The cfg-based extraction returns incorrect master keys on WeChat 4.1.13.12, while the Config.Cipher scan (which reads rawenc_keyvalues from XOR-decoded blobs) works correctly. This fixes the "0/24 keys verified" issue reported on newer WeChat versions.
v1.1.8 (2026-08-25)
- Fix missing
_derive_xor_keymethod in MediaDownloader: v1.1.7 release accidentally omitted the_derive_xor_key()method while code paths (_decrypt_v2,detect_image_key) still referenced it, causingAttributeErrorwhen decrypting images. Restored the method for XOR key derivation from thumbnail_t.dat/_h.datfiles. - Fix group-chat
sender_id→sender_usernameresolution:Listenercallbacks now receivesender_username(wxid format) in the message dict, resolved frommessage_resource.SenderName2Idmapping. Previously,sender_idwas a numeric ID that could not be used directly withsearch_contact(). - Thanks uiharukazari0105 for reporting the missing _derive_xor_key issue in v1.1.7.
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-PIDOpenProcess/ReadProcessMemorypermission, and multi-account mismatch (allwxid_*dirs vs. picked account, suggestingWeChatDB(account=...)). No need to rundiagnose_keysfirst. - 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 undefinedfound(NameError swallowed byListener._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_*.dbvoice 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 everymedia_*.db(not justmedia_0.db) — WeChat shards voice data across multiple media DBs; previously voices stored inmedia_1.dbetc. 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_imagenow falls back to the thumbnail (_t.dat) when the original is missing, saving it with a_thumbsuffix.
v1.1.3 (2026-08-17)
v1.1.2 (2026-08-16)
- UIA driver thread-safety:
WeChatUIAnow 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.dllare considered — auxiliary processes without the DLL (whose hot-activation always fails) no longer produce noise warnings. - Forward-voice fix:
Chat.ForwardVoiceMessageusesselfwhen no target is given (the previous_cur()could resolve the wrong chat). - Re-entrant UI lock:
LockManageris now re-entrant per thread —@uilockfunctions 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::XMenuViewfound 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 --photosdefault 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 amonitormode — polls continuously and persists the key toimage_keys.jsononce 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 for sharing the WeChat 4.1.12 UIA control-tree approach and debugging ideas in issue #1 — it made the UIA hybrid driver (v1.0.8) possible.
Thanks to nanshanjack for finding the UI-lock re-entrancy problem (fixed in v1.1.2).
Thanks to maozhitao12450 for reporting the WXAM (wxgf) image download issue (fixed in v1.1.3).
Thanks to uiharukazari0105 for finding that voice data stored in media_1.db (and later) was never searched (fixed in v1.1.4).
Thanks to wenjiavv for reporting the missing threading import that broke layout calibration in the published 1.2.2.5 (#28) and the substring/no-watermark hole in send verification (#29), both with reproductions and fix proposals (fixed in v1.2.2.6).
Thanks to dhz1145 for reporting issue #32 — auto-detection depended on the working directory whenever WeChat's storage location was a drive root (d:\), complete with step-by-step reproductions, the root cause, a GetFullPathNameW cross-check and a fix proposal (fixed in v1.2.5.1).
Thanks to WrenZephyrSol for the follow-up on issue #20: on the same chat list_message_chats() counted 2654 messages while get_messages() returned 763, and they pasted a line-by-line source comparison plus a workaround that avoided get_message_row for downloads by carrying server_id over themselves. The analysis was written against v1.2.1, but following it uncovered the real hole — local_id is not unique across shards (fixed in v1.2.6).
Thanks to tryqylz for reporting issue #34 after noticing it in real use: sender names came out misaligned. Both findings held up — _sender_id_index() resolved real_sender_id through SenderName2Id in the resource database when the number belongs to the shard's own Name2Id, and _resolve_sender() treated id 2 as the local account unconditionally. The proposed fix (join on Name2Id.rowid inside the shard the row lives in, and keep an unknown state when no mapping exists rather than guessing from another database) is exactly what shipped in v1.2.6.1.
Thanks to dhz1145 for the fix round spanning issues #38–#43, PR #37 and PR #44: each of the six problems (input-box offset, dead quote/reply targeting, remarks written to a similar-named contact, wrong window under multiple accounts, verification reading the wrong account's database, duplicate sends) arrived with three live reproductions, an independent database read-back and before/after comparisons, and the fix PRs carried 76 new offline regressions verified live on a two-account 4.1.15.55 setup (fixed in v1.2.8.1).
📄 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: [email protected]
Explore related projects
n8n-io/n8n
Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.
210,142 starslanggenius/dify
Build Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.
160,951 starsmicrosoft/PowerToys
Microsoft PowerToys is a collection of utilities that supercharge productivity and customization on Windows
140,833 starsstablyai/orca
Orca is the place for working with a fleet of agents (e.g. Grok Bot, Claude Code). Run any coding agent with your own subscription. Available on desktop, mobile and remote runtime.
122,099 stars