From c85367ff081df3a6b435b11db7515f335f1265f8 Mon Sep 17 00:00:00 2001 From: ylytdeng Date: Tue, 3 Mar 2026 11:55:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=8C=E5=AA=92=E4=BD=93=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=A7=A3=E6=9E=90=E3=80=81=E8=A1=A8=E6=83=85=E5=8C=85?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E3=80=81=E7=BB=84=E5=90=88=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表情包内联显示: emoticon.db CDN映射 + 下载缓存 - 富媒体内容: 链接卡片/文件/视频号/小程序/引用/位置等完整渲染 - 修复文字+图片组合消息丢失 (前端去重key加消息类型) - 新增隐藏消息检测: 异步查message DB找回同秒内其他消息 - MonitorDBCache线程安全: per-key锁防并发解密损坏 - Web UI优化: 气泡样式/群聊发送者/图片点击放大 Co-Authored-By: Claude Opus 4.6 --- README.md | 11 + monitor_web.py | 1124 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 1021 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 7b7582c..c955911 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ 微信 4.0 (Windows) 本地数据库解密工具。从运行中的微信进程内存提取加密密钥,解密所有 SQLCipher 4 加密数据库,并提供实时消息监听。 +## 更新日志 + +### 2025-03-03 — 富媒体内容 & 组合消息修复 + +- **表情包内联显示**: 自动从 emoticon.db 构建 MD5→CDN 映射,支持自定义表情(NonStore)和商店表情(Store),CDN 下载后本地缓存 +- **富媒体内容解析**: 链接卡片(type 49)、文件、视频号、小程序、引用回复、位置分享等在 Web UI 中完整渲染 +- **文字+图片组合消息不再丢失**: 修复同时发送文字和图片时只显示最后一条的问题(前端去重 key 增加消息类型) +- **隐藏消息检测**: 新增 `_check_hidden_messages` 机制,session.db 只保存最后一条消息摘要,现在会异步查 message DB 找回同一秒内的其他消息 +- **MonitorDBCache 线程安全**: 引入 per-key 锁,防止多线程并发解密同一数据库导致文件损坏 +- **Web UI 改进**: 消息气泡样式优化、群聊发送者显示、图片缩略图点击放大 + ## 原理 微信 4.0 使用 SQLCipher 4 加密本地数据库: diff --git a/monitor_web.py b/monitor_web.py index 04504b0..bcb9c9f 100644 --- a/monitor_web.py +++ b/monitor_web.py @@ -48,58 +48,260 @@ sse_lock = threading.Lock() messages_log = [] messages_lock = threading.Lock() MAX_LOG = 500 -_img_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='img') +_img_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix='img') +_hidden_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='hidden') + +# ---- Emoji 缓存 (md5 → {cdn_url, aes_key, encrypt_url}) ---- +_emoji_lookup = {} # md5 → dict +_emoji_lookup_lock = threading.Lock() + +_emoji_keys_dict = None # 保存 keys 引用供刷新用 +_emoji_last_refresh = 0 + +def _build_emoji_lookup(keys_dict): + """从 emoticon.db 构建 emoji md5 → URL 映射(直接解密,不走 cache)""" + global _emoji_lookup, _emoji_keys_dict, _emoji_last_refresh + _emoji_keys_dict = keys_dict + key_info = keys_dict.get("emoticon\\emoticon.db") + if not key_info: + print("[emoji] 无 emoticon.db key,跳过", flush=True) + return + + src = os.path.join(DB_DIR, "emoticon", "emoticon.db") + if not os.path.exists(src): + return + + import tempfile + dst = os.path.join(tempfile.gettempdir(), "wechat_emoticon_dec.db") + enc_key = bytes.fromhex(key_info["enc_key"]) + + try: + full_decrypt(src, dst, enc_key) + wal = src + "-wal" + if os.path.exists(wal): + decrypt_wal_full(wal, dst, enc_key) + except Exception as e: + print(f"[emoji] emoticon.db 解密失败: {e}", flush=True) + return + + try: + conn = sqlite3.connect(f"file:{dst}?mode=ro", uri=True) + new_lookup = {} + + # 1. NonStore 表情(有独立 cdn_url) + rows = conn.execute( + "SELECT md5, aes_key, cdn_url, encrypt_url, product_id FROM kNonStoreEmoticonTable" + ).fetchall() + # 收集每个 package 的 cdn_url 模板 + pkg_cdn_template = {} # package_id → cdn_url (任意一个) + for md5, aes_key, cdn_url, encrypt_url, product_id in rows: + if md5: + new_lookup[md5] = { + 'cdn_url': cdn_url or '', + 'aes_key': aes_key or '', + 'encrypt_url': encrypt_url or '', + } + if product_id and cdn_url: + pkg_cdn_template[product_id] = cdn_url + + non_store_count = len(new_lookup) + + # 2. Store 表情(尝试构造 cdn_url) + store_rows = conn.execute( + "SELECT package_id_, md5_ FROM kStoreEmoticonFilesTable" + ).fetchall() + store_added = 0 + for pkg_id, md5 in store_rows: + if md5 and md5 not in new_lookup: + # 尝试用同 package 的模板构造 URL + template = pkg_cdn_template.get(pkg_id, '') + if template and '&' in template: + # 替换 m= 参数为新 md5 + import re + constructed = re.sub(r'm=[0-9a-f]+', f'm={md5}', template) + new_lookup[md5] = { + 'cdn_url': constructed, + 'aes_key': '', + 'encrypt_url': '', + } + store_added += 1 + + conn.close() + with _emoji_lookup_lock: + _emoji_lookup = new_lookup + _emoji_last_refresh = time.time() + print(f"[emoji] 已加载 {non_store_count} NonStore + {store_added} Store = {len(new_lookup)} 个表情映射", flush=True) + except Exception as e: + print(f"[emoji] 构建映射失败: {e}", flush=True) + finally: + try: + os.unlink(dst) + except OSError: + pass + +def _download_emoji(md5): + """从 CDN 下载表情并缓存到 decoded_images/,返回文件名或 None""" + with _emoji_lookup_lock: + info = _emoji_lookup.get(md5) + if not info: + # Lookup miss: 刷新 emoticon.db(最多每60秒一次) + if _emoji_keys_dict and time.time() - _emoji_last_refresh > 60: + print(f" [emoji] lookup miss, 刷新 emoticon.db...", flush=True) + _build_emoji_lookup(_emoji_keys_dict) + with _emoji_lookup_lock: + info = _emoji_lookup.get(md5) + if not info: + return None + + # 先检查是否已缓存 + for ext in ('.gif', '.png', '.jpg', '.webp'): + cached = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}{ext}") + if os.path.exists(cached): + return f"emoji_{md5}{ext}" + + cdn_url = info.get('cdn_url', '') + aes_key = info.get('aes_key', '') + encrypt_url = info.get('encrypt_url', '') + + data = None + # 方法1: 从 cdn_url 直接下载(未加密) + if cdn_url: + try: + import urllib.request + req = urllib.request.Request(cdn_url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=15) + data = resp.read() + except Exception as e: + print(f" [emoji] cdn下载失败 {md5[:12]}: {e}", flush=True) + + # 方法2: 从 encrypt_url 下载 + AES-CBC 解密 + if not data and encrypt_url and aes_key: + try: + import urllib.request + req = urllib.request.Request(encrypt_url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=15) + enc_data = resp.read() + key_bytes = bytes.fromhex(aes_key) + cipher = AES.new(key_bytes, AES.MODE_CBC, iv=key_bytes) + data = cipher.decrypt(enc_data) + # 去除 PKCS7 padding + if data: + pad = data[-1] + if 1 <= pad <= 16 and data[-pad:] == bytes([pad]) * pad: + data = data[:-pad] + except Exception as e: + print(f" [emoji] encrypt下载解密失败 {md5[:12]}: {e}", flush=True) + + if not data or len(data) < 4: + return None + + # 检测格式 + if data[:3] == b'\xff\xd8\xff': + ext = '.jpg' + elif data[:4] == b'\x89PNG': + ext = '.png' + elif data[:3] == b'GIF': + ext = '.gif' + elif data[:4] == b'RIFF': + ext = '.webp' + elif data[:4] in (b'wxgf', b'wxam'): + # WXGF/WXAM 需要转换 + ext = '.gif' + tmp_path = os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.wxgf") + with open(tmp_path, 'wb') as f: + f.write(data) + jpg_path = _convert_hevc_to_jpeg(tmp_path, os.path.join(DECODED_IMAGE_DIR, f"emoji_{md5}.jpg")) + try: + os.unlink(tmp_path) + except OSError: + pass + if jpg_path: + return f"emoji_{md5}.jpg" + return None + else: + ext = '.bin' + + out_name = f"emoji_{md5}{ext}" + out_path = os.path.join(DECODED_IMAGE_DIR, out_name) + with open(out_path, 'wb') as f: + f.write(data) + print(f" [emoji] 下载缓存: {out_name} ({len(data)//1024}KB)", flush=True) + return out_name class MonitorDBCache: - """轻量 DB 缓存,mtime 检测变化时重新解密""" + """轻量 DB 缓存,mtime 检测变化时重新解密(线程安全)""" def __init__(self, keys, tmp_dir): self.keys = keys self.tmp_dir = tmp_dir os.makedirs(tmp_dir, exist_ok=True) self._state = {} # rel_key → (db_mtime, wal_mtime) + self._locks = {} # per-key 锁,防止并发解密同一 DB + self._meta_lock = threading.Lock() + + def _get_lock(self, rel_key): + with self._meta_lock: + if rel_key not in self._locks: + self._locks[rel_key] = threading.Lock() + return self._locks[rel_key] + + def invalidate(self, rel_key): + """强制清除缓存状态,下次 get() 会重新全量解密""" + lock = self._get_lock(rel_key) + with lock: + self._state.pop(rel_key, None) def get(self, rel_key): """返回解密后的临时文件路径,mtime 变化时自动重新解密""" if rel_key not in self.keys: return None - enc_key = bytes.fromhex(self.keys[rel_key]["enc_key"]) - rel_path = rel_key.replace('\\', os.sep) - db_path = os.path.join(DB_DIR, rel_path) - wal_path = db_path + "-wal" + lock = self._get_lock(rel_key) + with lock: + enc_key = bytes.fromhex(self.keys[rel_key]["enc_key"]) + rel_path = rel_key.replace('\\', os.sep) + db_path = os.path.join(DB_DIR, rel_path) + wal_path = db_path + "-wal" - if not os.path.exists(db_path): - return None + if not os.path.exists(db_path): + return None - try: - db_mtime = os.path.getmtime(db_path) - wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 - except OSError: - return None + try: + db_mtime = os.path.getmtime(db_path) + wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0 + except OSError: + return None - out_name = rel_key.replace('\\', '_') - out_path = os.path.join(self.tmp_dir, out_name) + out_name = rel_key.replace('\\', '_') + out_path = os.path.join(self.tmp_dir, out_name) - prev = self._state.get(rel_key) + prev = self._state.get(rel_key) - if prev is None or db_mtime != prev[0]: - t0 = time.perf_counter() - full_decrypt(db_path, out_path, enc_key) - if os.path.exists(wal_path): + if prev is None or db_mtime != prev[0]: + t0 = time.perf_counter() + for _retry in range(3): + try: + full_decrypt(db_path, out_path, enc_key) + break + except PermissionError: + if _retry < 2: + time.sleep(1) + else: + raise + if os.path.exists(wal_path): + decrypt_wal_full(wal_path, out_path, enc_key) + ms = (time.perf_counter() - t0) * 1000 + print(f" [cache] {rel_key} 全量解密 {ms:.0f}ms", flush=True) + self._state[rel_key] = (db_mtime, wal_mtime) + elif wal_mtime != prev[1]: + t0 = time.perf_counter() decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} 全量解密 {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) - elif wal_mtime != prev[1]: - t0 = time.perf_counter() - decrypt_wal_full(wal_path, out_path, enc_key) - ms = (time.perf_counter() - t0) * 1000 - print(f" [cache] {rel_key} WAL patch {ms:.0f}ms", flush=True) - self._state[rel_key] = (db_mtime, wal_mtime) + ms = (time.perf_counter() - t0) * 1000 + print(f" [cache] {rel_key} WAL patch {ms:.0f}ms", flush=True) + self._state[rel_key] = (db_mtime, wal_mtime) - return out_path + return out_path def build_username_db_map(): @@ -276,6 +478,53 @@ def broadcast_sse(msg_data): sse_clients.remove(q) +def _convert_hevc_to_jpeg(hevc_path, jpeg_path): + """将 wxgf/HEVC 文件转为 JPEG + + wxgf 是微信自有格式: wxgf header + ICC profile + HEVC NAL units + 通过扫描 HEVC VPS start code (00 00 00 01 40 01) 定位 Annex B 流, + 再用 PyAV (ffmpeg) 解码首帧为 JPEG。 + """ + try: + import av + + with open(hevc_path, 'rb') as f: + data = f.read() + + # 扫描 HEVC Annex B VPS start code: 00 00 00 01 40 01 + vps_sig = b'\x00\x00\x00\x01\x40\x01' + hevc_start = data.find(vps_sig) + if hevc_start < 0: + # fallback: 找 SPS (00 00 00 01 42 01) + hevc_start = data.find(b'\x00\x00\x00\x01\x42\x01') + if hevc_start < 0: + print(f" [img] wxgf 中未找到 HEVC VPS/SPS", flush=True) + return None + + # 提取 HEVC Annex B 流并用 PyAV 解码 + h265_path = hevc_path + '.h265' + with open(h265_path, 'wb') as f: + f.write(data[hevc_start:]) + + try: + container = av.open(h265_path, format='hevc') + for frame in container.decode(video=0): + img = frame.to_image() + img.save(jpeg_path, "JPEG", quality=90) + container.close() + return jpeg_path + container.close() + finally: + if os.path.exists(h265_path): + os.unlink(h265_path) + + except ImportError: + print(f" [img] 需要 PyAV: pip install av", flush=True) + except Exception as e: + print(f" [img] HEVC→JPEG 失败: {e}", flush=True) + return None + + # ============ 监听器 ============ class SessionMonitor: @@ -304,29 +553,39 @@ class SessionMonitor: table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" local_id = None for db_key in db_keys: - msg_db_path = self.db_cache.get(db_key) - if not msg_db_path: - continue - try: - conn = sqlite3.connect(f"file:{msg_db_path}?mode=ro", uri=True) - # 精确匹配 timestamp - row = conn.execute(f""" - SELECT local_id FROM [{table_name}] - WHERE local_type = 3 AND create_time = ? - """, (timestamp,)).fetchone() - if not row: - # 模糊匹配(±3秒内最近的图片消息) + for _try in range(2): + msg_db_path = self.db_cache.get(db_key) + if not msg_db_path: + break + try: + conn = sqlite3.connect(f"file:{msg_db_path}?mode=ro", uri=True) + # 微信4.0 图片的 local_type 可能是复合编码: (sub<<32)|3 row = conn.execute(f""" SELECT local_id FROM [{table_name}] - WHERE local_type = 3 AND ABS(create_time - ?) <= 3 - ORDER BY ABS(create_time - ?) LIMIT 1 - """, (timestamp, timestamp)).fetchone() - conn.close() - if row: - local_id = row[0] + WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) + AND create_time = ? + """, (timestamp,)).fetchone() + if not row: + row = conn.execute(f""" + SELECT local_id FROM [{table_name}] + WHERE (local_type = 3 OR (local_type > 4294967296 AND local_type % 4294967296 = 3)) + AND ABS(create_time - ?) <= 3 + ORDER BY ABS(create_time - ?) LIMIT 1 + """, (timestamp, timestamp)).fetchone() + conn.close() + if row: + local_id = row[0] break - except Exception as e: - print(f" [img] 查询 {db_key}/{table_name} 失败: {e}", flush=True) + except Exception as e: + if 'malformed' in str(e) and _try == 0: + print(f" [img] {db_key} malformed, 强制刷新...", flush=True) + self.db_cache.invalidate(db_key) + continue + if 'no such table' not in str(e): + print(f" [img] 查询 {db_key}/{table_name} 失败: {e}", flush=True) + break + if local_id: + break if not local_id: print(f" [img] 未找到 local_id: {username} t={timestamp}", flush=True) @@ -334,31 +593,37 @@ class SessionMonitor: # 4. 查 message_resource.db 获取 MD5 # local_id 不全局唯一,需要同时匹配 create_time - res_path = self.db_cache.get("message\\message_resource.db") - if not res_path: - return None - file_md5 = None - try: - conn = sqlite3.connect(f"file:{res_path}?mode=ro", uri=True) - row = conn.execute( - "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_local_id = ? AND message_create_time = ? AND message_local_type = 3", - (local_id, timestamp) - ).fetchone() - if not row: - # 降级: 只用 create_time + type + for _try in range(2): + res_path = self.db_cache.get("message\\message_resource.db") + if not res_path: + return None + try: + conn = sqlite3.connect(f"file:{res_path}?mode=ro", uri=True) row = conn.execute( "SELECT packed_info FROM MessageResourceInfo " - "WHERE message_create_time = ? AND message_local_type = 3", - (timestamp,) + "WHERE message_local_id = ? AND message_create_time = ? " + "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", + (local_id, timestamp) ).fetchone() - conn.close() - if row and row[0]: - file_md5 = extract_md5_from_packed_info(row[0]) - except Exception as e: - print(f" [img] 查询 message_resource 失败: {e}", flush=True) - return None + if not row: + row = conn.execute( + "SELECT packed_info FROM MessageResourceInfo " + "WHERE message_create_time = ? " + "AND (message_local_type = 3 OR message_local_type % 4294967296 = 3)", + (timestamp,) + ).fetchone() + conn.close() + if row and row[0]: + file_md5 = extract_md5_from_packed_info(row[0]) + break + except Exception as e: + if 'malformed' in str(e) and _try == 0: + print(f" [img] resource DB malformed, 强制刷新...", flush=True) + self.db_cache.invalidate("message\\message_resource.db") + continue + print(f" [img] 查询 message_resource 失败: {e}", flush=True) + return None if not file_md5: print(f" [img] 未找到 MD5: local_id={local_id} t={timestamp}", flush=True) @@ -379,54 +644,87 @@ class SessionMonitor: print(f" [img] 未找到 .dat: MD5={file_md5}", flush=True) return None - # 优先原图,然后高清 _h,最后缩略图 _t - selected = dat_files[0] + # 分类 .dat 文件 + # 优先级: 原图.dat(最大) > _h.dat > _W.dat > _t.dat(缩略图) + ranked = [] for f in dat_files: - fname = os.path.basename(f) - if not fname.startswith(file_md5 + '_'): - selected = f - break - for f in dat_files: - if f.endswith('_h.dat'): - selected = f - break + fname = os.path.basename(f).lower() + sz = os.path.getsize(f) + if '_t_' in fname: + rank = 5 # _t_W.dat 缩略图变体 + elif '_t.' in fname: + rank = 4 # _t.dat 缩略图 + elif '_w.' in fname: + rank = 2 # _W.dat (V2 可转 JPEG) + elif '_h.' in fname: + rank = 1 # 高清 + elif fname == f"{file_md5}.dat".lower(): + rank = 0 # 原图 (最优先) + else: + rank = 0 + ranked.append((rank, sz, f)) + ranked.sort(key=lambda x: (x[0], -x[1])) # 6. 解密图片 os.makedirs(DECODED_IMAGE_DIR, exist_ok=True) out_base = os.path.join(DECODED_IMAGE_DIR, file_md5) + rank_names = {0: 'orig', 1: 'h', 2: 'W', 4: 't', 5: 't_W'} + browser_formats = ('jpg', 'png', 'gif', 'webp') - # 已解密则跳过 - for ext in ('jpg', 'png', 'gif', 'webp', 'bmp', 'tif'): + # 已有可用缓存则跳过 + for ext in browser_formats: candidate = f"{out_base}.{ext}" if os.path.exists(candidate): - return os.path.basename(candidate) + cached_sz = os.path.getsize(candidate) + best_rank = ranked[0][0] if ranked else 99 + if cached_sz > 20480 or best_rank >= 4: + return os.path.basename(candidate) + os.unlink(candidate) + print(f" [img] 缩略图升级: {cached_sz/1024:.0f}KB → 重解密", flush=True) + break - # V2 新格式需要 AES key - if is_v2_format(selected) and not IMAGE_AES_KEY: - print(f" [img] V2 格式缺少 AES key: {os.path.basename(selected)}", flush=True) - print(f" [img] 请运行 find_image_key.py 提取密钥", flush=True) - return '__v2_unsupported__' + for rank, sz, selected in ranked: + sel_type = rank_names.get(rank, '?') + print(f" [img] 尝试 {sel_type}({sz/1024:.0f}KB): {os.path.basename(selected)}", flush=True) - result_path, fmt = decrypt_dat_file(selected, f"{out_base}.tmp", IMAGE_AES_KEY, IMAGE_XOR_KEY) - if not result_path: - print(f" [img] 解密失败: {selected}", flush=True) - return None + if is_v2_format(selected) and not IMAGE_AES_KEY: + print(f" [img] V2 格式缺少 AES key, 跳过", flush=True) + continue - final = f"{out_base}.{fmt}" - if os.path.exists(final): - os.unlink(final) - os.rename(result_path, final) - size_kb = os.path.getsize(final) / 1024 - print(f" [img] 解密成功: {os.path.basename(final)} ({size_kb:.0f}KB)", flush=True) - return os.path.basename(final) + result_path, fmt = decrypt_dat_file(selected, f"{out_base}.tmp", IMAGE_AES_KEY, IMAGE_XOR_KEY) + if not result_path: + print(f" [img] 解密失败, 跳过", flush=True) + continue + + # HEVC/wxgf → 用 pillow-heif 转 JPEG + if fmt in ('hevc', 'bin'): + jpg_path = _convert_hevc_to_jpeg(result_path, f"{out_base}.jpg") + os.unlink(result_path) + if jpg_path: + size_kb = os.path.getsize(jpg_path) / 1024 + print(f" [img] HEVC→JPEG 成功: {os.path.basename(jpg_path)} ({size_kb:.0f}KB)", flush=True) + return os.path.basename(jpg_path) + print(f" [img] HEVC→JPEG 转换失败, 尝试下一个", flush=True) + continue + + final = f"{out_base}.{fmt}" + if os.path.exists(final): + os.unlink(final) + os.rename(result_path, final) + size_kb = os.path.getsize(final) / 1024 + print(f" [img] 解密成功: {os.path.basename(final)} ({size_kb:.0f}KB)", flush=True) + return os.path.basename(final) + + print(f" [img] 所有 .dat 均无法解密", flush=True) + return '__v2_unsupported__' def _async_resolve_image(self, username, timestamp, msg_data): """后台线程: 解密图片并通过 SSE 推送更新""" + delays = [0.3, 1.0, 2.0] for attempt in range(3): try: img_name = self.resolve_image(username, timestamp) if img_name == '__v2_unsupported__': - # V2 新加密格式,显示占位提示 msg_data['content'] = '[图片 - 新加密格式暂不支持预览]' broadcast_sse({ 'event': 'image_update', @@ -447,11 +745,476 @@ class SessionMonitor: print(f" [img] 异步解密成功: {img_name}", flush=True) return elif attempt < 2: - time.sleep(1.5) + time.sleep(delays[attempt]) except Exception as e: print(f" [img] 异步解密失败(attempt={attempt}): {e}", flush=True) if attempt < 2: - time.sleep(1.5) + time.sleep(delays[attempt]) + + def _fresh_decrypt_query(self, db_key, table_name, prev_ts, curr_ts): + """独立解密 message DB 到临时文件并查询,避免共享缓存竞态""" + if db_key not in self.db_cache.keys: + return [] + enc_key = bytes.fromhex(self.db_cache.keys[db_key]["enc_key"]) + rel_path = db_key.replace('\\', os.sep) + db_path = os.path.join(DB_DIR, rel_path) + wal_path = db_path + "-wal" + if not os.path.exists(db_path): + return [] + + import tempfile + fd, tmp_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + try: + t0 = time.perf_counter() + full_decrypt(db_path, tmp_path, enc_key) + if os.path.exists(wal_path): + decrypt_wal_full(wal_path, tmp_path, enc_key) + ms = (time.perf_counter() - t0) * 1000 + print(f" [hidden] {db_key} 独立解密 {ms:.0f}ms", flush=True) + + conn = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True) + rows = conn.execute(f""" + SELECT create_time, local_type, message_content, WCDB_CT_message_content + FROM [{table_name}] + WHERE create_time >= ? AND create_time <= ? + ORDER BY create_time ASC + """, (prev_ts, curr_ts)).fetchall() + conn.close() + return rows + except Exception as e: + print(f" [hidden] {db_key} 独立解密失败: {e}", flush=True) + return [] + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender): + """检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等) + + 先用共享缓存查询(快),失败或可疑时用独立解密(慢但可靠)。 + """ + if not self.username_db_map: + return + db_keys = self.username_db_map.get(username) + if not db_keys: + return + + table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" + print(f" [hidden] 检查 {display[:15]} prev_ts={prev_ts} curr_ts={curr_ts} type={curr_msg_type}", flush=True) + + # 等待 message DB 写入完成 + time.sleep(1.0) + + # 快速路径: 用共享缓存查询(带重试) + all_rows = [] + cache_failed = False + for _try in range(3): + all_rows.clear() + if self.db_cache: + for db_key in db_keys: + dec_path = self.db_cache.get(db_key) + if not dec_path: + continue + try: + conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) + rows = conn.execute(f""" + SELECT create_time, local_type, message_content, WCDB_CT_message_content + FROM [{table_name}] + WHERE create_time >= ? AND create_time <= ? + ORDER BY create_time ASC + """, (prev_ts, curr_ts)).fetchall() + conn.close() + all_rows.extend(rows) + except Exception as e: + print(f" [hidden] 缓存查询失败 {db_key}: {e}", flush=True) + cache_failed = True + break + # 检查是否找到了 curr_ts 的消息(说明缓存是最新的) + has_curr = any(r[0] == curr_ts for r in all_rows) + if has_curr or cache_failed: + break + # 缓存可能还没更新到最新数据,短暂等待后重试 + if _try < 2: + time.sleep(1.5) + print(f" [hidden] 缓存未包含最新消息,重试({_try+1})...", flush=True) + + # 仅在缓存查询出错时才用昂贵的独立解密 + if cache_failed: + print(f" [hidden] 缓存异常,启动独立解密...", flush=True) + all_rows = [] + for db_key in db_keys: + rows = self._fresh_decrypt_query(db_key, table_name, prev_ts, curr_ts) + all_rows.extend(rows) + if rows: + break + else: + print(f" [hidden] 缓存查到 {len(all_rows)} 条", flush=True) + + # 过滤出隐藏消息 + hidden_msgs = [] + for ts, lt, mc, ct in all_rows: + base = lt % 4294967296 if lt > 4294967296 else lt + # 跳过与 session 当前消息相同时间戳+类型的(已显示) + if ts == curr_ts and base == curr_msg_type: + continue + # 跳过 prev_ts 的消息(上一轮已显示) + if ts == prev_ts: + continue + # 解压 zstd + if isinstance(mc, bytes) and ct == 4: + try: + mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') + except Exception: + mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else '' + elif isinstance(mc, bytes): + mc = mc.decode('utf-8', errors='replace') + hidden_msgs.append((ts, base, mc or '')) + + print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True) + + if not hidden_msgs: + return + + global messages_log + for ts, base, mc in hidden_msgs: + msg_data = { + 'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'), + 'timestamp': ts, + 'chat': display, + 'username': username, + 'is_group': is_group, + 'sender': sender, + } + if base == 3: + # 隐藏的图片消息 + time.sleep(0.5) + img_name = self.resolve_image(username, ts) + if img_name and img_name != '__v2_unsupported__': + msg_data.update({ + 'type': '图片', 'type_icon': '\U0001f5bc\ufe0f', + 'content': '', 'image_url': f'/img/{img_name}', + }) + print(f" [hidden] 补充图片: {img_name} t={ts}", flush=True) + else: + continue + elif base == 1: + # 隐藏的文字消息 + msg_data.update({ + 'type': '文本', 'type_icon': '\U0001f4ac', + 'content': mc, + }) + print(f" [hidden] 补充文字: {mc[:30]} t={ts}", flush=True) + elif base == 47: + # 隐藏的表情消息 + rich = self.resolve_rich_content(username, ts, 47) + msg_data.update({ + 'type': '表情', 'type_icon': '\U0001f600', + 'content': '[表情]', + }) + if rich: + msg_data['rich_content'] = rich + print(f" [hidden] 补充表情 t={ts}", flush=True) + elif base == 49: + # 隐藏的富媒体消息 + rich = self.resolve_rich_content(username, ts, 49) + msg_data.update({ + 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), + 'content': mc[:100] if mc else '', + }) + if rich: + msg_data['rich_content'] = rich + print(f" [hidden] 补充富媒体 t={ts}", flush=True) + else: + # 其他类型 + msg_data.update({ + 'type': format_msg_type(base), 'type_icon': msg_type_icon(base), + 'content': mc[:100] if mc else f'[{format_msg_type(base)}]', + }) + print(f" [hidden] 补充type={base} t={ts}", flush=True) + + with messages_lock: + messages_log.append(msg_data) + if len(messages_log) > MAX_LOG: + messages_log = messages_log[-MAX_LOG:] + broadcast_sse(msg_data) + + def _query_msg_content(self, username, timestamp, base_type): + """通用: 从 message_*.db 查找指定类型消息的 XML 内容 + + base_type: 基础类型 (47, 49, 43, 34 等) + 微信4.0 的 local_type 是复合编码: (sub_type << 32) | base_type + """ + db_keys = self.username_db_map.get(username, []) + if not db_keys: + return None + + tbl = f"Msg_{hashlib.md5(username.encode()).hexdigest()}" + for dk in db_keys: + for _try in range(2): + dec_path = self.db_cache.get(dk) + if not dec_path: + break + try: + conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True) + row = conn.execute(f''' + SELECT message_content, WCDB_CT_message_content, local_type + FROM "{tbl}" + WHERE (local_type = ? OR (local_type > 4294967296 AND local_type % 4294967296 = ?)) + AND create_time BETWEEN ? AND ? + ORDER BY create_time DESC LIMIT 1 + ''', (base_type, base_type, timestamp - 5, timestamp + 5)).fetchone() + conn.close() + + if not row: + break # 表存在但没找到匹配行,换下一个 DB + mc, ct_flag, full_type = row + if isinstance(mc, bytes) and ct_flag == 4: + mc = _zstd_dctx.decompress(mc).decode('utf-8', errors='replace') + elif isinstance(mc, bytes): + mc = mc.decode('utf-8', errors='replace') + if not mc: + break + + xml_start = mc.find('') + if xml_start < 0: + xml_start = mc.find(' 0: + mc = mc[xml_start:] + + return mc, full_type + + except Exception as e: + if 'malformed' in str(e) and _try == 0: + print(f" [rich] {dk} malformed, 强制刷新...", flush=True) + self.db_cache.invalidate(dk) + continue + if 'no such table' not in str(e): + print(f" [rich] 查询 {dk} 失败: {e}", flush=True) + break + return None + + def _parse_rich_content(self, username, timestamp, msg_type): + """解析富媒体消息, 返回 dict 或 None""" + import xml.etree.ElementTree as ET + + if msg_type == 47: + # --- 表情 --- + result = self._query_msg_content(username, timestamp, 47) + if not result: + print(f" [emoji] 查询失败 user={username[:10]} ts={timestamp}", flush=True) + return None + mc, _ = result + if '> 32 if full_type > 4294967296 else 0 + if '= 20: + break + except ET.ParseError: + pass + return { + 'type': 'chatlog', + 'title': title, + 'des': des[:200] if des else '', + 'items': items, + } + else: + # 其他子类型: 用 title 显示 + if title: + return { + 'type': 'link', + 'title': title, + 'des': des[:200] if des else '', + 'url': url, + } + except ET.ParseError: + pass + return None + + elif msg_type == 43: + # --- 视频 --- + result = self._query_msg_content(username, timestamp, 43) + if not result: + return None + mc, _ = result + try: + root = ET.fromstring(mc) + video = root.find('.//videomsg') + if video is None: + return None + length = int(video.get('playlength') or 0) + return { + 'type': 'video', + 'duration': length, + } + except ET.ParseError: + pass + return None + + elif msg_type == 34: + # --- 语音 --- + result = self._query_msg_content(username, timestamp, 34) + if not result: + return None + mc, _ = result + try: + root = ET.fromstring(mc) + voice = root.find('.//voicemsg') + if voice is None: + return None + length_ms = int(voice.get('voicelength') or 0) + return { + 'type': 'voice', + 'duration': round(length_ms / 1000, 1), + } + except ET.ParseError: + pass + return None + + return None + + def _async_resolve_rich(self, username, timestamp, msg_type, msg_data): + """后台线程: 解析富媒体内容并推送 SSE(带重试)""" + delays = [0.5, 1.5, 3.0] + for attempt in range(3): + try: + time.sleep(delays[attempt]) + info = self._parse_rich_content(username, timestamp, msg_type) + if info: + msg_data['rich'] = info + broadcast_sse({ + 'event': 'rich_update', + 'timestamp': timestamp, + 'username': username, + 'rich': info, + }) + print(f" [rich] {info['type']} 解析成功", flush=True) + return + except Exception as e: + print(f" [rich] 解析失败: {e}", flush=True) + print(f" [rich] type={msg_type} 3次重试均失败: {username}", flush=True) def query_state(self): """查询已解密副本的session状态""" @@ -502,7 +1265,10 @@ class SessionMonitor: new_msgs = [] for username, curr in curr_state.items(): prev = self.prev_state.get(username) - if prev and curr['timestamp'] > prev['timestamp']: + # 检测: 时间戳变化 OR 同一秒内消息类型变化(文字+图片组合) + is_new = prev and (curr['timestamp'] > prev['timestamp'] or + (curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type'))) + if is_new: display = self.contact_names.get(username, username) is_group = '@chatroom' in username sender = '' @@ -542,6 +1308,22 @@ class SessionMonitor: username, curr['timestamp'], msg_data ) + # 富媒体消息: 后台解析内容 + if curr['msg_type'] in (47, 49, 43, 34): + _img_executor.submit( + self._async_resolve_rich, + username, curr['timestamp'], curr['msg_type'], msg_data + ) + + # 检查时间窗口内是否有被 session 摘要覆盖的消息 + # (比如用户发了 图片+文字,session只记录最后一条) + prev_ts = prev['timestamp'] if prev else curr['timestamp'] - 5 + _hidden_executor.submit( + self._check_hidden_messages, + username, prev_ts, curr['timestamp'], curr['msg_type'], + display, is_group, sender + ) + # 按时间排序 new_msgs.sort(key=lambda m: m['timestamp']) @@ -661,6 +1443,30 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b .msg-content{font-size:13px;line-height:1.4;color:#bbb;word-break:break-all;padding-left:63px} .msg-img{max-width:300px;max-height:200px;border-radius:8px;cursor:pointer;margin-top:4px;transition:transform .2s} .msg-img:hover{transform:scale(1.02)} +.msg-emoji{max-width:120px;max-height:120px;border-radius:4px;margin-top:2px} +.msg-link{display:inline-block;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;margin-top:4px;max-width:400px;cursor:pointer;transition:background .2s} +.msg-link:hover{background:rgba(255,255,255,.1)} +.msg-link-title{font-size:13px;color:#4fc3f7;font-weight:500;line-height:1.3} +.msg-link-des{font-size:11px;color:#888;margin-top:3px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.msg-link-src{font-size:10px;color:#555;margin-top:4px} +.msg-quote{background:rgba(255,255,255,.04);border-left:2px solid #666;padding:4px 8px;margin-top:4px;border-radius:0 6px 6px 0} +.msg-quote-ref{font-size:11px;color:#777;margin-bottom:3px} +.msg-quote-ref b{color:#999;font-weight:500} +.msg-file{display:inline-flex;align-items:center;gap:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;margin-top:4px} +.msg-file-icon{font-size:24px} +.msg-file-name{font-size:13px;color:#ccc} +.msg-file-size{font-size:11px;color:#666} +.msg-voice{display:inline-flex;align-items:center;gap:6px;background:rgba(76,175,80,.1);border:1px solid rgba(76,175,80,.2);border-radius:16px;padding:6px 14px;margin-top:4px} +.msg-video{display:inline-flex;align-items:center;gap:6px;background:rgba(79,195,247,.08);border:1px solid rgba(79,195,247,.15);border-radius:8px;padding:6px 12px;margin-top:4px} +.msg-chatlog{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;margin-top:4px;max-width:450px} +.chatlog-body{margin-top:6px;border-top:1px solid rgba(255,255,255,.06);padding-top:6px} +.chatlog-item{font-size:12px;color:#999;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chatlog-item b{color:#bbb;font-weight:500} +.chatlog-more{font-size:11px;color:#555;margin-top:4px} +a.msg-link{text-decoration:none;color:inherit} +#lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center} +#lightbox.show{display:flex} +#lightbox img{max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px;box-shadow:0 4px 30px rgba(0,0,0,.5)} .empty{text-align:center;padding:80px 20px;color:#444} .empty .icon{font-size:48px;margin-bottom:12px} ::-webkit-scrollbar{width:4px} @@ -673,6 +1479,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
SSE 实时
0 消息
+
📡

等待新消息...

WAL增量解密 · SSE推送

@@ -683,10 +1490,61 @@ const seen = new Set(); // 去重: timestamp+username let sseReady = false; function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML} +const WX_EMOJI={'微笑':'😊','撇嘴':'😣','色':'😍','发呆':'😳','得意':'😎','流泪':'😢','害羞':'😳','闭嘴':'🤐','睡':'😴','大哭':'😭','尴尬':'😅','发怒':'😡','调皮':'😜','呲牙':'😁','惊讶':'😮','难过':'😞','酷':'😎','冷汗':'😰','抓狂':'😫','吐':'🤮','偷笑':'🤭','可爱':'🥰','白眼':'🙄','傲慢':'😤','饥饿':'🤤','困':'😪','惊恐':'😨','流汗':'😓','憨笑':'😄','大兵':'🫡','奋斗':'💪','咒骂':'🤬','疑问':'❓','嘘':'🤫','晕':'😵','折磨':'😩','衰':'😥','骷髅':'💀','敲打':'🔨','再见':'👋','擦汗':'😓','抠鼻':'🤏','鼓掌':'👏','糗大了':'😳','坏笑':'😏','左哼哼':'😤','右哼哼':'😤','哈欠':'🥱','鄙视':'😒','委屈':'🥺','快哭了':'🥺','阴险':'😈','亲亲':'😘','吓':'😱','可怜':'🥺','菜刀':'🔪','西瓜':'🍉','啤酒':'🍺','篮球':'🏀','乒乓':'🏓','咖啡':'☕','饭':'🍚','猪头':'🐷','玫瑰':'🌹','凋谢':'🥀','示爱':'💗','爱心':'❤️','心碎':'💔','蛋糕':'🎂','闪电':'⚡','炸弹':'💣','刀':'🔪','足球':'⚽','瓢虫':'🐞','便便':'💩','月亮':'🌙','太阳':'☀️','礼物':'🎁','拥抱':'🤗','强':'👍','弱':'👎','握手':'🤝','胜利':'✌️','抱拳':'🙏','勾引':'👆','拳头':'✊','差劲':'👎','爱你':'🤟','NO':'🙅','OK':'👌','爱情':'💑','飞吻':'😘','跳跳':'💃','发抖':'🥶','怄火':'😤','转圈':'💫','磕头':'🙇','回头':'🔙','跳绳':'🏃','挥手':'👋','激动':'🤩','街舞':'💃','献吻':'😘','左太极':'☯️','右太极':'☯️','嘿哈':'😆','捂脸':'🤦','奸笑':'😏','机智':'🤓','皱眉':'😟','耶':'✌️','红包':'🧧','鸡':'🐔','Emm':'🤔','加油':'💪','汗':'😓','天啊':'😱','社会社会':'🤙','旺柴':'🐕','好的':'👌','打脸':'🤦','哇':'😲','翻白眼':'🙄','666':'👍','让我看看':'👀','叹气':'😮‍💨','苦涩':'😣','裂开':'💔','嘴唇':'💋','爱心':'❤️','破涕为笑':'😂'}; +function wxEmoji(text){ + return text.replace(/\\[([^\\]]{1,4})\\]/g, (m,k)=>WX_EMOJI[k]||m); +} +function linkify(text){ + return text.replace(/(https?:\\/\\/[^\\s<>"'\\]\\)]+)/g, '$1'); +} +function fmtSize(b){ + if(b<1024) return b+'B'; + if(b<1048576) return (b/1024).toFixed(1)+'KB'; + return (b/1048576).toFixed(1)+'MB'; +} +function renderRich(r){ + if(!r) return null; + if(r.type==='emoji' && r.emoji_url) return ``; + if(r.type==='link') { + let src = r.source ? '' : ''; + return `${r.des?'':''}${src}`; + } + if(r.type==='file') return `
📄
${esc(r.title)}
${r.file_ext?r.file_ext.toUpperCase()+' · ':''}${fmtSize(r.file_size)}
`; + if(r.type==='quote') return `
${esc(r.ref_name)}: ${esc(r.ref_content)}
${esc(r.title)}
`; + if(r.type==='miniapp') return ``; + if(r.type==='channels') return `
📺 ${esc(r.title)} 视频号
`; + if(r.type==='chatlog') { + let items = r.items||[]; + let body = ''; + if(items.length>0) { + let preview = items.slice(0,4).map(it=>'
'+esc(it.name)+': '+esc(it.text)+'
').join(''); + let more = items.length>4 ? '
... 共'+items.length+'条消息
' : ''; + body = '
'+preview+more+'
'; + } else if(r.des) { + body = ''; + } + return `
${body}
`; + } + if(r.type==='voice') return `
🎤 语音 ${r.duration}s
`; + if(r.type==='video') return `
🎬 视频${r.duration?' '+r.duration+'s':''}
`; + return null; +} +function showLightbox(url){ + const lb=document.getElementById('lightbox'), img=document.getElementById('lb-img'); + img.src=url; + lb.classList.add('show'); +} +function renderContent(m){ + if(m.image_url) return `${esc(m.content||'')}`; + const richHtml = renderRich(m.rich); + if(richHtml) return richHtml; + const raw = esc(m.content||''); + return linkify(wxEmoji(raw)); +} function addMsg(m, animate){ - // 去重 - const key = m.timestamp + '|' + (m.username||m.chat); + // 去重(包含类型,避免同时间戳的文字+图片组合被误判重复) + const key = m.timestamp + '|' + (m.username||m.chat) + '|' + (m.type||''); if(seen.has(key)) return; seen.add(key); @@ -704,10 +1562,7 @@ function addMsg(m, animate){ const ur=m.unread>0?`${m.unread}`:''; const cc=m.is_group?'msg-chat grp':'msg-chat'; - let contentHtml = esc(m.content||''); - if(m.image_url){ - contentHtml = `${esc(m.content||'')}`; - } + let contentHtml = renderContent(m); const dk=m.timestamp+'|'+(m.username||m.chat); d.innerHTML=`
${m.time}${esc(m.chat)}${sn}
${m.type_icon} ${m.type}${ur}
${contentHtml}
`; @@ -743,12 +1598,24 @@ function connectSSE(){ if(d.v2_unsupported){ ct.innerHTML='[图片 - 新加密格式暂不支持预览]'; } else if(d.image_url){ - ct.innerHTML=``; + ct.innerHTML=``; } break; } } }); + es.addEventListener('rich_update', ev=>{ + const d=JSON.parse(ev.data); + const key=d.timestamp+'|'+(d.username||''); + for(const el of M.querySelectorAll('.msg')){ + const ct=el.querySelector('.msg-content'); + if(ct && ct.dataset.key===key){ + const html=renderRich(d.rich); + if(html) ct.innerHTML=html; + break; + } + } + }); es.onerror=()=>{ S.textContent='重连...'; S.className='status err'; @@ -870,13 +1737,42 @@ def main(): username_db_map = build_username_db_map() print(f"已映射 {len(username_db_map)} 个用户名", flush=True) + # 启动时清理可能损坏的缓存 + if os.path.isdir(MONITOR_CACHE_DIR): + for f in os.listdir(MONITOR_CACHE_DIR): + fp = os.path.join(MONITOR_CACHE_DIR, f) + if f.endswith('.db'): + try: + c = sqlite3.connect(fp) + c.execute("SELECT 1 FROM sqlite_master LIMIT 1") + c.close() + except Exception: + try: + os.unlink(fp) + print(f"[cleanup] 删除损坏缓存: {f}", flush=True) + except PermissionError: + print(f"[cleanup] 缓存被占用跳过: {f}", flush=True) + db_cache = MonitorDBCache(keys, MONITOR_CACHE_DIR) - # 后台预热 message_resource.db(图片解密必需) + # 后台预热所有 message DB(图片/emoji 解密必需) def _warmup(): t0 = time.perf_counter() - db_cache.get("message\\message_resource.db") - print(f"[warmup] message_resource.db 预热完成 {(time.perf_counter()-t0)*1000:.0f}ms", flush=True) + warmup_keys = ["message\\message_resource.db"] + for i in range(5): + k = f"message\\message_{i}.db" + if k in keys: + warmup_keys.append(k) + for k in warmup_keys: + t1 = time.perf_counter() + try: + db_cache.get(k) + print(f"[warmup] {k} {(time.perf_counter()-t1)*1000:.0f}ms", flush=True) + except Exception as e: + print(f"[warmup] {k} 失败: {e}", flush=True) + # 构建 emoji 映射(独立解密,不走 cache) + _build_emoji_lookup(keys) + print(f"[warmup] 全部完成 {(time.perf_counter()-t0)*1000:.0f}ms", flush=True) threading.Thread(target=_warmup, daemon=True).start() t = threading.Thread(target=monitor_thread, args=(enc_key, session_db, contact_names, db_cache, username_db_map), daemon=True)