Skip to content

Commit b7ecda8

Browse files
committed
fix(chat): read realtime messages from live db
1 parent b2547fe commit b7ecda8

4 files changed

Lines changed: 333 additions & 9 deletions

File tree

src/wechat_decrypt_tool/chat_helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ def _extract_md5_from_packed_info(packed_info: Any) -> str:
560560
s = packed_info.strip()
561561
if s.lower().startswith("0x"):
562562
s = s[2:]
563+
if len(s) == 32 and _PACKED_INFO_HEX_RE.fullmatch(s):
564+
return s.lower()
563565
if s and _PACKED_INFO_HEX_RE.fullmatch(s) and (len(s) % 2 == 0):
564566
try:
565567
data = bytes.fromhex(s)

src/wechat_decrypt_tool/routers/chat.py

Lines changed: 252 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
_split_group_sender_prefix,
6969
_to_char_token_text,
7070
)
71-
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
71+
from ..media_helpers import _clean_weflow_account_dir_name, _resolve_account_db_storage_dir, _try_find_decrypted_resource
7272
from .. import chat_edit_store
7373
from ..app_paths import get_output_dir
7474
from ..database_filters import list_countable_database_names
@@ -1470,6 +1470,28 @@ def _coerce_realtime_blobish_value(value: Any) -> Any:
14701470
return value
14711471

14721472

1473+
def _coerce_realtime_packed_info_value(value: Any) -> Any:
1474+
"""Normalize realtime packed_info_data without losing plain md5 tokens."""
1475+
1476+
raw = value
1477+
if isinstance(raw, memoryview):
1478+
raw = raw.tobytes()
1479+
if isinstance(raw, bytearray):
1480+
raw = bytes(raw)
1481+
if isinstance(raw, bytes):
1482+
try:
1483+
s = raw.decode("ascii").strip()
1484+
except Exception:
1485+
s = ""
1486+
if _is_hex_md5(s):
1487+
return s.lower()
1488+
if isinstance(raw, str):
1489+
s = raw.strip()
1490+
if _is_hex_md5(s):
1491+
return s.lower()
1492+
return _coerce_realtime_blobish_value(value)
1493+
1494+
14731495
def _normalize_realtime_message_item(item: dict[str, Any]) -> dict[str, Any]:
14741496
def _pick(*keys: str) -> Any:
14751497
return _pick_case_insensitive_value(item, *keys)
@@ -1497,7 +1519,7 @@ def _to_int(value: Any) -> int:
14971519
"compress_content": _coerce_realtime_blobish_value(
14981520
_pick("compress_content", "compressContent", "CompressContent")
14991521
),
1500-
"packed_info_data": _coerce_realtime_blobish_value(
1522+
"packed_info_data": _coerce_realtime_packed_info_value(
15011523
_pick("packed_info_data", "packedInfoData", "PackedInfoData")
15021524
),
15031525
"sender_username": str(
@@ -5234,6 +5256,199 @@ def _fetch_realtime_message_rows(
52345256
return rows, truncated, scanned
52355257

52365258

5259+
def _iter_realtime_message_db_paths(account_dir: Path, username: str) -> list[Path]:
5260+
"""Return live db_storage/message DB candidates for a conversation.
5261+
5262+
This stays in realtime scope: it only looks under the original db_storage path,
5263+
not the decrypted output snapshot.
5264+
"""
5265+
5266+
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
5267+
if db_storage_dir is None:
5268+
return []
5269+
message_dir = Path(db_storage_dir) / "message"
5270+
try:
5271+
if not message_dir.exists() or not message_dir.is_dir():
5272+
return []
5273+
except Exception:
5274+
return []
5275+
5276+
normal: list[Path] = []
5277+
biz: list[Path] = []
5278+
other: list[Path] = []
5279+
try:
5280+
for p in message_dir.iterdir():
5281+
try:
5282+
if not p.is_file():
5283+
continue
5284+
except Exception:
5285+
continue
5286+
ln = p.name.lower()
5287+
if re.match(r"^message(_\d+)?\.db$", ln):
5288+
normal.append(p)
5289+
elif re.match(r"^biz_message(_\d+)?\.db$", ln):
5290+
biz.append(p)
5291+
elif ln.endswith(".db") and "message" in ln:
5292+
other.append(p)
5293+
except Exception:
5294+
return []
5295+
5296+
normal.sort(key=lambda x: x.name.lower())
5297+
biz.sort(key=lambda x: x.name.lower())
5298+
other.sort(key=lambda x: x.name.lower())
5299+
5300+
uname = str(username or "").strip()
5301+
if uname.startswith("gh_"):
5302+
return biz + normal + other
5303+
return normal + biz + other
5304+
5305+
5306+
def _resolve_realtime_message_db_table_via_exec(
5307+
*,
5308+
rt_conn: Any,
5309+
account_dir: Path,
5310+
username: str,
5311+
) -> Optional[tuple[Path, str]]:
5312+
"""Resolve the live message DB/table with explicit WCDB exec_query.
5313+
5314+
The high-level native `wcdb_get_messages` path has its own message-db cache
5315+
and can return an empty list even when db_storage/message/message_*.db has
5316+
the Msg_<md5> table. This resolver bypasses that cache by specifying the
5317+
exact live DB path.
5318+
"""
5319+
5320+
uname = str(username or "").strip()
5321+
if not uname:
5322+
return None
5323+
import hashlib
5324+
5325+
expected = f"Msg_{hashlib.md5(uname.encode('utf-8')).hexdigest()}"
5326+
sql = (
5327+
"SELECT name FROM sqlite_master "
5328+
"WHERE type='table' AND lower(name)=lower("
5329+
+ _sql_literal(expected)
5330+
+ ") LIMIT 1"
5331+
)
5332+
5333+
for db_path in _iter_realtime_message_db_paths(account_dir, uname):
5334+
try:
5335+
rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(db_path), sql=sql)
5336+
except Exception:
5337+
continue
5338+
for row in rows or []:
5339+
if not isinstance(row, dict):
5340+
continue
5341+
actual = str(_pick_case_insensitive_value(row, "name") or "").strip()
5342+
if actual:
5343+
return db_path, actual
5344+
return None
5345+
5346+
5347+
def _lookup_realtime_my_rowid_via_exec(
5348+
*,
5349+
rt_conn: Any,
5350+
db_path: Path,
5351+
account_dir: Path,
5352+
) -> Optional[int]:
5353+
candidates: list[str] = []
5354+
for value in (
5355+
getattr(rt_conn, "native_wxid", ""),
5356+
account_dir.name,
5357+
_clean_weflow_account_dir_name(account_dir.name),
5358+
):
5359+
v = str(value or "").strip()
5360+
if v and v not in candidates:
5361+
candidates.append(v)
5362+
if not candidates:
5363+
return None
5364+
5365+
values = ", ".join(_sql_literal(v) for v in candidates)
5366+
order = " ".join(
5367+
f"WHEN user_name = {_sql_literal(v)} THEN {i}" for i, v in enumerate(candidates)
5368+
)
5369+
sql = (
5370+
"SELECT rowid AS rowid FROM Name2Id "
5371+
f"WHERE user_name IN ({values}) "
5372+
f"ORDER BY CASE {order} ELSE {len(candidates)} END "
5373+
"LIMIT 1"
5374+
)
5375+
try:
5376+
rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(db_path), sql=sql)
5377+
except Exception:
5378+
return None
5379+
if not rows:
5380+
return None
5381+
try:
5382+
rowid = int(_pick_case_insensitive_value(rows[0], "rowid") or 0)
5383+
except Exception:
5384+
rowid = 0
5385+
return rowid if rowid > 0 else None
5386+
5387+
5388+
def _fetch_realtime_message_rows_via_exec(
5389+
*,
5390+
rt_conn: Any,
5391+
account_dir: Path,
5392+
username: str,
5393+
take: int,
5394+
) -> tuple[list[dict[str, Any]], bool, Optional[Path], str, Optional[int]]:
5395+
"""Fetch newest-first live rows via explicit db_storage/message SQL.
5396+
5397+
Returns (rows, has_more, db_path, table_name, my_rowid).
5398+
"""
5399+
5400+
if int(take) <= 0:
5401+
return [], False, None, "", None
5402+
resolved = _resolve_realtime_message_db_table_via_exec(
5403+
rt_conn=rt_conn,
5404+
account_dir=account_dir,
5405+
username=username,
5406+
)
5407+
if resolved is None:
5408+
return [], False, None, "", None
5409+
5410+
db_path, table_name = resolved
5411+
my_rowid = _lookup_realtime_my_rowid_via_exec(rt_conn=rt_conn, db_path=db_path, account_dir=account_dir)
5412+
5413+
quoted_table = _quote_ident(table_name)
5414+
limit_probe = int(take) + 1
5415+
base_cols = (
5416+
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
5417+
"m.message_content, m.compress_content, "
5418+
)
5419+
packed_select = "m.packed_info_data AS packed_info_data, "
5420+
sender_join = "n.user_name AS sender_username "
5421+
sql_with_join = (
5422+
"SELECT "
5423+
+ base_cols
5424+
+ packed_select
5425+
+ sender_join
5426+
+ f"FROM {quoted_table} m "
5427+
+ "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
5428+
+ "ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC "
5429+
+ f"LIMIT {int(limit_probe)}"
5430+
)
5431+
sql_no_packed = (
5432+
"SELECT "
5433+
+ base_cols
5434+
+ "NULL AS packed_info_data, "
5435+
+ sender_join
5436+
+ f"FROM {quoted_table} m "
5437+
+ "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
5438+
+ "ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC "
5439+
+ f"LIMIT {int(limit_probe)}"
5440+
)
5441+
try:
5442+
raw_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(db_path), sql=sql_with_join)
5443+
except Exception:
5444+
raw_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(db_path), sql=sql_no_packed)
5445+
5446+
has_more = len(raw_rows or []) > int(take)
5447+
raw_rows = list(raw_rows or [])[: int(take)]
5448+
rows = [_normalize_realtime_message_item(r) for r in raw_rows if isinstance(r, dict)]
5449+
return rows, bool(has_more), db_path, table_name, my_rowid
5450+
5451+
52375452
def _sort_realtime_rows_chronological(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
52385453
return sorted(
52395454
rows,
@@ -5647,6 +5862,8 @@ def list_chat_messages(
56475862
# Realtime mode: fetch from newest (offset handled after render_type filtering).
56485863
table_name = _realtime_message_table_name(username)
56495864
rt_db_path = _realtime_message_db_path(account_dir)
5865+
my_rowid_realtime: Optional[int] = None
5866+
used_exec_query = False
56505867

56515868
while True:
56525869
probe = int(scan_take) + 1
@@ -5655,17 +5872,44 @@ def list_chat_messages(
56555872
if probe > 50000:
56565873
probe = 50000
56575874

5658-
with rt_conn.lock:
5659-
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=probe, offset=0)
5660-
has_more_any = len(raw_rows) > int(scan_take)
5661-
raw_rows = raw_rows[: int(scan_take)] if int(scan_take) > 0 else []
5875+
norm_rows: list[dict[str, Any]] = []
5876+
try:
5877+
with rt_conn.lock:
5878+
(
5879+
norm_rows,
5880+
has_more_any,
5881+
exec_db_path,
5882+
exec_table_name,
5883+
my_rowid_realtime,
5884+
) = _fetch_realtime_message_rows_via_exec(
5885+
rt_conn=rt_conn,
5886+
account_dir=account_dir,
5887+
username=username,
5888+
take=int(scan_take),
5889+
)
5890+
if exec_db_path is not None:
5891+
used_exec_query = True
5892+
rt_db_path = exec_db_path
5893+
table_name = exec_table_name or table_name
5894+
else:
5895+
used_exec_query = False
5896+
except Exception as e:
5897+
trace("realtime:exec-query:error", error=str(e))
5898+
norm_rows = []
5899+
used_exec_query = False
5900+
5901+
if not used_exec_query:
5902+
with rt_conn.lock:
5903+
raw_rows = _wcdb_get_messages(rt_conn.handle, username, limit=probe, offset=0)
5904+
has_more_any = len(raw_rows) > int(scan_take)
5905+
raw_rows = raw_rows[: int(scan_take)] if int(scan_take) > 0 else []
5906+
norm_rows = [_normalize_realtime_message_item(r) for r in raw_rows if isinstance(r, dict)]
56625907

56635908
merged = []
56645909
sender_usernames = []
56655910
quote_usernames = []
56665911
pat_usernames = set()
56675912

5668-
norm_rows = [_normalize_realtime_message_item(r) for r in raw_rows if isinstance(r, dict)]
56695913
_append_full_messages_from_rows(
56705914
merged=merged,
56715915
sender_usernames=sender_usernames,
@@ -5677,7 +5921,7 @@ def list_chat_messages(
56775921
username=username,
56785922
account_dir=account_dir,
56795923
is_group=bool(username.endswith("@chatroom")),
5680-
my_rowid=None,
5924+
my_rowid=my_rowid_realtime if used_exec_query else None,
56815925
resource_conn=resource_conn,
56825926
resource_chat_id=resource_chat_id,
56835927
)

tests/test_chat_list_messages_re_scope.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ def fake_collect_chat_messages(**_kwargs):
5959
chat, "_load_contact_rows", side_effect=sentinel
6060
):
6161
with self.assertRaises(_Sentinel):
62-
chat.list_chat_messages(request=request, username="44372432598@chatroom", account="acc")
62+
chat.list_chat_messages(
63+
request=request,
64+
username="44372432598@chatroom",
65+
account="acc",
66+
source="decrypted",
67+
)
6368

6469

6570
if __name__ == "__main__":

0 commit comments

Comments
 (0)