fix(image): scope local_id lookup by chat_id + use real column name (#82)

* fix(image): scope local_id lookup by chat_id + use real column name

`ImageResolver.get_image_md5` made two wrong assumptions about the
production `MessageResourceInfo` schema, which made the decode_image
MCP tool always fail with "无法找到 local_id=X 的图片信息":

1. The column is `message_local_id`, not `local_id`. The current query
   throws `sqlite3.OperationalError: no such column: local_id`, but the
   exception is swallowed by `except Exception: pass`, masking the real
   failure as a silent miss.
2. `message_local_id` is not globally unique. In production it repeats
   across chats, and within an active chat the same local_id can recur
   up to 7 times (observed on a real DB). The production schema scopes
   by `chat_id`, resolved from `ChatName2Id.rowid WHERE user_name = ?`.

Fix: `get_image_md5` now takes `(username, local_id)`:
- Resolve `username -> chat_id` via `ChatName2Id`.
- Query `MessageResourceInfo` filtered by `chat_id + message_local_id
  + message_local_type == 3` (image type; high bits are session flags,
  so use `% 2^32`), ordered by `message_create_time DESC LIMIT 1`.

External callers (`mcp_server.decode_image_tool` /
`list_chat_images_tool`) already pass `username` through
`ImageResolver.decode_image()` / `list_chat_images()`, so the public
API is unchanged. Only the internal helper signature shifts.

The existing test fixture in `test_decode_image_v2` used the same wrong
schema as the buggy code (`CREATE TABLE MessageResourceInfo (local_id
INTEGER PRIMARY KEY, packed_info BLOB)`), so the tests passed against a
self-consistent fiction. The fixture is rebuilt to match real columns
plus `ChatName2Id`, and three regression tests are added:

- cross-chat collision (same local_id in 3 chats; must pick the right one
  and not the type=43 video row)
- same-chat reuse (same local_id, two timestamps; must pick the newer)
- unknown chat (username not in ChatName2Id; structured error, no crash)

All 154 tests pass locally (151 baseline + 3 new).

* fix(image): surface get_image_md5 errors and use read-only DB open

Two follow-ups on top of the chat_id scoping fix:

1. The bare `except Exception: pass` was the original failure mode: it
   silently swallowed `OperationalError: no such column: local_id` when
   the production schema diverged from the old `local_id` column name,
   masking the bug this PR fixes. Print the exception to stderr so
   future schema drift surfaces immediately instead of returning a
   misleading "image not found" error.

2. Open message_resource.db with `file:...?mode=ro` URI to match the
   rest of the project (monitor_web.py uses this idiom in 9 places).
   The DB is read-only for our purposes and a running WeChat may still
   hold it; using URI ro avoids any chance of lock contention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Belugary
2026-05-12 16:57:44 +08:00
committed by GitHub
parent cd329afa1b
commit b2affdcf88
2 changed files with 157 additions and 17 deletions

View File

@@ -335,22 +335,42 @@ class ImageResolver:
self.aes_key = aes_key
self.xor_key = xor_key
def get_image_md5(self, local_id):
"""通过 local_id 查 message_resource.db 获取图片文件 MD5"""
def get_image_md5(self, username, local_id):
"""通过 (username, local_id) 查 message_resource.db 获取图片 MD5
message_local_id 在 MessageResourceInfo 中跨 chat 重复 (不全局唯一),
必须用 chat_id 缩小范围;同一 chat 内活跃聊天也会复用 local_id
(实测最高同 chat 7 条同 local_id 的记录), 默认取最新一条。
message_local_type 上 32 bit 是版本/会话 flag, 用 % 2^32 取低位匹配
图片类型 3, 同 monitor_web.py 里 push 路径的写法。
"""
path = self.cache.get("message/message_resource.db")
if not path:
return None
conn = sqlite3.connect(path)
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
chat_row = conn.execute(
"SELECT rowid FROM ChatName2Id WHERE user_name = ?",
(username,)
).fetchone()
if not chat_row:
return None
chat_id = chat_row[0]
row = conn.execute(
"SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?",
(local_id,)
"SELECT packed_info FROM MessageResourceInfo "
"WHERE chat_id = ? AND message_local_id = ? "
"AND (message_local_type = 3 OR message_local_type % 4294967296 = 3) "
"ORDER BY message_create_time DESC LIMIT 1",
(chat_id, local_id)
).fetchone()
if row and row[0]:
return extract_md5_from_packed_info(row[0])
except Exception:
pass
except Exception as e:
print(f"[get_image_md5] {type(e).__name__}: {e}",
file=sys.stderr, flush=True)
finally:
conn.close()
@@ -381,10 +401,10 @@ class ImageResolver:
Returns:
dict with keys: success, path, format, md5, error
"""
# 1. 获取 MD5
file_md5 = self.get_image_md5(local_id)
# 1. 获取 MD5 (chat-scoped: 同 local_id 跨 chat 重复)
file_md5 = self.get_image_md5(username, local_id)
if not file_md5:
return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'}
return {'success': False, 'error': f'无法从 message_resource.db 找到 {username} local_id={local_id} 的图片信息'}
# 2. 找 .dat 文件
dat_files = self.find_dat_files(username, file_md5)
@@ -448,7 +468,7 @@ class ImageResolver:
results = []
for local_id, create_time in rows:
file_md5 = self.get_image_md5(local_id)
file_md5 = self.get_image_md5(username, local_id)
info = {
'local_id': local_id,
'create_time': create_time,

View File

@@ -70,23 +70,68 @@ class _FakeCache:
return self._mapping.get(rel_key)
def _make_resource_db(path, local_id, file_md5):
"""构造最小 message_resource.db,只含一条 packed_info 记录。
def _make_resource_db(path, local_id, file_md5, username="wxid_test123",
chat_id=1, message_create_time=1700000000,
message_local_type=3, extra_rows=()):
"""构造最小 message_resource.db, 表 schema 对齐真实微信结构。
真实表里 message_local_id 不全局唯一 (跨 chat 重复, 活跃 chat 内也会复用),
解析必须用 ChatName2Id.rowid -> chat_id 限定 + message_local_type=3 过滤图片。
packed_info 里嵌入 extract_md5_from_packed_info 期望的 protobuf marker
(\\x12\\x22\\x0a\\x20) 加 32 字节 ASCII hex MD5。
Args:
extra_rows: 额外 (chat_id, message_local_id, message_local_type,
message_create_time, file_md5) 元组列表, 用于构造同 local_id
跨 chat / 同 chat 多版本的歧义场景。
"""
marker = b'\x12\x22\x0a\x20'
packed = b'\x00' * 8 + marker + file_md5.encode('ascii') + b'\x00' * 4
def _packed(md5_hex):
return b'\x00' * 8 + marker + md5_hex.encode('ascii') + b'\x00' * 4
conn = sqlite3.connect(path)
try:
conn.execute("""
CREATE TABLE MessageResourceInfo (
message_id INTEGER PRIMARY KEY,
chat_id INTEGER,
sender_id INTEGER,
message_local_type INTEGER,
message_create_time INTEGER,
message_local_id INTEGER,
message_svr_id INTEGER,
message_origin_source INTEGER,
packed_info BLOB
)
""")
conn.execute(
"CREATE TABLE MessageResourceInfo (local_id INTEGER PRIMARY KEY, packed_info BLOB)"
"CREATE TABLE ChatName2Id (user_name TEXT PRIMARY KEY, update_time INTEGER)"
)
conn.execute(
"INSERT INTO MessageResourceInfo VALUES (?, ?)",
(local_id, packed),
"INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (?, ?, ?)",
(chat_id, username, message_create_time),
)
next_msg_id = 1
conn.execute(
"INSERT INTO MessageResourceInfo "
"(message_id, chat_id, sender_id, message_local_type, message_create_time, "
" message_local_id, message_svr_id, message_origin_source, packed_info) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(next_msg_id, chat_id, 0, message_local_type, message_create_time,
local_id, 0, 0, _packed(file_md5)),
)
next_msg_id += 1
for extra in extra_rows:
ex_chat_id, ex_local_id, ex_type, ex_ctime, ex_md5 = extra
conn.execute(
"INSERT INTO MessageResourceInfo "
"(message_id, chat_id, sender_id, message_local_type, message_create_time, "
" message_local_id, message_svr_id, message_origin_source, packed_info) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(next_msg_id, ex_chat_id, 0, ex_type, ex_ctime, ex_local_id, 0, 0, _packed(ex_md5)),
)
next_msg_id += 1
conn.commit()
finally:
conn.close()
@@ -335,6 +380,81 @@ class TestImageResolverV2(unittest.TestCase):
self.assertTrue(result['success'], msg=result)
self.assertEqual(result['format'], 'png')
def test_decode_image_disambiguates_local_id_across_chats(self):
"""同 local_id 跨 chat 重复时, 必须按 username -> chat_id 选对; 否则会拿到
别的 chat 的 MD5 (或视频 type=43 的 packed_info), 解出错图。
生产 DB 上同一个 message_local_id 实测会出现在 5+ 个不同 chat 里,
其中混有图片 (type=3) / 视频 (type=43) / 群聊 / 私聊, 必须 chat-scoped
+ type 过滤才能定位。
"""
os.unlink(self.db_path)
other_md5 = "f" * 32
video_md5 = "a" * 32
_make_resource_db(
self.db_path, self.local_id, self.file_md5,
username=self.username, chat_id=7,
message_create_time=1778487726,
extra_rows=[
# 另一个 chat 同 local_id 同图片类型, MD5 不同 —— 选错就拿这个
(5, self.local_id, 3, 1700000000, other_md5),
# 又一个 chat 同 local_id 但是视频 (type=43), 应被 type 过滤
(132, self.local_id, 43, 1750000000, video_md5),
],
)
# 给冲突 chat 也注册 user_name, 否则 chat-scope 等价
conn = sqlite3.connect(self.db_path)
conn.execute(
"INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (5, ?, 0), (132, ?, 0)",
("other_chat_wxid", "video_chat_wxid"),
)
conn.commit()
conn.close()
resolver = ImageResolver(
self.wechat_base, self.out_dir, self.cache,
aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
)
result = resolver.decode_image(self.username, self.local_id)
self.assertTrue(result['success'], msg=result)
# 必须拿目标 chat 的 MD5, 不是 other_chat 也不是视频
self.assertEqual(result['md5'], self.file_md5)
def test_decode_image_picks_latest_when_same_chat_local_id_reused(self):
"""活跃 chat 里 local_id 会被复用 (实测同 chat 同 local_id 最多 7 条);
默认应返回 message_create_time 最新的那张, 对应用户最近一次 reference。
"""
os.unlink(self.db_path)
old_md5 = "c" * 32
# self.file_md5 / self.local_id 在 _make_resource_db 默认插入为 "latest" 那条
_make_resource_db(
self.db_path, self.local_id, self.file_md5,
username=self.username, chat_id=1,
message_create_time=1778487726,
extra_rows=[
# 同 chat 同 local_id 但更早, 不应该被选中
(1, self.local_id, 3, 1700000000, old_md5),
],
)
resolver = ImageResolver(
self.wechat_base, self.out_dir, self.cache,
aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
)
result = resolver.decode_image(self.username, self.local_id)
self.assertTrue(result['success'], msg=result)
self.assertEqual(result['md5'], self.file_md5)
def test_decode_image_unknown_chat_returns_error(self):
"""username 在 ChatName2Id 里找不到时, 应返回结构化错误而不是 crash 或乱选 row。"""
resolver = ImageResolver(
self.wechat_base, self.out_dir, self.cache,
aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
)
result = resolver.decode_image("wxid_does_not_exist", self.local_id)
self.assertFalse(result['success'])
self.assertIn('wxid_does_not_exist', result['error'])
def test_decode_image_v1_no_aes_key_uses_fixed_key(self):
# V1 magic 不会被 is_v2_format guard 拦截 (V1 magic 是 \x07\x08V1, V2 是 \x07\x08V2);
# 即便 ImageResolver(aes_key=None), V1 文件也应通过 decrypt_dat_file 内置固定 key 解密