Skip to content

Commit 2c9044d

Browse files
committed
fix(chat): tolerate invalid avatar URLs
1 parent 4aee26f commit 2c9044d

8 files changed

Lines changed: 172 additions & 24 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ MCP 仅暴露读取数据与获取媒体资源 URL/参数的能力;系统设
182182
- `wechat.biz`: 公众号/服务号与微信支付记录
183183
- `wechat.analytics`: 年度总结与聚合分析读取;年度总结只读取应用内已生成的缓存,未生成时请先在应用内打开年度总结
184184

185+
会话列表、联系人和头像相关接口均采用 best-effort 读取策略。即使 `contact.db` 中某些头像字段损坏或无法按 UTF-8 解码,也会继续返回昵称、会话摘要和其他可用内容,头像则自动降级为空或占位,不会阻塞整页数据加载。
186+
185187
媒体和视频不会直接塞进 MCP JSON 响应;相关工具返回可访问 URL 或资源参数。
186188

187189
配套 skill 可通过 HTTP 加载,访问时需要带 MCP token:

desktop/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "wechat-data-analysis-desktop",
33
"private": true,
4-
"version": "1.9.1",
4+
"version": "1.9.2",
55
"main": "src/main.cjs",
66
"scripts": {
77
"dev": "node scripts/dev.cjs",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "wechat-decrypt-tool"
3-
version = "1.9.1"
3+
version = "1.9.2"
44
description = "Modern WeChat database decryption tool with React frontend"
55
readme = "README.md"
66
requires-python = ">=3.11"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""微信数据库解密工具
22
"""
33

4-
__version__ = "1.9.1"
4+
__version__ = "1.9.2"
55
__author__ = "WeChat Decrypt Tool"

src/wechat_decrypt_tool/chat_helpers.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,45 +1992,93 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
19921992
return previews
19931993

19941994

1995-
def _pick_display_name(contact_row: Optional[sqlite3.Row], fallback_username: str) -> str:
1995+
def _row_get_value(row: Any, key: str, default: Any = None) -> Any:
1996+
if row is None:
1997+
return default
1998+
try:
1999+
return row[key]
2000+
except Exception:
2001+
pass
2002+
if isinstance(row, dict):
2003+
return row.get(key, default)
2004+
return default
2005+
2006+
2007+
def _normalize_contact_text(value: Any) -> str:
2008+
return _decode_sqlite_text(value).strip()
2009+
2010+
2011+
def _normalize_avatar_url(value: Any) -> str:
2012+
if value is None:
2013+
return ""
2014+
if isinstance(value, memoryview):
2015+
value = value.tobytes()
2016+
if isinstance(value, (bytes, bytearray)):
2017+
raw = bytes(value)
2018+
if not raw:
2019+
return ""
2020+
# Avatar URLs should be ASCII/UTF-8 HTTP(S) URLs. If invalid bytes were
2021+
# stored in the TEXT column, ignore that avatar instead of failing the
2022+
# surrounding chat/contact response.
2023+
try:
2024+
text = raw.decode("utf-8")
2025+
except UnicodeDecodeError:
2026+
return ""
2027+
else:
2028+
text = str(value or "")
2029+
2030+
text = text.strip()
2031+
if text.lower().startswith(("http://", "https://")):
2032+
return text
2033+
return ""
2034+
2035+
2036+
def _contact_row_to_dict(row: Any) -> dict[str, Any]:
2037+
username = _normalize_contact_text(_row_get_value(row, "username", ""))
2038+
return {
2039+
"username": username,
2040+
"remark": _normalize_contact_text(_row_get_value(row, "remark", "")),
2041+
"nick_name": _normalize_contact_text(_row_get_value(row, "nick_name", "")),
2042+
"alias": _normalize_contact_text(_row_get_value(row, "alias", "")),
2043+
"big_head_url": _normalize_avatar_url(_row_get_value(row, "big_head_url", "")),
2044+
"small_head_url": _normalize_avatar_url(_row_get_value(row, "small_head_url", "")),
2045+
}
2046+
2047+
2048+
def _pick_display_name(contact_row: Optional[Any], fallback_username: str) -> str:
19962049
if contact_row is None:
19972050
return fallback_username
19982051

19992052
for key in ("remark", "nick_name", "alias"):
2000-
try:
2001-
v = contact_row[key]
2002-
except Exception:
2003-
v = None
2004-
if isinstance(v, str) and v.strip():
2005-
return v.strip()
2053+
v = _normalize_contact_text(_row_get_value(contact_row, key, ""))
2054+
if v:
2055+
return v
20062056

20072057
return fallback_username
20082058

20092059

2010-
def _pick_avatar_url(contact_row: Optional[sqlite3.Row]) -> Optional[str]:
2060+
def _pick_avatar_url(contact_row: Optional[Any]) -> Optional[str]:
20112061
if contact_row is None:
20122062
return None
20132063

20142064
for key in ("big_head_url", "small_head_url"):
2015-
try:
2016-
v = contact_row[key]
2017-
except Exception:
2018-
v = None
2019-
if isinstance(v, str) and v.strip():
2020-
return v.strip()
2065+
v = _normalize_avatar_url(_row_get_value(contact_row, key, ""))
2066+
if v:
2067+
return v
20212068

20222069
return None
20232070

20242071

2025-
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, sqlite3.Row]:
2072+
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, dict[str, Any]]:
20262073
uniq = list(dict.fromkeys([u for u in usernames if u]))
20272074
if not uniq:
20282075
return {}
20292076

2030-
result: dict[str, sqlite3.Row] = {}
2077+
result: dict[str, dict[str, Any]] = {}
20312078

20322079
conn = sqlite3.connect(str(contact_db_path))
20332080
conn.row_factory = sqlite3.Row
2081+
conn.text_factory = bytes
20342082
try:
20352083
def query_table(table: str, targets: list[str]) -> None:
20362084
if not targets:
@@ -2043,7 +2091,10 @@ def query_table(table: str, targets: list[str]) -> None:
20432091
"""
20442092
rows = conn.execute(sql, targets).fetchall()
20452093
for r in rows:
2046-
result[r["username"]] = r
2094+
item = _contact_row_to_dict(r)
2095+
username = str(item.get("username") or "").strip()
2096+
if username:
2097+
result[username] = item
20472098

20482099
query_table("contact", uniq)
20492100
missing = [u for u in uniq if u not in result]

tests/test_chat_sessions_realtime_sender_preview.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
import threading
3+
import sqlite3
34
import unittest
45
from pathlib import Path
56
from tempfile import TemporaryDirectory
@@ -97,7 +98,101 @@ def test_realtime_sessions_group_url_summary_keeps_scheme(self):
9798
self.assertEqual(len(sessions), 1)
9899
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
99100

101+
def test_sessions_ignore_invalid_utf8_avatar_url(self):
102+
with TemporaryDirectory() as td:
103+
account_dir = Path(td) / "acc"
104+
account_dir.mkdir(parents=True, exist_ok=True)
105+
106+
session_conn = sqlite3.connect(str(account_dir / "session.db"))
107+
try:
108+
session_conn.execute(
109+
"""
110+
CREATE TABLE SessionTable (
111+
username TEXT,
112+
unread_count INTEGER,
113+
is_hidden INTEGER,
114+
summary TEXT,
115+
draft TEXT,
116+
last_timestamp INTEGER,
117+
sort_timestamp INTEGER,
118+
last_msg_locald_id INTEGER,
119+
last_msg_type INTEGER,
120+
last_msg_sub_type INTEGER,
121+
last_msg_sender TEXT,
122+
last_sender_display_name TEXT
123+
)
124+
"""
125+
)
126+
session_conn.execute(
127+
"INSERT INTO SessionTable VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
128+
("wxid_bad_avatar", 0, 0, "hello", "", 100, 100, 1, 1, 0, "", ""),
129+
)
130+
session_conn.commit()
131+
finally:
132+
session_conn.close()
133+
134+
contact_conn = sqlite3.connect(str(account_dir / "contact.db"))
135+
try:
136+
contact_conn.execute(
137+
"""
138+
CREATE TABLE contact (
139+
username TEXT,
140+
remark TEXT,
141+
nick_name TEXT,
142+
alias TEXT,
143+
flag INTEGER,
144+
big_head_url TEXT,
145+
small_head_url TEXT
146+
)
147+
"""
148+
)
149+
contact_conn.execute(
150+
"""
151+
CREATE TABLE stranger (
152+
username TEXT,
153+
remark TEXT,
154+
nick_name TEXT,
155+
alias TEXT,
156+
flag INTEGER,
157+
big_head_url TEXT,
158+
small_head_url TEXT
159+
)
160+
"""
161+
)
162+
contact_conn.execute(
163+
"""
164+
INSERT INTO contact
165+
(username, remark, nick_name, alias, flag, big_head_url, small_head_url)
166+
VALUES (?, ?, ?, ?, ?, CAST(x'fffe687474703a2f2f6578616d706c652e746573742f612e706e67' AS TEXT), ?)
167+
""",
168+
("wxid_bad_avatar", "", "坏头像好友", "", 0, ""),
169+
)
170+
contact_conn.commit()
171+
finally:
172+
contact_conn.close()
173+
174+
with (
175+
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
176+
patch.object(chat_router.WCDB_REALTIME, "get_status", return_value={}),
177+
patch.object(chat_router, "load_session_last_messages", return_value={}),
178+
patch.object(chat_router, "_load_latest_message_previews", return_value={}),
179+
):
180+
resp = chat_router.list_chat_sessions(
181+
_DummyRequest(),
182+
account="acc",
183+
limit=50,
184+
include_hidden=True,
185+
include_official=True,
186+
preview="session",
187+
)
188+
189+
self.assertEqual(resp.get("status"), "success")
190+
sessions = resp.get("sessions") or []
191+
self.assertEqual(len(sessions), 1)
192+
self.assertEqual(sessions[0].get("name"), "坏头像好友")
193+
self.assertEqual(sessions[0].get("lastMessage"), "hello")
194+
self.assertIn("/api/chat/avatar", sessions[0].get("avatar") or "")
195+
100196

101197
if __name__ == "__main__":
102198
unittest.main()
103-

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)