Skip to content

ROAD-354: expose contact labels, phone, and stable message IDs - #1

Merged
jacktator merged 8 commits into
mainfrom
road-354-expose-labels-phone-msgid
Sep 17, 2026
Merged

jacktator merged 8 commits into
mainfrom
road-354-expose-labels-phone-msgid

Conversation

@jacktator

@jacktator jacktator commented Sep 17, 2026

Copy link
Copy Markdown

Summary

Implements ROAD-354 — capabilities needed by the WeChat→HubSpot bridge (ROAD-336), plus the upstream key-count fix and image .dat decryption.

  • Contact labels (标签): decode contact.extra_buffer protobuf field 30 (comma-delimited contact_label.label_id_ list) and join against contact_label for names. Exposed as labels/label_ids in contacts --detail JSON and labels in contacts list JSON + text.
  • Mobile number: decode the same blob via field 14 → 2 → 1 (nested submessage, plain string). Exposed as phone in contacts --detail and list JSON.
  • Stable per-message ID: server_id added to the message query; collect_chat_history now returns structured entries. history JSON emits {local_id, server_id, timestamp, time, sender, text} per message — enables wechat_message_id-based dedup. Text output and export unchanged.
  • Cherry-picked huohuoer/wechat-cli#4: key_map now indexes by rel_path (matching the C binary's actual {rel_path: {enc_key}} schema) instead of a nonexistent salt key — fixes the "提取到 0 个密钥" misreport.
  • Image .dat decryption (cherry-picked from lijinma/wechat-cli): history --media now decrypts encrypted image .dat files to $TMPDIR/wechat_cli_media — legacy single-byte-XOR .dat plus macOS WeChat 4.x V2 (AES-128-ECB head + XOR tail; keys derived locally from kvcomm key_<n>_*.statistic filenames + account dir name). Includes full-size _h.dat preference and device-suffix wxid handling. Sources never modified; no network calls; ffmpeg (wxgf/HEVC only) via shutil.which + fixed argv + 15s timeout. Message→file binding uses XML size hints (hdlength/length/cdnthumblength) — probabilistic, documented in AGENTS.md.

Both new contact fields share one generic varint/tag parser in core/contacts.py, per the ticket's possible-solution note.

Third-party fork review (evaluate, not merge)

lijinma/wechat-cli image .dat decryption — ✅ cherry-picked (07786b8, 2ea35d5, 7297cfe).

  • Read-only on sources; decoded output only to $TMPDIR/wechat_cli_media; no network calls.
  • Excluded: 32c935e (only removes CDN-download call sites added by 76b3cec, which is itself out of scope) and all GUI-automation commits (view_unread, AppleScript chat-list scrolling, keyboard scripts, trigger_wechat_download/try_cdn_download_image machinery) — those drive the WeChat GUI / make CDN network calls and stay out per the read-only posture.

You-Agent/wechat-cli key-read fix (0ab50ee): reviewed, safe to cherry-pick if wanted later; inert until derived-pair keys exist.

  • Adds optional HMAC-SHA512 page verification before page decrypt — matches SQLCipher 4 spec (HMAC over page data + little-endian pgno, constant-time compare). Only active when an hmac_key is present.
  • db_cache dispatches on key_type == "sqlcipher_derived_pair" — dormant with today's all-raw all_keys.json; backward-compatible.
  • Deliberately excludes upstream PR fix(macos): recover runtime SQLCipher codec keys huohuoer/wechat-cli#20's LLDB runtime-capture machinery — keeps the fork read-only, no debugger attach. Good call.
  • ⚠️ Caveat: also relocates the decrypted-DB cache from $TMPDIR to ~/.wechat-cli/cache — consistent with the existing ~/.wechat-cli state dir, but decrypted DBs then persist across reboots; worth a conscious decision before adopting.
  • Same fork's image-decrypt command (7db8cfa) overlaps lijinma's media work — not needed now that lijinma's is in.

Test plan

  • contacts --detail wxid_s5gfoubooxqc12labels: ["买房客户"] (label_id 246), phone decoded — matches the ROAD-336 Oliver test contact exactly
  • Full-account decode sweep: 41 contacts yield valid phone numbers (field 14→2→1 cross-validated well beyond the single-contact caveat); label IDs resolve to real contact_label names
  • history JSON → local_id + server_id present per message; --format text and export output unchanged
  • search still works after the row-shape change
  • init --force run live: correctly reports 30 keys (old logic reported 0); regenerated keys file identical to prior
  • history "Angel" --type image --media → decrypted .jpg paths in $TMPDIR/wechat_cli_media; outputs verified as valid JPEGs (800×1740, 800×1160)
  • Hermetic suite: 53 tests — protobuf decoder, contacts, history IDs, cache torn-read poisoning, init db_dir preservation, media XOR/V2 decrypt + fallbacks

Generated with Devin

Chen17-sq and others added 2 commits September 18, 2026 08:35
The C binary find_all_keys_macos writes JSON in the schema
{"db.path": {"enc_key": "..."}} (without a salt field), but the
Python wrapper at scanner_macos.py was filtering on both enc_key and salt,
so every entry was rejected and key_map ended up empty. init.py then
echoed 'Extracted 0 keys' even though all_keys.json was correctly written
and downstream queries worked.

Build key_map keyed by rel_path instead, matching the C binary's actual
output and how core/key_utils.get_key_info already does lookups.

Fixes #2
- Decode contact.extra_buffer protobuf blob: field 30 (label IDs,
  joined to contact_label for names) and field 14->2->1 (mobile
  number) via one shared varint/tag parser. Surfaced as labels/phone
  in contacts list JSON and contacts --detail (labels, label_ids,
  phone).
- Add server_id to the message query and return structured entries
  from collect_chat_history; history JSON now emits
  local_id/server_id/timestamp/time/sender/text per message, enabling
  wechat_message_id-based dedup. Text output and export unchanged.
- Cherry-pick huohuoer#4: fix key_map indexing so init no
  longer misreports "提取到 0 个密钥" after a successful scan.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +63 to +66
messages = [
{k: e[k] for k in ('local_id', 'server_id', 'timestamp', 'time', 'sender', 'text')}
for e in entries
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Large message IDs lose precision

When server_id exceeds JavaScript’s safe integer range, JSON consumers round the emitted number. Distinct WeChat messages can then share a deduplication key.

Learn more

WeChat server IDs are 64-bit integers. JSON permits numbers of this size, but common consumers parse them as IEEE-754 doubles, which preserve integers exactly only through 9,007,199,254,740,991. The bridge can therefore mutate the identifier before storing or comparing it.

Example: The illustrative IDs 7000000000000000000 and 7000000000000000001 both become 7000000000000000000 when parsed as JavaScript numbers. Two different messages can then appear to have one identifier.

Recommended fix: Serialize server_id as a decimal string in the public JSON response. Preserve None as JSON null if the database lacks a server-assigned ID.

Suggested change
messages = [
{k: e[k] for k in ('local_id', 'server_id', 'timestamp', 'time', 'sender', 'text')}
for e in entries
]
messages = []
for e in entries:
message = {k: e[k] for k in ('local_id', 'timestamp', 'time', 'sender', 'text')}
message['server_id'] = str(e['server_id']) if e['server_id'] is not None else None
messages.append(message)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +114 to +116
for r in conn.execute(
"SELECT username, nick_name, remark, extra_buffer FROM contact"
).fetchall():

@devin-ai-integration devin-ai-integration Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Contact schema compatibility needs verification

_load_contacts_from now requires extra_buffer to load any contacts. Databases lacking that column yield an empty contact list instead of reduced metadata.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +413 to 414
SELECT local_id, server_id, local_type, create_time, real_sender_id, message_content,
WCDB_CT_message_content

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Message schema compatibility needs verification

_query_messages now requires server_id for history, export, and search. A supported schema lacking it makes every affected table unreadable.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

jacktator and others added 3 commits September 18, 2026 08:42
- db_cache: validate decrypted output with PRAGMA integrity_check and
  retry before caching — a torn read (WeChat checkpointing mid-decrypt)
  was previously cached by mtime and served forever, breaking all
  contact/message queries until manual cache removal.
- init: reuse existing config db_dir before auto-detect, so
  init --force cannot silently switch accounts when multiple
  xwechat_files db_storage dirs exist.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Captures the read-only security posture, init/re-sign setup, layout,
data-model gotchas (extra_buffer fields, Msg_ table naming, live-DB
decrypt validation), and verification commands learned implementing
ROAD-354.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 new potential issues.

Devin Review

if rel_key in self._cache:
c_db_mt, c_wal_mt, c_path = self._cache[rel_key]
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and os.path.exists(c_path):
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and _has_sqlite_header(c_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Corrupt cached databases pass validation

When corruption leaves the header intact, _has_sqlite_header accepts the cached database without checking its remaining pages. DBCache.get then returns unreadable or inconsistent data instead of rebuilding the cache.

Learn more

The decrypt path runs PRAGMA integrity_check, but cache hits only compare the first 16 bytes. Every decrypted first page receives the SQLite header from decrypt_page, so that header does not prove that later pages are intact. A partial file, a torn prior decrypt, or corruption after page one therefore remains reusable indefinitely while source mtimes stay unchanged.

Example: A cached 20-page database is truncated after page 5. Its first 16 bytes still equal SQLITE_HDR, so the next query returns it and SQLite fails when reading a table stored on page 12. The cache was expected to be rejected and decrypted again.

Recommended fix: Use _is_valid_sqlite(c_path) for cache hits so the same full integrity check gates both newly decrypted and reused files.

Suggested change
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and _has_sqlite_header(c_path):
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and _is_valid_sqlite(c_path):

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +33 to +35
existing = json.load(f).get("db_dir")
if existing and os.path.isdir(existing):
db_dir = existing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Relative configured paths trigger account switching

With a relative configured db_dir, os.path.isdir resolves it against the process directory and can reject a valid account. load_config resolves that value from the configuration directory. Initialization then auto-detects another account and overwrites the configured keys.

Learn more

Configuration paths can be relative to the directory containing config.json; load_config implements that contract. Forced initialization reads the raw value instead. Its existence check therefore depends on the shell's current directory, and a failed check enters account auto-detection despite an existing configured account.

Example: ~/.wechat-cli/config.json contains {"db_dir": "../wechat/account-a/db_storage"}. Running wechat-cli init --force from /tmp tests /tmp/../wechat/account-a/db_storage, rejects it, and can select account B. The command then replaces all_keys.json and config.json with account B's data.

Recommended fix: Resolve a relative saved db_dir against os.path.dirname(CONFIG_FILE) before checking it, matching load_config. Preserve the resolved configured path whenever it names an existing directory.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +32 to +33
with open(CONFIG_FILE, encoding="utf-8") as f:
existing = json.load(f).get("db_dir")

@devin-ai-integration devin-ai-integration Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Non-object config aborts initialization

When config.json contains valid non-object JSON, .get raises before init --force can recover or auto-detect an account.

Learn more

The exception handler covers invalid JSON and file errors, but JSON arrays, strings, numbers, and null decode successfully. None provides the mapping interface used by .get, so the command exits with an uncaught AttributeError before key extraction.

Example: If an interrupted manual repair leaves config.json containing [], wechat-cli init --force crashes at .get("db_dir"). It was expected to ignore the unusable saved value and run account detection.

Recommended fix: Decode into a temporary value and read db_dir only when that value is a dictionary. Treat every other JSON type as an empty configuration.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

jacktator and others added 3 commits September 18, 2026 08:48
26 tests covering the extra_buffer protobuf decoder, contact
loading/detail, history message IDs, db_cache torn-read poisoning,
and init db_dir preservation. Regression-verified: the init and
cache-poisoning tests fail against pre-fix code. Runs without WeChat
or real data — protects future changes from reintroducing the two
bugs found during ROAD-354 live testing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Designated place for personal-data exports on disk — dir contents
ignored except .gitkeep (JSON was already covered by the *.json rule;
this also covers md/txt exports and makes the convention explicit).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Parser previously stopped at wt=1/5 fields — a fixed-width field
appearing before field 30/14 would silently drop labels/phone.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@jacktator jacktator left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — approve with comments

Reviewed all commits on this branch against the ROAD-354 acceptance criteria. Implementation is correct, validated against live data, and the hermetic test suite regression-covers the two bugs found during testing. One defect was found and fixed during this review (77c07e4).

Fixed during review

  • _parse_protobuf_fields bailed on fixed-width wire types — a wt=1 (fixed64) or wt=5 (fixed32) field appearing before field 30/14 in extra_buffer would have silently dropped labels/phone. Now skipped per spec; test added.

Non-blocking comments

  1. history JSON is a schema changemessages went list[str]list[dict] with local_id/server_id/timestamp/sender/text. Intended per the ticket, but flag for any downstream consumer reading the old string format (text format unchanged).
  2. server_id can be 0 for local-only messages — consumers doing wechat_message_id dedup should fall back to (local_id, table) when server_id == 0. Worth a note in the bridge ticket.
  3. PRAGMA integrity_check cost — every decrypt now pays a full-DB scan. WAL mtimes churn constantly while WeChat runs, so large message_*.db decrypts (60–80MB) add ~seconds per CLI invocation. Correct trade-off (a poisoned cache was worse), but quick_check or schema-page sampling is a fallback if it ever feels slow.
  4. Residual edge in cache-hit path — hits only header-check the file, so a valid-header/torn-interior file written before this fix could still be served until its source mtime changes. New files can only enter cache post-integrity_check, so this is a shrinking edge; per-hit integrity checks would be too expensive.
  5. _decode_extra_labels takes the first field-30 occurrence only — fine for observed data (single occurrence per blob), but a repeated field-30 would be ignored.
  6. _build_search_entry unpacks server_id but discards it — consistent with the shared row shape; search results could expose IDs later if the bridge wants them.
  7. PR scope — the feature + two infra fixes (db_cache poisoning, init db_dir clobber) + tests + docs/chores ride together. The infra fixes are small and were discovered by this ticket's testing, so keeping them is reasonable, but a reviewer should know they're not strictly ROAD-354 scope.

Verification confirmed

  • Oliver test contact → labels: ["买房客户"] (246), phone: "+61451122734"; 41 real contacts validated for the 14→2→1 path
  • init --force run live → "提取到 30 个密钥" (was 0)
  • 27 hermetic tests pass; init + cache-poisoning tests confirmed failing against pre-fix code

LGTM to merge.

@jacktator
jacktator merged commit 880c8e3 into main Sep 17, 2026
1 check was pending

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Devin Review

Comment on lines 104 to 107
if rel_key in self._cache:
c_db_mt, c_wal_mt, c_path = self._cache[rel_key]
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and os.path.exists(c_path):
if c_db_mt == db_mtime and c_wal_mt == wal_mtime and _has_sqlite_header(c_path):
return c_path

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cache validation cost needs follow-up

Correct cache-hit validation can scan the entire database on every query. Cache successful validation against the cached file’s own metadata to avoid repeated full scans.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +63 to +65
messages = [
{k: e[k] for k in ('local_id', 'server_id', 'timestamp', 'time', 'sender', 'text')}
for e in entries

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Message identity needs a fallback contract

server_id can be absent before synchronization, while local_id is table-scoped. Document a composite fallback for consumers deduplicating messages across databases.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread pyproject.toml
Comment on lines +16 to +19
[project.optional-dependencies]
dev = [
"pytest>=8,<9",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Regression suite requires explicit setup

The checkout lacks pytest and no project virtual environment exists. Ensure CI installs .[dev] so the new regression tests actually run.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants