"""微信本地 DB 只读访问。 通过解密缓存避免锁定原 DB,再用 sqlite3 读取消息。所有方法为同步阻塞操作, 在 server.py 中通过 asyncio.to_thread() 包装调用,避免阻塞 event loop。 架构升级(bridge 1.3.0+): - 密钥统一由 KeyCache 管理(single source of truth),DbReader 不再自持密钥存储 - 密钥持久化、salt 映射、默认 key、已拒绝 salt 全部由 KeyCache 负责 - 解密后的明文 DB 缓存到 /config/woc-decrypted//,按 mtime 失效 - WAL 文件自动合并到明文缓存 - /api/status 不触发内存扫描,直接判断 keys 文件和缓存有效性 - 新增 clear_default_key 转发,env/api key 验证失败时仅清默认 key """ from __future__ import annotations import atexit import hashlib import logging import os import re as _re import sqlite3 import threading from typing import Optional from woc_bridge.db.decryptor import Decryptor from woc_bridge.db.key_cache import KeyCache from woc_bridge.models import BridgeError # SQLCipher page 常量 PAGE_SIZE = 4096 SALT_SIZE = 16 logger = logging.getLogger("woc-bridge") # 消息类型 → render_type 映射(规格 12.2 节) _TYPE_TO_RENDER_TYPE: dict[int, str] = { 1: "text", 3: "image", 34: "voice", 43: "video", 49: "file", 10000: "system", 10002: "system", } class DbReader: """微信本地 DB 只读访问器。 核心变化: 1. 以 /db_storage 为根,通过相对路径访问各类 DB 2. 密钥统一由 key_cache(KeyCache)管理,DbReader 仅查询不存储 3. 解密缓存到 decrypted_dir(/config/woc-decrypted//) 4. 查询时按需解密 + 自动 WAL 合并 """ def __init__( self, db_root: str = "/config", key_cache: Optional[KeyCache] = None, decrypted_dir: str = "/config/woc-decrypted", ) -> None: """初始化 DbReader。 Args: db_root: 微信数据根目录,默认 /config key_cache: 密钥统一存储(KeyCache 实例)。为 None 时内部创建 默认 KeyCache(/config/woc-keys.json),便于独立测试 decrypted_dir: 解密缓存根目录 """ self.db_root = db_root self.decrypted_dir = decrypted_dir # 密钥统一来源:所有 key 查询/注入通过 key_cache self.key_cache: KeyCache = key_cache if key_cache is not None else KeyCache() self._lock = threading.Lock() # 解密缓存的内存索引:rel_path -> (db_mtime, wal_mtime, decrypted_path) self._decrypted_cache: dict[str, tuple[float, float, str]] = {} # 媒体路径缓存:msg_id -> path(或 None),避免每次 get_media 都 os.walk self._media_path_cache: dict[str, Optional[str]] = {} self._media_path_cache_max = 2000 # 进程退出时清理未持久化的临时文件 atexit.register(self._cleanup_temp_files) # ------------------------------------------------------------------ # 当前账号 / db_storage 路径探测 # ------------------------------------------------------------------ def _get_current_wxid(self) -> Optional[str]: """通过 /config/xwechat_files/all_users/login//key_info.db mtime 判断当前登录账号。""" login_dir = os.path.join(self.db_root, "xwechat_files", "all_users", "login") if not os.path.isdir(login_dir): return None candidates = [] for name in os.listdir(login_dir): key_info = os.path.join(login_dir, name, "key_info.db") if os.path.isfile(key_info): candidates.append((name, os.path.getmtime(key_info))) if not candidates: return None candidates.sort(key=lambda x: x[1], reverse=True) return candidates[0][0] def _find_db_storage_dir(self) -> Optional[str]: """查找当前登录账号的 db_storage 目录。""" base = os.path.join(self.db_root, "xwechat_files") if not os.path.isdir(base): return None candidates = [] for name in os.listdir(base): if name == "all_users": continue path = os.path.join(base, name, "db_storage") if os.path.isdir(path): # 确认目录下确实有 DB has_db = False for root, _, files in os.walk(path): if any(f.endswith(".db") for f in files): has_db = True break if has_db: candidates.append((path, os.path.getmtime(path))) if not candidates: return None # 优先匹配当前 wxid current_wxid = self._get_current_wxid() if current_wxid: for path, _ in candidates: if current_wxid in path: return path # 否则选最近修改的 candidates.sort(key=lambda x: x[1], reverse=True) return candidates[0][0] def _find_db_path(self) -> Optional[str]: """返回一个可直接用于验证/探测的 DB 文件路径(contact.db 或 message_0.db)。""" storage = self._find_db_storage_dir() if storage is None: return None for rel in ("contact/contact.db", "message/message_0.db"): path = os.path.join(storage, rel) if os.path.isfile(path): return path return None def _get_db_abs_path(self, rel_path: str) -> Optional[str]: """把相对路径(如 contact/contact.db)转成绝对路径。 优先按拼接路径返回;若文件不存在则按 basename 在 storage 下递归查找, 兼容 WeChat 4.x 不同版本 DB 实际存放路径可能与硬编码相对路径不一致的情况。 """ storage = self._find_db_storage_dir() if storage is None: return None abs_path = os.path.normpath(os.path.join(storage, rel_path)) # 防止路径穿越 if not abs_path.startswith(os.path.normpath(storage) + os.sep): return None # 快速路径:拼接路径存在则直接返回 if os.path.isfile(abs_path): return abs_path # fallback:按 basename 在 storage 下递归查找 target_name = os.path.basename(rel_path) for root, _, files in os.walk(storage): if target_name in files: return os.path.join(root, target_name) return None def _get_wxid_from_storage(self, storage_dir: Optional[str] = None) -> Optional[str]: """从 db_storage 路径推断 wxid。""" if storage_dir is None: storage_dir = self._find_db_storage_dir() if storage_dir is None: return None # 路径形如 /config/xwechat_files/wxid_xxx_1234/db_storage parts = os.path.normpath(storage_dir).split(os.sep) for part in reversed(parts): if part.startswith("wxid_"): return part return None # ------------------------------------------------------------------ # 密钥访问(全部转发到 KeyCache,DbReader 不再自持密钥存储) # ------------------------------------------------------------------ def set_keys( self, key_map: dict[str, str], source: str = "auto_extract", pid: Optional[int] = None, start_time: Optional[float] = None, ) -> None: """批量设置 salt -> enc_key 映射。 转发到 KeyCache.set_key_map,由 KeyCache 负责持久化。 keys 变化后清空解密缓存索引,避免旧密钥解密的脏数据被复用。 Args: key_map: {salt_hex: enc_key_hex} source: 密钥来源(auto_extract / api / env) pid: 自动提取时关联的微信进程 PID(用于进程重启后失效检测) start_time: 自动提取时关联的进程启动时间 """ wxid = self._get_current_wxid() self.key_cache.set_wxid(wxid) self.key_cache.set_key_map( key_map, source=source, verified=True, pid=pid, start_time=start_time, ) # keys 变化后清空解密缓存,让查询重新生成 self._decrypted_cache.clear() def set_key( self, key_hex: Optional[str], salt_hex: Optional[str] = None, ) -> None: """设置单个密钥(转发到 KeyCache)。 任何 key 变更都会清空解密缓存索引,避免旧密钥解密的脏数据被复用。 """ if key_hex is None: self.key_cache.clear() else: self.key_cache.set_key(key_hex, salt_hex=salt_hex) self._decrypted_cache.clear() def set_default_key(self, key_hex: Optional[str]) -> None: """设置默认密钥(env 注入专用,转发到 KeyCache)。 不影响 salt_to_key 持久化映射,env key 仅作为默认兜底。 """ self.key_cache.set_default_key(key_hex, source="env", verified=False) def clear_default_key(self) -> None: """仅清空默认 key(保留持久化 salt_to_key 映射)。 用于 env/api key 验证失败后清除默认 key,不影响已持久化的多 salt 映射。 keys 变化后清空解密缓存索引。 """ self.key_cache.clear_default_key() self._decrypted_cache.clear() def get_keys(self) -> dict[str, str]: """返回当前持久化的 salt -> key 映射副本。""" return self.key_cache.get_keys() def has_keys(self) -> bool: """是否有可用的密钥(持久化或默认 key)。""" return self.key_cache.has_keys() def _get_effective_key(self, salt_hex: Optional[str] = None) -> Optional[str]: """返回指定 salt 的有效密钥(转发到 KeyCache)。""" return self.key_cache.get_key(salt_hex) def _mark_default_key_mismatch(self, salt_hex: str) -> None: """标记默认 key 不匹配该 salt(转发到 KeyCache)。""" self.key_cache.mark_default_key_mismatch(salt_hex) def _read_db_salt(self, db_path: str) -> Optional[str]: """读取 DB 第 1 页前 16 字节 salt。""" try: with open(db_path, "rb") as f: page1 = f.read(PAGE_SIZE) if len(page1) < SALT_SIZE: return None return page1[:SALT_SIZE].hex() except OSError: return None # ------------------------------------------------------------------ # 解密缓存 # ------------------------------------------------------------------ def _cleanup_temp_files(self) -> None: """进程退出时清理临时解密文件(如果有)。""" # 持久化缓存保留,不删除 pass def _decrypted_path_for(self, rel_path: str, wxid: Optional[str] = None) -> str: """计算解密缓存文件路径。""" if wxid is None: wxid = self._get_wxid_from_storage() or "unknown" # rel_path 本身通常以 .db 结尾(如 contact/contact.db),直接保留即可 safe_rel = rel_path.replace(os.sep, "_").replace("/", "_") cache_dir = os.path.join(self.decrypted_dir, wxid) os.makedirs(cache_dir, exist_ok=True) return os.path.join(cache_dir, safe_rel) def _is_plain_db(self, db_path: str) -> bool: """DB 是否为明文 SQLite。""" try: with open(db_path, "rb") as f: header = f.read(16) return header == b"SQLite format 3\x00" except OSError: return False def _ensure_decrypted(self, rel_path: str) -> str: """确保指定相对路径的 DB 已解密到缓存,返回明文 DB 绝对路径。 流程: 1. 定位原 DB 2. 明文 DB 直接返回原路径 3. 读取 salt,查找对应 key 4. 检查解密缓存是否有效(mtime + key 一致) 5. 无效则重新解密,并合并 WAL """ db_path = self._get_db_abs_path(rel_path) if db_path is None: raise BridgeError( code="DB_NOT_FOUND", message=f"未找到微信 DB: {rel_path}", ) if not os.path.isfile(db_path): raise BridgeError( code="DB_NOT_FOUND", message=f"DB 文件不存在: {rel_path}", ) # 明文直接返回 if self._is_plain_db(db_path): return db_path # 加密 DB:需要 key salt_hex = self._read_db_salt(db_path) if not salt_hex: raise BridgeError( code="DB_ENCRYPTED", message=f"无法读取 DB salt: {rel_path}", ) key_hex = self._get_effective_key(salt_hex) if not key_hex: raise BridgeError( code="DB_NEED_INIT", message="DB 已加密且没有可用密钥,请先调用 /api/db/init", ) wxid = self._get_wxid_from_storage() decrypted_path = self._decrypted_path_for(rel_path, wxid) wal_path = db_path + "-wal" try: db_mtime = os.path.getmtime(db_path) wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0 except OSError as e: raise BridgeError( code="DB_NOT_FOUND", message=f"无法获取 DB mtime: {e}", ) # 检查内存缓存索引 cached = self._decrypted_cache.get(rel_path) if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]): return cached[2] with self._lock: # 双重检查 cached = self._decrypted_cache.get(rel_path) if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]): return cached[2] # 解密 decryptor = Decryptor(key_hex) result = decryptor.decrypt_db(db_path, decrypted_path) if not result.get("success"): error = result.get("error", "unknown") if os.path.exists(decrypted_path): os.remove(decrypted_path) # 默认 key 解密失败时标记 salt,避免后续重复尝试 if key_hex == self.key_cache.get_default_key() and salt_hex: self._mark_default_key_mismatch(salt_hex) raise BridgeError( code="DB_ENCRYPTED", message=f"解密 {rel_path} 失败: {error}", ) # 合并 WAL if os.path.exists(wal_path): try: decryptor.decrypt_wal(wal_path, decrypted_path) except Exception: # WAL 合并失败不影响主 DB 读取,只是可能缺最新数据 pass os.chmod(decrypted_path, 0o644) self._decrypted_cache[rel_path] = (db_mtime, wal_mtime, decrypted_path) return decrypted_path def invalidate_decrypted_cache(self) -> None: """清空解密缓存索引(不删除文件,下次按 mtime 重新判断)。""" with self._lock: self._decrypted_cache.clear() # ------------------------------------------------------------------ # DB 可达性状态 # ------------------------------------------------------------------ def check_db_status(self) -> str: """检测 DB 状态。 Returns: "ok": 有 DB 且可直接/解密读取 "not_found": 未找到 DB "need_init": DB 加密但没有密钥文件/内存 key "key_invalid": 有密钥但验证失败(极少见) """ storage = self._find_db_storage_dir() if storage is None: return "not_found" # 找一个需要读取的 DB 探针(contact.db 或 message_0.db) probe_rel_paths = ["contact/contact.db", "message/message_0.db"] probe_path = None for rel in probe_rel_paths: p = os.path.join(storage, rel) if os.path.isfile(p): probe_path = p break if probe_path is None: # 目录存在但找不到探针文件,可能 DB 还没完全生成 return "not_found" if self._is_plain_db(probe_path): return "ok" if not self.has_keys(): return "need_init" # 有 keys,快速验证是否能解密 salt_hex = self._read_db_salt(probe_path) if salt_hex and self._get_effective_key(salt_hex): return "ok" return "key_invalid" def is_db_accessible(self) -> bool: """DB 是否可读。""" return self.check_db_status() == "ok" def get_db_mtime(self, rel_path: str) -> Optional[tuple[float, float]]: """返回指定 DB 及其 WAL 文件的 mtime,用于增量轮询感知变化。 Args: rel_path: 相对路径,如 message/message_0.db Returns: (db_mtime, wal_mtime):DB 不存在时返回 None; WAL 不存在时 wal_mtime 为 0.0;mtime 读取失败记为 0.0。 """ db_path = self._get_db_abs_path(rel_path) if db_path is None: return None try: db_mtime = os.path.getmtime(db_path) except OSError: db_mtime = 0.0 wal_path = db_path + "-wal" try: wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0 except OSError: wal_mtime = 0.0 return db_mtime, wal_mtime # ------------------------------------------------------------------ # 辅助:表/列探测 # ------------------------------------------------------------------ @staticmethod def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: """用 PRAGMA table_info 取指定表的所有列名。""" try: cur = conn.execute(f"PRAGMA table_info({table})") return [str(row[1]) for row in cur.fetchall() if row[1]] except sqlite3.Error: return [] @staticmethod def _pick_column(columns: list[str], candidates: list[str]) -> Optional[str]: """从候选列名中返回第一个匹配(不区分大小写)。""" lower = {c.lower(): c for c in columns} for cand in candidates: if cand.lower() in lower: return lower[cand.lower()] return None @staticmethod def _find_contact_table(conn: sqlite3.Connection) -> Optional[str]: """探测联系人表名。""" try: cur = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) all_tables = {row[0] for row in cur.fetchall() if row[0]} except sqlite3.Error: return None for cand in ("contact", "contacts", "rcontact"): if cand in all_tables: return cand for name in all_tables: if "contact" in name.lower(): return name return None @staticmethod def _find_table_by_name(conn: sqlite3.Connection, candidates: list[str]) -> Optional[str]: """按候选名查找表。""" try: cur = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) all_tables = {row[0] for row in cur.fetchall() if row[0]} except sqlite3.Error: return None for cand in candidates: if cand in all_tables: return cand return None # ------------------------------------------------------------------ # 当前账号信息 # ------------------------------------------------------------------ def get_self_info(self) -> dict: """读取当前登录账号的 wxid 与 nickname。""" result = {"wxid": "", "nickname": ""} storage = self._find_db_storage_dir() if storage is None: return result wxid = self._get_wxid_from_storage(storage) result["wxid"] = wxid or "" try: db_path = self._ensure_decrypted("contact/contact.db") conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_contact_table(conn) if table is None: return result cols = self._table_columns(conn, table) username_col = self._pick_column(cols, ["username", "wxid"]) nickname_col = self._pick_column(cols, ["nickname", "nick_name"]) if username_col is None or not wxid: return result cur = conn.execute( f"SELECT {nickname_col or 'NULL'} AS nickname FROM {table} " f"WHERE {username_col} = ? LIMIT 1", (wxid,), ) row = cur.fetchone() if row: result["nickname"] = str(row["nickname"] or "") finally: conn.close() except Exception: pass return result # ------------------------------------------------------------------ # 消息查询 # ------------------------------------------------------------------ def _row_to_message(self, row: sqlite3.Row) -> dict: """将 SQL 行映射为 Message dict。""" msg_id = str(row["msg_id"]) talker = row["talker"] or "" is_sender = bool(row["is_sender"]) msg_type = int(row["type"]) if row["type"] is not None else 0 content = row["content"] or "" create_time = int(row["create_time"]) if row["create_time"] is not None else 0 render_type = _TYPE_TO_RENDER_TYPE.get(msg_type, "text") session_type = "group" if talker.endswith("@chatroom") else "p2p" sender = "" if session_type == "group" and is_sender is False and "\n" in content: head, _, _ = content.partition("\n") if head.endswith(":"): sender = head[:-1] content = content.split("\n", 1)[1] return { "msg_id": msg_id, "talker": talker, "sender": sender, "is_sender": is_sender, "type": msg_type, "render_type": render_type, "content": content, "create_time": create_time, "session_type": session_type, } def _load_name2id(self, conn: sqlite3.Connection) -> dict[int, str]: """加载 Name2Id 表,返回 {rowid: user_name} 映射。""" id_to_username: dict[int, str] = {} try: for rowid, user_name in conn.execute( "SELECT rowid, user_name FROM Name2Id" ).fetchall(): if user_name: id_to_username[rowid] = user_name except sqlite3.Error: pass return id_to_username def _resolve_self_rowid( self, conn: sqlite3.Connection, self_wxid: str ) -> Optional[int]: """返回 self_wxid 在 Name2Id 表中的 rowid,用于 SQL 层按发送方向过滤。 找不到时返回 None;调用方据此决定是否短路返回空结果 (is_sender 非 None 但无法定位本人时无法准确过滤方向)。 """ if not self_wxid: return None try: row = conn.execute( "SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", (self_wxid,), ).fetchone() if row is not None: return int(row[0]) except sqlite3.Error: pass return None @staticmethod def _build_sender_filter( is_sender: Optional[bool], self_rowid: Optional[int], ) -> tuple[str, list]: """构造发送方向 SQL 过滤片段。 Args: is_sender: True=仅本人发送 / False=仅对方发送 / None=不过滤 self_rowid: 当前账号在 Name2Id 中的 rowid Returns: (sql_fragment, params):sql_fragment 为 "" 表示无需过滤; 否则形如 "real_sender_id = ?" 或 "real_sender_id != ?" """ if is_sender is None or self_rowid is None: return "", [] op = "=" if is_sender else "!=" return f"real_sender_id {op} ?", [self_rowid] def _resolve_msg_table_talkers(self, conn: sqlite3.Connection) -> dict[str, str]: """解析所有 Msg_* 分片表名到 talker username 的映射。 WeChat 4.x 用 Msg_ 作为每个会话的消息分片表名。 遍历 Name2Id 中的 user_name,计算 MD5 匹配实际存在的表名。 """ tables = [ r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" ).fetchall() ] if not tables: return {} table_set = set(tables) table_to_talker: dict[str, str] = {} try: for (user_name,) in conn.execute( "SELECT user_name FROM Name2Id" ).fetchall(): if not user_name: continue table_name = "Msg_" + hashlib.md5(user_name.encode()).hexdigest() if table_name in table_set and table_name not in table_to_talker: table_to_talker[table_name] = user_name except sqlite3.Error: pass for t in tables: if t not in table_to_talker: table_to_talker[t] = t return table_to_talker @staticmethod def _decompress_msg_content(content, ct_flag) -> str: """解压消息内容。ct_flag=4 表示 zstd 压缩。""" if content is None: return "" if ct_flag == 4 and isinstance(content, (bytes, bytearray)): try: import zstandard return ( zstandard.ZstdDecompressor() .decompress(content) .decode("utf-8", errors="replace") ) except Exception: return "" if isinstance(content, (bytes, bytearray)): try: return content.decode("utf-8", errors="replace") except Exception: return "" return str(content) def _row_to_message_dict( self, row: sqlite3.Row, talker: str, id_to_username: dict[int, str], self_wxid: str, ) -> dict: """把 Msg_* 表的 SQL 行映射为 Message dict(统一三处调用方逻辑)。 处理:zstd/GBK 解压、real_sender_id → is_sender、群消息首行 sender 剥离、 render_type 映射。 """ content = self._decompress_msg_content( row["message_content"], row["WCDB_CT_message_content"] ) sender_username = id_to_username.get(row["real_sender_id"], "") is_sender = bool(sender_username) and sender_username == self_wxid msg_type = int(row["local_type"]) if row["local_type"] is not None else 0 create_time = int(row["create_time"]) if row["create_time"] is not None else 0 sender = "" session_type = "group" if talker.endswith("@chatroom") else "p2p" if session_type == "group" and not is_sender and "\n" in content: head, sep, rest = content.partition("\n") if head.endswith(":"): sender = head[:-1] content = rest render_type = _TYPE_TO_RENDER_TYPE.get(msg_type & 0xFFFFFFFF, "text") return { "msg_id": str(row["local_id"]), "talker": talker, "sender": sender, "is_sender": is_sender, "type": msg_type, "render_type": render_type, "content": content, "create_time": create_time, "session_type": session_type, } def get_messages_since( self, cursor: int, limit: int = 50, is_sender: Optional[bool] = None, ) -> dict: """读取 create_time > cursor 的增量消息。 WeChat 4.x 消息存储在 Msg_ 分片表中,每个会话一张表。 遍历所有分片表合并结果后按 create_time 排序返回。 Args: cursor: 上次拉取的最大 create_time limit: 最多返回条数 is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)。SQL 层通过 real_sender_id 与 Name2Id 中 self_wxid 的 rowid 比对实现,避免读出再裁剪。 当 is_sender 非 None 但无法定位本人 rowid 时返回空结果。 """ db_path = self._ensure_decrypted("message/message_0.db") conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table_to_talker = self._resolve_msg_table_talkers(conn) if not table_to_talker: raise BridgeError( code="DB_NOT_FOUND", message="未找到 Msg_* 消息分片表", ) id_to_username = self._load_name2id(conn) self_wxid = self._get_current_wxid() or "" # 发送方向过滤:无法定位本人 rowid 时短路返回空 sender_sql: str = "" sender_params: list = [] if is_sender is not None: self_rowid = self._resolve_self_rowid(conn, self_wxid) sender_sql, sender_params = self._build_sender_filter( is_sender, self_rowid ) if not sender_sql: return { "messages": [], "next_cursor": cursor, "has_more": False, } all_rows: list[dict] = [] for table_name, talker in table_to_talker.items(): where_parts = ["create_time > ?"] params: list = [cursor] if sender_sql: where_parts.append(sender_sql) params.extend(sender_params) sql = ( f"SELECT local_id, local_type, create_time, real_sender_id, " f"message_content, WCDB_CT_message_content " f"FROM [{table_name}] WHERE {' AND '.join(where_parts)} " f"ORDER BY create_time ASC LIMIT ?" ) params.append(limit) cur = conn.execute(sql, tuple(params)) for row in cur.fetchall(): all_rows.append(self._row_to_message_dict( row, talker, id_to_username, self_wxid )) all_rows.sort(key=lambda r: r["create_time"]) messages = all_rows[:limit] finally: conn.close() next_cursor = messages[-1]["create_time"] if messages else cursor has_more = len(messages) >= limit return { "messages": messages, "next_cursor": next_cursor, "has_more": has_more, } def get_messages_by_session( self, talker: str, cursor: int = 0, limit: int = 50, direction: str = "before", is_sender: Optional[bool] = None, ) -> dict: """按会话拉取历史消息(单会话,O(1) 定位分片表)。 通过 talker 计算 Msg_ 表名,直接查单表,避免遍历所有分片。 支持向前翻页(before:取 create_time < cursor 的旧消息)和向后翻页 (after:取 create_time > cursor 的新消息)。 Args: talker: 会话对方 wxid(群消息为 chatroom id) cursor: 游标,首次传 0;before 模式取该时间之前的旧消息, after 模式取该时间之后的新消息 limit: 最多返回条数,1~200 direction: before(默认,往前翻历史)/ after(往后拉新消息) is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)。无法定位本人 rowid 时返回空结果。 Returns: {"messages": [...], "next_cursor": int, "has_more": bool, "talker": str} before 模式 next_cursor 为最旧消息的 create_time; after 模式 next_cursor 为最新消息的 create_time """ # clamping 防止 limit 越界(-1 在 SQLite 中表示无限制,有性能风险) limit = max(1, min(limit, 200)) db_path = self._ensure_decrypted("message/message_0.db") conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table_name = "Msg_" + hashlib.md5(talker.encode()).hexdigest() # 探测表是否存在,不存在返回空 row = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,), ).fetchone() if row is None: return { "messages": [], "next_cursor": cursor, "has_more": False, "talker": talker, } id_to_username = self._load_name2id(conn) self_wxid = self._get_current_wxid() or "" # 发送方向过滤:无法定位本人 rowid 时短路返回空 sender_sql: str = "" sender_params: list = [] if is_sender is not None: self_rowid = self._resolve_self_rowid(conn, self_wxid) sender_sql, sender_params = self._build_sender_filter( is_sender, self_rowid ) if not sender_sql: return { "messages": [], "next_cursor": cursor, "has_more": False, "talker": talker, } # 收集 WHERE 片段:时间过滤 + 发送方向过滤 where_parts: list[str] = [] time_params: list = [] if direction == "after": where_parts.append("create_time > ?") time_params.append(cursor) order_sql = "create_time ASC" else: # before 模式:cursor=0 时不加时间过滤(取最新 limit 条) if cursor > 0: where_parts.append("create_time < ?") time_params.append(cursor) order_sql = "create_time DESC" if sender_sql: where_parts.append(sender_sql) where_clause = " AND ".join(where_parts) if where_parts else "1=1" sql = ( f"SELECT local_id, local_type, create_time, real_sender_id, " f"message_content, WCDB_CT_message_content " f"FROM [{table_name}] WHERE {where_clause} " f"ORDER BY {order_sql} LIMIT ?" ) params: tuple = (*time_params, *sender_params, limit) cur = conn.execute(sql, params) rows = cur.fetchall() messages = [ self._row_to_message_dict(row, talker, id_to_username, self_wxid) for row in rows ] # before 模式取的是 DESC,返回时反转为升序便于客户端展示 if direction == "before": messages.reverse() finally: conn.close() if messages: if direction == "after": next_cursor = messages[-1]["create_time"] else: next_cursor = messages[0]["create_time"] else: next_cursor = cursor has_more = len(messages) >= limit return { "messages": messages, "next_cursor": next_cursor, "has_more": has_more, "talker": talker, } def search_messages( self, keyword: str, talker: Optional[str] = None, start_time: Optional[int] = None, end_time: Optional[int] = None, limit: int = 50, is_sender: Optional[bool] = None, ) -> dict: """按关键词搜索历史消息。 遍历所有 Msg_* 分片表(或指定 talker 的单表),对 message_content 做 LIKE 模糊匹配,合并后按 create_time 降序返回(最新匹配在前)。 Args: keyword: 搜索关键词(非空),对原始 message_content 列做子串匹配 talker: 可选,限定在指定会话内搜索 start_time: 可选,起始时间戳(含) end_time: 可选,结束时间戳(含) limit: 最多返回条数,1~200 is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)。无法定位本人 rowid 时返回空结果。 Returns: {"messages": [...], "total": int} total 为合并后返回的条数(受每表 LIMIT 截断,可能小于实际命中数) Notes: - 限制:LIKE 在 SQL 层对原始 message_content 列操作,无法搜索 zstd 压缩消息(WCDB_CT_message_content==4 时存储的是压缩 BLOB, LIKE 不匹配二进制)。压缩消息会被静默漏掉。 - 如需搜索压缩消息,需先 SELECT 候选行再在 Python 侧解压后过滤。 - keyword 中的 % 和 _ 会按 SQL 通配符解释,已做转义处理为字面量。 """ if not keyword: return {"messages": [], "total": 0} # clamping 防止 limit 越界 limit = max(1, min(limit, 200)) db_path = self._ensure_decrypted("message/message_0.db") conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: id_to_username = self._load_name2id(conn) self_wxid = self._get_current_wxid() or "" # 发送方向过滤:无法定位本人 rowid 时短路返回空 sender_sql: str = "" sender_params: list = [] if is_sender is not None: self_rowid = self._resolve_self_rowid(conn, self_wxid) sender_sql, sender_params = self._build_sender_filter( is_sender, self_rowid ) if not sender_sql: return {"messages": [], "total": 0} # 构建 WHERE 子句(LIKE 转义 % _ \ 为字面量) escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") where_clauses = ["message_content LIKE ? ESCAPE '\\'"] params: list = [f"%{escaped}%"] if start_time is not None: where_clauses.append("create_time >= ?") params.append(start_time) if end_time is not None: where_clauses.append("create_time <= ?") params.append(end_time) if sender_sql: where_clauses.append(sender_sql) params.extend(sender_params) where_sql = " AND ".join(where_clauses) # 确定要搜索的表集合 if talker: table_name = "Msg_" + hashlib.md5(talker.encode()).hexdigest() row = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,), ).fetchone() if row is None: return {"messages": [], "total": 0} tables_to_search = {table_name: talker} else: tables_to_search = self._resolve_msg_table_talkers(conn) if not tables_to_search: return {"messages": [], "total": 0} all_rows: list[dict] = [] for table_name, tk in tables_to_search.items(): try: sql = ( f"SELECT local_id, local_type, create_time, real_sender_id, " f"message_content, WCDB_CT_message_content " f"FROM [{table_name}] WHERE {where_sql} " f"ORDER BY create_time DESC LIMIT ?" ) cur = conn.execute(sql, (*params, limit)) for row in cur.fetchall(): all_rows.append(self._row_to_message_dict( row, tk, id_to_username, self_wxid )) except sqlite3.Error: continue finally: conn.close() # 合并后按 create_time 降序,取前 limit 条 all_rows.sort(key=lambda r: r["create_time"], reverse=True) total = len(all_rows) messages = all_rows[:limit] return {"messages": messages, "total": total} def get_max_create_time(self) -> Optional[int]: """返回所有 Msg_* 分片表中最大的 create_time,用于初始化推送游标。 DB 不可读或无消息表时返回 None。供 MessageStreamer 在启动时 把全局游标对齐到当前最新消息,避免向新连接推送全量历史。 """ try: db_path = self._ensure_decrypted("message/message_0.db") except BridgeError: return None conn = sqlite3.connect(db_path, isolation_level=None) try: table_to_talker = self._resolve_msg_table_talkers(conn) if not table_to_talker: return None max_ct: Optional[int] = None for table_name in table_to_talker: try: row = conn.execute( f"SELECT MAX(create_time) FROM [{table_name}]" ).fetchone() if row and row[0] is not None: ct = int(row[0]) if max_ct is None or ct > max_ct: max_ct = ct except sqlite3.Error: continue return max_ct except Exception: return None finally: conn.close() # ------------------------------------------------------------------ # 朋友圈(Moments)DB 探测与读取 # ------------------------------------------------------------------ # WeChat 4.x Linux 朋友圈数据存放路径与表结构官方未公开,以下实现基于 # 常见命名做容错探测:优先查找 moment/sns 相关 DB 文件,再按候选表名/列名探测。 _MOMENT_DB_CANDIDATES = ( "moment/moment_0.db", "sns/sns_0.db", "moment/moment.db", "sns/sns.db", ) _MOMENT_TABLE_CANDIDATES = ( "Moment", "MomentInfo", "SNS", "SnsInfo", "moment", "moment_info", "sns_info", ) def _find_moment_db_path(self) -> Optional[str]: """探测朋友圈 DB 文件路径。 依次尝试 _MOMENT_DB_CANDIDATES 中的相对路径,命中即返回; 全部不存在时按 basename 在 storage 下递归查找 moment_0.db / sns_0.db。 """ for rel in self._MOMENT_DB_CANDIDATES: path = self._get_db_abs_path(rel) if path and os.path.isfile(path): return path # fallback:递归查找 storage = self._find_db_storage_dir() if storage is None: return None for target in ("moment_0.db", "sns_0.db", "moment.db", "sns.db"): for root, _, files in os.walk(storage): if target in files: return os.path.join(root, target) return None def _find_moment_table(self, conn: sqlite3.Connection) -> Optional[str]: """探测朋友圈表名。""" try: cur = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) all_tables = {row[0] for row in cur.fetchall() if row[0]} except sqlite3.Error: return None for cand in self._MOMENT_TABLE_CANDIDATES: if cand in all_tables: return cand # 模糊匹配含 moment / sns 的表名(收紧匹配避免误匹配短子串) for name in all_tables: low = name.lower() if low.startswith("moment") or low.startswith("sns"): return name return None def get_moments_timeline( self, cursor: int = 0, limit: int = 20, ) -> dict: """读取朋友圈时间线。 WeChat 4.x 朋友圈 schema 未公开,本方法做容错探测: 1. 探测 moment/sns DB 文件 2. 探测朋友圈表名(Moment / SNS / MomentInfo 等) 3. 探测列名(content / text / description / create_time / create_time_sec 等) 4. 按 create_time 降序返回 Args: cursor: 游标,首次传 0;取 create_time < cursor 的更旧朋友圈 limit: 最多返回条数,1~50 Returns: {"moments": [...], "next_cursor": int, "has_more": bool} 单条 moment dict 字段:moment_id / content / create_time / author_wxid / extra(原始列名→值的字典,供前端兜底展示) Raises: BridgeError(DB_NOT_FOUND): 未找到朋友圈 DB 文件 BridgeError(MOMENT_TABLE_NOT_FOUND): DB 存在但无朋友圈表(不抛 异常,改返回空列表 + 可读的 status 字段;仅当 DB 完全不可读时抛 DB_NOT_FOUND) """ # clamping 防止 limit 越界 limit = max(1, min(limit, 50)) moment_db = self._find_moment_db_path() if moment_db is None: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "db_not_found", } # 复用解密缓存机制:把绝对路径转成相对路径调用 _ensure_decrypted storage = self._find_db_storage_dir() if storage is None: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "storage_not_found", } try: rel_path = os.path.relpath(moment_db, storage) except ValueError: rel_path = os.path.basename(moment_db) # 规范为 unix 风格相对路径 rel_path = rel_path.replace(os.sep, "/") try: db_path = self._ensure_decrypted(rel_path) except BridgeError: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "db_encrypted", } conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_moment_table(conn) if table is None: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "table_not_found", } cols = self._table_columns(conn, table) if not cols: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "no_columns", } # 探测关键列名 id_col = self._pick_column(cols, [ "id", "moment_id", "sns_id", "rowid", ]) or "rowid" content_col = self._pick_column(cols, [ "content", "text", "description", "moment_content", ]) time_col = self._pick_column(cols, [ "create_time", "create_time_sec", "ctime", "post_time", "time", ]) author_col = self._pick_column(cols, [ "author", "wxid", "username", "user_name", "poster", ]) if time_col is None: return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "no_time_column", } # 构造 SELECT select_parts = [f"{id_col} AS moment_id"] if content_col: select_parts.append(f"{content_col} AS content") if author_col: select_parts.append(f"{author_col} AS author_wxid") select_parts.append(f"{time_col} AS create_time") select_sql = ", ".join(select_parts) if cursor > 0: sql = ( f"SELECT {select_sql} FROM [{table}] " f"WHERE {time_col} < ? ORDER BY {time_col} DESC LIMIT ?" ) params: tuple = (cursor, limit) else: sql = ( f"SELECT {select_sql} FROM [{table}] " f"ORDER BY {time_col} DESC LIMIT ?" ) params = (limit,) cur = conn.execute(sql, params) moments: list[dict] = [] for row in cur.fetchall(): ct = int(row["create_time"]) if row["create_time"] is not None else 0 moments.append({ "moment_id": str(row["moment_id"]), "content": str(row["content"] or "") if content_col else "", "create_time": ct, "author_wxid": str(row["author_wxid"] or "") if author_col else "", }) except sqlite3.Error as e: # 对外只返回通用错误码,详细异常记日志避免信息泄漏 logger.warning("get_moments_timeline 查询失败: %s", e) return { "moments": [], "next_cursor": cursor, "has_more": False, "status": "query_error", } finally: conn.close() next_cursor = moments[-1]["create_time"] if moments else cursor has_more = len(moments) >= limit return { "moments": moments, "next_cursor": next_cursor, "has_more": has_more, "status": "ok", } # ------------------------------------------------------------------ # 联系人字段映射(公共) # ------------------------------------------------------------------ def _pick_contact_columns(self, cols: list[str]) -> dict[str, Optional[str]]: """根据实际表列名,挑选 contact 表各字段对应的列名。""" return { "username": self._pick_column(cols, ["username", "wxid"]), "nickname": self._pick_column(cols, ["nickname", "nick_name"]), "remark": self._pick_column(cols, ["remark", "conRemark"]), "alias": self._pick_column(cols, ["alias"]), "encrypt_username": self._pick_column(cols, ["encrypt_username", "encryptUsername"]), "quan_pin": self._pick_column(cols, ["quan_pin", "quanPin"]), "pin_yin_initial": self._pick_column(cols, ["pin_yin_initial", "pinYinInitial"]), "big_head_url": self._pick_column(cols, ["big_head_url", "bigHeadImgUrl"]), "small_head_url": self._pick_column(cols, ["small_head_url", "smallHeadImgUrl", "head_img_url"]), "description": self._pick_column(cols, ["description", "signature"]), "local_type": self._pick_column(cols, ["local_type", "localType"]), "verify_flag": self._pick_column(cols, ["verify_flag", "verifyFlag"]), "delete_flag": self._pick_column(cols, ["delete_flag", "deleteFlag"]), "chat_room_type": self._pick_column(cols, ["chat_room_type", "chatRoomType"]), } @staticmethod def _avatar_url_from_row(row: sqlite3.Row, wxid: str) -> str: """优先使用 DB 里的头像 URL,没有则回退到本地 avatar 接口。""" for col in ("small_head_url", "big_head_url", "avatar"): val = row[col] if col in row.keys() else None if val: return str(val) return f"/api/media/avatar/{wxid}" # 系统账号白名单(去掉 @stranger 等后缀后的 base 部分) _SYSTEM_WXIDS = frozenset({ "weixin", "weixinteam", "filehelper", "fmessage", "medianote", "floatbottle", "qmessage", "newsapp", "tmessage", "qmail", "brandsessionholder", "helper_entry", }) @staticmethod def _infer_contact_type( wxid: str, local_type: Optional[int], verify_flag: Optional[int], fallback: str = "person", ) -> str: """根据 wxid 前缀 / local_type / verify_flag 推断联系人类型。 返回: person / official / system / group - 群聊(@chatroom 后缀)由调用方直接传 fallback="group" 标记 - 真人好友 → person - 公众号 / 服务号 → official - 系统账号(微信团队、文件传输助手等)→ system """ if not wxid: return fallback # 公众号 / 服务号前缀 if wxid.startswith("gh_"): return "official" # 系统账号白名单(忽略 @stranger 等后缀) base = wxid.split("@", 1)[0] if base in DbReader._SYSTEM_WXIDS: return "system" # local_type 判断(WeChat 4.x 常见值) if local_type is not None: if local_type == 2: # 公众号 return "official" if local_type == 512: # 系统账号 return "system" # verify_flag 判断(0x08 = 已认证的公众号/品牌号) if verify_flag is not None and (verify_flag & 0x08): return "official" return fallback def _row_to_contact(self, row: sqlite3.Row, contact_type: str = "friend") -> dict: """把 sqlite3.Row 转成 Contact 兼容字典。""" wxid = str(row["username"] or "") def _val(col: str) -> Optional[str]: if col not in row.keys(): return None v = row[col] if v is None: return None return str(v) if not isinstance(v, bytes) else None def _int_val(col: str) -> Optional[int]: if col not in row.keys(): return None v = row[col] if v is None: return None try: return int(v) except (TypeError, ValueError): return None # 群聊直接标记为 group;其他按字段推断 person/official/system if contact_type == "group": inferred_type = "group" else: inferred_type = self._infer_contact_type( wxid, _int_val("local_type"), _int_val("verify_flag"), fallback="person", ) return { "wxid": wxid, "nickname": _val("nickname") or "", "remark": _val("remark") or "", "avatar_url": self._avatar_url_from_row(row, wxid), "type": inferred_type, "alias": _val("alias"), "encrypt_username": _val("encrypt_username"), "quan_pin": _val("quan_pin"), "pin_yin_initial": _val("pin_yin_initial"), "big_head_url": _val("big_head_url"), "small_head_url": _val("small_head_url"), "description": _val("description"), "local_type": _int_val("local_type"), "verify_flag": _int_val("verify_flag"), "delete_flag": _int_val("delete_flag"), "chat_room_type": _int_val("chat_room_type"), } def _select_contact_columns(self, col_map: dict[str, Optional[str]]) -> str: """生成 SELECT 子句,只选实际存在的列。""" selects = [f"{col_map['username']} AS username"] for alias, col in col_map.items(): if alias == "username" or col is None: continue selects.append(f"{col} AS {alias}") return ", ".join(selects) def get_contacts(self, keyword: str = "", limit: int = 50) -> dict: """联系人查询。""" empty = {"contacts": [], "total": 0} try: db_path = self._ensure_decrypted("contact/contact.db") except BridgeError: return empty conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_contact_table(conn) if table is None: return empty cols = self._table_columns(conn, table) col_map = self._pick_contact_columns(cols) username_col = col_map["username"] nickname_col = col_map["nickname"] remark_col = col_map["remark"] if username_col is None: return empty select_sql = self._select_contact_columns(col_map) if keyword: escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") like = f"%{escaped}%" where_clauses = [f"{username_col} LIKE ? ESCAPE '\\'"] params: list = [like] if nickname_col: where_clauses.append(f"{nickname_col} LIKE ? ESCAPE '\\'") params.append(like) if remark_col: where_clauses.append(f"{remark_col} LIKE ? ESCAPE '\\'") params.append(like) where_sql = " OR ".join(where_clauses) sql = ( f"SELECT {select_sql} " f"FROM {table} WHERE ({where_sql}) " f"AND {username_col} NOT LIKE '%@chatroom' " f"LIMIT ?" ) params.append(limit) cur = conn.execute(sql, params) else: sql = ( f"SELECT {select_sql} " f"FROM {table} " f"WHERE {username_col} NOT LIKE '%@chatroom' " f"LIMIT ?" ) cur = conn.execute(sql, (limit,)) contacts = [self._row_to_contact(row, contact_type="friend") for row in cur.fetchall()] return {"contacts": contacts, "total": len(contacts)} except sqlite3.Error: return empty finally: conn.close() def get_contact_detail(self, wxid: str) -> Optional[dict]: """单条联系人查询。""" try: db_path = self._ensure_decrypted("contact/contact.db") except BridgeError: return None conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_contact_table(conn) if table is None: return None cols = self._table_columns(conn, table) col_map = self._pick_contact_columns(cols) username_col = col_map["username"] if username_col is None: return None select_sql = self._select_contact_columns(col_map) sql = f"SELECT {select_sql} FROM {table} WHERE {username_col} = ? LIMIT 1" cur = conn.execute(sql, (wxid,)) row = cur.fetchone() if row is None: return None return self._row_to_contact(row, contact_type="friend") except sqlite3.Error: return None finally: conn.close() # ------------------------------------------------------------------ # 群聊与群成员查询 # ------------------------------------------------------------------ def get_groups(self, limit: int = 50) -> dict: """查询群聊列表。""" empty = {"groups": [], "total": 0} try: db_path = self._ensure_decrypted("contact/contact.db") except BridgeError: return empty conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_contact_table(conn) if table is None: return empty cols = self._table_columns(conn, table) col_map = self._pick_contact_columns(cols) username_col = col_map["username"] if username_col is None: return empty select_sql = self._select_contact_columns(col_map) sql = ( f"SELECT {select_sql} " f"FROM {table} WHERE {username_col} LIKE '%@chatroom' LIMIT ?" ) cur = conn.execute(sql, (limit,)) groups = [self._row_to_contact(row, contact_type="group") for row in cur.fetchall()] return {"groups": groups, "total": len(groups)} except sqlite3.Error: return empty finally: conn.close() def get_group_members(self, group_wxid: str) -> dict: """查询群成员。""" empty = {"group_wxid": group_wxid, "members": [], "total": 0} try: db_path = self._ensure_decrypted("contact/contact.db") except BridgeError: return empty conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: table = self._find_table_by_name( conn, ["chatroom_member", "chatroom_members", "group_member", "group_members"], ) if table is None: return empty cols = self._table_columns(conn, table) member_wxid_col = self._pick_column(cols, ["username", "wxid", "member_wxid"]) nickname_col = self._pick_column(cols, ["nickname", "nick_name"]) display_name_col = self._pick_column(cols, ["display_name", "displayName", "group_nickname"]) group_col = self._pick_column(cols, ["chatroom", "group_id", "chatroom_username", "room_id"]) admin_col = self._pick_column( cols, ["is_admin", "admin", "is_manager", "manager", "room_admin", "is_room_admin"], ) if member_wxid_col is None or group_col is None: return empty sql = ( f"SELECT {member_wxid_col} AS wxid, " f"{nickname_col or 'NULL'} AS nickname, " f"{display_name_col or 'NULL'} AS display_name, " f"{admin_col or 'NULL'} AS is_admin " f"FROM {table} WHERE {group_col} = ?" ) cur = conn.execute(sql, (group_wxid,)) owner_wxid = self._get_chatroom_owner(conn, group_wxid) members: list[dict] = [] for row in cur.fetchall(): wxid = str(row["wxid"] or "") raw_admin = row["is_admin"] if isinstance(raw_admin, str) and raw_admin.lower() in ("false", "0", "no", ""): raw_admin = False is_admin = bool(raw_admin) or (bool(wxid) and wxid == owner_wxid) members.append({ "wxid": wxid, "nickname": str(row["nickname"] or ""), "display_name": str(row["display_name"] or ""), "is_admin": is_admin, }) return {"group_wxid": group_wxid, "members": members, "total": len(members)} except sqlite3.Error: return empty finally: conn.close() def _get_chatroom_owner(self, conn: sqlite3.Connection, group_wxid: str) -> Optional[str]: """从 chatroom 表查询群主 wxid。""" table = self._find_table_by_name(conn, ["chatroom", "chatrooms", "chat_room"]) if table is None: return None cols = self._table_columns(conn, table) owner_col = self._pick_column(cols, ["roomowner", "room_owner", "owner"]) if owner_col is None: return None group_col = self._pick_column( cols, ["chatroom", "chatroomname", "chatroom_username", "group_id", "username", "room_id"], ) if group_col is None: return None try: cur = conn.execute( f"SELECT {owner_col} AS owner FROM {table} " f"WHERE {group_col} = ? LIMIT 1", (group_wxid,), ) row = cur.fetchone() if row is None: return None return str(row["owner"] or "") or None except sqlite3.Error: return None # ------------------------------------------------------------------ # 媒体路径解析 # ------------------------------------------------------------------ def get_media_path(self, msg_id: str) -> Optional[str]: """根据 msg_id 解析媒体文件路径。""" info = self._get_media_info(msg_id) if info is None: return None return info.get("path") def get_media_bytes(self, msg_id: str) -> Optional[tuple[bytes, str]]: """读取媒体文件内容。""" path = self.get_media_path(msg_id) if path is None or not os.path.isfile(path) or not os.access(path, os.R_OK): return None try: with open(path, "rb") as f: data = f.read() except OSError: return None mime = self._guess_mime(path) return (data, mime) def _get_media_info(self, msg_id: str) -> Optional[dict]: """从 DB 查询单条消息的 type / content。""" try: db_path = self._ensure_decrypted("message/message_0.db") except BridgeError: return None conn = sqlite3.connect(db_path, isolation_level=None) conn.row_factory = sqlite3.Row try: cols = self._table_columns(conn, "message") if not cols: return None msg_id_col = self._pick_column(cols, ["msg_id"]) type_col = self._pick_column(cols, ["type"]) content_col = self._pick_column(cols, ["content"]) if not (msg_id_col and type_col and content_col): return None cur = conn.execute( f"SELECT {type_col} AS type, {content_col} AS content " f"FROM message WHERE {msg_id_col} = ? LIMIT 1", (msg_id,), ) row = cur.fetchone() if row is None: return None msg_type = int(row["type"]) if row["type"] is not None else 0 content = str(row["content"] or "") path = self._resolve_media_path(msg_id, msg_type, content) return {"type": msg_type, "content": content, "path": path} except sqlite3.Error: return None finally: conn.close() def _resolve_media_path(self, msg_id: str, msg_type: int, content: str) -> Optional[str]: """根据消息类型与内容启发式解析媒体文件路径。 优化点: - 媒体路径按 msg_id 缓存,避免每次 get_media 都 os.walk 全盘扫描 - 优先扫描已知媒体目录(attachment/message/file/image/video/voice), 避免遍历 db_storage 下的 .db 文件 - 移除 5000 文件硬上限(改为按目录优先级扫描,命中即返回) """ if msg_type not in (3, 34, 43, 49): return None # 命中缓存 if msg_id in self._media_path_cache: return self._media_path_cache[msg_id] result: Optional[str] = None db_root_abs = os.path.realpath(self.db_root) # 策略 1:从 content 提取路径 if content: matches = _re.findall(r"/[^\s\"'<>]+", content) for cand in matches: if not os.path.isfile(cand) or not os.access(cand, os.R_OK): continue cand_abs = os.path.realpath(cand) if cand_abs.startswith(db_root_abs + os.sep): result = cand break # 策略 2:在已知媒体目录下按 msg_id 前缀查找 if result is None and msg_id: storage = self._find_db_storage_dir() search_dirs: list[str] = [] if storage: # 媒体文件通常在 db_storage 同级或上层目录 parent = os.path.dirname(storage) for sub in ("attachment", "message", "file", "image", "video", "voice", "audio"): p = os.path.join(parent, sub) if os.path.isdir(p): search_dirs.append(p) # db_storage 本身也作为兜底 search_dirs.append(storage) if not search_dirs and os.path.isdir(self.db_root): search_dirs = [self.db_root] for search_dir in search_dirs: if result: break for dirpath, _, filenames in os.walk(search_dir): if result: break for name in filenames: if name.endswith(".db"): continue stem, _ = os.path.splitext(name) if stem == msg_id or stem.startswith(msg_id + "_") or stem.startswith(msg_id + "-"): full = os.path.join(dirpath, name) if os.access(full, os.R_OK): result = full break # 入缓存(含 None 结果,避免重复扫描未命中的 msg_id) if len(self._media_path_cache) >= self._media_path_cache_max: # FIFO 淘汰最旧 oldest = next(iter(self._media_path_cache)) del self._media_path_cache[oldest] self._media_path_cache[msg_id] = result return result @staticmethod def _guess_mime(path: str) -> str: """根据扩展名粗略猜测 MIME 类型。""" ext = os.path.splitext(path)[1].lower() return { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp", ".webp": "image/webp", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".amr": "audio/amr", ".mp4": "video/mp4", ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".zip": "application/zip", ".txt": "text/plain", }.get(ext, "application/octet-stream")