2026-07-07 19:04:13 +08:00
|
|
|
|
"""微信本地 DB 只读访问。
|
|
|
|
|
|
|
|
|
|
|
|
通过解密缓存避免锁定原 DB,再用 sqlite3 读取消息。所有方法为同步阻塞操作,
|
|
|
|
|
|
在 server.py 中通过 asyncio.to_thread() 包装调用,避免阻塞 event loop。
|
|
|
|
|
|
|
|
|
|
|
|
架构升级(bridge 1.3.0+):
|
|
|
|
|
|
- 密钥统一由 KeyCache 管理(single source of truth),DbReader 不再自持密钥存储
|
|
|
|
|
|
- 密钥持久化、salt 映射、默认 key、已拒绝 salt 全部由 KeyCache 负责
|
|
|
|
|
|
- 解密后的明文 DB 缓存到 /config/woc-decrypted/<wxid>/,按 mtime 失效
|
|
|
|
|
|
- WAL 文件自动合并到明文缓存
|
|
|
|
|
|
- /api/status 不触发内存扫描,直接判断 keys 文件和缓存有效性
|
|
|
|
|
|
- 新增 clear_default_key 转发,env/api key 验证失败时仅清默认 key
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import atexit
|
2026-07-08 12:49:05 +08:00
|
|
|
|
import hashlib
|
2026-07-08 23:25:58 +08:00
|
|
|
|
import logging
|
2026-07-07 19:04:13 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
import threading
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
from woc_bridge.db.decryptor import Decryptor
|
|
|
|
|
|
from woc_bridge.db.key_cache import KeyCache
|
|
|
|
|
|
from woc_bridge.models import BridgeError
|
2026-07-07 19:04:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# SQLCipher page 常量
|
|
|
|
|
|
PAGE_SIZE = 4096
|
|
|
|
|
|
SALT_SIZE = 16
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
logger = logging.getLogger("woc-bridge")
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
# 消息类型 → 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_cache(KeyCache)管理,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
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 密钥访问(全部转发到 KeyCache,DbReader 不再自持密钥存储)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def set_keys(
|
|
|
|
|
|
self,
|
|
|
|
|
|
key_map: dict[str, str],
|
|
|
|
|
|
source: str = "auto_extract",
|
|
|
|
|
|
pid: Optional[int] = None,
|
|
|
|
|
|
start_time: Optional[float] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""批量设置 salt -> enc_key 映射。
|
|
|
|
|
|
|
|
|
|
|
|
转发到 KeyCache.set_key_map,由 KeyCache 负责持久化。
|
|
|
|
|
|
keys 变化后清空解密缓存索引,避免旧密钥解密的脏数据被复用。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
key_map: {salt_hex: enc_key_hex}
|
|
|
|
|
|
source: 密钥来源(auto_extract / api / env)
|
|
|
|
|
|
pid: 自动提取时关联的微信进程 PID(用于进程重启后失效检测)
|
|
|
|
|
|
start_time: 自动提取时关联的进程启动时间
|
|
|
|
|
|
"""
|
|
|
|
|
|
wxid = self._get_current_wxid()
|
|
|
|
|
|
self.key_cache.set_wxid(wxid)
|
|
|
|
|
|
self.key_cache.set_key_map(
|
|
|
|
|
|
key_map,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
verified=True,
|
|
|
|
|
|
pid=pid,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
)
|
|
|
|
|
|
# keys 变化后清空解密缓存,让查询重新生成
|
|
|
|
|
|
self._decrypted_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def set_key(
|
|
|
|
|
|
self,
|
|
|
|
|
|
key_hex: Optional[str],
|
|
|
|
|
|
salt_hex: Optional[str] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""设置单个密钥(转发到 KeyCache)。
|
|
|
|
|
|
|
|
|
|
|
|
任何 key 变更都会清空解密缓存索引,避免旧密钥解密的脏数据被复用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if key_hex is None:
|
|
|
|
|
|
self.key_cache.clear()
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.key_cache.set_key(key_hex, salt_hex=salt_hex)
|
|
|
|
|
|
self._decrypted_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def set_default_key(self, key_hex: Optional[str]) -> None:
|
|
|
|
|
|
"""设置默认密钥(env 注入专用,转发到 KeyCache)。
|
|
|
|
|
|
|
|
|
|
|
|
不影响 salt_to_key 持久化映射,env key 仅作为默认兜底。
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.key_cache.set_default_key(key_hex, source="env", verified=False)
|
|
|
|
|
|
|
|
|
|
|
|
def clear_default_key(self) -> None:
|
|
|
|
|
|
"""仅清空默认 key(保留持久化 salt_to_key 映射)。
|
|
|
|
|
|
|
|
|
|
|
|
用于 env/api key 验证失败后清除默认 key,不影响已持久化的多 salt 映射。
|
|
|
|
|
|
keys 变化后清空解密缓存索引。
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.key_cache.clear_default_key()
|
|
|
|
|
|
self._decrypted_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def get_keys(self) -> dict[str, str]:
|
|
|
|
|
|
"""返回当前持久化的 salt -> key 映射副本。"""
|
|
|
|
|
|
return self.key_cache.get_keys()
|
|
|
|
|
|
|
|
|
|
|
|
def has_keys(self) -> bool:
|
|
|
|
|
|
"""是否有可用的密钥(持久化或默认 key)。"""
|
|
|
|
|
|
return self.key_cache.has_keys()
|
|
|
|
|
|
|
|
|
|
|
|
def _get_effective_key(self, salt_hex: Optional[str] = None) -> Optional[str]:
|
|
|
|
|
|
"""返回指定 salt 的有效密钥(转发到 KeyCache)。"""
|
|
|
|
|
|
return self.key_cache.get_key(salt_hex)
|
|
|
|
|
|
|
|
|
|
|
|
def _mark_default_key_mismatch(self, salt_hex: str) -> None:
|
|
|
|
|
|
"""标记默认 key 不匹配该 salt(转发到 KeyCache)。"""
|
|
|
|
|
|
self.key_cache.mark_default_key_mismatch(salt_hex)
|
|
|
|
|
|
|
|
|
|
|
|
def _read_db_salt(self, db_path: str) -> Optional[str]:
|
|
|
|
|
|
"""读取 DB 第 1 页前 16 字节 salt。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(db_path, "rb") as f:
|
|
|
|
|
|
page1 = f.read(PAGE_SIZE)
|
|
|
|
|
|
if len(page1) < SALT_SIZE:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return page1[:SALT_SIZE].hex()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 解密缓存
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def _cleanup_temp_files(self) -> None:
|
|
|
|
|
|
"""进程退出时清理临时解密文件(如果有)。"""
|
|
|
|
|
|
# 持久化缓存保留,不删除
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _decrypted_path_for(self, rel_path: str, wxid: Optional[str] = None) -> str:
|
|
|
|
|
|
"""计算解密缓存文件路径。"""
|
|
|
|
|
|
if wxid is None:
|
|
|
|
|
|
wxid = self._get_wxid_from_storage() or "unknown"
|
|
|
|
|
|
# rel_path 本身通常以 .db 结尾(如 contact/contact.db),直接保留即可
|
|
|
|
|
|
safe_rel = rel_path.replace(os.sep, "_").replace("/", "_")
|
|
|
|
|
|
cache_dir = os.path.join(self.decrypted_dir, wxid)
|
|
|
|
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
|
|
|
|
return os.path.join(cache_dir, safe_rel)
|
|
|
|
|
|
|
|
|
|
|
|
def _is_plain_db(self, db_path: str) -> bool:
|
|
|
|
|
|
"""DB 是否为明文 SQLite。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(db_path, "rb") as f:
|
|
|
|
|
|
header = f.read(16)
|
|
|
|
|
|
return header == b"SQLite format 3\x00"
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_decrypted(self, rel_path: str) -> str:
|
|
|
|
|
|
"""确保指定相对路径的 DB 已解密到缓存,返回明文 DB 绝对路径。
|
|
|
|
|
|
|
|
|
|
|
|
流程:
|
|
|
|
|
|
1. 定位原 DB
|
|
|
|
|
|
2. 明文 DB 直接返回原路径
|
|
|
|
|
|
3. 读取 salt,查找对应 key
|
|
|
|
|
|
4. 检查解密缓存是否有效(mtime + key 一致)
|
|
|
|
|
|
5. 无效则重新解密,并合并 WAL
|
|
|
|
|
|
"""
|
|
|
|
|
|
db_path = self._get_db_abs_path(rel_path)
|
|
|
|
|
|
if db_path is None:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_NOT_FOUND",
|
|
|
|
|
|
message=f"未找到微信 DB: {rel_path}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not os.path.isfile(db_path):
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_NOT_FOUND",
|
|
|
|
|
|
message=f"DB 文件不存在: {rel_path}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 明文直接返回
|
|
|
|
|
|
if self._is_plain_db(db_path):
|
|
|
|
|
|
return db_path
|
|
|
|
|
|
|
|
|
|
|
|
# 加密 DB:需要 key
|
|
|
|
|
|
salt_hex = self._read_db_salt(db_path)
|
|
|
|
|
|
if not salt_hex:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message=f"无法读取 DB salt: {rel_path}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
key_hex = self._get_effective_key(salt_hex)
|
|
|
|
|
|
if not key_hex:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_NEED_INIT",
|
|
|
|
|
|
message="DB 已加密且没有可用密钥,请先调用 /api/db/init",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
wxid = self._get_wxid_from_storage()
|
|
|
|
|
|
decrypted_path = self._decrypted_path_for(rel_path, wxid)
|
|
|
|
|
|
wal_path = db_path + "-wal"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
db_mtime = os.path.getmtime(db_path)
|
|
|
|
|
|
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
|
|
|
|
|
except OSError as e:
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_NOT_FOUND",
|
|
|
|
|
|
message=f"无法获取 DB mtime: {e}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 检查内存缓存索引
|
|
|
|
|
|
cached = self._decrypted_cache.get(rel_path)
|
|
|
|
|
|
if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]):
|
|
|
|
|
|
return cached[2]
|
|
|
|
|
|
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
# 双重检查
|
|
|
|
|
|
cached = self._decrypted_cache.get(rel_path)
|
|
|
|
|
|
if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]):
|
|
|
|
|
|
return cached[2]
|
|
|
|
|
|
|
|
|
|
|
|
# 解密
|
|
|
|
|
|
decryptor = Decryptor(key_hex)
|
|
|
|
|
|
result = decryptor.decrypt_db(db_path, decrypted_path)
|
|
|
|
|
|
if not result.get("success"):
|
|
|
|
|
|
error = result.get("error", "unknown")
|
|
|
|
|
|
if os.path.exists(decrypted_path):
|
|
|
|
|
|
os.remove(decrypted_path)
|
|
|
|
|
|
# 默认 key 解密失败时标记 salt,避免后续重复尝试
|
|
|
|
|
|
if key_hex == self.key_cache.get_default_key() and salt_hex:
|
|
|
|
|
|
self._mark_default_key_mismatch(salt_hex)
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message=f"解密 {rel_path} 失败: {error}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 合并 WAL
|
|
|
|
|
|
if os.path.exists(wal_path):
|
|
|
|
|
|
try:
|
|
|
|
|
|
decryptor.decrypt_wal(wal_path, decrypted_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# WAL 合并失败不影响主 DB 读取,只是可能缺最新数据
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
os.chmod(decrypted_path, 0o644)
|
|
|
|
|
|
self._decrypted_cache[rel_path] = (db_mtime, wal_mtime, decrypted_path)
|
|
|
|
|
|
return decrypted_path
|
|
|
|
|
|
|
|
|
|
|
|
def invalidate_decrypted_cache(self) -> None:
|
|
|
|
|
|
"""清空解密缓存索引(不删除文件,下次按 mtime 重新判断)。"""
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
self._decrypted_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# DB 可达性状态
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def check_db_status(self) -> str:
|
|
|
|
|
|
"""检测 DB 状态。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
"ok": 有 DB 且可直接/解密读取
|
|
|
|
|
|
"not_found": 未找到 DB
|
|
|
|
|
|
"need_init": DB 加密但没有密钥文件/内存 key
|
|
|
|
|
|
"key_invalid": 有密钥但验证失败(极少见)
|
|
|
|
|
|
"""
|
|
|
|
|
|
storage = self._find_db_storage_dir()
|
|
|
|
|
|
if storage is None:
|
|
|
|
|
|
return "not_found"
|
|
|
|
|
|
|
|
|
|
|
|
# 找一个需要读取的 DB 探针(contact.db 或 message_0.db)
|
|
|
|
|
|
probe_rel_paths = ["contact/contact.db", "message/message_0.db"]
|
|
|
|
|
|
probe_path = None
|
|
|
|
|
|
for rel in probe_rel_paths:
|
|
|
|
|
|
p = os.path.join(storage, rel)
|
|
|
|
|
|
if os.path.isfile(p):
|
|
|
|
|
|
probe_path = p
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if probe_path is None:
|
|
|
|
|
|
# 目录存在但找不到探针文件,可能 DB 还没完全生成
|
|
|
|
|
|
return "not_found"
|
|
|
|
|
|
|
|
|
|
|
|
if self._is_plain_db(probe_path):
|
|
|
|
|
|
return "ok"
|
|
|
|
|
|
|
|
|
|
|
|
if not self.has_keys():
|
|
|
|
|
|
return "need_init"
|
|
|
|
|
|
|
|
|
|
|
|
# 有 keys,快速验证是否能解密
|
|
|
|
|
|
salt_hex = self._read_db_salt(probe_path)
|
|
|
|
|
|
if salt_hex and self._get_effective_key(salt_hex):
|
|
|
|
|
|
return "ok"
|
|
|
|
|
|
|
|
|
|
|
|
return "key_invalid"
|
|
|
|
|
|
|
|
|
|
|
|
def is_db_accessible(self) -> bool:
|
|
|
|
|
|
"""DB 是否可读。"""
|
|
|
|
|
|
return self.check_db_status() == "ok"
|
|
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
def get_db_mtime(self, rel_path: str) -> Optional[tuple[float, float]]:
|
|
|
|
|
|
"""返回指定 DB 及其 WAL 文件的 mtime,用于增量轮询感知变化。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
rel_path: 相对路径,如 message/message_0.db
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(db_mtime, wal_mtime):DB 不存在时返回 None;
|
|
|
|
|
|
WAL 不存在时 wal_mtime 为 0.0;mtime 读取失败记为 0.0。
|
|
|
|
|
|
"""
|
|
|
|
|
|
db_path = self._get_db_abs_path(rel_path)
|
|
|
|
|
|
if db_path is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
db_mtime = os.path.getmtime(db_path)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
db_mtime = 0.0
|
|
|
|
|
|
wal_path = db_path + "-wal"
|
|
|
|
|
|
try:
|
|
|
|
|
|
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
wal_mtime = 0.0
|
|
|
|
|
|
return db_mtime, wal_mtime
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 辅助:表/列探测
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@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")
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 12:49:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-07-08 12:49:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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")
|
2026-07-16 01:19:47 +08:00
|
|
|
|
local_id = int(row["local_id"]) if row["local_id"] is not None else 0
|
2026-07-08 23:25:58 +08:00
|
|
|
|
return {
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"msg_id": str(local_id),
|
|
|
|
|
|
"local_id": local_id,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
def get_messages_since(
|
|
|
|
|
|
self,
|
|
|
|
|
|
cursor: int,
|
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
|
is_sender: Optional[bool] = None,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
cursor_local_id: int = 0,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
) -> dict:
|
2026-07-08 12:49:05 +08:00
|
|
|
|
"""读取 create_time > cursor 的增量消息。
|
|
|
|
|
|
|
|
|
|
|
|
WeChat 4.x 消息存储在 Msg_<MD5(talker)> 分片表中,每个会话一张表。
|
|
|
|
|
|
遍历所有分片表合并结果后按 create_time 排序返回。
|
2026-07-10 22:15:45 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
cursor: 上次拉取的最大 create_time
|
|
|
|
|
|
limit: 最多返回条数
|
|
|
|
|
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。SQL 层通过 real_sender_id 与
|
|
|
|
|
|
Name2Id 中 self_wxid 的 rowid 比对实现,避免读出再裁剪。
|
|
|
|
|
|
当 is_sender 非 None 但无法定位本人 rowid 时返回空结果。
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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_time,next_cursor_local_id 为对应
|
|
|
|
|
|
消息的 local_id(无消息时回退为入参 cursor_local_id)。
|
2026-07-08 12:49:05 +08:00
|
|
|
|
"""
|
2026-07-07 19:04:13 +08:00
|
|
|
|
db_path = self._ensure_decrypted("message/message_0.db")
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
|
try:
|
2026-07-08 12:49:05 +08:00
|
|
|
|
table_to_talker = self._resolve_msg_table_talkers(conn)
|
|
|
|
|
|
if not table_to_talker:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 微信未登录或尚无消息时,Msg_* 分片表可能不存在;返回空列表
|
|
|
|
|
|
# 而不是抛 DB_NOT_FOUND(500),避免前端轮询进入错误重试循环。
|
|
|
|
|
|
return {
|
|
|
|
|
|
"messages": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"next_cursor_local_id": cursor_local_id,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
}
|
2026-07-08 12:49:05 +08:00
|
|
|
|
id_to_username = self._load_name2id(conn)
|
|
|
|
|
|
self_wxid = self._get_current_wxid() or ""
|
|
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
# 发送方向过滤:无法定位本人 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,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"next_cursor_local_id": cursor_local_id,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
"has_more": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 12:49:05 +08:00
|
|
|
|
all_rows: list[dict] = []
|
|
|
|
|
|
for table_name, talker in table_to_talker.items():
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# 复合游标 (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]
|
2026-07-10 22:15:45 +08:00
|
|
|
|
if sender_sql:
|
|
|
|
|
|
where_parts.append(sender_sql)
|
|
|
|
|
|
params.extend(sender_params)
|
|
|
|
|
|
sql = (
|
2026-07-08 12:49:05 +08:00
|
|
|
|
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
|
|
|
|
|
f"message_content, WCDB_CT_message_content "
|
2026-07-10 22:15:45 +08:00
|
|
|
|
f"FROM [{table_name}] WHERE {' AND '.join(where_parts)} "
|
2026-07-16 01:19:47 +08:00
|
|
|
|
f"ORDER BY create_time ASC, local_id ASC LIMIT ?"
|
2026-07-08 12:49:05 +08:00
|
|
|
|
)
|
2026-07-10 22:15:45 +08:00
|
|
|
|
params.append(limit)
|
|
|
|
|
|
cur = conn.execute(sql, tuple(params))
|
2026-07-08 12:49:05 +08:00
|
|
|
|
for row in cur.fetchall():
|
2026-07-08 23:25:58 +08:00
|
|
|
|
all_rows.append(self._row_to_message_dict(
|
|
|
|
|
|
row, talker, id_to_username, self_wxid
|
|
|
|
|
|
))
|
2026-07-08 12:49:05 +08:00
|
|
|
|
|
2026-07-16 01:19:47 +08:00
|
|
|
|
all_rows.sort(key=lambda r: (r["create_time"], r["local_id"]))
|
2026-07-08 12:49:05 +08:00
|
|
|
|
messages = all_rows[:limit]
|
2026-07-07 19:04:13 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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
|
2026-07-07 19:04:13 +08:00
|
|
|
|
has_more = len(messages) >= limit
|
|
|
|
|
|
return {
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"next_cursor": next_cursor,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"next_cursor_local_id": int(next_cursor_local_id),
|
2026-07-07 19:04:13 +08:00
|
|
|
|
"has_more": has_more,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
def get_messages_by_session(
|
|
|
|
|
|
self,
|
|
|
|
|
|
talker: str,
|
|
|
|
|
|
cursor: int = 0,
|
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
|
direction: str = "before",
|
2026-07-16 01:19:47 +08:00
|
|
|
|
cursor_local_id: int = 0,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: Optional[bool] = None,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
|
|
|
|
|
|
|
|
|
|
|
通过 talker 计算 Msg_<MD5(talker)> 表名,直接查单表,避免遍历所有分片。
|
|
|
|
|
|
支持向前翻页(before:取 create_time < cursor 的旧消息)和向后翻页
|
|
|
|
|
|
(after:取 create_time > cursor 的新消息)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
talker: 会话对方 wxid(群消息为 chatroom id)
|
|
|
|
|
|
cursor: 游标,首次传 0;before 模式取该时间之前的旧消息,
|
|
|
|
|
|
after 模式取该时间之后的新消息
|
|
|
|
|
|
limit: 最多返回条数,1~200
|
|
|
|
|
|
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
2026-07-16 01:19:47 +08:00
|
|
|
|
cursor_local_id: 配合 cursor 的 tie-breaker 游标(local_id),
|
|
|
|
|
|
用于避免同 create_time 的消息在分页时丢失(群聊同秒消息常见)。
|
|
|
|
|
|
默认 0 表示不使用 tie-breaker(向后兼容旧客户端)。下一页应
|
|
|
|
|
|
同时传入上次返回的 next_cursor 与 next_cursor_local_id。
|
|
|
|
|
|
before 模式 cursor=0 时本参数被忽略。
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。无法定位本人 rowid 时返回空结果。
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-07-16 01:19:47 +08:00
|
|
|
|
{"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
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
# 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,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"next_cursor_local_id": cursor_local_id,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"talker": talker,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
id_to_username = self._load_name2id(conn)
|
|
|
|
|
|
self_wxid = self._get_current_wxid() or ""
|
|
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
# 发送方向过滤:无法定位本人 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,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"next_cursor_local_id": cursor_local_id,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"talker": talker,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 收集 WHERE 片段:时间过滤 + 发送方向过滤
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# 复合游标 (create_time, local_id) 作为 tie-breaker,
|
|
|
|
|
|
# 避免同 create_time 消息在分页边界被跳过
|
2026-07-10 22:15:45 +08:00
|
|
|
|
where_parts: list[str] = []
|
|
|
|
|
|
time_params: list = []
|
2026-07-08 23:25:58 +08:00
|
|
|
|
if direction == "after":
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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"
|
2026-07-08 23:25:58 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# before 模式:cursor=0 时不加时间过滤(取最新 limit 条)
|
|
|
|
|
|
if cursor > 0:
|
2026-07-16 01:19:47 +08:00
|
|
|
|
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"
|
2026-07-10 22:15:45 +08:00
|
|
|
|
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)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
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":
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# after 模式:游标指向本批最新一条
|
2026-07-08 23:25:58 +08:00
|
|
|
|
next_cursor = messages[-1]["create_time"]
|
2026-07-16 01:19:47 +08:00
|
|
|
|
next_cursor_local_id = messages[-1].get("local_id", 0)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
else:
|
2026-07-16 01:19:47 +08:00
|
|
|
|
# before 模式:游标指向本批最旧一条
|
2026-07-08 23:25:58 +08:00
|
|
|
|
next_cursor = messages[0]["create_time"]
|
2026-07-16 01:19:47 +08:00
|
|
|
|
next_cursor_local_id = messages[0].get("local_id", 0)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
else:
|
|
|
|
|
|
next_cursor = cursor
|
2026-07-16 01:19:47 +08:00
|
|
|
|
next_cursor_local_id = cursor_local_id
|
2026-07-08 23:25:58 +08:00
|
|
|
|
has_more = len(messages) >= limit
|
|
|
|
|
|
return {
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"next_cursor": next_cursor,
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"next_cursor_local_id": int(next_cursor_local_id),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"has_more": has_more,
|
|
|
|
|
|
"talker": talker,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 导出查询(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(昵称) > talker(wxid 兜底)。
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
def search_messages(
|
|
|
|
|
|
self,
|
|
|
|
|
|
keyword: str,
|
|
|
|
|
|
talker: Optional[str] = None,
|
|
|
|
|
|
start_time: Optional[int] = None,
|
|
|
|
|
|
end_time: Optional[int] = None,
|
|
|
|
|
|
limit: int = 50,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: Optional[bool] = None,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""按关键词搜索历史消息。
|
|
|
|
|
|
|
|
|
|
|
|
遍历所有 Msg_* 分片表(或指定 talker 的单表),对 message_content
|
|
|
|
|
|
做 LIKE 模糊匹配,合并后按 create_time 降序返回(最新匹配在前)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
keyword: 搜索关键词(非空),对原始 message_content 列做子串匹配
|
|
|
|
|
|
talker: 可选,限定在指定会话内搜索
|
|
|
|
|
|
start_time: 可选,起始时间戳(含)
|
|
|
|
|
|
end_time: 可选,结束时间戳(含)
|
|
|
|
|
|
limit: 最多返回条数,1~200
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。无法定位本人 rowid 时返回空结果。
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
# 发送方向过滤:无法定位本人 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}
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
# 构建 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)
|
2026-07-10 22:15:45 +08:00
|
|
|
|
if sender_sql:
|
|
|
|
|
|
where_clauses.append(sender_sql)
|
|
|
|
|
|
params.extend(sender_params)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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}
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
def get_max_create_time(self) -> Optional[tuple[int, int]]:
|
|
|
|
|
|
"""返回所有 Msg_* 分片表中最大的 (create_time, local_id),用于初始化推送游标。
|
2026-07-07 20:17:06 +08:00
|
|
|
|
|
2026-07-08 12:49:05 +08:00
|
|
|
|
DB 不可读或无消息表时返回 None。供 MessageStreamer 在启动时
|
2026-07-17 18:10:31 +08:00
|
|
|
|
把全局游标对齐到当前最新消息,避免同秒消息在启动后被重复推送。
|
2026-07-07 20:17:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
db_path = self._ensure_decrypted("message/message_0.db")
|
|
|
|
|
|
except BridgeError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
|
|
|
|
|
try:
|
2026-07-08 12:49:05 +08:00
|
|
|
|
table_to_talker = self._resolve_msg_table_talkers(conn)
|
|
|
|
|
|
if not table_to_talker:
|
2026-07-07 20:17:06 +08:00
|
|
|
|
return None
|
2026-07-08 12:49:05 +08:00
|
|
|
|
max_ct: Optional[int] = None
|
2026-07-17 18:10:31 +08:00
|
|
|
|
max_local_id: int = 0
|
2026-07-08 12:49:05 +08:00
|
|
|
|
for table_name in table_to_talker:
|
|
|
|
|
|
try:
|
|
|
|
|
|
row = conn.execute(
|
2026-07-17 18:10:31 +08:00
|
|
|
|
f"SELECT create_time, local_id FROM [{table_name}] "
|
|
|
|
|
|
f"ORDER BY create_time DESC, local_id DESC LIMIT 1"
|
2026-07-08 12:49:05 +08:00
|
|
|
|
).fetchone()
|
|
|
|
|
|
if row and row[0] is not None:
|
|
|
|
|
|
ct = int(row[0])
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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
|
|
|
|
|
|
):
|
2026-07-08 12:49:05 +08:00
|
|
|
|
max_ct = ct
|
2026-07-17 18:10:31 +08:00
|
|
|
|
max_local_id = lid
|
2026-07-08 12:49:05 +08:00
|
|
|
|
except sqlite3.Error:
|
|
|
|
|
|
continue
|
2026-07-17 18:10:31 +08:00
|
|
|
|
if max_ct is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return (max_ct, max_local_id)
|
2026-07-07 20:17:06 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
2026-07-16 13:03:58 +08:00
|
|
|
|
def get_friend_requests_since(
|
|
|
|
|
|
self, cursor_create_time: int, cursor_local_id: int = 0, limit: int = 50
|
|
|
|
|
|
) -> Optional[dict]:
|
|
|
|
|
|
"""查询 fmessage 会话中指定游标之后的好友申请消息。
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
优先查 Msg_<MD5("fmessage")> 单表;若 fmessage 表无数据,
|
|
|
|
|
|
则 fallback 到 contact.db 的 ticket_info / stranger 表读取待处理申请。
|
2026-07-16 13:03:58 +08:00
|
|
|
|
复合游标 (create_time, local_id) 作为 tie-breaker,避免同秒消息丢失。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
cursor_create_time: 上次处理到的 create_time(0 = 从最新开始)
|
|
|
|
|
|
cursor_local_id: 同 create_time 下已处理的 local_id(tie-breaker)
|
|
|
|
|
|
limit: 单次读取上限
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{"requests": list[dict], "next_create_time": int, "next_local_id": int}
|
|
|
|
|
|
或 None(DB 不可读)
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
result = {
|
2026-07-16 13:03:58 +08:00
|
|
|
|
"requests": requests,
|
|
|
|
|
|
"next_create_time": next_ct,
|
|
|
|
|
|
"next_local_id": next_lid,
|
|
|
|
|
|
}
|
2026-07-17 18:10:31 +08:00
|
|
|
|
|
|
|
|
|
|
# fallback:fmessage 无新申请时,尝试从 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
|
2026-07-16 13:03:58 +08:00
|
|
|
|
except sqlite3.Error:
|
|
|
|
|
|
return empty
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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;表不存在或为空时返回 0;DB 不可读时返回 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()
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
def verify_friend_accepted(self, stranger_wxid: str) -> bool:
|
|
|
|
|
|
"""校验好友申请是否已通过。
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
判定逻辑:
|
|
|
|
|
|
1. contact 表中存在 base_wxid 记录(已变为好友)
|
|
|
|
|
|
2. contact 表中不存在 base_wxid@stranger 残留
|
|
|
|
|
|
3. ticket_info 表中不存在该 stranger 的待处理 ticket
|
|
|
|
|
|
任一条件不满足均视为未通过。
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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
|
2026-07-16 13:03:58 +08:00
|
|
|
|
except sqlite3.Error:
|
|
|
|
|
|
return False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
def get_max_create_time_for_talker(self, talker: str) -> Optional[tuple[int, int]]:
|
|
|
|
|
|
"""获取指定会话的消息最大 (create_time, local_id)(游标初始化用)。
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
直接查 Msg_<MD5(talker)> 单表,避免遍历所有分片。
|
|
|
|
|
|
返回 None 表示 DB 不可读(与 get_max_create_time 语义一致),
|
2026-07-17 18:10:31 +08:00
|
|
|
|
返回 (0, 0) 表示表为空或不存在。
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
talker: 会话 talker(如 "fmessage")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
最大 (create_time, local_id),或 None / (0, 0)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
row = cur.fetchone()
|
2026-07-17 18:10:31 +08:00
|
|
|
|
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)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
except sqlite3.Error:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
return (0, 0)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 朋友圈(Moments)DB 探测与读取
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# WeChat 4.x Linux 朋友圈数据存放路径与表结构官方未公开,以下实现基于
|
|
|
|
|
|
# 常见命名做容错探测:优先查找 moment/sns 相关 DB 文件,再按候选表名/列名探测。
|
|
|
|
|
|
_MOMENT_DB_CANDIDATES = (
|
|
|
|
|
|
"moment/moment_0.db",
|
|
|
|
|
|
"sns/sns_0.db",
|
|
|
|
|
|
"moment/moment.db",
|
|
|
|
|
|
"sns/sns.db",
|
|
|
|
|
|
)
|
|
|
|
|
|
_MOMENT_TABLE_CANDIDATES = (
|
|
|
|
|
|
"Moment", "MomentInfo", "SNS", "SnsInfo",
|
|
|
|
|
|
"moment", "moment_info", "sns_info",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _find_moment_db_path(self) -> Optional[str]:
|
|
|
|
|
|
"""探测朋友圈 DB 文件路径。
|
|
|
|
|
|
|
|
|
|
|
|
依次尝试 _MOMENT_DB_CANDIDATES 中的相对路径,命中即返回;
|
|
|
|
|
|
全部不存在时按 basename 在 storage 下递归查找 moment_0.db / sns_0.db。
|
|
|
|
|
|
"""
|
|
|
|
|
|
for rel in self._MOMENT_DB_CANDIDATES:
|
|
|
|
|
|
path = self._get_db_abs_path(rel)
|
|
|
|
|
|
if path and os.path.isfile(path):
|
|
|
|
|
|
return path
|
|
|
|
|
|
# fallback:递归查找
|
|
|
|
|
|
storage = self._find_db_storage_dir()
|
|
|
|
|
|
if storage is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for target in ("moment_0.db", "sns_0.db", "moment.db", "sns.db"):
|
|
|
|
|
|
for root, _, files in os.walk(storage):
|
|
|
|
|
|
if target in files:
|
|
|
|
|
|
return os.path.join(root, target)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _find_moment_table(self, conn: sqlite3.Connection) -> Optional[str]:
|
|
|
|
|
|
"""探测朋友圈表名。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
cur = conn.execute(
|
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
|
|
|
|
)
|
|
|
|
|
|
all_tables = {row[0] for row in cur.fetchall() if row[0]}
|
|
|
|
|
|
except sqlite3.Error:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for cand in self._MOMENT_TABLE_CANDIDATES:
|
|
|
|
|
|
if cand in all_tables:
|
|
|
|
|
|
return cand
|
|
|
|
|
|
# 模糊匹配含 moment / sns 的表名(收紧匹配避免误匹配短子串)
|
|
|
|
|
|
for name in all_tables:
|
|
|
|
|
|
low = name.lower()
|
|
|
|
|
|
if low.startswith("moment") or low.startswith("sns"):
|
|
|
|
|
|
return name
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def get_moments_timeline(
|
|
|
|
|
|
self,
|
|
|
|
|
|
cursor: int = 0,
|
|
|
|
|
|
limit: int = 20,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""读取朋友圈时间线。
|
|
|
|
|
|
|
|
|
|
|
|
WeChat 4.x 朋友圈 schema 未公开,本方法做容错探测:
|
|
|
|
|
|
1. 探测 moment/sns DB 文件
|
|
|
|
|
|
2. 探测朋友圈表名(Moment / SNS / MomentInfo 等)
|
|
|
|
|
|
3. 探测列名(content / text / description / create_time / create_time_sec 等)
|
|
|
|
|
|
4. 按 create_time 降序返回
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
cursor: 游标,首次传 0;取 create_time < cursor 的更旧朋友圈
|
|
|
|
|
|
limit: 最多返回条数,1~50
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{"moments": [...], "next_cursor": int, "has_more": bool}
|
|
|
|
|
|
单条 moment dict 字段:moment_id / content / create_time /
|
|
|
|
|
|
author_wxid / extra(原始列名→值的字典,供前端兜底展示)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(DB_NOT_FOUND): 未找到朋友圈 DB 文件
|
|
|
|
|
|
BridgeError(MOMENT_TABLE_NOT_FOUND): DB 存在但无朋友圈表(不抛
|
|
|
|
|
|
异常,改返回空列表 + 可读的 status 字段;仅当 DB 完全不可读时抛 DB_NOT_FOUND)
|
|
|
|
|
|
"""
|
|
|
|
|
|
# clamping 防止 limit 越界
|
|
|
|
|
|
limit = max(1, min(limit, 50))
|
|
|
|
|
|
moment_db = self._find_moment_db_path()
|
|
|
|
|
|
if moment_db is None:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "db_not_found",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 复用解密缓存机制:把绝对路径转成相对路径调用 _ensure_decrypted
|
|
|
|
|
|
storage = self._find_db_storage_dir()
|
|
|
|
|
|
if storage is None:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "storage_not_found",
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
rel_path = os.path.relpath(moment_db, storage)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
rel_path = os.path.basename(moment_db)
|
|
|
|
|
|
# 规范为 unix 风格相对路径
|
|
|
|
|
|
rel_path = rel_path.replace(os.sep, "/")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
db_path = self._ensure_decrypted(rel_path)
|
|
|
|
|
|
except BridgeError:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "db_encrypted",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
|
try:
|
|
|
|
|
|
table = self._find_moment_table(conn)
|
|
|
|
|
|
if table is None:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "table_not_found",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cols = self._table_columns(conn, table)
|
|
|
|
|
|
if not cols:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "no_columns",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 探测关键列名
|
|
|
|
|
|
id_col = self._pick_column(cols, [
|
|
|
|
|
|
"id", "moment_id", "sns_id", "rowid",
|
|
|
|
|
|
]) or "rowid"
|
|
|
|
|
|
content_col = self._pick_column(cols, [
|
|
|
|
|
|
"content", "text", "description", "moment_content",
|
|
|
|
|
|
])
|
|
|
|
|
|
time_col = self._pick_column(cols, [
|
|
|
|
|
|
"create_time", "create_time_sec", "ctime", "post_time", "time",
|
|
|
|
|
|
])
|
|
|
|
|
|
author_col = self._pick_column(cols, [
|
|
|
|
|
|
"author", "wxid", "username", "user_name", "poster",
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
if time_col is None:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "no_time_column",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 构造 SELECT
|
|
|
|
|
|
select_parts = [f"{id_col} AS moment_id"]
|
|
|
|
|
|
if content_col:
|
|
|
|
|
|
select_parts.append(f"{content_col} AS content")
|
|
|
|
|
|
if author_col:
|
|
|
|
|
|
select_parts.append(f"{author_col} AS author_wxid")
|
|
|
|
|
|
select_parts.append(f"{time_col} AS create_time")
|
|
|
|
|
|
select_sql = ", ".join(select_parts)
|
|
|
|
|
|
|
|
|
|
|
|
if cursor > 0:
|
|
|
|
|
|
sql = (
|
|
|
|
|
|
f"SELECT {select_sql} FROM [{table}] "
|
|
|
|
|
|
f"WHERE {time_col} < ? ORDER BY {time_col} DESC LIMIT ?"
|
|
|
|
|
|
)
|
|
|
|
|
|
params: tuple = (cursor, limit)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sql = (
|
|
|
|
|
|
f"SELECT {select_sql} FROM [{table}] "
|
|
|
|
|
|
f"ORDER BY {time_col} DESC LIMIT ?"
|
|
|
|
|
|
)
|
|
|
|
|
|
params = (limit,)
|
|
|
|
|
|
|
|
|
|
|
|
cur = conn.execute(sql, params)
|
|
|
|
|
|
moments: list[dict] = []
|
|
|
|
|
|
for row in cur.fetchall():
|
|
|
|
|
|
ct = int(row["create_time"]) if row["create_time"] is not None else 0
|
|
|
|
|
|
moments.append({
|
|
|
|
|
|
"moment_id": str(row["moment_id"]),
|
|
|
|
|
|
"content": str(row["content"] or "") if content_col else "",
|
|
|
|
|
|
"create_time": ct,
|
|
|
|
|
|
"author_wxid": str(row["author_wxid"] or "") if author_col else "",
|
|
|
|
|
|
})
|
|
|
|
|
|
except sqlite3.Error as e:
|
|
|
|
|
|
# 对外只返回通用错误码,详细异常记日志避免信息泄漏
|
|
|
|
|
|
logger.warning("get_moments_timeline 查询失败: %s", e)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": [],
|
|
|
|
|
|
"next_cursor": cursor,
|
|
|
|
|
|
"has_more": False,
|
|
|
|
|
|
"status": "query_error",
|
|
|
|
|
|
}
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
next_cursor = moments[-1]["create_time"] if moments else cursor
|
|
|
|
|
|
has_more = len(moments) >= limit
|
|
|
|
|
|
return {
|
|
|
|
|
|
"moments": moments,
|
|
|
|
|
|
"next_cursor": next_cursor,
|
|
|
|
|
|
"has_more": has_more,
|
|
|
|
|
|
"status": "ok",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 联系人字段映射(公共)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
# 系统账号白名单(去掉 @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
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
# 群聊直接标记为 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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"wxid": wxid,
|
|
|
|
|
|
"nickname": _val("nickname") or "",
|
|
|
|
|
|
"remark": _val("remark") or "",
|
|
|
|
|
|
"avatar_url": self._avatar_url_from_row(row, wxid),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"type": inferred_type,
|
2026-07-07 19:04:13 +08:00
|
|
|
|
"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}
|
2026-07-16 01:19:47 +08:00
|
|
|
|
db_path = self._ensure_decrypted("contact/contact.db")
|
2026-07-07 19:04:13 +08:00
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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]:
|
|
|
|
|
|
"""单条联系人查询。"""
|
2026-07-16 01:19:47 +08:00
|
|
|
|
db_path = self._ensure_decrypted("contact/contact.db")
|
2026-07-07 19:04:13 +08:00
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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}
|
2026-07-16 01:19:47 +08:00
|
|
|
|
db_path = self._ensure_decrypted("contact/contact.db")
|
2026-07-07 19:04:13 +08:00
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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}
|
2026-07-16 01:19:47 +08:00
|
|
|
|
db_path = self._ensure_decrypted("contact/contact.db")
|
2026-07-07 19:04:13 +08:00
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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]]:
|
2026-07-16 16:53:42 +08:00
|
|
|
|
"""读取媒体文件内容。
|
|
|
|
|
|
|
|
|
|
|
|
微信 4.x 的 .dat 媒体文件采用单字节 XOR 加密:首字节为密文,
|
|
|
|
|
|
通过对比已知图片格式 magic bytes 推导 XOR key 后逐字节解密。
|
|
|
|
|
|
推导失败时回退原密文返回,避免阻塞调用方。
|
|
|
|
|
|
"""
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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
|
2026-07-16 16:53:42 +08:00
|
|
|
|
|
|
|
|
|
|
# 微信 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
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
mime = self._guess_mime(path)
|
|
|
|
|
|
return (data, mime)
|
|
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-07 20:17:06 +08:00
|
|
|
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
2026-07-07 19:04:13 +08:00
|
|
|
|
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")
|