WechatOnCloud/bridge/woc_bridge/db/reader.py
Kris 5d84df902a feat: 新增消息发送方向过滤、日志打点与发送稳定性优化
1.  为消息拉取/搜索接口新增is_sender参数,支持按发送方向过滤消息
2.  为send_text接口添加全链路耗时打点与详细日志
3.  为发送队列添加限流、执行状态日志与耗时统计
4.  修复微信4.x自绘UI输入框焦点问题,新增点击输入框聚焦逻辑
5.  为xdotool命令添加超时保护,避免协程永久阻塞
6.  重构数据库查询逻辑,在SQL层提前过滤发送方向消息,减少数据传输
2026-07-10 22:15:45 +08:00

1805 lines
70 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")
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_<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 时返回空结果。
"""
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_<MD5(talker)> 表名,直接查单表,避免遍历所有分片。
支持向前翻页before取 create_time < cursor 的旧消息)和向后翻页
after取 create_time > cursor 的新消息)。
Args:
talker: 会话对方 wxid群消息为 chatroom id
cursor: 游标,首次传 0before 模式取该时间之前的旧消息,
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()
# ------------------------------------------------------------------
# 朋友圈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}
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")