WechatOnCloud/bridge/woc_bridge/db/reader.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

2393 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""微信本地 DB 只读访问。
通过解密缓存避免锁定原 DB再用 sqlite3 读取消息。所有方法为同步阻塞操作,
在 server.py 中通过 asyncio.to_thread() 包装调用,避免阻塞 event loop。
架构升级bridge 1.3.0+
- 密钥统一由 KeyCache 管理single source of truthDbReader 不再自持密钥存储
- 密钥持久化、salt 映射、默认 key、已拒绝 salt 全部由 KeyCache 负责
- 解密后的明文 DB 缓存到 /config/woc-decrypted/<wxid>/,按 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. 以 <wxid>/db_storage 为根,通过相对路径访问各类 DB
2. 密钥统一由 key_cacheKeyCache管理DbReader 仅查询不存储
3. 解密缓存到 decrypted_dir/config/woc-decrypted/<wxid>/
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/<wxid>/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
# ------------------------------------------------------------------
# 密钥访问(全部转发到 KeyCacheDbReader 不再自持密钥存储)
# ------------------------------------------------------------------
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.0mtime 读取失败记为 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_<MD5(username)> 作为每个会话的消息分片表名。
遍历 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")
local_id = int(row["local_id"]) if row["local_id"] is not None else 0
return {
"msg_id": str(local_id),
"local_id": 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,
cursor_local_id: int = 0,
) -> dict:
"""读取 create_time > cursor 的增量消息。
WeChat 4.x 消息存储在 Msg_<MD5(talker)> 分片表中,每个会话一张表。
遍历所有分片表合并结果后按 create_time 排序返回。
Args:
cursor: 上次拉取的最大 create_time
limit: 最多返回条数
is_sender: 发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认。SQL 层通过 real_sender_id 与
Name2Id 中 self_wxid 的 rowid 比对实现,避免读出再裁剪。
当 is_sender 非 None 但无法定位本人 rowid 时返回空结果。
cursor_local_id: 配合 cursor 的 tie-breaker 游标local_id
用于避免同 create_time 的消息在分页时丢失。默认 0 表示
不使用 tie-breaker向后兼容旧客户端。下一页应同时传入
上次返回的 next_cursor 与 next_cursor_local_id。
Returns:
{"messages": [...], "next_cursor": int, "next_cursor_local_id": int,
"has_more": bool}
next_cursor 为本批最大 create_timenext_cursor_local_id 为对应
消息的 local_id无消息时回退为入参 cursor_local_id
"""
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:
# 微信未登录或尚无消息时Msg_* 分片表可能不存在;返回空列表
# 而不是抛 DB_NOT_FOUND(500),避免前端轮询进入错误重试循环。
return {
"messages": [],
"next_cursor": cursor,
"next_cursor_local_id": cursor_local_id,
"has_more": False,
}
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,
"next_cursor_local_id": cursor_local_id,
"has_more": False,
}
all_rows: list[dict] = []
for table_name, talker in table_to_talker.items():
# 复合游标 (create_time, local_id) 作为 tie-breaker
# 避免同 create_time 消息在分页边界被跳过
where_parts = [
"(create_time > ? OR (create_time = ? AND local_id > ?))"
]
params: list = [cursor, cursor, cursor_local_id]
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, local_id 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"], r["local_id"]))
messages = all_rows[:limit]
finally:
conn.close()
if messages:
next_cursor = messages[-1]["create_time"]
next_cursor_local_id = messages[-1].get("local_id", 0)
else:
next_cursor = cursor
next_cursor_local_id = cursor_local_id
has_more = len(messages) >= limit
return {
"messages": messages,
"next_cursor": next_cursor,
"next_cursor_local_id": int(next_cursor_local_id),
"has_more": has_more,
}
def get_messages_by_session(
self,
talker: str,
cursor: int = 0,
limit: int = 50,
direction: str = "before",
cursor_local_id: int = 0,
is_sender: Optional[bool] = None,
) -> dict:
"""按会话拉取历史消息单会话O(1) 定位分片表)。
通过 talker 计算 Msg_<MD5(talker)> 表名,直接查单表,避免遍历所有分片。
支持向前翻页before取 create_time < cursor 的旧消息)和向后翻页
after取 create_time > cursor 的新消息)。
Args:
talker: 会话对方 wxid群消息为 chatroom id
cursor: 游标,首次传 0before 模式取该时间之前的旧消息,
after 模式取该时间之后的新消息
limit: 最多返回条数1~200
direction: before默认往前翻历史/ after往后拉新消息
cursor_local_id: 配合 cursor 的 tie-breaker 游标local_id
用于避免同 create_time 的消息在分页时丢失(群聊同秒消息常见)。
默认 0 表示不使用 tie-breaker向后兼容旧客户端。下一页应
同时传入上次返回的 next_cursor 与 next_cursor_local_id。
before 模式 cursor=0 时本参数被忽略。
is_sender: 发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤(默认)。无法定位本人 rowid 时返回空结果。
Returns:
{"messages": [...], "next_cursor": int, "next_cursor_local_id": int,
"has_more": bool, "talker": str}
before 模式 next_cursor 为最旧消息的 create_time
next_cursor_local_id 为对应 local_id
after 模式 next_cursor 为最新消息的 create_time
next_cursor_local_id 为对应 local_id
"""
# 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,
"next_cursor_local_id": cursor_local_id,
"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,
"next_cursor_local_id": cursor_local_id,
"has_more": False,
"talker": talker,
}
# 收集 WHERE 片段:时间过滤 + 发送方向过滤
# 复合游标 (create_time, local_id) 作为 tie-breaker
# 避免同 create_time 消息在分页边界被跳过
where_parts: list[str] = []
time_params: list = []
if direction == "after":
where_parts.append(
"(create_time > ? OR (create_time = ? AND local_id > ?))"
)
time_params.extend([cursor, cursor, cursor_local_id])
order_sql = "create_time ASC, local_id ASC"
else:
# before 模式cursor=0 时不加时间过滤(取最新 limit 条)
if cursor > 0:
where_parts.append(
"(create_time < ? OR (create_time = ? AND local_id < ?))"
)
time_params.extend([cursor, cursor, cursor_local_id])
order_sql = "create_time DESC, local_id 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":
# after 模式:游标指向本批最新一条
next_cursor = messages[-1]["create_time"]
next_cursor_local_id = messages[-1].get("local_id", 0)
else:
# before 模式:游标指向本批最旧一条
next_cursor = messages[0]["create_time"]
next_cursor_local_id = messages[0].get("local_id", 0)
else:
next_cursor = cursor
next_cursor_local_id = cursor_local_id
has_more = len(messages) >= limit
return {
"messages": messages,
"next_cursor": next_cursor,
"next_cursor_local_id": int(next_cursor_local_id),
"has_more": has_more,
"talker": talker,
}
# ------------------------------------------------------------------
# 导出查询Task 18
# ------------------------------------------------------------------
def get_messages_for_export(
self,
talker: str,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
limit: int = 1000,
offset: int = 0,
) -> Optional[list[dict]]:
"""导出查询:分页拉取消息 + 媒体路径。
复用 get_messages_by_session 的 _ensure_decrypted + 复合游标逻辑,
但改用时间范围过滤 + LIMIT/OFFSET 分页,并附加 media_path 字段
供导出端点决定如何渲染媒体。
Args:
talker: 会话对方 wxid群消息为 chatroom id
start_time: 起始时间戳None 表示不限制
end_time: 结束时间戳None 表示不限制
limit: 本页最多返回条数(建议 200外层分页拉取
offset: 偏移量(按 create_time ASC, local_id ASC 排序)
Returns:
list[dict],每条含:
msg_id / local_id / create_time / local_type /
content / is_sender / render_type / sender / session_type /
media_path媒体消息才有文本消息为 None
DB 不可读时抛 BridgeError(DB_ENCRYPTED),不返回 None
Notes:
- 表不存在时返回空列表(会话可能从未有过消息)
- 媒体路径解析失败时 media_path=None由调用方决定写占位
- 不做 limit 边界 clamping由调用方控制导出可超 200
- P0 修复DB_ENCRYPTED 等错误直接抛出(不吞返回 None
让 with_db_retry 装饰器自动重试密钥提取
"""
try:
db_path = self._ensure_decrypted("message/message_0.db")
except BridgeError:
# P0 修复:不吞 BridgeError让 DB_ENCRYPTED 传播到调用方。
# 原实现吞掉异常返回 None导出端点收到 None 后抛 DB_NOT_FOUND(500)
# 客户端无法区分"DB 不存在"和"DB 加密但无密钥"。
raise
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 []
id_to_username = self._load_name2id(conn)
self_wxid = self._get_current_wxid() or ""
where_parts: list[str] = []
params: list = []
if start_time is not None:
where_parts.append("create_time >= ?")
params.append(int(start_time))
if end_time is not None:
where_parts.append("create_time <= ?")
params.append(int(end_time))
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 create_time ASC, local_id ASC "
f"LIMIT ? OFFSET ?"
)
params.append(int(limit))
params.append(int(offset))
cur = conn.execute(sql, tuple(params))
rows = cur.fetchall()
messages: list[dict] = []
for row in rows:
msg = self._row_to_message_dict(
row, talker, id_to_username, self_wxid
)
# 媒体路径解析:仅图片/语音/视频/文件类消息尝试
msg_type = msg.get("type", 0)
if msg_type in (3, 34, 43, 49):
try:
msg["media_path"] = self._resolve_media_path(
msg["msg_id"], msg_type, msg.get("content", "")
)
except Exception:
msg["media_path"] = None
else:
msg["media_path"] = None
messages.append(msg)
return messages
except sqlite3.Error as e:
logger.warning("get_messages_for_export 查询失败: %s", e)
return None
finally:
conn.close()
def get_contact_nickname(self, talker: str) -> Optional[str]:
"""获取联系人/群昵称(导出 HTML 头部用)。
优先级remark备注 > nickname昵称 > talkerwxid 兜底)。
DB 不可读或未找到时返回 None调用方回退到 talker 显示)。
"""
try:
contact = self.get_contact_detail(talker)
except Exception:
return None
if contact is None:
return None
return contact.get("remark") or contact.get("nickname") or None
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[tuple[int, int]]:
"""返回所有 Msg_* 分片表中最大的 (create_time, local_id),用于初始化推送游标。
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
max_local_id: int = 0
for table_name in table_to_talker:
try:
row = conn.execute(
f"SELECT create_time, local_id FROM [{table_name}] "
f"ORDER BY create_time DESC, local_id DESC LIMIT 1"
).fetchone()
if row and row[0] is not None:
ct = int(row[0])
lid = int(row[1]) if row[1] is not None else 0
if max_ct is None or ct > max_ct or (
ct == max_ct and lid > max_local_id
):
max_ct = ct
max_local_id = lid
except sqlite3.Error:
continue
if max_ct is None:
return None
return (max_ct, max_local_id)
except Exception:
return None
finally:
conn.close()
def get_friend_requests_since(
self, cursor_create_time: int, cursor_local_id: int = 0, limit: int = 50
) -> Optional[dict]:
"""查询 fmessage 会话中指定游标之后的好友申请消息。
优先查 Msg_<MD5("fmessage")> 单表;若 fmessage 表无数据,
则 fallback 到 contact.db 的 ticket_info / stranger 表读取待处理申请。
复合游标 (create_time, local_id) 作为 tie-breaker避免同秒消息丢失。
Args:
cursor_create_time: 上次处理到的 create_time0 = 从最新开始)
cursor_local_id: 同 create_time 下已处理的 local_idtie-breaker
limit: 单次读取上限
Returns:
{"requests": list[dict], "next_create_time": int, "next_local_id": int}
或 NoneDB 不可读)
Notes:
- _ensure_decrypted 可能抛 BridgeError(DB_ENCRYPTED / DB_NEED_INIT)
需 catch BridgeError 而非仅 sqlite3.Error
- WCDB_CT_message_content 列可能不存在(非 WCDB 表),用 PRAGMA 预探测
"""
empty = {
"requests": [],
"next_create_time": cursor_create_time,
"next_local_id": cursor_local_id,
}
try:
db_path = self._ensure_decrypted("message/message_0.db")
except BridgeError:
# DB 加密/无 key返回 None 让调用方知道 DB 不可读
return None
conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row
try:
# 计算 fmessage 分片表名
table_name = f"Msg_{hashlib.md5(b'fmessage').hexdigest()}"
# 验证表存在
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
if cur.fetchone() is None:
return empty
# 动态探测 WCDB_CT_message_content 列是否存在
cols = self._table_columns(conn, table_name)
has_ct_col = "WCDB_CT_message_content" in cols
ct_col = "WCDB_CT_message_content" if has_ct_col else "0 AS WCDB_CT_message_content"
# 复合游标查询(与 get_messages_since 一致的 tie-breaker
sql = (
f"SELECT local_id, create_time, message_content, {ct_col} "
f"FROM [{table_name}] "
f"WHERE (create_time > ? OR (create_time = ? AND local_id > ?)) "
f"ORDER BY create_time ASC, local_id ASC LIMIT ?"
)
rows = conn.execute(
sql, (cursor_create_time, cursor_create_time, cursor_local_id, limit)
).fetchall()
requests = []
next_ct = cursor_create_time
next_lid = cursor_local_id
for row in rows:
content = self._decompress_msg_content(
row["message_content"],
row["WCDB_CT_message_content"],
)
requests.append({
"local_id": row["local_id"],
"create_time": row["create_time"],
"content": content,
})
next_ct = row["create_time"]
next_lid = row["local_id"]
result = {
"requests": requests,
"next_create_time": next_ct,
"next_local_id": next_lid,
}
# fallbackfmessage 无新申请时,尝试从 contact.db 的 ticket_info 读取
if not requests and cursor_create_time == 0:
ticket_requests = self.get_pending_requests_from_ticket_info(limit)
if ticket_requests:
result["requests"] = ticket_requests
# ticket_info 无 create_time 时统一用 1 作为占位,确保 watcher 能处理
result["next_create_time"] = max(
(r["create_time"] for r in ticket_requests), default=1
)
result["next_local_id"] = max(
(r["local_id"] for r in ticket_requests), default=0
)
return result
except sqlite3.Error:
return empty
finally:
conn.close()
def get_pending_requests_from_ticket_info(
self, limit: int = 50, since_id: int = 0
) -> list[dict]:
"""从 contact.db 的 ticket_info / stranger 表读取待处理好友申请。
微信 4.x Linux 中fmessage 表可能不包含好友申请消息,
但 contact.db 的 ticket_info 表中会留存待验证的 ticket形如 wxid_xxx@stranger
本方法作为 get_friend_requests_since 的兜底数据源。
Args:
limit: 单次最大返回条数。
since_id: 只返回 id > since_id 的记录,用于跳过历史数据。
Returns:
[{"local_id": int, "create_time": int, "content": str}, ...]
"""
try:
db_path = self._ensure_decrypted("contact/contact.db")
except BridgeError:
return []
conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row
requests: list[dict] = []
try:
# 1. 尝试 ticket_info 表
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "ticket_info" in tables:
cols = self._table_columns(conn, "ticket_info")
ticket_col = self._pick_column(cols, ["ticket"])
id_col = self._pick_column(cols, ["id", "rowid"])
if ticket_col and id_col:
cur = conn.execute(
f"SELECT {id_col} AS local_id, {ticket_col} AS ticket "
f"FROM ticket_info WHERE {ticket_col} LIKE '%@%' "
f"AND {id_col} > ? "
f"LIMIT ?",
(since_id, limit),
)
for row in cur.fetchall():
ticket = row["ticket"] or ""
if "@" not in ticket:
continue
# 构造最小 sysmsg XML供 parse_friend_request 解析
xml = (
f'<sysmsg type="verifyUser">'
f'<Link><UserName>{ticket}</UserName></Link>'
f'</sysmsg>'
)
requests.append({
"local_id": row["local_id"],
"create_time": 1,
"content": xml,
})
# 2. 尝试 stranger / stranger_ticket_info 表
if not requests and "stranger" in tables:
cols = self._table_columns(conn, "stranger")
username_col = self._pick_column(cols, ["username", "wxid", "stranger"])
id_col = self._pick_column(cols, ["id", "rowid"])
if username_col and id_col:
cur = conn.execute(
f"SELECT {id_col} AS local_id, {username_col} AS username "
f"FROM stranger WHERE {username_col} LIKE '%@%' "
f"AND {id_col} > ? "
f"LIMIT ?",
(since_id, limit),
)
for row in cur.fetchall():
username = row["username"] or ""
if "@" not in username:
username = f"{username}@stranger"
xml = (
f'<sysmsg type="verifyUser">'
f'<Link><UserName>{username}</UserName></Link>'
f'</sysmsg>'
)
requests.append({
"local_id": row["local_id"],
"create_time": 1,
"content": xml,
})
except sqlite3.Error as e:
logger.warning("_get_pending_requests_from_ticket_info 异常: %s", e)
finally:
conn.close()
return requests
def get_max_ticket_info_id(self) -> Optional[int]:
"""获取 ticket_info 表当前最大 id用于初始化增量游标
Returns:
最大 id表不存在或为空时返回 0DB 不可读时返回 None。
"""
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:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "ticket_info" not in tables:
return 0
cols = self._table_columns(conn, "ticket_info")
id_col = self._pick_column(cols, ["id", "rowid"])
if id_col is None:
return 0
cur = conn.execute(
f"SELECT COALESCE(MAX({id_col}), 0) AS max_id FROM ticket_info"
)
row = cur.fetchone()
return int(row["max_id"]) if row else 0
except sqlite3.Error as e:
logger.warning("get_max_ticket_info_id 异常: %s", e)
return None
finally:
conn.close()
def verify_friend_accepted(self, stranger_wxid: str) -> bool:
"""校验好友申请是否已通过。
判定逻辑:
1. contact 表中存在 base_wxid 记录(已变为好友)
2. contact 表中不存在 base_wxid@stranger 残留
3. ticket_info 表中不存在该 stranger 的待处理 ticket
任一条件不满足均视为未通过。
Args:
stranger_wxid: 含 @ 后缀的 wxid
Returns:
True 表示已通过False 表示仍待验证或 DB 不可读
"""
try:
db_path = self._ensure_decrypted("contact/contact.db")
except BridgeError:
return False
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 False
# 动态探测 username 列名(候选: username / wxid
cols = self._table_columns(conn, table)
username_col = self._pick_column(cols, ["username", "wxid"])
if username_col is None:
return False
base_wxid = stranger_wxid.split("@")[0]
# 单条 SQL同时统计 base_wxid 记录数和 base_wxid@% 残留数
sql = (
f"SELECT "
f" SUM(CASE WHEN {username_col} = ? THEN 1 ELSE 0 END) AS base_cnt, "
f" SUM(CASE WHEN {username_col} LIKE ? THEN 1 ELSE 0 END) AS stranger_cnt "
f"FROM {table}"
)
cur = conn.execute(sql, (base_wxid, f"{base_wxid}@%"))
row = cur.fetchone()
if row is None:
return False
base_cnt = row["base_cnt"] or 0
stranger_cnt = row["stranger_cnt"] or 0
contact_passed = base_cnt > 0 and stranger_cnt == 0
# 同时检查 ticket_info 表是否还有该 stranger 的待处理 ticket
ticket_pending = False
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "ticket_info" in tables:
ticket_cols = self._table_columns(conn, "ticket_info")
ticket_col = self._pick_column(ticket_cols, ["ticket"])
if ticket_col:
cur = conn.execute(
f"SELECT COUNT(*) FROM ticket_info WHERE {ticket_col} LIKE ?",
(f"{base_wxid}@%",),
)
ticket_pending = (cur.fetchone()[0] or 0) > 0
# 通过 = contact 已建立且 ticket 已消失
return contact_passed and not ticket_pending
except sqlite3.Error:
return False
finally:
conn.close()
def get_max_create_time_for_talker(self, talker: str) -> Optional[tuple[int, int]]:
"""获取指定会话的消息最大 (create_time, local_id)(游标初始化用)。
直接查 Msg_<MD5(talker)> 单表,避免遍历所有分片。
返回 None 表示 DB 不可读(与 get_max_create_time 语义一致),
返回 (0, 0) 表示表为空或不存在。
Args:
talker: 会话 talker"fmessage"
Returns:
最大 (create_time, local_id),或 None / (0, 0)
"""
try:
db_path = self._ensure_decrypted("message/message_0.db")
except BridgeError:
return None
conn = sqlite3.connect(db_path, isolation_level=None)
try:
table_name = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
if cur.fetchone() is None:
return (0, 0)
cur = conn.execute(
f"SELECT create_time, local_id FROM [{table_name}] "
f"ORDER BY create_time DESC, local_id DESC LIMIT 1"
)
row = cur.fetchone()
if row and row[0] is not None:
return (int(row[0]), int(row[1]) if row[1] is not None else 0)
return (0, 0)
except sqlite3.Error:
return (0, 0)
finally:
conn.close()
# ------------------------------------------------------------------
# 朋友圈MomentsDB 探测与读取
# ------------------------------------------------------------------
# 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}
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 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]:
"""单条联系人查询。"""
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 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}
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 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}
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_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]]:
"""读取媒体文件内容。
微信 4.x 的 .dat 媒体文件采用单字节 XOR 加密:首字节为密文,
通过对比已知图片格式 magic bytes 推导 XOR key 后逐字节解密。
推导失败时回退原密文返回,避免阻塞调用方。
"""
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
# 微信 4.x .dat 文件需要 XOR 解密
if path.lower().endswith(".dat"):
decrypted = self._decrypt_dat(data)
if decrypted is not None:
return decrypted
# P0 修复:解密失败时返回 None而非回退密文让调用方写 [媒体缺失] 占位。
# 原实现回退密文会导致导出文件含损坏数据base64 密文嵌入 HTML / 密文写入 media/
# 客户端无法区分"正常媒体"和"损坏媒体"。
logger.warning("dat 文件 XOR 解密失败,返回 None: %s", path)
return None
mime = self._guess_mime(path)
return (data, mime)
def read_media_file_by_path(self, path: str) -> Optional[tuple[bytes, str]]:
"""按路径读取媒体文件并解密 .dat供导出等已有 media_path 的场景使用)。
与 get_media_bytes(msg_id) 的区别:本方法直接接受文件路径,
不经过 msg_id → get_media_path 解析,适合导出流程中 message dict
已携带 media_path 的场景。
Returns:
(明文字节, mime_type) 或 None文件不存在/不可读)
.dat 解密失败时回退原密文(与 get_media_bytes 一致)
"""
if not path 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
if path.lower().endswith(".dat"):
decrypted = self._decrypt_dat(data)
if decrypted is not None:
return decrypted
# P0 修复:解密失败返回 None不回退密文同 get_media_bytes
logger.warning("dat 文件 XOR 解密失败,返回 None: %s", path)
return None
mime = self._guess_mime(path)
return (data, mime)
@staticmethod
def _decrypt_dat(data: bytes) -> Optional[tuple[bytes, str]]:
"""对微信 4.x .dat 密文做 XOR 解密。
通过对比已知图片格式 magic bytes 推导单字节 XOR key
key = 密文首字节 XOR 明文首字节magic byte
推导成功返回 (明文, mime_type),失败返回 None。
推导流程:读取密文首字节,对每种格式的 magic byte 计算
candidate_key = 密文首字节 XOR magic_byte再校验解密后的
前 N 字节是否匹配该格式完整签名,命中即全文解密。
"""
if not data:
return None
cipher_first = data[0]
# (明文首字节 magic, 完整签名, mime_type) —— 用于推导并校验 XOR key
candidates: list[tuple[int, bytes, str]] = [
(0xFF, b"\xff\xd8\xff", "image/jpeg"),
(0x89, b"\x89PNG\r\n\x1a\n", "image/png"),
(0x47, b"GIF8", "image/gif"),
(0x42, b"BM", "image/bmp"),
]
for magic_first, signature, mime in candidates:
key = cipher_first ^ magic_first
# 校验前 N 字节是否符合该格式签名,避免误判
prefix_len = len(signature)
if bytes(b ^ key for b in data[:prefix_len]) == signature:
# 命中:用查表法全文 XOR 解密bytes.translate 走 C 实现,高效)
table = bytes(key ^ i for i in range(256))
return (data.translate(table), mime)
return None
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")