_<其他段>.statistic
+# code 必须紧跟在 "key_" 之后(不能是 "key_reportnow_..." 这种带前缀的)
+_KVCOMM_FILENAME_RE = re.compile(r"^key_(\d+)_.+\.statistic$", re.IGNORECASE)
+
+# AES 解密结果允许的图像 magic
+_IMAGE_MAGICS = (
+ b"\xff\xd8\xff", # JPEG
+ b"\x89\x50\x4e\x47", # PNG
+ b"GIF", # GIF
+ b"RIFF", # WebP container(首块只能看前 16B,全检需 [8:12]==b"WEBP")
+ b"wxgf", # 微信 HEVC GIF / Live Photo
+)
+
+
+def normalize_wxid(account_id):
+ """归一化账号 ID。
+
+ - wxid_ 形式:保留 wxid_,丢弃后续下划线分段
+ - _<4 alnum> 形式:丢弃 _<4 alnum> 后缀(macOS 路径目录名常见)
+ - 其他:原样返回
+ """
+ aid = (account_id or "").strip()
+ if not aid:
+ return ""
+ if aid.lower().startswith("wxid_"):
+ m = re.match(r"^(wxid_[^_]+)", aid, re.IGNORECASE)
+ return m.group(1) if m else aid
+ m = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", aid)
+ return m.group(1) if m else aid
+
+
+def derive_image_keys(code, wxid):
+ """从 (code, wxid) 派生 (xor_key, aes_key_ascii)。
+
+ aes_key_ascii 是 16 字符 hex 字符串;调用方按 ASCII 编码取前 16 字节作为
+ AES-128 密钥。本函数不做 wxid 归一化(由调用方枚举原值与归一化值)。
+ """
+ xor_key = int(code) & 0xFF
+ aes_key = hashlib.md5(f"{code}{wxid}".encode("utf-8")).hexdigest()[:16]
+ return xor_key, aes_key
+
+
+def derive_kvcomm_dir_candidates(db_dir):
+ """从 db_dir 推算所有可能的 kvcomm 缓存目录(按优先级排序)。
+
+ 微信 4.x 在不同版本 / 安装方式下 kvcomm 路径不固定,需要枚举多个候选。
+ 返回的列表里至少有一项被 os.path.isdir 确认存在时才算可用。
+ """
+ parts = db_dir.rstrip(os.sep).split(os.sep)
+ candidates = []
+ if "xwechat_files" in parts:
+ idx = parts.index("xwechat_files")
+ documents_root = os.sep.join(parts[:idx])
+ # 1) 与 xwechat_files 兄弟目录的 app_data
+ candidates.append(os.path.join(documents_root, "app_data", "net", "kvcomm"))
+ # 2) 旧版可能放 xwechat 子目录
+ candidates.append(os.path.join(documents_root, "xwechat", "net", "kvcomm"))
+ # 3) 容器内 Application Support 路径(部分版本)
+ if idx >= 1:
+ container_root = os.sep.join(parts[:idx - 1]) # Documents 之上
+ candidates.append(os.path.join(
+ container_root, "Library", "Application Support",
+ "com.tencent.xinWeChat", "xwechat", "net", "kvcomm"))
+ candidates.append(os.path.join(
+ container_root, "Library", "Application Support",
+ "com.tencent.xinWeChat", "net", "kvcomm"))
+ # 4) 兜底:HOME 下默认沙盒路径
+ home = os.path.expanduser("~")
+ candidates.append(os.path.join(
+ home, "Library", "Containers", "com.tencent.xinWeChat", "Data",
+ "Documents", "app_data", "net", "kvcomm"))
+ # 去重,保留顺序
+ seen = set()
+ deduped = []
+ for c in candidates:
+ if c not in seen:
+ seen.add(c)
+ deduped.append(c)
+ return deduped
+
+
+def find_existing_kvcomm_dir(db_dir):
+ """从候选路径中返回第一个存在的 kvcomm 目录;都不存在返回 None。"""
+ for candidate in derive_kvcomm_dir_candidates(db_dir):
+ if os.path.isdir(candidate):
+ return candidate
+ return None
+
+
+def collect_kvcomm_codes(kvcomm_dir):
+ """扫 kvcomm 目录,返回去重排序的 code 列表。"""
+ if not kvcomm_dir or not os.path.isdir(kvcomm_dir):
+ return []
+ codes = set()
+ try:
+ names = os.listdir(kvcomm_dir)
+ except OSError:
+ return []
+ for name in names:
+ m = _KVCOMM_FILENAME_RE.match(name)
+ if not m:
+ continue
+ try:
+ code = int(m.group(1))
+ except ValueError:
+ continue
+ if 0 < code <= 0xFFFFFFFF:
+ codes.add(code)
+ return sorted(codes)
+
+
+def collect_wxid_candidates(db_dir):
+ """从 db_dir 提取候选 wxid(含原值和归一化值)。"""
+ parts = db_dir.rstrip(os.sep).split(os.sep)
+ if "xwechat_files" not in parts:
+ return []
+ idx = parts.index("xwechat_files")
+ if idx + 1 >= len(parts):
+ return []
+ raw = parts[idx + 1]
+ candidates = [raw]
+ normalized = normalize_wxid(raw)
+ if normalized and normalized != raw:
+ candidates.append(normalized)
+ return candidates
+
+
+def find_v2_template_ciphertexts(attach_dir, max_templates=3, max_files=64):
+ """在 attach_dir 下找 V2 .dat 文件的模板密文([0xF:0x1F] 16 字节)。
+
+ 优先 _t.dat(缩略图小、读得快),找不到再降级用任意 .dat。
+ 返回最多 max_templates 个**不同**的密文,用于交叉验证防止短 magic 偶然命中。
+ """
+ if not attach_dir or not os.path.isdir(attach_dir):
+ return []
+
+ def _scan(suffix):
+ # 出口条件只看是否凑够 max_templates 个**不同**密文;不因为
+ # examined 达到 max_files 提前退出 —— 否则若前 64 个文件都是同一
+ # 张图的副本,结果只有 1 个 template,交叉验证就退化成单模板。
+ out, seen = [], set()
+ examined = 0
+ for root, _, files in os.walk(attach_dir):
+ for f in files:
+ if not f.endswith(suffix):
+ continue
+ examined += 1
+ try:
+ with open(os.path.join(root, f), "rb") as fp:
+ data = fp.read(0x20)
+ except OSError:
+ continue
+ if len(data) >= 0x1F and data[:6] == V2_MAGIC:
+ ct = data[0xF:0x1F]
+ if ct not in seen:
+ seen.add(ct)
+ out.append(ct)
+ if len(out) >= max_templates:
+ return out
+ # 兜底:扫了 max_files 个文件还凑不齐 max_templates 个不同的,
+ # 提前停止以免在巨型 attach 目录里跑很久(只在 out 不空时才能停)
+ if examined >= max_files and out:
+ return out
+ return out
+
+ return _scan("_t.dat") or _scan(".dat")
+
+
+def verify_aes_key(aes_key_ascii, template_ct):
+ """AES-128-ECB 解 template_ct(16 字节),检查头部是否是图像 magic。"""
+ if not aes_key_ascii or not template_ct or len(template_ct) != 16:
+ return False
+ key_bytes = aes_key_ascii.encode("ascii", errors="ignore")[:16]
+ if len(key_bytes) < 16:
+ return False
+ try:
+ cipher = AES.new(key_bytes, AES.MODE_ECB)
+ decrypted = cipher.decrypt(template_ct)
+ except (ValueError, KeyError):
+ return False
+ return any(decrypted.startswith(m) for m in _IMAGE_MAGICS)
+
+
+def verify_aes_key_against_all(aes_key_ascii, templates):
+ """在多个模板上交叉验证 aes_key。全部通过才算命中(防短 magic 偶然碰撞)。"""
+ if not templates:
+ return False
+ return all(verify_aes_key(aes_key_ascii, ct) for ct in templates)
+
+
+# ---------- 方案2 (wxid 后缀候选搜索, fallback) ---------- #
+
+# md5 hex 后缀只可能是 [0-9a-f]; 严格匹配避免误吃非 hex 字符的 wxid 后缀
+# (microsoft 改方案 / 异常路径) 后悄悄返回空候选误导用户。
+_WXID_HEX_SUFFIX_RE = re.compile(r"^(.+)_([0-9a-fA-F]{4})$")
+
+
+def extract_wxid_parts(db_dir):
+ """从 db_dir 提取 (wxid_full, wxid_norm, suffix)。
+
+ db_dir 形如 .../xwechat_files/_<4hex>/db_storage
+ 返回 ('your_wxid_a1b2', 'your_wxid', 'a1b2') 或 None(不匹配 _<4 hex> 后缀)。
+
+ suffix 是 4 位小写 hex(macOS 路径目录名固定格式 = md5(str(uin))[:4]),
+ 用作方案2 中候选搜索的 md5 前缀目标。
+ """
+ wxid_candidates = collect_wxid_candidates(db_dir)
+ if not wxid_candidates:
+ return None
+ wxid_full = wxid_candidates[0] # raw 总是第一个
+ m = _WXID_HEX_SUFFIX_RE.match(wxid_full)
+ if not m:
+ return None
+ return wxid_full, m.group(1), m.group(2).lower()
+
+
+def derive_xor_key_from_v2_dat(attach_dir, sample=10, min_samples=3):
+ """扫多个 V2 .dat 末字节投票反推 xor_key(假设 JPG EOI = 0xD9)。
+
+ macOS 缩略图 _t.dat 几乎都是 JPG,末字节 = 0xD9 ^ xor_key 反推稳定。
+ 投票多数一致才信;分歧大说明假设破灭(不全是 JPG)。
+
+ Args:
+ attach_dir: 微信 attach 目录
+ sample: 扫到 N 个 V2 .dat 即停止(性能上限)
+ min_samples: 至少 N 个样本才视为"投票可信"。低于此返回 None,
+ 避免 1-2 个样本时一旦撞到非 JPG 就 lock 错 xor_key。
+ Returns:
+ (xor_key, votes, total) 或 None (样本不足 / 找不到 V2 .dat)。
+ votes < total 时调用方应警告 (假设可能破灭)。
+ """
+ if not attach_dir or not os.path.isdir(attach_dir):
+ return None
+ last_bytes = []
+ for root, _, files in os.walk(attach_dir):
+ for f in files:
+ if not f.endswith(".dat"):
+ continue
+ path = os.path.join(root, f)
+ try:
+ if os.path.getsize(path) < 0x20:
+ continue
+ with open(path, "rb") as fp:
+ head = fp.read(6)
+ if head != V2_MAGIC:
+ continue
+ fp.seek(-1, 2)
+ last = fp.read(1)[0]
+ last_bytes.append(last ^ 0xD9)
+ if len(last_bytes) >= sample:
+ break
+ except OSError:
+ continue
+ if len(last_bytes) >= sample:
+ break
+ if len(last_bytes) < min_samples:
+ return None
+ top, votes = Counter(last_bytes).most_common(1)[0]
+ return top, votes, len(last_bytes)
+
+
+def bruteforce_uin_candidates(xor_key, wxid_suffix):
+ """枚举 0~2^32 中 (uin & 0xff == xor_key) 且 md5(str(uin))[:4] == suffix 的 uin。
+
+ 单核 ~7-8 秒(2^24 = 16M MD5)。期望命中数 ~256(2^24 / 16^4)。
+
+ 注意 uin 上限假设为 2^32(4 字节无符号整数)。函数命名沿用密码学
+ 候选搜索的 brute-force 术语;中文 prose 用 "枚举 / 候选搜索" 表述。
+
+ 本函数是单进程 + hex 比较版本, 主要用作算法金标准 (测试) 与
+ parallel 路径不可用时的 fallback。生产 dispatcher 走 parallel
+ 版本 (见 `_bruteforce_with_aes_parallel`)。
+ """
+ target = wxid_suffix.lower()
+ out = []
+ for uin in range(xor_key, 2 ** 32, 256):
+ if hashlib.md5(str(uin).encode()).hexdigest()[:4] == target:
+ out.append(uin)
+ return out
+
+
+def _aes_template_match(aes_bytes, ciphertext):
+ """worker 进程内: AES-128-ECB 解 ciphertext 并检查图像 magic。
+
+ 放模块顶层是为了 multiprocessing pickle (worker 函数必须可 import).
+ 比 verify_aes_key 更紧凑 (省去 try-except 默认通过短路) — 在百万次
+ 调用循环里这点开销有意义。
+ """
+ try:
+ decrypted = AES.new(aes_bytes, AES.MODE_ECB).decrypt(ciphertext)
+ except (ValueError, KeyError):
+ return False
+ return any(decrypted.startswith(m) for m in _IMAGE_MAGICS)
+
+
+def _bruteforce_worker_chunk(start, end, xor_key, suffix_bytes, wxid_bytes,
+ templates, result_queue):
+ """worker: 扫候选区间, 命中 (md5 前缀 + 全模板 AES) 推入 queue 即返回。
+
+ 内联做 md5 + AES 验证 (不分两 pass) 让早停在 worker 内有效。
+ suffix 用 binary 比 (digest()[:2] vs hexdigest()[:4]), 节省 hex 转换。
+ """
+ for i in range(start, end):
+ uin = (i << 8) | xor_key
+ uin_bytes = str(uin).encode("ascii")
+ if hashlib.md5(uin_bytes).digest()[:2] == suffix_bytes:
+ aes_hex = hashlib.md5(uin_bytes + wxid_bytes).hexdigest()[:16]
+ aes_bytes = aes_hex.encode("ascii")
+ if all(_aes_template_match(aes_bytes, ct) for ct in templates):
+ result_queue.put((uin, aes_hex))
+ return
+
+
+def _bruteforce_with_aes_parallel(xor_key, suffix_hex, wxid_norm, templates,
+ workers=None, timeout=60):
+ """方案2 多进程实现 — 加速思路借鉴自 @H3CoF6 PR #69.
+
+ 与单进程版本的差异:
+ - cpu_count 个 worker 并行扫 0~2^32 候选 (~5-8x 加速)
+ - 二进制 md5 digest()[:2] 替代 hexdigest()[:4] (省 hex 转换)
+ - 内联多模板 AES 验证 (无两 pass; PR #69 是单模板, 本实现保留多模板
+ 交叉验证防短 magic 偶然命中)
+ - 任一 worker 命中即推 queue, 主进程 terminate 其他 (早停)
+
+ Returns:
+ (uin, aes_key_hex) 或 None (timeout / 全 worker 跑完未命中)
+ """
+ suffix_bytes = bytes.fromhex(suffix_hex)
+ wxid_bytes = wxid_norm.encode("ascii")
+ if workers is None:
+ workers = max(1, multiprocessing.cpu_count())
+ total = 1 << 24
+ chunk = total // workers
+
+ queue = multiprocessing.Queue()
+ procs = []
+ for i in range(workers):
+ start_i = i * chunk
+ end_i = (i + 1) * chunk if i != workers - 1 else total
+ p = multiprocessing.Process(
+ target=_bruteforce_worker_chunk,
+ args=(start_i, end_i, xor_key, suffix_bytes, wxid_bytes,
+ templates, queue),
+ daemon=True,
+ )
+ p.start()
+ procs.append(p)
+
+ found = None
+ deadline = time.time() + timeout
+ try:
+ while any(p.is_alive() for p in procs) and time.time() < deadline:
+ try:
+ found = queue.get(timeout=0.1)
+ break
+ except _queue.Empty:
+ continue
+ # 所有 worker 死亡后 queue 仍可能有最后入队的数据
+ if not found:
+ try:
+ found = queue.get_nowait()
+ except _queue.Empty:
+ pass
+ finally:
+ for p in procs:
+ if p.is_alive():
+ p.terminate()
+ for p in procs:
+ p.join(timeout=1)
+ return found
+
+
+# ---------- Dispatcher + 两条路径 ---------- #
+
+def _find_via_kvcomm(db_dir, templates):
+ """方案1:从 kvcomm 缓存文件名提 uin 候选。
+
+ 要求:~/.../app_data/net/kvcomm/key__*.statistic 存在。
+ 返回 (xor_key, aes_key) 或 None(kvcomm 缺失 / 无 code / wxid 提不出 /
+ 所有组合都验证失败)。
+ """
+ kvcomm_dir = find_existing_kvcomm_dir(db_dir)
+ if not kvcomm_dir:
+ print("[!] 方案1: 找不到 kvcomm 缓存目录,已尝试以下候选:", flush=True)
+ for c in derive_kvcomm_dir_candidates(db_dir):
+ print(f" {c}", flush=True)
+ return None
+ print(f"[+] 方案1: 使用 kvcomm 目录 {kvcomm_dir}", flush=True)
+
+ codes = collect_kvcomm_codes(kvcomm_dir)
+ if not codes:
+ print("[!] 方案1: kvcomm 目录无 key_*.statistic 文件", flush=True)
+ return None
+ print(f"[+] 方案1: 找到 {len(codes)} 个 uin 候选", flush=True)
+
+ wxid_candidates = collect_wxid_candidates(db_dir)
+ if not wxid_candidates:
+ print("[!] 方案1: 无法从 db_dir 提取 wxid", flush=True)
+ return None
+ print(f"[+] 方案1: wxid 候选 {wxid_candidates}", flush=True)
+
+ # 穷举顺序:wxid 外、uin 内。多账号系统下当前账号的所有 uin 优先尝试。
+ for wxid in wxid_candidates:
+ for code in codes:
+ xor_key, aes_key = derive_image_keys(code, wxid)
+ if verify_aes_key_against_all(aes_key, templates):
+ print()
+ print("[OK] 方案1 验证成功(所有模板均通过):", flush=True)
+ print(f" uin = {code}", flush=True)
+ print(f" wxid = {wxid}", flush=True)
+ print(f" xor_key = 0x{xor_key:02x}", flush=True)
+ print(f" aes_key = {aes_key}", flush=True)
+ return xor_key, aes_key
+
+ print("[!] 方案1: 所有 (wxid × uin) 组合都未通过交叉验证", flush=True)
+ return None
+
+
+def _find_via_bruteforce(db_dir, attach_dir, templates):
+ """方案2 (fallback):从 wxid 后缀候选搜索 uin(不依赖 kvcomm)。
+
+ 流程:wxid 后缀 + V2 .dat 末字节投票反推 xor_key → 枚举 2^24 个 uin
+ 候选 → 用 templates 跑 AES 验证唯一定位。
+ """
+ parts = extract_wxid_parts(db_dir)
+ if not parts:
+ print("[!] 方案2: wxid 路径不含 _<4 hex> 后缀,无法应用方案2", flush=True)
+ return None
+ wxid_full, wxid_norm, suffix = parts
+ print(f"[+] 方案2: wxid_full={wxid_full}, suffix={suffix}", flush=True)
+
+ xres = derive_xor_key_from_v2_dat(attach_dir)
+ if not xres:
+ print("[!] 方案2: V2 .dat 样本不足 (需 >= 3 个), 无法投票反推 xor_key",
+ flush=True)
+ print(" 请先在微信中再看 1-2 张图片,让微信生成更多 V2 .dat 文件",
+ flush=True)
+ return None
+ xor_key, votes, total = xres
+ if votes == total:
+ print(f"[+] 方案2: xor_key=0x{xor_key:02x} ({votes}/{total} 一致, 假设 JPG)",
+ flush=True)
+ else:
+ print(f"[!] 方案2: xor_key 投票分歧 {votes}/{total}, 取多数 0x{xor_key:02x} "
+ f"(可能 attach 不全是 JPG)", flush=True)
+
+ workers = max(1, multiprocessing.cpu_count())
+ print(f"[*] 方案2: 多进程枚举 (workers={workers}, 预计 ~1-2 秒)...",
+ flush=True)
+
+ # 同时试 wxid_full 和 wxid_norm(normalize_wxid 可能去掉后缀)
+ wxid_tries = [wxid_norm]
+ if wxid_full != wxid_norm:
+ wxid_tries.append(wxid_full)
+
+ t0 = time.time()
+ for wxid_try in wxid_tries:
+ result = _bruteforce_with_aes_parallel(
+ xor_key, suffix, wxid_try, templates, workers=workers
+ )
+ if result:
+ uin, aes_key = result
+ elapsed = time.time() - t0
+ print()
+ print(f"[OK] 方案2 (fallback) 验证成功 (耗时 {elapsed:.1f}s):",
+ flush=True)
+ print(f" uin = {uin}", flush=True)
+ print(f" wxid = {wxid_try}", flush=True)
+ print(f" xor_key = 0x{xor_key:02x}", flush=True)
+ print(f" aes_key = {aes_key}", flush=True)
+ return xor_key, aes_key
+
+ elapsed = time.time() - t0
+ print(f"[!] 方案2: 所有 uin 候选都未通过 AES 验证 (耗时 {elapsed:.1f}s)",
+ flush=True)
+ return None
+
+
+def find_image_key_macos(db_dir):
+ """在 macOS 上派生并交叉验证 V2 图片密钥。
+
+ Dispatcher:先尝试方案1 (kvcomm),失败 fallback 到方案2 (候选搜索)。
+ 两条路径都需要 V2 .dat 模板做 AES 验证 — 模板缺失就直接失败。
+
+ Returns:
+ (xor_key, aes_key_ascii) on success;失败返回 None 并打印诊断信息。
+ """
+ base_dir = os.path.dirname(db_dir) # 去掉 db_storage
+ attach_dir = os.path.join(base_dir, "msg", "attach")
+ templates = find_v2_template_ciphertexts(attach_dir)
+ if not templates:
+ print(f"[!] 在 {attach_dir} 下找不到 V2 模板文件", flush=True)
+ print(" 请先在微信中查看 1-2 张图片,让微信生成 V2 .dat 文件",
+ flush=True)
+ return None
+ print(f"[+] 找到 {len(templates)} 个不同模板用于交叉验证", flush=True)
+
+ # 方案1 (主路径): kvcomm 缓存
+ result = _find_via_kvcomm(db_dir, templates)
+ if result is not None:
+ return result
+
+ # 方案2 (fallback): wxid 后缀候选搜索
+ print()
+ print("[*] 方案1 失败, 尝试方案2 (wxid 后缀候选搜索, fallback)", flush=True)
+ return _find_via_bruteforce(db_dir, attach_dir, templates)
+
+
+def _save_config_atomic(config_path, config):
+ """原子写 config.json:tmp + os.replace 防止中断留下半截文件。
+
+ 若 json.dump 或 os.replace 抛错,向上抛出(让 main 给出 stacktrace
+ 而不是默默写坏 config);同时清理可能残留的 .tmp 文件。
+ """
+ tmp_path = config_path + ".tmp"
+ try:
+ with open(tmp_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=2, ensure_ascii=False)
+ os.replace(tmp_path, config_path)
+ finally:
+ # 失败路径上 .tmp 可能残留;成功路径上 os.replace 已经把 tmp 移走了
+ if os.path.exists(tmp_path):
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def main(config_path=None):
+ """CLI 入口。`config_path` 默认是脚本同目录下的 config.json,
+ 暴露此参数主要为方便单元测试注入隔离的临时配置。"""
+ if platform.system().lower() != "darwin":
+ print("此脚本只在 macOS 上工作。其他平台请用 find_image_key.py(内存扫描)。",
+ file=sys.stderr, flush=True)
+ sys.exit(1)
+
+ if config_path is None:
+ config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ "config.json")
+ try:
+ with open(config_path, encoding="utf-8") as f:
+ config = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ print(f"[!] 读取 {config_path} 失败: {e}", file=sys.stderr, flush=True)
+ sys.exit(1)
+
+ db_dir = config.get("db_dir", "")
+ if not db_dir:
+ print("[!] config.json 中未配置 db_dir", file=sys.stderr, flush=True)
+ sys.exit(1)
+ db_dir = os.path.expanduser(os.path.expandvars(db_dir))
+ print(f"[*] db_dir = {db_dir}", flush=True)
+
+ # 短路:如果已有 image_aes_key 且仍能在所有模板上验证通过,直接退出
+ # (沿用 find_image_key.py 的 UX 约定,避免无谓重写 config.json)
+ existing_aes = config.get("image_aes_key")
+ if existing_aes:
+ base_dir = os.path.dirname(db_dir)
+ attach_dir = os.path.join(base_dir, "msg", "attach")
+ templates = find_v2_template_ciphertexts(attach_dir)
+ if templates and verify_aes_key_against_all(existing_aes, templates):
+ print(f"[+] 已有 image_aes_key={existing_aes} 在 "
+ f"{len(templates)} 个模板上仍然有效,无需重新派生", flush=True)
+ return
+
+ result = find_image_key_macos(db_dir)
+ if result is None:
+ sys.exit(1)
+
+ xor_key, aes_key = result
+ config["image_aes_key"] = aes_key
+ config["image_xor_key"] = xor_key
+ _save_config_atomic(config_path, config)
+ print()
+ print(f"[+] 已写入 {config_path}", flush=True)
+ print(" 下次启动 monitor_web.py 时会自动加载新密钥,图片消息显示内联预览",
+ flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/find_image_key_monitor.py b/find_image_key_monitor.py
index fb68cee..20a7400 100644
--- a/find_image_key_monitor.py
+++ b/find_image_key_monitor.py
@@ -235,7 +235,7 @@ def main():
except (FileNotFoundError, json.JSONDecodeError):
config_raw = {}
- db_dir = config['db_dir']
+ db_dir = os.path.expanduser(os.path.expandvars(config['db_dir']))
base_dir = os.path.dirname(db_dir)
attach_dir = os.path.join(base_dir, 'msg', 'attach')
diff --git a/main.py b/main.py
index 885f975..9862af6 100644
--- a/main.py
+++ b/main.py
@@ -1,14 +1,21 @@
"""
WeChat Decrypt 一键启动
-python main.py # 提取密钥 + 启动 Web UI
-python main.py decrypt # 提取密钥 + 解密全部数据库
+python main.py # 提取密钥 + 启动 Web UI
+python main.py decrypt # 提取密钥 + 解密全部数据库
+python main.py export # 提取密钥 + 解密 + 批量导出聊天记录
+python main.py all # 从零到完成:密钥 → 解密 → 导出
+python main.py status # 显示当前数据状态
"""
-import json
-import os
-import sys
import functools
+import glob
+import json
+import os
+import platform
+import subprocess
+import sys
+
print = functools.partial(print, flush=True)
from key_utils import strip_key_metadata
@@ -16,6 +23,8 @@ from key_utils import strip_key_metadata
def check_wechat_running():
"""检查微信是否在运行,返回 True/False"""
+ if platform.system().lower() == "darwin":
+ return subprocess.run(["pgrep", "-x", "WeChat"], capture_output=True).returncode == 0
from find_all_keys import get_pids
try:
get_pids()
@@ -24,6 +33,97 @@ def check_wechat_running():
return False
+def _run_decode_images(cfg, argv):
+ """`decode-images` 子命令:批量把 .dat 图片解密成明文图片树。
+
+ 与 decrypt 不同,decode-images **不需要** 微信进程在运行,也不需要 DB 密钥
+ (只读已存在的 .dat 文件;V2 文件用 config.json 里的 image_aes_key)。
+ """
+ import argparse
+ from decode_image import decode_all_dats
+
+ parser = argparse.ArgumentParser(
+ prog="main.py decode-images",
+ description=(
+ "批量解密微信本地 .dat 图片到明文图片树。"
+ "区别于 decode_image.py 单文件 CLI,本子命令扫描 attach_dir 下"
+ "全部 .dat,镜像目录结构产出明文(jpg / png / gif / webp / hevc)。"
+ ),
+ )
+ default_base = cfg.get("wechat_base_dir") or os.path.dirname(cfg["db_dir"])
+ default_attach = os.path.join(default_base, "msg", "attach")
+ default_out = cfg.get("decoded_image_dir", "decoded_images")
+ parser.add_argument(
+ "--attach-dir", default=None,
+ help=f"微信 msg/attach 根目录,覆盖默认推断(默认: {default_attach})",
+ )
+ parser.add_argument(
+ "--decoded-dir", default=None,
+ help=f"明文图片输出根目录,覆盖 config.json 的 decoded_image_dir(默认: {default_out})",
+ )
+ parser.add_argument(
+ "--aes-key", default=None,
+ help="V2 AES key(16 字节 ASCII 字符串),覆盖 config.json 的 image_aes_key",
+ )
+ parser.add_argument(
+ "--xor-key", default=None,
+ help="V2 XOR key(可十进制或 0x 十六进制),覆盖 config.json 的 image_xor_key(默认: 0x88)",
+ )
+ parser.add_argument(
+ "--force", action="store_true",
+ help="忽略已存在目标重新解密(默认按 basename 跳过)",
+ )
+ args = parser.parse_args(argv)
+
+ attach_dir = args.attach_dir or default_attach
+ out_dir = args.decoded_dir or default_out
+ aes_key = args.aes_key if args.aes_key is not None else cfg.get("image_aes_key")
+ xor_key_raw = args.xor_key if args.xor_key is not None else cfg.get("image_xor_key", 0x88)
+ if isinstance(xor_key_raw, str):
+ xor_key = int(xor_key_raw, 0)
+ else:
+ xor_key = xor_key_raw
+
+ if not os.path.isdir(attach_dir):
+ print(f"[ERROR] attach 目录不存在: {attach_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ if aes_key is None:
+ print(
+ "[NOTE] 未配置 image_aes_key,V2 加密图片将被跳过(计入 skipped_no_key);"
+ "V1 / 老 XOR 图片不受影响。提取 V2 key 见 README 的图片解密章节。",
+ file=sys.stderr,
+ )
+
+ print(f" attach_dir = {attach_dir}")
+ print(f" out_dir = {out_dir}")
+ print(f" aes_key = {'已配置' if aes_key else '未配置'}")
+ print(f" xor_key = 0x{xor_key:02x}")
+ print(f" force = {args.force}")
+ print()
+
+ stats = decode_all_dats(
+ attach_dir=attach_dir,
+ out_dir=out_dir,
+ aes_key=aes_key,
+ xor_key=xor_key,
+ force=args.force,
+ )
+
+ print()
+ print("=" * 60)
+ print(f"扫描 {stats['total']} 个 .dat 文件")
+ print(f" 解码: {stats['decoded']} 跳过(已存在): {stats['skipped']} "
+ f"无 key 跳过: {stats['skipped_no_key']} 失败: {stats['failed']}")
+ if stats["formats"]:
+ fmt_summary = ", ".join(f"{ext}={n}" for ext, n in sorted(stats["formats"].items()))
+ print(f" 按格式: {fmt_summary}")
+ print(f"输出在: {out_dir}")
+
+ if stats["failed"] > 0:
+ sys.exit(2)
+
+
def ensure_keys(keys_file, db_dir):
"""确保密钥文件存在且匹配当前 db_dir,否则重新提取"""
if os.path.exists(keys_file):
@@ -32,7 +132,6 @@ def ensure_keys(keys_file, db_dir):
keys = json.load(f)
except (json.JSONDecodeError, ValueError):
keys = {}
- # 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥)
saved_dir = keys.pop("_db_dir", None)
if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)):
print(f"[!] 密钥文件对应的目录已变更,需要重新提取")
@@ -54,7 +153,6 @@ def ensure_keys(keys_file, db_dir):
sys.exit(1)
print()
- # 提取后再次检查
if not os.path.exists(keys_file):
print("[!] 密钥提取失败")
sys.exit(1)
@@ -70,16 +168,126 @@ def ensure_keys(keys_file, db_dir):
sys.exit(1)
+def show_status():
+ """显示当前数据状态"""
+ cfg = {}
+ config_file = "config.json"
+ if os.path.exists(config_file):
+ with open(config_file, encoding="utf-8") as f:
+ cfg = json.load(f)
+ print(f"[config] db_dir = {cfg.get('db_dir', '?')}")
+ else:
+ print("[config] 未找到 config.json")
+
+ keys_files = sorted(glob.glob("all_keys*.json"))
+ print(f"[keys] {len(keys_files)} 个密钥文件")
+ for kf in keys_files:
+ sz = os.path.getsize(kf) / 1024
+ print(f" {kf} ({sz:.0f} KB)")
+
+ decrypted_dir = cfg.get("decrypted_dir", "decrypted")
+ if os.path.exists(decrypted_dir):
+ dbs = glob.glob(os.path.join(decrypted_dir, "**/*.db"), recursive=True)
+ total_mb = sum(os.path.getsize(f) for f in dbs) / 1024 / 1024
+ print(f"[decrypt] {len(dbs)} 个数据库 ({total_mb:.0f} MB)")
+ # 检查是否有消息内容(约略估计是否已导出)
+ for db in dbs:
+ if "message" in os.path.basename(db):
+ sz = os.path.getsize(db) / 1024 / 1024
+ print(f" 消息库: {len([d for d in dbs if 'message' in d])} 个 ({sz:.0f} MB)")
+ break
+ else:
+ print("[decrypt] 未解密 (运行: python main.py decrypt)")
+
+ exported_dir = "exported_chats"
+ if os.path.exists(exported_dir):
+ jsons = [f for f in glob.glob(os.path.join(exported_dir, "*.json"))
+ if not f.endswith("_transcribed.json")]
+ tx_jsons = glob.glob(os.path.join(exported_dir, "*_transcribed.json"))
+ total_sz = sum(os.path.getsize(f) for f in jsons) / 1024 / 1024
+ print(f"[export] {len(jsons)} 个 JSON ({total_sz:.0f} MB)")
+ else:
+ print("[export] 未导出 (运行: python main.py export)")
+
+ if os.path.exists(exported_dir):
+ total_voice = 0
+ total_tx = 0
+ for jp in glob.glob(os.path.join(exported_dir, "*_transcribed.json")):
+ try:
+ with open(jp, encoding="utf-8") as f:
+ data = json.load(f)
+ except Exception:
+ continue
+ if isinstance(data, dict) and "chats" in data:
+ for chat in data["chats"]:
+ for m in chat.get("messages", []):
+ if m.get("type") == "voice":
+ total_voice += 1
+ if m.get("transcription"):
+ total_tx += 1
+ elif isinstance(data, dict):
+ for m in data.get("messages", []):
+ if m.get("type") == "voice":
+ total_voice += 1
+ if m.get("transcription"):
+ total_tx += 1
+ if total_voice > 0:
+ pct = total_tx * 100 // max(total_voice, 1)
+ print(f"[transcribe] {total_tx}/{total_voice} ({pct}%) 条语音已转录")
+
+ # 建议的下一步
+ print()
+ steps = []
+ if not os.path.exists(decrypted_dir):
+ steps.append("python main.py decrypt — 解密数据库")
+ elif not os.path.exists(exported_dir):
+ steps.append("main.py export — 导出聊天记录")
+ if steps:
+ print("建议的下一步:")
+ for s in steps:
+ print(f" {s}")
+ else:
+ print("所有步骤已完成。")
+
+
+def print_usage():
+ print("用法:")
+ print(" python main.py 启动实时消息监听 (Web UI)")
+ print(" python main.py decrypt 解密全部数据库到 decrypted/")
+ print(" python main.py decode-images 批量解密 .dat 图片到 decoded_image_dir/")
+ print(" python main.py decode-images --help 查看 decode-images 全部选项")
+ print(" python main.py export 解密 + 批量导出聊天记录")
+ print(" python main.py all 从零到完成:密钥 → 解密 → 导出")
+ print(" python main.py status 显示当前状态和磁盘用量")
+
+
def main():
print("=" * 60)
print(" WeChat Decrypt")
print("=" * 60)
print()
- # 1. 加载配置(自动检测 db_dir)
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "web"
+
+ # help / status 不需要密钥和微信进程
+ if cmd in ("help", "-h", "--help"):
+ print_usage()
+ return
+ if cmd in ("status", "-s"):
+ show_status()
+ return
+
+ # 以下命令需要配置 + 微信进程
from config import load_config
cfg = load_config()
+ # 早路由:decode-images 不需要微信进程在运行,也不需要 DB 密钥
+ if len(sys.argv) > 1 and sys.argv[1] == "decode-images":
+ print("[*] 批量解密图片...")
+ print()
+ _run_decode_images(cfg, sys.argv[2:])
+ return
+
# 2. 检查微信进程
if not check_wechat_running():
print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})")
@@ -87,28 +295,52 @@ def main():
sys.exit(1)
print("[+] 微信进程运行中")
- # 3. 提取密钥
ensure_keys(cfg["keys_file"], cfg["db_dir"])
- # 4. 根据子命令执行
- cmd = sys.argv[1] if len(sys.argv) > 1 else "web"
-
if cmd == "decrypt":
print("[*] 开始解密全部数据库...")
print()
from decrypt_db import main as decrypt_all
decrypt_all()
+
+ elif cmd in ("export", "all"):
+ print("[*] 开始解密全部数据库...")
+ print()
+ from decrypt_db import main as decrypt_all
+ decrypt_all()
+ print()
+ print("[*] 开始批量导出聊天记录...")
+ print()
+ from export_all_chats import main as export_all
+ try:
+ export_all()
+ except SystemExit:
+ pass
+
+ if cmd == "all" and os.path.exists("exported_chats"):
+ print()
+ print("[*] 检查语音转录配置...")
+ from config import load_config
+ cfg2 = load_config()
+ from mcp_server import _resolve_active_backend
+ backend = _resolve_active_backend()
+ if backend and backend != "local":
+ print(f" 检测到 backend = {backend}")
+ print(" 如需转录语音,运行: python export_all_chats.py --with-transcriptions")
+ else:
+ print(" 未配置语音转录 backend (config.json 中设置)")
+ print(" 配置后运行: python export_all_chats.py --with-transcriptions")
+
elif cmd == "web":
print("[*] 启动 Web UI...")
print()
from monitor_web import main as start_web
start_web()
+
else:
print(f"[!] 未知命令: {cmd}")
print()
- print("用法:")
- print(" python main.py 启动实时消息监听 (Web UI)")
- print(" python main.py decrypt 解密全部数据库到 decrypted/")
+ print_usage()
sys.exit(1)
diff --git a/mcp_server.py b/mcp_server.py
index 455cf8f..bc360ba 100644
--- a/mcp_server.py
+++ b/mcp_server.py
@@ -5,10 +5,13 @@ Based on FastMCP (stdio transport), reuses existing decryption.
Runs on Windows Python (needs access to D:\ WeChat databases).
"""
-import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re
-import hmac as hmac_mod
-from contextlib import closing
-from datetime import datetime
+import io
+import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading, subprocess
+import glob
+import wave
+import hmac as hmac_mod
+from contextlib import closing
+from datetime import datetime, timedelta
import xml.etree.ElementTree as ET
from Crypto.Cipher import AES
from mcp.server.fastmcp import FastMCP
@@ -220,11 +223,21 @@ atexit.register(_cache.cleanup)
_contact_names = None # {username: display_name}
_contact_full = None # [{username, nick_name, remark}]
-_self_username = None
-_XML_UNSAFE_RE = re.compile(r'> 3
+ wire_type = tag & 0x07
+ if wire_type == 0: # varint
+ while pos < n and data[pos] & 0x80:
+ pos += 1
+ pos += 1
+ elif wire_type == 2: # length-delimited
+ length = 0; shift = 0
+ while pos < n:
+ b = data[pos]; pos += 1
+ length |= (b & 0x7f) << shift
+ if not (b & 0x80):
+ break
+ shift += 7
+ if field_num == 30:
+ try:
+ return data[pos:pos + length].decode('utf-8')
+ except Exception:
+ return None
+ pos += length
+ elif wire_type == 1: # 64-bit
+ pos += 8
+ elif wire_type == 5: # 32-bit
+ pos += 4
+ else:
+ break
+ return None
+
+
+def _load_contact_tags():
+ """加载并缓存联系人标签数据"""
+ global _contact_tags
+
+ db_path = _get_contact_db_path()
+ if not db_path:
+ return {}
+
+ if _contact_tags is not None:
+ return _contact_tags
+
+ try:
+ conn = sqlite3.connect(db_path)
+ except Exception:
+ return {}
+
+ try:
+ # 1. 加载标签定义
+ try:
+ label_rows = conn.execute(
+ "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_"
+ ).fetchall()
+ except sqlite3.OperationalError:
+ return {}
+ if not label_rows:
+ return {}
+
+ labels = {}
+ for lid, lname, sort_order in label_rows:
+ labels[lid] = {'name': lname, 'sort_order': sort_order, 'members': []}
+
+ # 2. 扫描联系人的标签关联
+ names = get_contact_names()
+ rows = conn.execute(
+ "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL"
+ ).fetchall()
+
+ for username, buf in rows:
+ label_str = _extract_pb_field_30(buf)
+ if not label_str:
+ continue
+ display = names.get(username, username)
+ for lid_s in label_str.split(','):
+ try:
+ lid = int(lid_s.strip())
+ except (ValueError, AttributeError):
+ continue
+ if lid in labels:
+ labels[lid]['members'].append({'username': username, 'display_name': display})
+
+ _contact_tags = labels
+ return _contact_tags
+ except Exception:
+ return {}
+ finally:
+ conn.close()
+
+
# ============ 辅助函数 ============
def format_msg_type(t):
@@ -336,7 +470,11 @@ def _decompress_content(content, ct):
def _parse_message_content(content, local_type, is_group):
- """解析消息内容,返回 (sender_id, text)"""
+ """解析消息内容,返回 (sender_id, text)。
+
+ 群消息 content 形如 'wxid_xxx:\n';某些 type=19 合并转发也会
+ 写成 'wxid_xxx: _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content):
+# 合并转发消息(含 recorditem 内嵌 XML)在 dataitem 数量多时显著超过默认 20K 上限,
+# 实测真实 outer XML 可达 ~500KB。caller 可通过 max_len 参数为 type=19 类大消息放宽限制。
+_RECORD_XML_PARSE_MAX_LEN = 500_000
+
+
+def _safe_basename(name):
+ """对 user-derived filename(从消息 XML 来,不可信)做严格 sanitize。
+
+ Reject 而不是 normalize:哪怕 os.path.basename 把 '../foo' 剥成 'foo' 是
+ safe 的,意图依然可疑,应该显式失败让用户看到。
+ """
+ if not name:
+ return ''
+ if '\x00' in name:
+ return ''
+ if os.path.isabs(name):
+ return ''
+ # 任何 path separator 或 .. component 直接拒(不做 normalize)
+ parts = name.replace('\\', '/').split('/')
+ if any(p in ('', '.', '..') for p in parts) and len(parts) > 1:
+ return ''
+ if len(parts) > 1:
+ return ''
+ if name in ('.', '..'):
+ return ''
+ return name
+
+
+def _path_under_root(path, root):
+ """resolve realpath 后确认仍在 root 下(防 symlink 跳出)。"""
+ try:
+ real_path = os.path.realpath(path)
+ real_root = os.path.realpath(root)
+ except OSError:
+ return False
+ return real_path == real_root or real_path.startswith(real_root + os.sep)
+
+
+# 大附件 md5 校验时的安全上限:超过此 size 直接拒绝校验(避免 MCP 进程
+# 在 100MB+ 视频/附件上一次性 read() 整文件爆内存或长时间阻塞)。
+_MD5_VERIFY_MAX_SIZE = 500 * 1024 * 1024 # 500 MB
+_MD5_CHUNK_SIZE = 64 * 1024 # 64 KB
+
+
+def _md5_file_chunked(path, max_size=_MD5_VERIFY_MAX_SIZE):
+ """流式分块计算文件 md5,避免大文件一次读完爆内存。
+
+ 超过 max_size 直接拒绝(DoS 防御 + 大附件 md5 校验现实意义不大)。
+ 返回 (md5_hex, error);成功时 error 为 None。
+ """
+ try:
+ size = os.path.getsize(path)
+ except OSError as e:
+ return None, f"无法读取文件 size: {e}"
+ if size > max_size:
+ return None, f"文件 size {size:,} 超过 md5 校验上限 {max_size:,}(防 DoS)"
+ h = hashlib.md5()
+ try:
+ with open(path, 'rb') as f:
+ while True:
+ chunk = f.read(_MD5_CHUNK_SIZE)
+ if not chunk:
+ break
+ h.update(chunk)
+ except OSError as e:
+ return None, f"读取文件失败: {e}"
+ return h.hexdigest().lower(), None
+
+
+def _parse_xml_root(content, max_len=_XML_PARSE_MAX_LEN):
+ if not content or len(content) > max_len or _XML_UNSAFE_RE.search(content):
return None
try:
@@ -459,12 +675,50 @@ def _parse_int(value, fallback=0):
return fallback
+def _parse_app_message_outer(content):
+ """Parse outer appmsg XML,对 type=19 合并卡片自动放宽到 _RECORD_XML_PARSE_MAX_LEN。
+
+ 所有解析 outer appmsg 的 caller(get_chat_history 渲染 / decode_file_message /
+ decode_record_item)共用此 helper,避免同一条大消息在不同 caller 上行为不一致。
+ Substring 短路保证非 type=19 的大 appmsg 不付出 500K parse 代价。"""
+ root = _parse_xml_root(content)
+ if root is None and content and len(content) <= _RECORD_XML_PARSE_MAX_LEN:
+ if '19 ' in content:
+ root = _parse_xml_root(content, max_len=_RECORD_XML_PARSE_MAX_LEN)
+ return root
+
+
+def _format_namecard_text(content):
+ """Parse type=42 (名片) XML into a compact human-readable line.
+
+ Source XML carries dozens of fields (antispamticket, biznamecardinfo,
+ brand URLs, image MD5s) but the useful signal is just three attrs:
+ ``nickname`` (display name), ``username`` (wxid; ``gh_*`` for 公众号),
+ and ``certinfo`` (the user-authored bio). Everything else is either
+ auth tokens that should not be piped to downstream systems, or
+ rendering metadata that bloats the chat log without helping a human
+ or an LLM understand the conversation.
+ """
+ root = _parse_xml_root(content)
+ if root is None:
+ return None
+ nickname = (root.get("nickname") or "").strip()
+ username = (root.get("username") or "").strip()
+ certinfo = _collapse_text(root.get("certinfo") or "")
+ if not nickname and not username:
+ return None
+ head = nickname or username
+ if username.startswith("gh_"):
+ head = f"{head} (公众号 {username})"
+ return f"[名片] {head}: {certinfo}" if certinfo else f"[名片] {head}"
+
+
def _format_app_message_text(content, local_type, is_group, chat_username, chat_display_name, names):
if not content or ' 160:
- ref_content = ref_content[:160] + "..."
+ return _format_refer_message_text(
+ appmsg, is_group, chat_username, chat_display_name, names
+ )
- quote_text = title or "[引用消息]"
- if ref_content:
- ref_label = _resolve_quote_sender_label(
- ref_user, ref_display_name, is_group, chat_username, chat_display_name, names
- )
- prefix = f"回复 {ref_label}: " if ref_label else "回复: "
- quote_text += f"\n ↳ {prefix}{ref_content}"
- return quote_text
+ if app_type == 19:
+ return _format_record_message_text(appmsg, title)
+
+ if app_type == 2000:
+ return _format_transfer_message_text(appmsg, title)
if app_type == 6:
return f"[文件] {title}" if title else "[文件]"
@@ -508,6 +752,311 @@ def _format_app_message_text(content, local_type, is_group, chat_username, chat_
return "[链接/文件]"
+_RECORD_MAX_ITEMS = 50
+_RECORD_MAX_LINE_LEN = 200
+
+# 合并转发 dataitem 的 datatype → wechat 缓存子目录映射。仅这 4 类有真本地
+# binary 文件;其他 datatype(链接/名片/小程序/视频号 等)只有 metadata。
+_RECORD_BINARY_SUBDIR = {'8': 'F', '2': 'Img', '5': 'V', '4': 'A'}
+
+# datatype → 中文标签,散在多处使用:渲染合并卡片 / decode_record_item 的
+# 错误提示 / 单元测试。统一在模块顶部维护避免漂移。
+_RECORD_DATATYPE_LABEL = {
+ '1': '文本', '2': '图片', '3': '名片', '4': '语音',
+ '5': '视频', '6': '链接', '7': '位置', '8': '文件',
+ '17': '聊天记录', '19': '小程序', '22': '视频号',
+ '23': '视频号直播', '29': '音乐', '36': '小程序/H5',
+ '37': '表情包',
+}
+
+
+def _format_record_dataitem(item):
+ """格式化合并记录中的单个 dataitem,返回展示文本。"""
+ datatype = (item.get('datatype') or '').strip()
+
+ if datatype == '1':
+ return _collapse_text(item.findtext('datadesc') or '') or '[文本]'
+ if datatype in ('2', '3', '4', '5', '7', '23', '37'):
+ return f"[{_RECORD_DATATYPE_LABEL[datatype]}]"
+ if datatype in ('6', '36'):
+ link_title = _collapse_text(item.findtext('datatitle') or '')
+ label = _RECORD_DATATYPE_LABEL[datatype]
+ return f"[{label}] {link_title}" if link_title else f"[{label}]"
+ if datatype == '8':
+ file_title = _collapse_text(item.findtext('datatitle') or '')
+ return f"[文件] {file_title}" if file_title else '[文件]'
+ if datatype == '17':
+ nested_title = _collapse_text(item.findtext('datatitle') or '')
+ return f"[聊天记录] {nested_title}" if nested_title else '[聊天记录]'
+ if datatype == '19':
+ # 小程序:appbranditem/sourcedisplayname 是直接子代,不需要 .// 递归
+ app_name = _collapse_text(item.findtext('appbranditem/sourcedisplayname') or '')
+ item_title = _collapse_text(item.findtext('datatitle') or '')
+ label = item_title or app_name or '小程序'
+ return f"[小程序] {label}"
+ if datatype == '22':
+ feed_desc = _collapse_text(item.findtext('finderFeed/desc') or '')
+ return f"[视频号] {feed_desc[:80]}" if feed_desc else '[视频号]'
+ if datatype == '29':
+ song = _collapse_text(item.findtext('datatitle') or '')
+ artist = _collapse_text(item.findtext('datadesc') or '')
+ if song and artist:
+ return f"[音乐] {song} - {artist}"
+ return f"[音乐] {song}" if song else '[音乐]'
+
+ desc = _collapse_text(item.findtext('datadesc') or '')
+ title_text = _collapse_text(item.findtext('datatitle') or '')
+ fallback = desc or title_text
+ return fallback if fallback else f"[未知类型 {datatype}]"
+
+
+def _format_record_message_text(appmsg, title):
+ """解析合并转发的聊天记录卡片(appmsg type=19, recorditem)。"""
+ fallback_title = title or '聊天记录'
+ record_node = appmsg.find('recorditem')
+ if record_node is None or not record_node.text:
+ return f"[聊天记录] {fallback_title}(待加载)"
+
+ inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN)
+ if inner is None:
+ return f"[聊天记录] {fallback_title}"
+
+ record_title = _collapse_text(inner.findtext('title') or '') or fallback_title
+ is_chatroom = (inner.findtext('isChatRoom') or '').strip() == '1'
+ datalist = inner.find('datalist')
+ items = list(datalist.findall('dataitem')) if datalist is not None else []
+ if not items:
+ suffix = "(群聊转发,待加载)" if is_chatroom else "(待加载)"
+ return f"[聊天记录] {record_title}{suffix}"
+
+ header = f"[聊天记录] {record_title}"
+ if is_chatroom:
+ header += "(群聊转发)"
+ header += f",共 {len(items)} 条"
+
+ lines = [header + ":"]
+ for idx, item in enumerate(items[:_RECORD_MAX_ITEMS]):
+ sender = _collapse_text(item.findtext('sourcename') or '')
+ when = _collapse_text(item.findtext('sourcetime') or '')
+ content = _format_record_dataitem(item)
+
+ if len(content) > _RECORD_MAX_LINE_LEN:
+ content = content[:_RECORD_MAX_LINE_LEN] + '…'
+
+ # 0-based index 让用户能用 decode_record_item(chat, local_id, item_index) 引用
+ prefix_parts = [f"[{idx}]"] + [p for p in (when, sender) if p]
+ prefix = ' '.join(prefix_parts)
+ lines.append(f" {prefix}: {content}")
+
+ if len(items) > _RECORD_MAX_ITEMS:
+ lines.append(f" …(还有 {len(items) - _RECORD_MAX_ITEMS} 条未显示)")
+
+ return "\n".join(lines)
+
+
+# 微信转账 (appmsg type=2000, ) paysubtype 含义。
+# 微信官方无公开文档,此表来自社区抓包归纳。1/3/4 在所有已知版本一致;
+# 5/7/8 在不同版本存在变体("过期已退还"在某些抓包里也归为 4),所以遇到
+# 未识别值时降级显示原始数字,方便用户自行核对。
+_TRANSFER_PAYSUBTYPE_LABEL = {
+ '1': '发起转账', # 发送方记录:等待对方收钱
+ '3': '已收款', # 双向:发送方看到"对方已收",接收方看到"已收钱"
+ '4': '已退还', # 主动退还或被退还
+ '5': '过期已退还', # 24h 未收,自动退还(发送方记录)
+ '7': '待领取', # 已发起未接收
+ '8': '已领取', # 部分版本:转账被领取(接收方记录)
+}
+
+
+# 微信引用回复(appmsg type=57, )内层 的标签映射。
+# refermsg/ 用的是顶层 base_type 数字(跟 format_msg_type 重合),
+# 但语义不同:format_msg_type 给"消息类型 chip",这里给"被引用消息的一行摘要",
+# 不展开 cdn url / aeskey / md5 等二进制元数据(直接截断 XML 字符串当摘要是
+# 现状的 bug,会把"图片/语音/视频/动画表情/嵌套卡片"渲染成乱码——见 issue #44 #45)。
+_REFER_INNER_TYPE_LABEL = {
+ '1': '文本', # 特殊:直接展开 content
+ '3': '图片',
+ '34': '语音',
+ '42': '名片',
+ '43': '视频',
+ '47': '动画表情',
+ '48': '位置',
+ '49': '链接/卡片', # 特殊:嵌套 appmsg,进一步解 inner type
+ '50': '通话',
+}
+
+# refer_type=49 时 content 是嵌套 ...,inner appmsg/ → 标签。
+# 跟合并转发 _RECORD_DATATYPE_LABEL 的数字含义不同(datatype 是 recorditem 的私有
+# schema),独立维护。
+_INNER_APPMSG_TYPE_LABEL = {
+ '5': '链接', '6': '文件', '8': '动画表情卡',
+ '19': '聊天记录', '33': '小程序', '36': '小程序',
+ '51': '视频号', '57': '引用消息',
+ '2000': '转账', '2001': '红包',
+}
+
+
+def _extract_refer_info(appmsg):
+ """从 appmsg type=57 解出 refermsg 各字段,返回 dict 或 None。
+
+ refermsg/ 是 escape 后的字符串,内层 type 决定其 schema:
+ type=1 (纯文本) / 3 (img cdn) / 34 (voicemsg) / 47 (emoji)
+ / 49 (嵌套 appmsg) / ...
+
+ refer_content 保留原始字符串(不 collapse),让 _summarize_refer_content
+ 按 type 进一步处理(type=49 还要再解一层 XML)。其他字段过 _collapse_text
+ 清掉换行/前后空白。
+ """
+ refer = appmsg.find('refermsg')
+ if refer is None:
+ return None
+
+ return {
+ 'reply_text': _collapse_text(appmsg.findtext('title') or ''),
+ 'refer_type': _collapse_text(refer.findtext('type') or ''),
+ 'refer_svrid': _collapse_text(refer.findtext('svrid') or ''),
+ 'refer_fromusr': _collapse_text(refer.findtext('fromusr') or ''),
+ 'refer_chatusr': _collapse_text(refer.findtext('chatusr') or ''),
+ 'refer_displayname': _collapse_text(refer.findtext('displayname') or ''),
+ 'refer_content': refer.findtext('content') or '',
+ 'refer_createtime': _collapse_text(refer.findtext('createtime') or ''),
+ }
+
+
+def _summarize_refer_content(refer_type, content, max_len=160):
+ """把被引用消息的 content 摘要成一行可读文本。
+
+ 分支规则:
+ type=1 (文本): 取原文,截断到 max_len
+ type=3/34/43/47/...: 给标签兜底,不展开 cdn url / aeskey / md5
+ type=49 (嵌套 appmsg): 解一层 inner appmsg/type + title,给"[链接] xxx"
+ 未识别 type: 给 [type=N] 兜底,方便用户自查
+
+ max_len 只对 type=1 文本生效;标签型摘要本身就短。
+ """
+ refer_type = (refer_type or '').strip()
+
+ if not content:
+ label = _REFER_INNER_TYPE_LABEL.get(refer_type)
+ if label:
+ return f'[{label}]'
+ return f'[type={refer_type}]' if refer_type else '[引用消息]'
+
+ if refer_type == '1':
+ text = _collapse_text(content)
+ return text[:max_len] + '…' if len(text) > max_len else text
+
+ if refer_type == '49':
+ # 嵌套 appmsg:content 是来源不可信的微信侧 payload,走 _parse_xml_root
+ # 经 _XML_UNSAFE_RE 过滤 DOCTYPE/ENTITY 防 XXE 注入。
+ inner_root = _parse_xml_root(content)
+ if inner_root is None:
+ return '[卡片]'
+ inner_appmsg = inner_root.find('.//appmsg')
+ if inner_appmsg is None:
+ return '[卡片]'
+ inner_type = _collapse_text(inner_appmsg.findtext('type') or '')
+ inner_title = _collapse_text(inner_appmsg.findtext('title') or '')
+ label = _INNER_APPMSG_TYPE_LABEL.get(
+ inner_type, f'卡片 type={inner_type}' if inner_type else '卡片'
+ )
+ return f'[{label}] {inner_title}' if inner_title else f'[{label}]'
+
+ label = _REFER_INNER_TYPE_LABEL.get(refer_type)
+ if label:
+ return f'[{label}]'
+ return f'[type={refer_type}]'
+
+
+def _format_refer_message_text(appmsg, is_group, chat_username, chat_display_name, names):
+ """渲染微信引用回复(appmsg type=57)的两行展示文本。
+
+ 格式:
+ <用户的回复正文>
+ ↳ 回复 <对方>: <被引用消息摘要>
+
+ fallback:
+ 1) refermsg 缺失 → 退回到外层 title 兜底
+ 2) refer_content 空 → summary 给"[refer_type 标签]"或"[引用消息]"
+ 3) sender 解析不出来 → "回复:" 不带名字
+ """
+ info = _extract_refer_info(appmsg)
+ if info is None:
+ title = _collapse_text(appmsg.findtext('title') or '')
+ return title or '[引用消息]'
+
+ summary = _summarize_refer_content(info['refer_type'], info['refer_content'])
+ sender_label = _resolve_quote_sender_label(
+ info['refer_fromusr'], info['refer_displayname'],
+ is_group, chat_username, chat_display_name, names
+ )
+
+ quote_text = info['reply_text'] or '[引用消息]'
+ prefix = f'回复 {sender_label}: ' if sender_label else '回复: '
+ quote_text += f'\n ↳ {prefix}{summary}'
+ return quote_text
+
+
+def _extract_transfer_info(appmsg):
+ """从 appmsg type=2000 解出 wcpayinfo 各字段,返回 dict 或 None。
+
+ 字段大小写在不同微信版本间漂移(见过 feedesc/feeDesc, pay_memo/paymemo),
+ 用 lower-case 兜底。所有值用 _collapse_text 清掉换行/前后空白。
+ """
+ info = appmsg.find('wcpayinfo')
+ if info is None:
+ return None
+
+ def _pick(*tags):
+ for t in tags:
+ v = _collapse_text(info.findtext(t) or '')
+ if v:
+ return v
+ return ''
+
+ paysubtype = _pick('paysubtype')
+ return {
+ 'paysubtype': paysubtype,
+ 'paysubtype_label': _TRANSFER_PAYSUBTYPE_LABEL.get(
+ paysubtype, f'未知(paysubtype={paysubtype})' if paysubtype else ''
+ ),
+ # feedesc 通常是 "¥0.01" 风格的展示串;feedescxml 是富文本变体
+ 'fee_desc': _pick('feedesc', 'feeDesc'),
+ 'pay_memo': _pick('pay_memo', 'paymemo'),
+ # 三种交易号:transcationid 是微信支付侧(注意拼写是 transc 不是 trans),
+ # transferid 是微信内部转账 id,paymsgid 偶见于旧版本
+ 'transcation_id': _pick('transcationid', 'transcationId'),
+ 'transfer_id': _pick('transferid', 'transferId'),
+ 'pay_msg_id': _pick('paymsgid', 'payMsgId'),
+ 'begin_transfer_time': _pick('begintransfertime', 'beginTransferTime'),
+ 'invalid_time': _pick('invalidtime', 'invalidTime'),
+ 'effective_date': _pick('effectivedate', 'effectiveDate'),
+ 'payer_username': _pick('payer_username', 'payerUsername'),
+ 'receiver_username': _pick('receiver_username', 'receiverUsername'),
+ }
+
+
+def _format_transfer_message_text(appmsg, title):
+ """渲染微信转账(appmsg type=2000)一行展示文本,给 history / monitor_web 共用。
+
+ fallback 顺序:
+ 1) wcpayinfo 缺失 → 只显示 title 兜底,避免吞数据
+ 2) paysubtype 未知 → 显示原始数字让用户自查
+ 3) 没有 fee_desc → 至少给个方向标签
+ """
+ info = _extract_transfer_info(appmsg)
+ if not info:
+ return f"[转账] {title}" if title else "[转账]"
+
+ label = info['paysubtype_label'] or '转账'
+ parts = [f"[转账·{label}]"] if label != '转账' else ["[转账]"]
+ if info['fee_desc']:
+ parts.append(info['fee_desc'])
+ if info['pay_memo']:
+ parts.append(f"备注: {info['pay_memo']}")
+ return ' '.join(parts)
+
+
def _format_voip_message_text(content):
if not content or ' limit_max:
- raise ValueError(f"limit 不能大于 {limit_max}")
- if offset < 0:
- raise ValueError("offset 不能小于 0")
+def _find_msg_tables_for_user(username):
+ """返回用户在所有 message_N.db 中对应的消息表,按最新消息时间倒序排列。"""
+ table_hash = hashlib.md5(username.encode()).hexdigest()
+ table_name = f"Msg_{table_hash}"
+ if not _is_safe_msg_table_name(table_name):
+ return []
+
+ matches = []
+ for rel_key in MSG_DB_KEYS:
+ path = _cache.get(rel_key)
+ if not path:
+ continue
+ conn = sqlite3.connect(path)
+ try:
+ exists = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
+ (table_name,)
+ ).fetchone()
+ if not exists:
+ continue
+ max_create_time = conn.execute(
+ f"SELECT MAX(create_time) FROM [{table_name}]"
+ ).fetchone()[0] or 0
+ matches.append({
+ 'db_path': path,
+ 'table_name': table_name,
+ 'max_create_time': max_create_time,
+ })
+ except Exception:
+ pass
+ finally:
+ conn.close()
+
+ matches.sort(key=lambda item: item['max_create_time'], reverse=True)
+ return matches
+
+
+def _validate_pagination(limit, offset=0, limit_max=_QUERY_LIMIT_MAX):
+ if limit <= 0:
+ raise ValueError("limit 必须大于 0")
+ if limit_max is not None and limit > limit_max:
+ raise ValueError(f"limit 不能大于 {limit_max}")
+ if offset < 0:
+ raise ValueError("offset 不能小于 0")
def _parse_time_value(value, field_name, is_end=False):
@@ -677,7 +1262,53 @@ def _parse_time_range(start_time='', end_time=''):
return start_ts, end_ts
-def _build_message_filters(start_ts=None, end_ts=None, keyword=''):
+def _pagination_hint(count, limit, offset):
+ """当返回结果数 == limit 时,提示调用方可能还有更多。
+
+ 用于工具返回字符串末尾,帮助 LLM 决定是否需要继续翻页。
+ 返回结果数 < limit 表示已读到当前查询条件下的全部结果,不再提示。
+ """
+ if limit and count >= limit:
+ return f"\n\n(可能还有更多结果,可设 offset={offset + limit} 继续查询)"
+ return ""
+
+
+_MSG_TYPE_MAP = {
+ 'text': [1],
+ 'image': [3],
+ 'voice': [34],
+ 'namecard': [42],
+ 'video': [43],
+ 'emoji': [47],
+ 'location': [48],
+ 'app': [49],
+ 'voip': [50],
+ 'system': [10000],
+}
+
+
+def _resolve_msg_types(msg_types):
+ """把 ['text', 'image'] 风格的输入翻成 local_type 整数列表。
+
+ 返回 (type_filter_list, error_msg); 任一项无效返回 (None, error)。
+ None / 空列表表示不过滤。
+ """
+ if not msg_types:
+ return None, None
+ type_filter = []
+ for t in msg_types:
+ key = t.strip().lower()
+ if key == 'file':
+ key = 'app' # 'file' 是常见叫法; WeChat 把文件归到 type=49 (app message)
+ if key not in _MSG_TYPE_MAP:
+ return None, (
+ f"未知消息类型 \"{t}\"。可选: " + ", ".join(sorted(_MSG_TYPE_MAP))
+ )
+ type_filter.extend(_MSG_TYPE_MAP[key])
+ return type_filter, None
+
+
+def _build_message_filters(start_ts=None, end_ts=None, keyword='', type_filter=None):
clauses = []
params = []
if start_ts is not None:
@@ -689,62 +1320,67 @@ def _build_message_filters(start_ts=None, end_ts=None, keyword=''):
if keyword:
clauses.append('message_content LIKE ?')
params.append(f'%{keyword}%')
+ if type_filter:
+ placeholders = ','.join('?' * len(type_filter))
+ clauses.append(f'local_type IN ({placeholders})')
+ params.extend(type_filter)
return clauses, params
-def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0):
- if not _is_safe_msg_table_name(table_name):
- raise ValueError(f'非法消息表名: {table_name}')
-
- clauses, params = _build_message_filters(start_ts, end_ts, keyword)
- where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ''
- sql = f"""
- SELECT local_id, local_type, create_time, real_sender_id, message_content,
- WCDB_CT_message_content
- FROM [{table_name}]
- {where_sql}
- ORDER BY create_time DESC
- """
- if limit is None:
- return conn.execute(sql, params).fetchall()
- sql += "\n LIMIT ? OFFSET ?"
- return conn.execute(sql, (*params, limit, offset)).fetchall()
+def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0, oldest_first=False, type_filter=None):
+ if not _is_safe_msg_table_name(table_name):
+ raise ValueError(f'非法消息表名: {table_name}')
+
+ clauses, params = _build_message_filters(start_ts, end_ts, keyword, type_filter)
+ where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ''
+ order = 'ASC' if oldest_first else 'DESC'
+ sql = f"""
+ SELECT local_id, local_type, create_time, real_sender_id, message_content,
+ WCDB_CT_message_content
+ FROM [{table_name}]
+ {where_sql}
+ ORDER BY create_time {order}
+ """
+ if limit is None:
+ return conn.execute(sql, params).fetchall()
+ sql += "\n LIMIT ? OFFSET ?"
+ return conn.execute(sql, (*params, limit, offset)).fetchall()
-def _resolve_chat_context(chat_name):
- username = resolve_username(chat_name)
- if not username:
- return None
-
- names = get_contact_names()
- display_name = names.get(username, username)
- message_tables = _find_msg_tables_for_user(username)
- if not message_tables:
- return {
- 'query': chat_name,
- 'username': username,
- 'display_name': display_name,
- 'db_path': None,
- 'table_name': None,
- 'message_tables': [],
- 'is_group': '@chatroom' in username,
- }
-
- primary = message_tables[0]
- return {
- 'query': chat_name,
- 'username': username,
- 'display_name': display_name,
- 'db_path': primary['db_path'],
- 'table_name': primary['table_name'],
- 'message_tables': message_tables,
- 'is_group': '@chatroom' in username,
- }
+def _resolve_chat_context(chat_name):
+ username = resolve_username(chat_name)
+ if not username:
+ return None
+
+ names = get_contact_names()
+ display_name = names.get(username, username)
+ message_tables = _find_msg_tables_for_user(username)
+ if not message_tables:
+ return {
+ 'query': chat_name,
+ 'username': username,
+ 'display_name': display_name,
+ 'db_path': None,
+ 'table_name': None,
+ 'message_tables': [],
+ 'is_group': '@chatroom' in username,
+ }
+
+ primary = message_tables[0]
+ return {
+ 'query': chat_name,
+ 'username': username,
+ 'display_name': display_name,
+ 'db_path': primary['db_path'],
+ 'table_name': primary['table_name'],
+ 'message_tables': message_tables,
+ 'is_group': '@chatroom' in username,
+ }
-def _resolve_chat_contexts(chat_names):
- if not chat_names:
- raise ValueError('chat_names 不能为空')
+def _resolve_chat_contexts(chat_names):
+ if not chat_names:
+ raise ValueError('chat_names 不能为空')
resolved = []
unresolved = []
@@ -760,57 +1396,58 @@ def _resolve_chat_contexts(chat_names):
if not ctx:
unresolved.append(name)
continue
- if not ctx['message_tables']:
- missing_tables.append(ctx['display_name'])
- continue
- if ctx['username'] in seen:
- continue
+ if not ctx['message_tables']:
+ missing_tables.append(ctx['display_name'])
+ continue
+ if ctx['username'] in seen:
+ continue
seen.add(ctx['username'])
resolved.append(ctx)
-
- return resolved, unresolved, missing_tables
-
-
-def _normalize_chat_names(chat_name):
- if chat_name is None:
- return []
- if isinstance(chat_name, str):
- value = chat_name.strip()
- return [value] if value else []
- if isinstance(chat_name, (list, tuple, set)):
- normalized = []
- for item in chat_name:
- if item is None:
- continue
- value = str(item).strip()
- if value:
- normalized.append(value)
- return normalized
- value = str(chat_name).strip()
- return [value] if value else []
+
+ return resolved, unresolved, missing_tables
-def _format_history_lines(rows, username, display_name, is_group, names, id_to_username):
- lines = []
- ctx = {
- 'username': username,
- 'display_name': display_name,
- 'is_group': is_group,
- }
- for row in reversed(rows):
- _, line = _build_history_line(row, ctx, names, id_to_username)
- lines.append(line)
- return lines
+def _normalize_chat_names(chat_name):
+ if chat_name is None:
+ return []
+ if isinstance(chat_name, str):
+ value = chat_name.strip()
+ return [value] if value else []
+ if isinstance(chat_name, (list, tuple, set)):
+ normalized = []
+ for item in chat_name:
+ if item is None:
+ continue
+ value = str(item).strip()
+ if value:
+ normalized.append(value)
+ return normalized
+ value = str(chat_name).strip()
+ return [value] if value else []
-def _build_search_entry(row, ctx, names, id_to_username):
+def _format_history_lines(rows, username, display_name, is_group, names, id_to_username):
+ lines = []
+ ctx = {
+ 'username': username,
+ 'display_name': display_name,
+ 'is_group': is_group,
+ }
+ for row in reversed(rows):
+ _, line = _build_history_line(row, ctx, names, id_to_username)
+ lines.append(line)
+ return lines
+
+
+def _build_search_entry(row, ctx, names, id_to_username):
local_id, local_type, create_time, real_sender_id, content, ct = row
content = _decompress_content(content, ct)
if content is None:
return None
sender, text = _format_message_text(
- local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names
+ local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names,
+ create_time=create_time,
)
if text and len(text) > 300:
text = text[:300] + '...'
@@ -828,346 +1465,349 @@ def _build_search_entry(row, ctx, names, id_to_username):
entry = f"[{time_str}] [{ctx['display_name']}]"
if sender_label:
entry += f" {sender_label}:"
- entry += f" {text}"
- return create_time, entry
-
-
-def _build_history_line(row, ctx, names, id_to_username):
- local_id, local_type, create_time, real_sender_id, content, ct = row
- time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
- content = _decompress_content(content, ct)
- if content is None:
- content = '(无法解压)'
-
- sender, text = _format_message_text(
- local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names
- )
-
- sender_label = _resolve_sender_label(
- real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username
- )
- if sender_label:
- return create_time, f'[{time_str}] {sender_label}: {text}'
- return create_time, f'[{time_str}] {text}'
-
-
-def _get_chat_message_tables(ctx):
- if ctx.get('message_tables'):
- return ctx['message_tables']
- if ctx.get('db_path') and ctx.get('table_name'):
- return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}]
- return []
-
-
-def _iter_table_contexts(ctx):
- for table in _get_chat_message_tables(ctx):
- yield {
- 'query': ctx['query'],
- 'username': ctx['username'],
- 'display_name': ctx['display_name'],
- 'db_path': table['db_path'],
- 'table_name': table['table_name'],
- 'is_group': ctx['is_group'],
- }
-
-
-def _candidate_page_size(limit, offset):
- return limit + offset
-
-
-def _message_query_batch_size(candidate_limit):
- return candidate_limit
-
-
-def _history_query_batch_size(candidate_limit):
- return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE)
-
-
-def _page_ranked_entries(entries, limit, offset):
- ordered = sorted(entries, key=lambda item: item[0], reverse=True)
- paged = ordered[offset:offset + limit]
- paged.sort(key=lambda item: item[0])
- return paged
-
-
-def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0):
- collected = []
- failures = []
- candidate_limit = _candidate_page_size(limit, offset)
- batch_size = _history_query_batch_size(candidate_limit)
-
- for table_ctx in _iter_table_contexts(ctx):
- try:
- with closing(sqlite3.connect(table_ctx['db_path'])) as conn:
- id_to_username = _load_name2id_maps(conn)
- fetch_offset = 0
- collected_before_table = len(collected)
- # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。
- while len(collected) - collected_before_table < candidate_limit:
- rows = _query_messages(
- conn,
- table_ctx['table_name'],
- start_ts=start_ts,
- end_ts=end_ts,
- limit=batch_size,
- offset=fetch_offset,
- )
- if not rows:
- break
- fetch_offset += len(rows)
-
- for row in rows:
- try:
- collected.append(_build_history_line(row, table_ctx, names, id_to_username))
- except Exception as e:
- failures.append(
- f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}"
- )
- if len(collected) - collected_before_table >= candidate_limit:
- break
-
- if len(rows) < batch_size:
- break
- except Exception as e:
- failures.append(f"{table_ctx['db_path']}: {e}")
-
- paged = _page_ranked_entries(collected, limit, offset)
- return [line for _, line in paged], failures
-
-
-def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20):
- collected = []
- failures = []
- contexts_by_db = {}
- for table_ctx in _iter_table_contexts(ctx):
- contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx)
-
- for db_path, db_contexts in contexts_by_db.items():
- try:
- with closing(sqlite3.connect(db_path)) as conn:
- db_entries, db_failures = _collect_search_entries(
- conn,
- db_contexts,
- names,
- keyword,
- start_ts=start_ts,
- end_ts=end_ts,
- candidate_limit=candidate_limit,
- )
- collected.extend(db_entries)
- failures.extend(db_failures)
- except Exception as e:
- failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts)
-
- return collected, failures
-
-
-def _load_search_contexts_from_db(conn, db_path, names):
- tables = conn.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
- ).fetchall()
-
- table_to_username = {}
- try:
- for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall():
- if not user_name:
- continue
- table_hash = hashlib.md5(user_name.encode()).hexdigest()
- table_to_username[f"Msg_{table_hash}"] = user_name
- except sqlite3.Error:
- pass
-
- contexts = []
- for (table_name,) in tables:
- username = table_to_username.get(table_name, '')
- display_name = names.get(username, username) if username else table_name
- contexts.append({
- 'query': display_name,
- 'username': username,
- 'display_name': display_name,
- 'db_path': db_path,
- 'table_name': table_name,
- 'is_group': '@chatroom' in username,
- })
- return contexts
-
-
-def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20):
- collected = []
- failures = []
- id_to_username = _load_name2id_maps(conn)
- batch_size = _message_query_batch_size(candidate_limit)
-
- for ctx in contexts:
- try:
- fetch_offset = 0
- collected_before_table = len(collected)
- # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。
- while len(collected) - collected_before_table < candidate_limit:
- rows = _query_messages(
- conn,
- ctx['table_name'],
- start_ts=start_ts,
- end_ts=end_ts,
- keyword=keyword,
- limit=batch_size,
- offset=fetch_offset,
- )
- if not rows:
- break
- fetch_offset += len(rows)
-
- for row in rows:
- formatted = _build_search_entry(row, ctx, names, id_to_username)
- if formatted:
- collected.append(formatted)
- if len(collected) - collected_before_table >= candidate_limit:
- break
-
- if len(rows) < batch_size:
- break
- except Exception as e:
- failures.append(f"{ctx['display_name']}: {e}")
-
- return collected, failures
-
-
-def _page_search_entries(entries, limit, offset):
- return _page_ranked_entries(entries, limit, offset)
-
-
-def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset):
- names = get_contact_names()
- candidate_limit = _candidate_page_size(limit, offset)
-
- entries, failures = _collect_chat_search_entries(
- ctx,
- names,
- keyword,
- start_ts=start_ts,
- end_ts=end_ts,
- candidate_limit=candidate_limit,
- )
-
- paged = _page_search_entries(entries, limit, offset)
-
- if not paged:
- if failures:
- return "查询失败: " + ";".join(failures)
- return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息"
-
- header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})"
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if failures:
- header += "\n查询失败: " + ";".join(failures)
- return header + ":\n\n" + "\n\n".join(item[1] for item in paged)
-
-
-def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset):
- try:
- resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names)
- except ValueError as e:
- return f"错误: {e}"
-
- if not resolved_contexts:
- details = []
- if unresolved:
- details.append("未找到联系人: " + "、".join(unresolved))
- if missing_tables:
- details.append("无消息表: " + "、".join(missing_tables))
- suffix = f"\n{chr(10).join(details)}" if details else ""
- return f"错误: 没有可查询的聊天对象{suffix}"
-
- names = get_contact_names()
- candidate_limit = _candidate_page_size(limit, offset)
- collected = []
- failures = []
- for ctx in resolved_contexts:
- chat_entries, chat_failures = _collect_chat_search_entries(
- ctx,
- names,
- keyword,
- start_ts=start_ts,
- end_ts=end_ts,
- candidate_limit=candidate_limit,
- )
- collected.extend(chat_entries)
- failures.extend(chat_failures)
-
- paged = _page_search_entries(collected, limit, offset)
-
- notes = []
- if unresolved:
- notes.append("未找到联系人: " + "、".join(unresolved))
- if missing_tables:
- notes.append("无消息表: " + "、".join(missing_tables))
- if failures:
- notes.append("查询失败: " + ";".join(failures))
-
- if not paged:
- header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息"
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if notes:
- header += "\n" + "\n".join(notes)
- return header
-
- header = (
- f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果"
- f"(offset={offset}, limit={limit})"
- )
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if notes:
- header += "\n" + "\n".join(notes)
- return header + ":\n\n" + "\n\n".join(item[1] for item in paged)
-
-
-def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset):
- names = get_contact_names()
- collected = []
- failures = []
- candidate_limit = _candidate_page_size(limit, offset)
-
- for rel_key in MSG_DB_KEYS:
- path = _cache.get(rel_key)
- if not path:
- continue
-
- try:
- with closing(sqlite3.connect(path)) as conn:
- contexts = _load_search_contexts_from_db(conn, path, names)
- db_entries, db_failures = _collect_search_entries(
- conn,
- contexts,
- names,
- keyword,
- start_ts=start_ts,
- end_ts=end_ts,
- candidate_limit=candidate_limit,
- )
- collected.extend(db_entries)
- failures.extend(db_failures)
- except Exception as e:
- failures.append(f"{rel_key}: {e}")
-
- paged = _page_search_entries(collected, limit, offset)
-
- if not paged:
- header = f"未找到包含 \"{keyword}\" 的消息"
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if failures:
- header += "\n查询失败: " + ";".join(failures)
- return header
-
- header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})"
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if failures:
- header += "\n查询失败: " + ";".join(failures)
- return header + ":\n\n" + "\n\n".join(item[1] for item in paged)
+ entry += f" {text}"
+ return create_time, entry
+
+
+def _build_history_line(row, ctx, names, id_to_username):
+ local_id, local_type, create_time, real_sender_id, content, ct = row
+ time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
+ content = _decompress_content(content, ct)
+ if content is None:
+ content = '(无法解压)'
+
+ sender, text = _format_message_text(
+ local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names,
+ create_time=create_time,
+ )
+
+ sender_label = _resolve_sender_label(
+ real_sender_id, sender, ctx['is_group'], ctx['username'], ctx['display_name'], names, id_to_username
+ )
+ if sender_label:
+ return create_time, f'[{time_str}] {sender_label}: {text}'
+ return create_time, f'[{time_str}] {text}'
+
+
+def _get_chat_message_tables(ctx):
+ if ctx.get('message_tables'):
+ return ctx['message_tables']
+ if ctx.get('db_path') and ctx.get('table_name'):
+ return [{'db_path': ctx['db_path'], 'table_name': ctx['table_name']}]
+ return []
+
+
+def _iter_table_contexts(ctx):
+ for table in _get_chat_message_tables(ctx):
+ yield {
+ 'query': ctx['query'],
+ 'username': ctx['username'],
+ 'display_name': ctx['display_name'],
+ 'db_path': table['db_path'],
+ 'table_name': table['table_name'],
+ 'is_group': ctx['is_group'],
+ }
+
+
+def _candidate_page_size(limit, offset):
+ return limit + offset
+
+
+def _message_query_batch_size(candidate_limit):
+ return candidate_limit
+
+
+def _history_query_batch_size(candidate_limit):
+ return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE)
+
+
+def _page_ranked_entries(entries, limit, offset, oldest_first=False):
+ ordered = sorted(entries, key=lambda item: item[0], reverse=not oldest_first)
+ paged = ordered[offset:offset + limit]
+ paged.sort(key=lambda item: item[0])
+ return paged
+
+
+def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0, oldest_first=False, type_filter=None):
+ collected = []
+ failures = []
+ candidate_limit = _candidate_page_size(limit, offset)
+ batch_size = _history_query_batch_size(candidate_limit)
+
+ for table_ctx in _iter_table_contexts(ctx):
+ try:
+ with closing(sqlite3.connect(table_ctx['db_path'])) as conn:
+ id_to_username = _load_name2id_maps(conn)
+ fetch_offset = 0
+ collected_before_table = len(collected)
+ # 当前页上的消息一定落在各分表最近的 offset+limit 条记录内。
+ while len(collected) - collected_before_table < candidate_limit:
+ rows = _query_messages(
+ conn,
+ table_ctx['table_name'],
+ start_ts=start_ts,
+ end_ts=end_ts,
+ limit=batch_size,
+ offset=fetch_offset,
+ oldest_first=oldest_first,
+ type_filter=type_filter,
+ )
+ if not rows:
+ break
+ fetch_offset += len(rows)
+
+ for row in rows:
+ try:
+ collected.append(_build_history_line(row, table_ctx, names, id_to_username))
+ except Exception as e:
+ failures.append(
+ f"{table_ctx['display_name']} local_id={row[0]} create_time={row[2]}: {e}"
+ )
+ if len(collected) - collected_before_table >= candidate_limit:
+ break
+
+ if len(rows) < batch_size:
+ break
+ except Exception as e:
+ failures.append(f"{table_ctx['db_path']}: {e}")
+
+ paged = _page_ranked_entries(collected, limit, offset, oldest_first=oldest_first)
+ return [line for _, line in paged], failures
+
+
+def _collect_chat_search_entries(ctx, names, keyword, start_ts=None, end_ts=None, candidate_limit=20):
+ collected = []
+ failures = []
+ contexts_by_db = {}
+ for table_ctx in _iter_table_contexts(ctx):
+ contexts_by_db.setdefault(table_ctx['db_path'], []).append(table_ctx)
+
+ for db_path, db_contexts in contexts_by_db.items():
+ try:
+ with closing(sqlite3.connect(db_path)) as conn:
+ db_entries, db_failures = _collect_search_entries(
+ conn,
+ db_contexts,
+ names,
+ keyword,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ candidate_limit=candidate_limit,
+ )
+ collected.extend(db_entries)
+ failures.extend(db_failures)
+ except Exception as e:
+ failures.extend(f"{table_ctx['display_name']}: {e}" for table_ctx in db_contexts)
+
+ return collected, failures
+
+
+def _load_search_contexts_from_db(conn, db_path, names):
+ tables = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
+ ).fetchall()
+
+ table_to_username = {}
+ try:
+ for (user_name,) in conn.execute("SELECT user_name FROM Name2Id").fetchall():
+ if not user_name:
+ continue
+ table_hash = hashlib.md5(user_name.encode()).hexdigest()
+ table_to_username[f"Msg_{table_hash}"] = user_name
+ except sqlite3.Error:
+ pass
+
+ contexts = []
+ for (table_name,) in tables:
+ username = table_to_username.get(table_name, '')
+ display_name = names.get(username, username) if username else table_name
+ contexts.append({
+ 'query': display_name,
+ 'username': username,
+ 'display_name': display_name,
+ 'db_path': db_path,
+ 'table_name': table_name,
+ 'is_group': '@chatroom' in username,
+ })
+ return contexts
+
+
+def _collect_search_entries(conn, contexts, names, keyword, start_ts=None, end_ts=None, candidate_limit=20):
+ collected = []
+ failures = []
+ id_to_username = _load_name2id_maps(conn)
+ batch_size = _message_query_batch_size(candidate_limit)
+
+ for ctx in contexts:
+ try:
+ fetch_offset = 0
+ collected_before_table = len(collected)
+ # 全局分页只需要每个分表最新的 offset+limit 条有效命中,无需把整表命中读进内存。
+ while len(collected) - collected_before_table < candidate_limit:
+ rows = _query_messages(
+ conn,
+ ctx['table_name'],
+ start_ts=start_ts,
+ end_ts=end_ts,
+ keyword=keyword,
+ limit=batch_size,
+ offset=fetch_offset,
+ )
+ if not rows:
+ break
+ fetch_offset += len(rows)
+
+ for row in rows:
+ formatted = _build_search_entry(row, ctx, names, id_to_username)
+ if formatted:
+ collected.append(formatted)
+ if len(collected) - collected_before_table >= candidate_limit:
+ break
+
+ if len(rows) < batch_size:
+ break
+ except Exception as e:
+ failures.append(f"{ctx['display_name']}: {e}")
+
+ return collected, failures
+
+
+def _page_search_entries(entries, limit, offset):
+ return _page_ranked_entries(entries, limit, offset)
+
+
+def _search_single_chat(ctx, keyword, start_ts, end_ts, start_time, end_time, limit, offset):
+ names = get_contact_names()
+ candidate_limit = _candidate_page_size(limit, offset)
+
+ entries, failures = _collect_chat_search_entries(
+ ctx,
+ names,
+ keyword,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ candidate_limit=candidate_limit,
+ )
+
+ paged = _page_search_entries(entries, limit, offset)
+
+ if not paged:
+ if failures:
+ return "查询失败: " + ";".join(failures)
+ return f"未在 {ctx['display_name']} 中找到包含 \"{keyword}\" 的消息"
+
+ header = f"在 {ctx['display_name']} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if failures:
+ header += "\n查询失败: " + ";".join(failures)
+ return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset)
+
+
+def _search_multiple_chats(chat_names, keyword, start_ts, end_ts, start_time, end_time, limit, offset):
+ try:
+ resolved_contexts, unresolved, missing_tables = _resolve_chat_contexts(chat_names)
+ except ValueError as e:
+ return f"错误: {e}"
+
+ if not resolved_contexts:
+ details = []
+ if unresolved:
+ details.append("未找到联系人: " + "、".join(unresolved))
+ if missing_tables:
+ details.append("无消息表: " + "、".join(missing_tables))
+ suffix = f"\n{chr(10).join(details)}" if details else ""
+ return f"错误: 没有可查询的聊天对象{suffix}"
+
+ names = get_contact_names()
+ candidate_limit = _candidate_page_size(limit, offset)
+ collected = []
+ failures = []
+ for ctx in resolved_contexts:
+ chat_entries, chat_failures = _collect_chat_search_entries(
+ ctx,
+ names,
+ keyword,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ candidate_limit=candidate_limit,
+ )
+ collected.extend(chat_entries)
+ failures.extend(chat_failures)
+
+ paged = _page_search_entries(collected, limit, offset)
+
+ notes = []
+ if unresolved:
+ notes.append("未找到联系人: " + "、".join(unresolved))
+ if missing_tables:
+ notes.append("无消息表: " + "、".join(missing_tables))
+ if failures:
+ notes.append("查询失败: " + ";".join(failures))
+
+ if not paged:
+ header = f"在 {len(resolved_contexts)} 个聊天对象中未找到包含 \"{keyword}\" 的消息"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if notes:
+ header += "\n" + "\n".join(notes)
+ return header
+
+ header = (
+ f"在 {len(resolved_contexts)} 个聊天对象中搜索 \"{keyword}\" 找到 {len(paged)} 条结果"
+ f"(offset={offset}, limit={limit})"
+ )
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if notes:
+ header += "\n" + "\n".join(notes)
+ return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset)
+
+
+def _search_all_messages(keyword, start_ts, end_ts, start_time, end_time, limit, offset):
+ names = get_contact_names()
+ collected = []
+ failures = []
+ candidate_limit = _candidate_page_size(limit, offset)
+
+ for rel_key in MSG_DB_KEYS:
+ path = _cache.get(rel_key)
+ if not path:
+ continue
+
+ try:
+ with closing(sqlite3.connect(path)) as conn:
+ contexts = _load_search_contexts_from_db(conn, path, names)
+ db_entries, db_failures = _collect_search_entries(
+ conn,
+ contexts,
+ names,
+ keyword,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ candidate_limit=candidate_limit,
+ )
+ collected.extend(db_entries)
+ failures.extend(db_failures)
+ except Exception as e:
+ failures.append(f"{rel_key}: {e}")
+
+ paged = _page_search_entries(collected, limit, offset)
+
+ if not paged:
+ header = f"未找到包含 \"{keyword}\" 的消息"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if failures:
+ header += "\n查询失败: " + ";".join(failures)
+ return header
+
+ header = f"搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if failures:
+ header += "\n查询失败: " + ";".join(failures)
+ return header + ":\n\n" + "\n\n".join(item[1] for item in paged) + _pagination_hint(len(paged), limit, offset)
# ============ MCP Server ============
@@ -1179,7 +1819,7 @@ _last_check_state = {} # {username: last_timestamp}
@mcp.tool()
-def get_recent_sessions(limit: int = 20) -> str:
+def get_recent_sessions(limit: int = 20) -> str:
"""获取微信最近会话列表,包含最新消息摘要、未读数、时间等。
用于了解最近有哪些人/群在聊天。
@@ -1191,15 +1831,15 @@ def get_recent_sessions(limit: int = 20) -> str:
return "错误: 无法解密 session.db"
names = get_contact_names()
- with closing(sqlite3.connect(path)) as conn:
- rows = conn.execute("""
- SELECT username, unread_count, summary, last_timestamp,
- last_msg_type, last_msg_sender, last_sender_display_name
- FROM SessionTable
- WHERE last_timestamp > 0
- ORDER BY last_timestamp DESC
- LIMIT ?
- """, (limit,)).fetchall()
+ with closing(sqlite3.connect(path)) as conn:
+ rows = conn.execute("""
+ SELECT username, unread_count, summary, last_timestamp,
+ last_msg_type, last_msg_sender, last_sender_display_name
+ FROM SessionTable
+ WHERE last_timestamp > 0
+ ORDER BY last_timestamp DESC
+ LIMIT ?
+ """, (limit,)).fetchall()
results = []
for r in rows:
@@ -1237,21 +1877,28 @@ def get_recent_sessions(limit: int = 20) -> str:
@mcp.tool()
-def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str:
- """获取指定聊天的消息记录。
-
- Args:
- chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配
- limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用
- offset: 分页偏移量,默认0
- start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
- end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
- """
- try:
- _validate_pagination(limit, offset, limit_max=None)
- start_ts, end_ts = _parse_time_range(start_time, end_time)
- except ValueError as e:
- return f"错误: {e}"
+def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "", oldest_first: bool = False, msg_types: list[str] | None = None) -> str:
+ """获取指定聊天的消息记录。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid,自动模糊匹配
+ limit: 返回的消息数量,默认50;支持较大的值,建议配合 offset 分页使用
+ offset: 分页偏移量,默认0
+ start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
+ end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
+ oldest_first: 为 True 时返回最早的消息(默认 False 返回最新消息)
+ msg_types: 按消息类型过滤,可选值: text, image, voice, video, file(=app),
+ emoji, location, namecard, voip, system。传 None 或不传表示不过滤
+ """
+ try:
+ _validate_pagination(limit, offset, limit_max=None)
+ start_ts, end_ts = _parse_time_range(start_time, end_time)
+ except ValueError as e:
+ return f"错误: {e}"
+
+ type_filter, type_err = _resolve_msg_types(msg_types)
+ if type_err:
+ return f"错误: {type_err}"
ctx = _resolve_chat_context(chat_name)
if not ctx:
@@ -1259,102 +1906,106 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim
if not ctx['db_path']:
return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)"
- names = get_contact_names()
- lines, failures = _collect_chat_history_lines(
- ctx,
- names,
- start_ts=start_ts,
- end_ts=end_ts,
- limit=limit,
- offset=offset,
- )
-
- if not lines:
- if failures:
- return "查询失败: " + ";".join(failures)
- return f"{ctx['display_name']} 无消息记录"
-
- header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})"
- if ctx['is_group']:
- header += " [群聊]"
- if start_time or end_time:
- header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
- if failures:
- header += "\n查询失败: " + ";".join(failures)
- return header + ":\n\n" + "\n".join(lines)
+ names = get_contact_names()
+ lines, failures = _collect_chat_history_lines(
+ ctx,
+ names,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ limit=limit,
+ offset=offset,
+ oldest_first=oldest_first,
+ type_filter=type_filter,
+ )
+
+ if not lines:
+ if failures:
+ return "查询失败: " + ";".join(failures)
+ return f"{ctx['display_name']} 无消息记录"
+
+ header = f"{ctx['display_name']} 的消息记录(返回 {len(lines)} 条,offset={offset}, limit={limit})"
+ if ctx['is_group']:
+ header += " [群聊]"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ if msg_types:
+ header += f"\n类型过滤: {', '.join(msg_types)}"
+ if failures:
+ header += "\n查询失败: " + ";".join(failures)
+ return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset)
-@mcp.tool()
-def search_messages(
- keyword: str,
- chat_name: str | list[str] | None = None,
- start_time: str = "",
- end_time: str = "",
- limit: int = 20,
- offset: int = 0,
-) -> str:
- """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。
-
- Args:
- keyword: 搜索关键词
- chat_name: 聊天对象名称,可为空、单个字符串或字符串列表
- start_time: 起始时间,可为空
- end_time: 结束时间,可为空
- limit: 返回的结果数量,默认20,最大500
- offset: 分页偏移量,默认0
- """
- if not keyword or len(keyword) < 1:
- return "请提供搜索关键词"
-
- chat_names = _normalize_chat_names(chat_name)
-
- try:
- _validate_pagination(limit, offset)
- start_ts, end_ts = _parse_time_range(start_time, end_time)
- except ValueError as e:
- return f"错误: {e}"
-
- if len(chat_names) == 1:
- ctx = _resolve_chat_context(chat_names[0])
- if not ctx:
- return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人"
- if not ctx['db_path']:
- return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)"
- return _search_single_chat(
- ctx,
- keyword,
- start_ts,
- end_ts,
- start_time,
- end_time,
- limit,
- offset,
- )
-
- if len(chat_names) > 1:
- return _search_multiple_chats(
- chat_names,
- keyword,
- start_ts,
- end_ts,
- start_time,
- end_time,
- limit,
- offset,
- )
-
- return _search_all_messages(
- keyword,
- start_ts,
- end_ts,
- start_time,
- end_time,
- limit,
- offset,
- )
+@mcp.tool()
+def search_messages(
+ keyword: str,
+ chat_name: str | list[str] | None = None,
+ start_time: str = "",
+ end_time: str = "",
+ limit: int = 20,
+ offset: int = 0,
+) -> str:
+ """搜索消息内容,支持全库、单个聊天对象、多个聊天对象,以及时间范围和分页。
-@mcp.tool()
-def get_contacts(query: str = "", limit: int = 50) -> str:
+ Args:
+ keyword: 搜索关键词
+ chat_name: 聊天对象名称,可为空、单个字符串或字符串列表
+ start_time: 起始时间,可为空
+ end_time: 结束时间,可为空
+ limit: 返回的结果数量,默认20,最大500
+ offset: 分页偏移量,默认0
+ """
+ if not keyword or len(keyword) < 1:
+ return "请提供搜索关键词"
+
+ chat_names = _normalize_chat_names(chat_name)
+
+ try:
+ _validate_pagination(limit, offset)
+ start_ts, end_ts = _parse_time_range(start_time, end_time)
+ except ValueError as e:
+ return f"错误: {e}"
+
+ if len(chat_names) == 1:
+ ctx = _resolve_chat_context(chat_names[0])
+ if not ctx:
+ return f"找不到聊天对象: {chat_names[0]}\n提示: 可以用 get_contacts(query='{chat_names[0]}') 搜索联系人"
+ if not ctx['db_path']:
+ return f"找不到 {ctx['display_name']} 的消息记录(可能在未解密的DB中或无消息)"
+ return _search_single_chat(
+ ctx,
+ keyword,
+ start_ts,
+ end_ts,
+ start_time,
+ end_time,
+ limit,
+ offset,
+ )
+
+ if len(chat_names) > 1:
+ return _search_multiple_chats(
+ chat_names,
+ keyword,
+ start_ts,
+ end_ts,
+ start_time,
+ end_time,
+ limit,
+ offset,
+ )
+
+ return _search_all_messages(
+ keyword,
+ start_ts,
+ end_ts,
+ start_time,
+ end_time,
+ limit,
+ offset,
+ )
+
+@mcp.tool()
+def get_contacts(query: str = "", limit: int = 50) -> str:
"""搜索或列出微信联系人。
Args:
@@ -1376,6 +2027,7 @@ def get_contacts(query: str = "", limit: int = 50) -> str:
else:
filtered = contacts
+ total = len(filtered)
filtered = filtered[:limit]
if not filtered:
@@ -1393,11 +2045,72 @@ def get_contacts(query: str = "", limit: int = 50) -> str:
header = f"找到 {len(filtered)} 个联系人"
if query:
header += f"(搜索: {query})"
- return header + ":\n\n" + "\n".join(lines)
+ result = header + ":\n\n" + "\n".join(lines)
+ if total > limit:
+ result += f"\n\n(共 {total} 个匹配,当前仅显示前 {limit} 个,可增大 limit 查看更多)"
+ return result
@mcp.tool()
-def get_new_messages() -> str:
+def get_contact_tags() -> str:
+ """列出所有微信联系人标签及成员数量。"""
+ tags = _load_contact_tags()
+ if not tags:
+ return "未找到标签数据(contact_label 表可能不存在)"
+
+ sorted_tags = sorted(tags.values(), key=lambda t: t['sort_order'])
+ total_assoc = sum(len(t['members']) for t in sorted_tags)
+
+ lines = [f"共 {len(sorted_tags)} 个标签,{total_assoc} 个关联:\n"]
+ for t in sorted_tags:
+ lines.append(f" [{t['name']}] {len(t['members'])}人")
+ return "\n".join(lines)
+
+
+@mcp.tool()
+def get_tag_members(tag_name: str) -> str:
+ """获取指定标签下的所有联系人。支持模糊匹配标签名。
+
+ Args:
+ tag_name: 标签名称,支持精确和模糊匹配
+ """
+ tags = _load_contact_tags()
+ if not tags:
+ return "未找到标签数据(contact_label 表可能不存在)"
+
+ q = tag_name.strip().lower()
+
+ # 精确匹配
+ exact = [t for t in tags.values() if t['name'].lower() == q]
+ if exact:
+ matched = exact[0]
+ else:
+ # 模糊匹配 (contains)
+ fuzzy = [t for t in tags.values() if q in t['name'].lower()]
+ if not fuzzy:
+ all_names = [t['name'] for t in sorted(tags.values(), key=lambda t: t['sort_order'])]
+ return f"未找到匹配 \"{tag_name}\" 的标签。\n\n现有标签: {', '.join(all_names)}"
+ if len(fuzzy) == 1:
+ matched = fuzzy[0]
+ else:
+ names = [t['name'] for t in fuzzy]
+ return f"找到 {len(fuzzy)} 个匹配的标签,请指定:\n" + "\n".join(f" [{n}]" for n in names)
+
+ members = matched['members']
+ if not members:
+ return f"标签 [{matched['name']}] 没有成员"
+
+ lines = [f"标签 [{matched['name']}] 共 {len(members)} 人:\n"]
+ for m in members:
+ line = m['username']
+ if m['display_name'] != m['username']:
+ line += f" {m['display_name']}"
+ lines.append(f" {line}")
+ return "\n".join(lines)
+
+
+@mcp.tool()
+def get_new_messages() -> str:
"""获取自上次调用以来的新消息。首次调用返回最近的会话状态。"""
global _last_check_state
@@ -1406,14 +2119,14 @@ def get_new_messages() -> str:
return "错误: 无法解密 session.db"
names = get_contact_names()
- with closing(sqlite3.connect(path)) as conn:
- rows = conn.execute("""
- SELECT username, unread_count, summary, last_timestamp,
- last_msg_type, last_msg_sender, last_sender_display_name
- FROM SessionTable
- WHERE last_timestamp > 0
- ORDER BY last_timestamp DESC
- """).fetchall()
+ with closing(sqlite3.connect(path)) as conn:
+ rows = conn.execute("""
+ SELECT username, unread_count, summary, last_timestamp,
+ last_msg_type, last_msg_sender, last_sender_display_name
+ FROM SessionTable
+ WHERE last_timestamp > 0
+ ORDER BY last_timestamp DESC
+ """).fetchall()
curr_state = {}
for r in rows:
@@ -1489,7 +2202,12 @@ def get_new_messages() -> str:
# ============ 图片解密 ============
-_image_resolver = ImageResolver(WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache)
+_image_aes_key = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取)
+_image_xor_key = _cfg.get("image_xor_key", 0x88)
+_image_resolver = ImageResolver(
+ WECHAT_BASE_DIR, DECODED_IMAGE_DIR, _cache,
+ aes_key=_image_aes_key, xor_key=_image_xor_key,
+)
@mcp.tool()
@@ -1524,7 +2242,853 @@ def decode_image(chat_name: str, local_id: int) -> str:
@mcp.tool()
-def get_chat_images(chat_name: str, limit: int = 20) -> str:
+def decode_file_message(chat_name: str, local_id: int, create_time: int = 0) -> str:
+ """获取微信聊天中外层文件消息(PDF/docx/xlsx 等)的本地副本路径。
+
+ 微信会把对方发来的文件下载到 ~/Library/.../msg/file/{YYYY-MM}/原文件名.{ext}
+ (macOS)。本工具从消息记录解析出文件名/大小,在本地缓存中精确定位,
+ 然后返回原始路径,可直接交给 Read/PDF 工具读取。
+
+ 使用流程:先用 get_chat_history 找到 [文件] xxx.pdf (local_id=N, ts=T),
+ 把 N 和 T 一起传给本工具。create_time(ts) 用于跨分片场景下唯一定位。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ local_id: 文件消息的 local_id(从 get_chat_history 获取)
+ create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。
+ 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误
+ """
+ try:
+ local_id = int(local_id)
+ create_time = int(create_time)
+ except (TypeError, ValueError):
+ return "错误: local_id 和 create_time 必须是整数"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ # 同一 chat 的消息可能分散在多个 message_N.db 分片中。扫所有分片收集 row,
+ # 多于一条就报歧义错误(避免 silent decoding wrong message)。
+ shards = _find_msg_tables_for_user(username)
+ if not shards:
+ return f"找不到 {chat_name} 的消息表"
+
+ # 扫所有分片收集 row。如果调用者传了 create_time,用 (local_id, create_time)
+ # 精确匹配;否则只按 local_id 收集,多匹配时报歧义并提示加 create_time。
+ matches = []
+ for shard in shards:
+ if not _is_safe_msg_table_name(shard['table_name']):
+ continue
+ with closing(sqlite3.connect(shard['db_path'])) as conn:
+ if create_time:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
+ (local_id, create_time)
+ ).fetchone()
+ else:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=?",
+ (local_id,)
+ ).fetchone()
+ if candidate_row:
+ matches.append((shard['db_path'], candidate_row))
+ if not matches:
+ if create_time:
+ return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
+ return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
+ if len(matches) > 1:
+ details = []
+ for db_p, r in matches:
+ ct = r[1]
+ ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?'
+ details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})")
+ return (
+ f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
+ + '\n '.join(details)
+ + f"\n请加 create_time 参数:decode_file_message(chat_name, local_id={local_id}, create_time=N)"
+ )
+
+ _, row = matches[0]
+ local_type, create_time, content, ct_compress = row
+ base_type, _ = _split_msg_type(local_type)
+ if base_type != 49:
+ return (
+ f"不是文件消息(local_type={local_type},base_type={base_type}),"
+ f"文件消息应为 base_type=49 且 appmsg type=6"
+ )
+
+ xml_text = _decompress_content(content, ct_compress)
+ if not xml_text:
+ return "消息 content 为空或无法解码"
+
+ # 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式
+ is_group = username.endswith('@chatroom')
+ _, xml_text = _parse_message_content(xml_text, local_type, is_group)
+
+ root = _parse_app_message_outer(xml_text)
+ if root is None:
+ return "无法解析消息 XML"
+
+ appmsg = root.find('.//appmsg')
+ if appmsg is None:
+ return "消息中没有 appmsg 段(可能不是文件类型)"
+
+ # 必须是 appmsg type=6 (文件),否则可能是链接/小程序/合并转发等带 title 的卡片,
+ # 按 title/size 全盘搜会误命中无关本地文件并伪装成"找到了"。
+ app_type_in_msg = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
+ if app_type_in_msg != 6:
+ return (
+ f"不是文件消息(appmsg type={app_type_in_msg})。"
+ f"文件消息要求 appmsg type=6;type=19 请用 decode_record_item,"
+ f"type=5/33/36/44 等是链接/小程序,没有可下载的本地文件"
+ )
+
+ raw_title = _collapse_text(appmsg.findtext('title') or '')
+ fileext = _collapse_text(appmsg.findtext('.//fileext') or '')
+ totallen = _parse_int(_collapse_text(appmsg.findtext('.//totallen') or ''), 0)
+ # md5 字段在 type=6 外层(不是 appattach 子节点)—— 用于强校验候选文件归属
+ expected_md5 = _collapse_text(appmsg.findtext('md5') or '').lower()
+
+ # 没有 appattach 节点 = 不是真正的文件消息(type=6 必带 appattach)
+ if appmsg.find('appattach') is None:
+ return "消息没有 appattach 节点(可能 schema 异常或不是真文件消息)"
+
+ if not raw_title:
+ return "消息中没有文件名 (title)"
+
+ # title 来自不可信的 message XML,对方可能发恶意消息(含绝对路径或 ../)。
+ # 必须 sanitize 成 safe basename 才能拼路径 + glob,否则有 path-traversal 风险。
+ title = _safe_basename(raw_title)
+ if not title:
+ return f"消息中的文件名 {raw_title!r} 不安全(含绝对路径/路径分隔符/..),拒绝处理"
+
+ # 性能优化:先按消息时间精确定位 msg/file/{YYYY-MM}/,命中即返回;
+ # 否则才退回 walk 全盘 os.walk(msg/attach 含数十万小文件,全盘扫描可达数秒)
+ candidates = []
+ msg_file_dir = os.path.join(WECHAT_BASE_DIR, 'msg/file')
+ if create_time and os.path.isdir(msg_file_dir):
+ # 同名文件可能落到收到消息的当月、上一月或下一月(罕见跨月边界)
+ ts_dt = datetime.fromtimestamp(create_time)
+ candidate_months = {
+ ts_dt.strftime('%Y-%m'),
+ (ts_dt - timedelta(days=31)).strftime('%Y-%m'),
+ (ts_dt + timedelta(days=31)).strftime('%Y-%m'),
+ }
+ escaped_stem = glob.escape(os.path.splitext(title)[0])
+ ext = os.path.splitext(title)[1]
+ for ym in candidate_months:
+ month_dir = os.path.join(msg_file_dir, ym)
+ if not os.path.isdir(month_dir):
+ continue
+ # 精确匹配 + 同名 (1)(2) 后缀变体
+ for pattern in (
+ glob.escape(title),
+ f"{escaped_stem}*{glob.escape(ext)}" if ext else f"{escaped_stem}*",
+ ):
+ for hit in glob.glob(os.path.join(month_dir, pattern)):
+ # 有 totallen 时立刻 size 验证:避免月扫命中"同名但 size 不对"的副本
+ # 阻塞 walk 兜底,最终返回错误文件
+ if totallen:
+ try:
+ if os.path.getsize(hit) != totallen:
+ continue
+ except OSError:
+ continue
+ if hit not in candidates:
+ candidates.append(hit)
+
+ # 退路:未命中或没 create_time 时只 walk msg/file(slow path 兜底)。
+ # 文件名匹配严格化:只接受精确匹配或 wechat 自动加副本的 "(N)" 后缀变体,
+ # 不做 stem 子串匹配——避免 "某某论文.pdf" 被当成 "论文.pdf"。
+ if not candidates:
+ d = os.path.join(WECHAT_BASE_DIR, 'msg/file')
+ stem, ext = os.path.splitext(title)
+ copy_pattern = re.compile(
+ r'^' + re.escape(stem) + r' ?\(\d+\)' + re.escape(ext) + r'$'
+ )
+ if os.path.isdir(d):
+ for root_dir, _, files in os.walk(d):
+ for f in files:
+ if f.startswith('.'):
+ continue
+ full = os.path.join(root_dir, f)
+ is_exact = (f == title)
+ is_copy_variant = bool(copy_pattern.match(f))
+ if not (is_exact or is_copy_variant):
+ continue
+ if totallen:
+ try:
+ if os.path.getsize(full) != totallen:
+ continue
+ except OSError:
+ continue
+ candidates.append(full)
+
+ if not candidates:
+ return (
+ f"在本地缓存中找不到 {title}\n"
+ f" 期望路径模式: {WECHAT_BASE_DIR}/msg/file/YYYY-MM/{title}\n"
+ f" 可能原因:从未在 PC/Mac 微信打开过 / 已被清理"
+ )
+
+ # 严格 size 过滤(如果 totallen 已知,不匹配的全淘汰)
+ if totallen:
+ candidates = [c for c in candidates if os.path.getsize(c) == totallen]
+ if not candidates:
+ return (
+ f"在本地缓存中找不到 {title} (期望 size={totallen:,})\n"
+ f" 说明:找到了同名文件但 size 都不匹配——可能从未真正下载完整 / 已被清理"
+ )
+
+ # 路径绑定策略:有 md5 → cryptographic verify;没 md5 → heuristic +
+ # warning。本工具是用户主动通过 MCP 调用,path 只在本地对话显示,所以
+ # 没 md5 时不强制 fail-closed。
+ cache_root = os.path.join(WECHAT_BASE_DIR, 'msg')
+ md5_verified = False
+ if expected_md5 and len(expected_md5) == 32:
+ # 用 md5 过滤候选——同 md5 = 真同一文件副本。
+ md5_match = []
+ md5_errors = []
+ for c in candidates:
+ if not _path_under_root(c, cache_root):
+ md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过")
+ continue
+ actual_md5, err = _md5_file_chunked(c)
+ if err:
+ md5_errors.append(f"{c}: {err}")
+ continue
+ if actual_md5 == expected_md5:
+ md5_match.append(c)
+ break # 多候选共享同 md5 = 同一文件副本,第一个命中即停
+ if not md5_match:
+ info = (
+ f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n"
+ f" 期望 md5: {expected_md5}\n"
+ f" 说明:找到 {len(candidates)} 个同名同 size 的本地文件但 md5 都不对。"
+ f"目标文件可能未在 wechat 客户端打开过,或已被清理。"
+ )
+ if md5_errors:
+ info += "\n 校验异常:\n " + "\n ".join(md5_errors)
+ return info
+ candidates = md5_match
+ md5_verified = True
+
+ # 没 md5 时多 candidates 仍 fail-closed(避免 silent mtime pick)
+ if len(candidates) > 1 and not md5_verified:
+ details = []
+ for c in candidates:
+ try:
+ mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat()
+ except OSError:
+ mt = '?'
+ details.append(f"{c} (mtime={mt})")
+ return (
+ f"在本地缓存找到 {len(candidates)} 个匹配的副本,无法唯一定位"
+ f"(同名同 size 多份,且消息 XML 没含 md5 用于强校验):\n "
+ + '\n '.join(details)
+ + f"\n请人工 inspect mtime / 上下文区分"
+ )
+
+ chosen = candidates[0]
+ if not _path_under_root(chosen, cache_root):
+ return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)"
+
+ binding_note = (
+ "✅ md5 校验通过,路径与消息唯一绑定"
+ if md5_verified else
+ f"⚠️ 消息 XML 没含 md5,路径基于 (filename+size) 启发式匹配——"
+ f"如果同 chat 缓存里另有同名同 size 的不相关文件,可能返回错副本,请人工验证。"
+ )
+ return (
+ f"找到本地文件:\n"
+ f" 路径: {chosen}\n"
+ f" 大小: {os.path.getsize(chosen):,} bytes\n"
+ f" 扩展名: {fileext or os.path.splitext(title)[1].lstrip('.') or '?'}\n"
+ f" 期望大小: {totallen:,} bytes\n"
+ f" {binding_note}"
+ )
+
+
+@mcp.tool()
+def decode_record_item(chat_name: str, local_id: int, item_index: int, create_time: int = 0) -> str:
+ """获取合并转发聊天记录中某个内嵌文件/图片的本地副本路径。
+
+ 使用流程:
+ 1. 先用 get_chat_history 找到 [聊天记录] xxx (local_id=N, ts=T) 卡片,记下 N 和 T,
+ 以及展开行里 [item_index] 前缀(0-based)
+ 2. 用本工具拿本地路径,create_time 传 history 里的 ts 部分
+ 3. 如果未下载,工具会精确告诉你去 wechat 客户端点击合并卡片里的第几项触发下载
+
+ 注意:合并转发里的内嵌文件只有在用户**点击查看**后 wechat 才会下载到本地。
+ 没点过的 dataitem 用本工具会得到"未下载"提示。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ local_id: 合并转发消息(带"[聊天记录]"标记)的 local_id
+ item_index: dataitem 在 datalist 里的 0-based 索引(history 输出里的 [N] 前缀)
+ create_time: 消息的 unix 时间戳;用于跨分片唯一定位,传 0 时多匹配会报歧义
+ """
+ try:
+ local_id = int(local_id)
+ item_index = int(item_index)
+ create_time = int(create_time)
+ except (TypeError, ValueError):
+ return "错误: local_id / item_index / create_time 必须是整数"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ # 多分片扫描 + ambiguity 检测(避免 silent decoding wrong message,参考 decode_file_message)
+ shards = _find_msg_tables_for_user(username)
+ if not shards:
+ return f"找不到 {chat_name} 的消息表"
+
+ matches = []
+ for shard in shards:
+ if not _is_safe_msg_table_name(shard['table_name']):
+ continue
+ with closing(sqlite3.connect(shard['db_path'])) as conn:
+ if create_time:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
+ (local_id, create_time)
+ ).fetchone()
+ else:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=?",
+ (local_id,)
+ ).fetchone()
+ if candidate_row:
+ matches.append((shard['table_name'], candidate_row))
+ if not matches:
+ if create_time:
+ return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
+ return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
+ if len(matches) > 1:
+ details = []
+ for tn, r in matches:
+ ts_str = datetime.fromtimestamp(r[1]).isoformat() if r[1] else '?'
+ details.append(f"table={tn[:12]}... create_time={r[1]} ({ts_str})")
+ return (
+ f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
+ + '\n '.join(details)
+ + f"\n请加 create_time 参数:decode_record_item(chat_name, local_id={local_id}, item_index={item_index}, create_time=N)"
+ )
+
+ table_name, row = matches[0]
+ local_type, _create_time, content, ct_compress = row
+ base_type, _ = _split_msg_type(local_type)
+ if base_type != 49:
+ return (
+ f"不是合并转发消息(local_type={local_type}, base_type={base_type}),"
+ f"合并转发应为 base_type=49 + appmsg type=19"
+ )
+
+ xml_text = _decompress_content(content, ct_compress)
+ if not xml_text:
+ return "消息 content 为空或无法解码"
+
+ # 复用项目内现有 helper 剥离群聊 sender 前缀,避免自己写启发式
+ is_group = username.endswith('@chatroom')
+ _, xml_text = _parse_message_content(xml_text, local_type, is_group)
+
+ root = _parse_app_message_outer(xml_text)
+ if root is None:
+ return "无法解析消息 XML(可能不是合并转发消息)"
+ appmsg = root.find('.//appmsg')
+ if appmsg is None:
+ return "消息中没有 appmsg 段"
+
+ app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
+ if app_type != 19:
+ return (
+ f"不是合并转发消息(appmsg type={app_type}),"
+ f"合并转发应为 type=19。请用 decode_file_message 处理外层独立文件"
+ )
+
+ record_node = appmsg.find('recorditem')
+ if record_node is None or not record_node.text:
+ return "消息中没有 recorditem(datalist 还未加载,请在 wechat 中点开此卡片让客户端拉取)"
+
+ inner = _parse_xml_root(record_node.text, max_len=_RECORD_XML_PARSE_MAX_LEN)
+ if inner is None:
+ return "无法解析 recorditem 内嵌 XML"
+
+ datalist = inner.find('datalist')
+ items = list(datalist.findall('dataitem')) if datalist is not None else []
+ if not items:
+ return "datalist 为空(合并记录还未加载内容)"
+ if item_index < 0 or item_index >= len(items):
+ return f"item_index={item_index} 超出范围(共 {len(items)} 条 dataitem,0-based)"
+
+ item = items[item_index]
+ datatype = (item.get('datatype') or '').strip()
+ raw_datatitle = _collapse_text(item.findtext('datatitle') or '')
+ # datatitle 来自不可信 XML,sanitize 防 path traversal
+ datatitle = _safe_basename(raw_datatitle) if raw_datatitle else ''
+ if raw_datatitle and not datatitle:
+ return f"该 dataitem 的 datatitle {raw_datatitle!r} 不安全(含绝对路径/分隔符/..),拒绝处理"
+ datasize = _parse_int(_collapse_text(item.findtext('datasize') or ''), 0)
+ datafmt = _collapse_text(item.findtext('datafmt') or '')
+ sourcename = _collapse_text(item.findtext('sourcename') or '')
+ # fullmd5 是文件内容唯一标识,用于把候选绑定到这条 record,避免误命中
+ # 同 chat 内别条 record 的同名同 size 文件。
+ expected_md5 = _collapse_text(item.findtext('fullmd5') or '').lower()
+
+ type_label = _RECORD_DATATYPE_LABEL.get(datatype, f'datatype={datatype}')
+
+ if datatype == '1':
+ text_content = _collapse_text(item.findtext('datadesc') or '')
+ return (
+ f"该 dataitem 是文本,无需下载:\n"
+ f" 发送者: {sourcename}\n"
+ f" 内容: {text_content}"
+ )
+
+ # 仅以下 datatype 在 wechat 缓存里有真本地 binary(图片/语音/视频/文件);
+ # 其他类型如链接/位置/名片/小程序/视频号/嵌套聊天记录等只是 metadata,
+ # 没有可下载的本地副本。不在白名单里的 datatype 直接拒绝,避免 wildcard
+ # sub='*' 通配命中无关 record 的同名文件。
+ subdir_map = _RECORD_BINARY_SUBDIR
+ if datatype not in subdir_map:
+ return (
+ f"该 dataitem 类型 [{type_label}] 没有本地 binary 文件,无需下载\n"
+ f" 发送者: {sourcename}\n"
+ f" 标题: {datatitle or '(无)'}\n"
+ f" 说明:仅 datatype=2/4/5/8(图片/语音/视频/文件)有可下载内容;"
+ f"链接/位置/名片/小程序/视频号/嵌套聊天记录等是 metadata-only。"
+ f"\n如果你需要这条 dataitem 的 metadata 详情,看 get_chat_history 输出里"
+ f"已展开的 [{item_index}] 行内容即可。"
+ )
+
+ table_hash = table_name.replace('Msg_', '', 1)
+ attach_dir = os.path.join(WECHAT_BASE_DIR, 'msg/attach', table_hash)
+
+ candidates = []
+ if os.path.isdir(attach_dir):
+ import glob as glob_mod
+ sub = subdir_map.get(datatype, '*')
+ idx_str = str(item_index)
+
+ # datatype=2 图片走 flat 文件命名 (Img/0_t / Img/0 / Img/0.{ext}),
+ # 不像文件类的 F/{idx}/{filename}。
+ if datatype == '2':
+ flat_patterns = [
+ f"{idx_str}_t",
+ idx_str,
+ f"{idx_str}.*",
+ f"{idx_str}_*",
+ ]
+ for fp in flat_patterns:
+ for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, fp)):
+ if datasize:
+ try:
+ if os.path.getsize(hit) != datasize:
+ continue
+ except OSError:
+ continue
+ if hit not in candidates:
+ candidates.append(hit)
+
+ # 文件 / 视频 / 语音类: F|V|A/{idx}/{filename}
+ if datatype != '2' and datatitle:
+ escaped_title = glob.escape(datatitle)
+ for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, escaped_title)):
+ if datasize:
+ try:
+ if os.path.getsize(hit) != datasize:
+ continue
+ except OSError:
+ continue
+ if hit not in candidates:
+ candidates.append(hit)
+
+ # size only 兜底:仅在 datatitle 缺失且非 image(image 已上面处理)时启用
+ if not candidates and not datatitle and datasize and datatype != '2':
+ for hit in glob.glob(os.path.join(attach_dir, '*/Rec/*', sub, idx_str, '*')):
+ try:
+ if os.path.getsize(hit) == datasize:
+ candidates.append(hit)
+ except OSError:
+ pass
+
+ if not candidates:
+ return (
+ f"在本地缓存中找不到此 dataitem(很可能未在 wechat 客户端点击查看过)\n"
+ f" 消息: {chat_name} 的 local_id={local_id}\n"
+ f" dataitem[{item_index}]: {sourcename}: [{type_label}] {datatitle or '(无标题)'}\n"
+ f" 期望大小: {datasize:,} bytes\n"
+ f" 期望路径模式: {attach_dir}/YYYY-MM/Rec/*/{subdir_map.get(datatype, '?')}/{item_index}/{datatitle}\n"
+ f" 解决方法: 在 wechat 客户端打开此合并记录卡片,点击第 {item_index + 1} 项让客户端下载,再试"
+ )
+
+ # 注意:早 ambiguity check(在 md5 filter 之前)已经被删除——它会让有 fullmd5
+ # 但多 candidates 的合理 case silent 失败。md5 filter 后再做歧义判断(见下方)。
+ # 威胁模型:本工具是用户主动通过 MCP 调用 + path 只在本地显示。
+ # 跟 decode_file_message 一致路线:有 md5 强校验,没 md5 fallback 到
+ # heuristic + warning(实用 over 严格)。
+ cache_root = os.path.join(WECHAT_BASE_DIR, 'msg')
+ md5_verified = False
+ if expected_md5 and len(expected_md5) == 32:
+ md5_match = []
+ md5_errors = []
+ for c in candidates:
+ if not _path_under_root(c, cache_root):
+ md5_errors.append(f"{c}: 不在 {cache_root} 下,跳过")
+ continue
+ actual_md5, err = _md5_file_chunked(c)
+ if err:
+ md5_errors.append(f"{c}: {err}")
+ continue
+ if actual_md5 == expected_md5:
+ md5_match.append(c)
+ break # 多候选共享同 md5 = 同一文件副本,第一个命中即停
+ if not md5_match:
+ info = (
+ f"⚠️ 候选文件 md5 都不匹配,拒绝返回错文件:\n"
+ f" 期望 md5: {expected_md5}\n"
+ f" 说明:候选 {len(candidates)} 个,md5 都不对。"
+ f"目标 dataitem 可能未在 wechat 客户端点开过,请点击第 {item_index + 1} 项触发下载。"
+ )
+ if md5_errors:
+ info += "\n 校验异常:\n " + "\n ".join(md5_errors)
+ return info
+ candidates = md5_match
+ md5_verified = True
+
+ # 没 fullmd5 时多 candidates 仍 fail-closed
+ if len(candidates) > 1 and not md5_verified:
+ details = []
+ for c in candidates:
+ try:
+ mt = datetime.fromtimestamp(os.path.getmtime(c)).isoformat()
+ except OSError:
+ mt = '?'
+ details.append(f"{c} (mtime={mt})")
+ return (
+ f"找到 {len(candidates)} 个匹配的本地副本,无法唯一定位"
+ f"(同位置同名同 size 多份,且 dataitem XML 没含 fullmd5 用于强校验):\n "
+ + '\n '.join(details)
+ + f"\n请人工 inspect mtime / 上下文区分"
+ )
+
+ chosen = candidates[0]
+ if not _path_under_root(chosen, cache_root):
+ return f"匹配到的路径 {chosen!r} 不在 {cache_root} 下,拒绝返回(可能是 symlink 攻击)"
+
+ binding_note = (
+ "✅ md5 校验通过,路径与 dataitem 唯一绑定"
+ if md5_verified else
+ f"⚠️ 此 dataitem XML 没含 fullmd5,路径基于 (item_index+filename+size) 启发式匹配——"
+ f"如果同 chat 内多条合并卡片碰巧含同位置同名同 size 的文件,可能返回别条 record 的副本,请人工验证。"
+ )
+ return (
+ f"找到本地文件:\n"
+ f" 路径: {chosen}\n"
+ f" 大小: {os.path.getsize(chosen):,} bytes\n"
+ f" 期望大小: {datasize:,} bytes\n"
+ f" 发送者: {sourcename}\n"
+ f" 类型: [{type_label}] {datatitle or '(无标题)'}\n"
+ f" {binding_note}"
+ )
+
+
+@mcp.tool()
+def decode_transfer(chat_name: str, local_id: int, create_time: int = 0) -> str:
+ """读取微信转账消息(appmsg type=2000)的结构化信息。
+
+ 返回方向(发起/收款/退还)、金额、备注、付款人/收款人 wxid、交易号、
+ 发起/失效时间。仅 1v1 聊天有转账消息(微信不支持群转账)。
+
+ 使用流程:先用 get_chat_history 找到 [转账·xxx] 行 (local_id=N, ts=T),
+ 把 N 和 T 一起传进来。create_time(ts) 用于跨分片场景下唯一定位。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ local_id: 转账消息的 local_id(从 get_chat_history 获取)
+ create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。
+ 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误
+ """
+ try:
+ local_id = int(local_id)
+ create_time = int(create_time)
+ except (TypeError, ValueError):
+ return "错误: local_id 和 create_time 必须是整数"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ # 多分片扫描 + ambiguity 检测,跟 decode_file_message 一致
+ shards = _find_msg_tables_for_user(username)
+ if not shards:
+ return f"找不到 {chat_name} 的消息表"
+
+ matches = []
+ for shard in shards:
+ if not _is_safe_msg_table_name(shard['table_name']):
+ continue
+ with closing(sqlite3.connect(shard['db_path'])) as conn:
+ if create_time:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
+ (local_id, create_time)
+ ).fetchone()
+ else:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=?",
+ (local_id,)
+ ).fetchone()
+ if candidate_row:
+ matches.append((shard['db_path'], candidate_row))
+
+ if not matches:
+ if create_time:
+ return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
+ return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
+ if len(matches) > 1:
+ details = []
+ for db_p, r in matches:
+ ct = r[1]
+ ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?'
+ details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})")
+ return (
+ f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
+ + '\n '.join(details)
+ + f"\n请加 create_time 参数:decode_transfer(chat_name, local_id={local_id}, create_time=N)"
+ )
+
+ _, row = matches[0]
+ local_type, msg_create_time, content, ct_compress = row
+ base_type, _ = _split_msg_type(local_type)
+ if base_type != 49:
+ return (
+ f"不是转账消息(local_type={local_type}, base_type={base_type}),"
+ f"转账消息应为 base_type=49 + appmsg type=2000"
+ )
+
+ xml_text = _decompress_content(content, ct_compress)
+ if not xml_text:
+ return "消息 content 为空或无法解码"
+
+ is_group = username.endswith('@chatroom')
+ _, xml_text = _parse_message_content(xml_text, local_type, is_group)
+
+ root = _parse_app_message_outer(xml_text)
+ if root is None:
+ return "无法解析消息 XML"
+ appmsg = root.find('.//appmsg')
+ if appmsg is None:
+ return "消息中没有 appmsg 段(不像转账)"
+
+ app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
+ if app_type != 2000:
+ return (
+ f"不是转账消息(appmsg type={app_type})。"
+ f"转账要求 appmsg type=2000;type=6 是文件,type=19 是合并转发,"
+ f"请用对应的 decode_file_message / decode_record_item 工具"
+ )
+
+ info = _extract_transfer_info(appmsg)
+ if info is None:
+ return "消息是 type=2000 但缺 节点(schema 异常)"
+
+ def _fmt_ts(ts_str):
+ ts = _parse_int(ts_str, 0)
+ if not ts:
+ return ''
+ try:
+ return datetime.fromtimestamp(ts).isoformat()
+ except (ValueError, OSError, OverflowError):
+ return f'(无效 ts={ts_str})'
+
+ direction = info['paysubtype_label'] or '(未知)'
+ raw_paysubtype = info['paysubtype'] or '?'
+ title = _collapse_text(appmsg.findtext('title') or '') or '微信转账'
+ des = _collapse_text(appmsg.findtext('des') or '')
+
+ lines = [f"转账消息: {title}"]
+ if des:
+ lines.append(f" 描述: {des}")
+ lines.append(f" 方向: {direction} (paysubtype={raw_paysubtype})")
+ if info['fee_desc']:
+ lines.append(f" 金额: {info['fee_desc']}")
+ if info['pay_memo']:
+ lines.append(f" 备注: {info['pay_memo']}")
+ if info['payer_username']:
+ lines.append(f" 付款方 wxid: {info['payer_username']}")
+ if info['receiver_username']:
+ lines.append(f" 收款方 wxid: {info['receiver_username']}")
+ begin_ts = _fmt_ts(info['begin_transfer_time'])
+ if begin_ts:
+ lines.append(f" 发起时间: {begin_ts}")
+ invalid_ts = _fmt_ts(info['invalid_time'])
+ if invalid_ts:
+ lines.append(f" 失效时间: {invalid_ts}")
+ if info['transfer_id']:
+ lines.append(f" 转账 ID: {info['transfer_id']}")
+ if info['transcation_id']:
+ lines.append(f" 支付交易号: {info['transcation_id']}")
+ if info['pay_msg_id']:
+ lines.append(f" paymsgid: {info['pay_msg_id']}")
+ return "\n".join(lines)
+
+
+@mcp.tool()
+def decode_refer(chat_name: str, local_id: int, create_time: int = 0) -> str:
+ """读取微信引用回复消息(appmsg type=57)的结构化信息。
+
+ 返回回复正文、被引用消息的发送者/类型/摘要/svrid/createtime。被引用消息的
+ type 决定摘要风格:1 文本展开原文,3/34/43/47/48/50 给 [图片]/[语音]/...
+ 标签,49 嵌套 appmsg 解一层 inner type 给 [链接] xxx。svrid 可用于回查
+ 原消息(在 history / export_chat 输出里搜)。
+
+ 使用流程:先用 get_chat_history 找到 [引用消息] 行 (local_id=N, ts=T),
+ 把 N 和 T 一起传进来。create_time(ts) 用于跨分片场景下唯一定位。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或 wxid
+ local_id: 引用消息的 local_id(从 get_chat_history 获取)
+ create_time: 消息的 unix 时间戳,从 get_chat_history 输出 ts=N 部分获取。
+ 用于在 local_id 跨分片冲突时唯一定位;传 0 时若多个分片含同 local_id 会报歧义错误
+ """
+ try:
+ local_id = int(local_id)
+ create_time = int(create_time)
+ except (TypeError, ValueError):
+ return "错误: local_id 和 create_time 必须是整数"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ shards = _find_msg_tables_for_user(username)
+ if not shards:
+ return f"找不到 {chat_name} 的消息表"
+
+ matches = []
+ for shard in shards:
+ if not _is_safe_msg_table_name(shard['table_name']):
+ continue
+ with closing(sqlite3.connect(shard['db_path'])) as conn:
+ if create_time:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=? AND create_time=?",
+ (local_id, create_time)
+ ).fetchone()
+ else:
+ candidate_row = conn.execute(
+ f"SELECT local_type, create_time, message_content, WCDB_CT_message_content "
+ f"FROM [{shard['table_name']}] WHERE local_id=?",
+ (local_id,)
+ ).fetchone()
+ if candidate_row:
+ matches.append((shard['db_path'], candidate_row))
+
+ if not matches:
+ if create_time:
+ return f"找不到 (local_id={local_id}, create_time={create_time}) 的消息(已扫描 {len(shards)} 个分片)"
+ return f"找不到 local_id={local_id} 的消息(已扫描 {len(shards)} 个分片)"
+ if len(matches) > 1:
+ details = []
+ for db_p, r in matches:
+ ct = r[1]
+ ts_str = datetime.fromtimestamp(ct).isoformat() if ct else '?'
+ details.append(f"{os.path.basename(db_p)} create_time={ct} ({ts_str})")
+ return (
+ f"local_id={local_id} 在 {len(matches)} 个分片中都存在,无法唯一定位:\n "
+ + '\n '.join(details)
+ + f"\n请加 create_time 参数:decode_refer(chat_name, local_id={local_id}, create_time=N)"
+ )
+
+ _, row = matches[0]
+ local_type, msg_create_time, content, ct_compress = row
+ base_type, _ = _split_msg_type(local_type)
+ if base_type != 49:
+ return (
+ f"不是引用消息(local_type={local_type}, base_type={base_type}),"
+ f"引用消息应为 base_type=49 + appmsg type=57"
+ )
+
+ xml_text = _decompress_content(content, ct_compress)
+ if not xml_text:
+ return "消息 content 为空或无法解码"
+
+ is_group = username.endswith('@chatroom')
+ _, xml_text = _parse_message_content(xml_text, local_type, is_group)
+
+ root = _parse_app_message_outer(xml_text)
+ if root is None:
+ return "无法解析消息 XML"
+ appmsg = root.find('.//appmsg')
+ if appmsg is None:
+ return "消息中没有 appmsg 段(不像引用回复)"
+
+ app_type = _parse_int(_collapse_text(appmsg.findtext('type') or ''), 0)
+ if app_type != 57:
+ return (
+ f"不是引用消息(appmsg type={app_type})。"
+ f"引用回复要求 appmsg type=57;type=6 是文件、type=19 是合并转发、"
+ f"type=2000 是转账,请用对应的 decode_file_message / decode_record_item / "
+ f"decode_transfer 工具"
+ )
+
+ info = _extract_refer_info(appmsg)
+ if info is None:
+ return "消息是 type=57 但缺 节点(schema 异常)"
+
+ refer_type_label = _REFER_INNER_TYPE_LABEL.get(info['refer_type'], '')
+ summary = _summarize_refer_content(info['refer_type'], info['refer_content'])
+ sender_label = _resolve_quote_sender_label(
+ info['refer_fromusr'], info['refer_displayname'],
+ is_group, username, chat_name, get_contact_names()
+ )
+
+ def _fmt_ts(ts_str):
+ ts = _parse_int(ts_str, 0)
+ if not ts:
+ return ''
+ try:
+ return datetime.fromtimestamp(ts).isoformat()
+ except (ValueError, OSError, OverflowError):
+ return f'(无效 ts={ts_str})'
+
+ lines = [f"引用回复消息: {info['reply_text'] or '(无回复正文)'}"]
+ if sender_label:
+ lines.append(f" 被引用消息发送者: {sender_label}")
+ if info['refer_displayname']:
+ lines.append(f" 被引用消息显示名: {info['refer_displayname']}")
+ if info['refer_fromusr']:
+ lines.append(f" 被引用消息 from: {info['refer_fromusr']}")
+ if info['refer_chatusr']:
+ lines.append(f" 被引用消息 chatusr (群内发送者 wxid): {info['refer_chatusr']}")
+ raw_type = info['refer_type'] or '?'
+ type_display = (
+ f"{refer_type_label} (refer_type={raw_type})"
+ if refer_type_label else f"refer_type={raw_type}"
+ )
+ lines.append(f" 被引用消息类型: {type_display}")
+ lines.append(f" 被引用消息摘要: {summary}")
+ refer_ts = _fmt_ts(info['refer_createtime'])
+ if refer_ts:
+ lines.append(f" 被引用消息创建时间: {refer_ts}")
+ if info['refer_svrid']:
+ lines.append(f" 被引用消息 server_id: {info['refer_svrid']}")
+
+ return "\n".join(lines)
+
+
+@mcp.tool()
+def get_chat_images(chat_name: str, limit: int = 20, offset: int = 0, start_time: str = "", end_time: str = "") -> str:
"""列出某个聊天中的图片消息。
返回图片的时间、local_id、MD5、文件大小等信息。
@@ -1533,7 +3097,16 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
Args:
chat_name: 聊天对象的名字、备注名或wxid
limit: 返回数量,默认20
+ offset: 分页偏移量,默认0
+ start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
+ end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
"""
+ try:
+ _validate_pagination(limit, offset)
+ start_ts, end_ts = _parse_time_range(start_time, end_time)
+ except ValueError as e:
+ return f"错误: {e}"
+
username = resolve_username(chat_name)
if not username:
return f"找不到聊天对象: {chat_name}"
@@ -1541,16 +3114,33 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
names = get_contact_names()
display_name = names.get(username, username)
- db_path, table_name = _find_msg_table_for_user(username)
- if not db_path:
+ # 同 chat 的消息会分散在多个 message_N.db shard 里 (上限 ~100MB/shard 时滚动到下一个);
+ # 单 shard 查找会漏掉其他 shard 的图片。其他工具 (get_chat_history / search_messages /
+ # decode_image) 早已用复数版本 scan 全部 shard, 这里对齐一致。
+ shards = _find_msg_tables_for_user(username)
+ if not shards:
return f"找不到 {display_name} 的消息记录"
- images = _image_resolver.list_chat_images(db_path, table_name, username, limit)
- if not images:
+ # 每个 shard 取 limit+offset 张候选, 合并后按 create_time DESC 全局排序, 切片
+ # [offset : offset+limit] 出本页。单 shard 至少凑得起本页, 避免某 shard 缺数据
+ # 时本页变短。
+ candidate_limit = limit + offset
+ all_images = []
+ for shard in shards:
+ shard_images = _image_resolver.list_chat_images(
+ shard['db_path'], shard['table_name'], username,
+ limit=candidate_limit, start_ts=start_ts, end_ts=end_ts,
+ )
+ all_images.extend(shard_images)
+
+ if not all_images:
return f"{display_name} 无图片消息"
+ all_images.sort(key=lambda img: img['create_time'], reverse=True)
+ paged = all_images[offset:offset + limit]
+
lines = []
- for img in images:
+ for img in paged:
time_str = datetime.fromtimestamp(img['create_time']).strftime('%Y-%m-%d %H:%M')
line = f"[{time_str}] local_id={img['local_id']}"
if img.get('md5'):
@@ -1562,7 +3152,600 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
line += " (无资源信息)"
lines.append(line)
- return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines)
+ header = f"{display_name} 的 {len(lines)} 张图片(offset={offset}, limit={limit})"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset)
+
+
+# ============ 语音解密 ============
+
+DECODED_VOICE_DIR = os.path.join(SCRIPT_DIR, "decoded_voices")
+
+# media DB 与 message DB 同样会分片(media_0.db、media_1.db…),
+# 每个分片各有独立的 Name2Id / VoiceInfo 表。
+MEDIA_DB_KEYS = sorted([
+ k for k in ALL_KEYS
+ if any(v.startswith("message/") for v in key_path_variants(k))
+ and any(re.search(r"media_\d+\.db$", v) for v in key_path_variants(k))
+])
+
+
+def _iter_media_db_paths():
+ for rel_key in MEDIA_DB_KEYS:
+ path = _cache.get(rel_key)
+ if path:
+ yield path
+
+
+def _get_chat_name_id(conn, username):
+ row = conn.execute(
+ "SELECT rowid FROM Name2Id WHERE user_name = ?", (username,)
+ ).fetchone()
+ return row[0] if row else None
+
+
+def _fetch_voice_row(username, local_id):
+ """遍历所有 media DB 分片,返回 (voice_data, create_time);找不到返回 None。"""
+ for media_db in _iter_media_db_paths():
+ with closing(sqlite3.connect(media_db)) as conn:
+ chat_name_id = _get_chat_name_id(conn, username)
+ if chat_name_id is None:
+ continue
+ row = conn.execute(
+ "SELECT voice_data, create_time FROM VoiceInfo "
+ "WHERE chat_name_id = ? AND local_id = ?",
+ (chat_name_id, local_id),
+ ).fetchone()
+ if row:
+ return row
+ return None
+
+
+def _silk_to_wav(voice_data, create_time, username, local_id):
+ """Decode SILK voice blob to WAV file, return output path."""
+ # pypi 上有多个 SILK 相关包名(silk-python / pysilk / pilk),
+ # 这里用的是 synodriver/pysilk —— 安装包名 silk-python,import 名 pysilk
+ import pysilk
+ data = bytes(voice_data)
+ silk_data = data[1:] if data[0] == 0x02 else data
+ os.makedirs(DECODED_VOICE_DIR, exist_ok=True)
+ time_str = datetime.fromtimestamp(create_time).strftime('%Y%m%d_%H%M%S')
+ out_path = os.path.join(DECODED_VOICE_DIR, f"{username}_{time_str}_{local_id}.wav")
+ inp = io.BytesIO(silk_data)
+ out = io.BytesIO()
+ pysilk.decode(inp, out, 24000)
+ pcm = out.getvalue()
+ with wave.open(out_path, 'wb') as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(24000)
+ wf.writeframes(pcm)
+ return out_path, len(pcm)
+
+
+@mcp.tool()
+def get_voice_messages(chat_name: str, limit: int = 20, offset: int = 0, start_time: str = "", end_time: str = "") -> str:
+ """列出某个聊天中的语音消息。
+
+ 返回语音的时间、local_id 和大小,可配合 decode_voice 工具解码。
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ limit: 返回数量,默认20
+ offset: 分页偏移量,默认0
+ start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
+ end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
+ """
+ try:
+ _validate_pagination(limit, offset)
+ start_ts, end_ts = _parse_time_range(start_time, end_time)
+ except ValueError as e:
+ return f"错误: {e}"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ names = get_contact_names()
+ display_name = names.get(username, username)
+
+ if not MEDIA_DB_KEYS:
+ return "找不到 media DB"
+
+ # 每分片各取 limit+offset 条候选, 合并后全局排序切片 [offset:offset+limit] 出本页。
+ candidate_limit = limit + offset
+ clauses = ['chat_name_id = ?']
+ if start_ts is not None:
+ clauses.append('create_time >= ?')
+ if end_ts is not None:
+ clauses.append('create_time <= ?')
+ where_sql = ' AND '.join(clauses)
+
+ rows = []
+ for media_db in _iter_media_db_paths():
+ with closing(sqlite3.connect(media_db)) as conn:
+ chat_name_id = _get_chat_name_id(conn, username)
+ if chat_name_id is None:
+ continue
+ params = [chat_name_id]
+ if start_ts is not None:
+ params.append(start_ts)
+ if end_ts is not None:
+ params.append(end_ts)
+ params.append(candidate_limit)
+ rows.extend(conn.execute(
+ f"SELECT local_id, create_time, length(voice_data) FROM VoiceInfo "
+ f"WHERE {where_sql} ORDER BY create_time DESC LIMIT ?",
+ params,
+ ).fetchall())
+
+ if not rows:
+ return f"{display_name} 无语音消息"
+
+ rows.sort(key=lambda r: r[1], reverse=True)
+ paged = rows[offset:offset + limit]
+
+ lines = []
+ for local_id, create_time, size in paged:
+ time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
+ lines.append(f"[{time_str}] local_id={local_id} {size/1024:.0f}KB")
+
+ header = f"{display_name} 的 {len(lines)} 条语音消息(offset={offset}, limit={limit})"
+ if start_time or end_time:
+ header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
+ return header + ":\n\n" + "\n".join(lines) + _pagination_hint(len(lines), limit, offset)
+
+
+@mcp.tool()
+def decode_voice(chat_name: str, local_id: int) -> str:
+ """解码微信语音消息为 WAV 文件。
+
+ 先用 get_voice_messages 获取 local_id,再用此工具解码。
+ 输出文件保存在 decoded_voices/ 目录。
+
+ 依赖: pip install silk-python (import 名为 pysilk)
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ local_id: 语音消息的 local_id(从 get_voice_messages 获取)
+ """
+ try:
+ import pysilk # noqa: F401
+ except ImportError:
+ return "缺少依赖: pip install silk-python"
+
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ row = _fetch_voice_row(username, local_id)
+ if row is None:
+ return f"找不到 local_id={local_id} 的语音消息"
+
+ voice_data, create_time = row
+ out_path, pcm_len = _silk_to_wav(voice_data, create_time, username, local_id)
+ duration_s = pcm_len / (24000 * 2)
+ return (
+ f"解码成功!\n"
+ f" 文件: {out_path}\n"
+ f" 时长: {duration_s:.1f}秒\n"
+ f" 大小: {os.path.getsize(out_path):,} bytes"
+ )
+
+
+# ============ 语音转录缓存 ============
+#
+# Whisper 转录耗时(CPU 下每条数秒到数十秒),且结果是确定性的
+# (同一段 voice_data → 同一段 text),非常适合缓存。
+#
+# 缓存 key 用 json.dumps([username, local_id]):local_id 在单个 username 下
+# 稳定唯一,套一层 JSON 序列化保证 username 里若含分隔符也不会与其它条目碰撞。
+#
+# 写入走 temp + os.replace 原子替换,避免进程中途被杀导致整份缓存损坏
+# (Whisper 的单次代价远高于 DBCache,破档不可接受)。
+#
+# 条目里记录 model_size:Whisper 升级默认模型后,旧条目自动视为失效并重跑。
+
+VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join(SCRIPT_DIR, "voice_transcriptions.json")
+
+_voice_transcription_cache = None # 懒加载 dict;None 表示尚未加载
+_voice_transcription_cache_lock = threading.Lock()
+_voice_transcription_save_warned = False # 写失败仅首次写 stderr,避免刷屏
+
+
+def _voice_transcription_cache_key(username, local_id):
+ """构造缓存 key。用 json.dumps 兜底 username 里可能出现的分隔符。"""
+ return json.dumps([username, int(local_id)], ensure_ascii=False)
+
+
+def _load_voice_transcription_cache():
+ """加载缓存到模块级 dict,返回该 dict。
+
+ 文件不存在 → 空 dict。JSON 损坏或 payload 非 dict → 空 dict
+ (与上游 DBCache 的容错风格一致:缓存坏了不要拖垮工具调用)。
+ """
+ global _voice_transcription_cache
+ with _voice_transcription_cache_lock:
+ if _voice_transcription_cache is not None:
+ return _voice_transcription_cache
+ if not os.path.exists(VOICE_TRANSCRIPTION_CACHE_FILE):
+ _voice_transcription_cache = {}
+ return _voice_transcription_cache
+ try:
+ with open(VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f:
+ loaded = json.load(f)
+ _voice_transcription_cache = loaded if isinstance(loaded, dict) else {}
+ except (json.JSONDecodeError, OSError):
+ _voice_transcription_cache = {}
+ return _voice_transcription_cache
+
+
+def _save_voice_transcription_cache():
+ """持久化缓存到磁盘。
+
+ - 原子写:先写 .tmp 再 os.replace,避免 crash 中途留下半截文件。
+ - 未加载过也允许保存:此时把 module 状态初始化为空 dict,避免上层
+ 代码因调用顺序错误而静默丢数据。
+ - OSError 不抛:避免转录成功但落盘失败时让工具调用也失败;但首次
+ 失败会在 stderr 打一行警告,用户知道磁盘满 / 权限问题需要处理。
+ """
+ global _voice_transcription_cache, _voice_transcription_save_warned
+ with _voice_transcription_cache_lock:
+ if _voice_transcription_cache is None:
+ _voice_transcription_cache = {}
+ tmp_path = VOICE_TRANSCRIPTION_CACHE_FILE + ".tmp"
+ try:
+ with open(tmp_path, "w", encoding="utf-8") as f:
+ json.dump(_voice_transcription_cache, f, ensure_ascii=False)
+ os.replace(tmp_path, VOICE_TRANSCRIPTION_CACHE_FILE)
+ except OSError as exc:
+ if not _voice_transcription_save_warned:
+ print(
+ f"[voice_cache] 写入失败(后续不再提示): {exc}",
+ file=sys.stderr,
+ flush=True,
+ )
+ _voice_transcription_save_warned = True
+ # 清理可能残留的 .tmp
+ try:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+# ============ 语音转录后端 ============
+#
+# 默认 local: 完全保留原有行为,CPU 上跑本地 Whisper。
+# opt-in openai: 需要 transcription_backend="openai" 且 openai_api_key 都齐
+# 才会上云;任一缺失静默回退 local + stderr 一行警告(用户感知到误配置但不阻塞)。
+# 详见 README "语音转录隐私" 章节。
+
+TRANSCRIPTION_BACKEND = _cfg.get("transcription_backend", "local")
+LOCAL_WHISPER_MODEL = _cfg.get("local_whisper_model", "base")
+OPENAI_API_KEY = _cfg.get("openai_api_key", "")
+
+OPENAI_WHISPER_MODEL = "whisper-1" # OpenAI 当前唯一型号
+OPENAI_AUDIO_LIMIT_BYTES = 25 * 1024 * 1024 # OpenAI 25MB 上限
+
+_whisper_model = None
+_openai_client = None
+_openai_warning_emitted = False
+_fallback_warning_emitted = False
+
+# whisper.cpp 后端(macOS Metal GPU 加速)
+# 路径选项均为可选,默认自动检测
+WHISPER_CPP_BINARY = _cfg.get("whisper_cpp_binary", "")
+WHISPER_CPP_MODEL = _cfg.get("whisper_cpp_model", "")
+WHISPER_CPP_LANGUAGE = _cfg.get("whisper_cpp_language", "zh")
+WHISPER_CPP_THREADS = _cfg.get("whisper_cpp_threads", 0)
+
+_WHISPER_CPP_BINARY_SEARCH_PATHS = [
+ "/opt/homebrew/bin/whisper-cpp",
+ "/usr/local/bin/whisper-cpp",
+ os.path.expanduser("~/.local/bin/whisper-cpp"),
+]
+
+_WHISPER_CPP_MODEL_SEARCH_PATHS = [
+ os.path.expanduser("~/Library/Application Support/whisper-cpp"),
+ os.path.expanduser("~/Library/Application Support/Recordly/whisper"),
+ os.path.expanduser("~/whisper-models"),
+ os.path.expanduser("~/models"),
+ os.path.expanduser("~/Downloads"),
+ "/opt/homebrew/share/whisper-cpp/models",
+ "/usr/local/share/whisper-cpp/models",
+]
+
+_whisper_cpp_binary_resolved = None # None=未检测, ""=未找到, str=路径
+_whisper_cpp_model_resolved = None # 同上
+
+
+def _resolve_whisper_cpp_binary():
+ global _whisper_cpp_binary_resolved
+ if _whisper_cpp_binary_resolved is not None:
+ return _whisper_cpp_binary_resolved
+ if WHISPER_CPP_BINARY:
+ if os.path.isfile(WHISPER_CPP_BINARY) and os.access(WHISPER_CPP_BINARY, os.X_OK):
+ _whisper_cpp_binary_resolved = WHISPER_CPP_BINARY
+ return _whisper_cpp_binary_resolved
+ for p in _WHISPER_CPP_BINARY_SEARCH_PATHS:
+ if os.path.isfile(p) and os.access(p, os.X_OK):
+ _whisper_cpp_binary_resolved = p
+ return _whisper_cpp_binary_resolved
+ _whisper_cpp_binary_resolved = ""
+ return ""
+
+
+def _resolve_whisper_cpp_model():
+ global _whisper_cpp_model_resolved
+ if _whisper_cpp_model_resolved is not None:
+ return _whisper_cpp_model_resolved
+ if WHISPER_CPP_MODEL:
+ if os.path.isfile(WHISPER_CPP_MODEL):
+ _whisper_cpp_model_resolved = WHISPER_CPP_MODEL
+ return _whisper_cpp_model_resolved
+ for search_dir in _WHISPER_CPP_MODEL_SEARCH_PATHS:
+ if not os.path.isdir(search_dir):
+ continue
+ for f in sorted(os.listdir(search_dir)):
+ if f.startswith("ggml-") and f.endswith(".bin"):
+ _whisper_cpp_model_resolved = os.path.join(search_dir, f)
+ return _whisper_cpp_model_resolved
+ _whisper_cpp_model_resolved = ""
+ return ""
+
+
+def _resolve_active_backend():
+ """两因素 opt-in:openai 需要 flag + key 都齐才生效。
+ whisper_cpp 需要 binary 可检测到,否则回退 local。"""
+ global _fallback_warning_emitted
+ if TRANSCRIPTION_BACKEND == "openai":
+ if not OPENAI_API_KEY:
+ if not _fallback_warning_emitted:
+ print(
+ "[whisper] transcription_backend=openai 但未配置 openai_api_key,"
+ "回退到本地模型",
+ file=sys.stderr, flush=True,
+ )
+ _fallback_warning_emitted = True
+ return "local"
+ return "openai"
+ if TRANSCRIPTION_BACKEND == "whisper_cpp":
+ if not _resolve_whisper_cpp_binary():
+ if not _fallback_warning_emitted:
+ print(
+ "[whisper] transcription_backend=whisper_cpp 但未找到 "
+ "whisper-cpp 二进制文件,回退到本地模型。"
+ "安装: brew install whisper-cpp",
+ file=sys.stderr, flush=True,
+ )
+ _fallback_warning_emitted = True
+ return "local"
+ return "whisper_cpp"
+ return "local"
+
+
+def _cache_signature():
+ """当前生效后端 + 模型,用作缓存命中判定 + 落盘字段。"""
+ backend = _resolve_active_backend()
+ if backend == "openai":
+ return {"backend": "openai", "model_size": OPENAI_WHISPER_MODEL}
+ if backend == "whisper_cpp":
+ model_path = _resolve_whisper_cpp_model()
+ model_name = os.path.basename(model_path) if model_path else "unknown"
+ return {"backend": "whisper_cpp", "model_size": model_name}
+ return {"backend": "local", "model_size": LOCAL_WHISPER_MODEL}
+
+
+def _get_whisper_model(model_size=None):
+ global _whisper_model
+ if model_size is None:
+ model_size = LOCAL_WHISPER_MODEL
+ if _whisper_model is None:
+ import whisper
+ _whisper_model = whisper.load_model(model_size)
+ return _whisper_model
+
+
+def _transcribe_local(wav_path):
+ model = _get_whisper_model()
+ result = model.transcribe(wav_path)
+ return {
+ "language": result.get("language", "unknown"),
+ "text": result.get("text", "").strip(),
+ }
+
+
+def _transcribe_openai(wav_path):
+ """通过 OpenAI Whisper API 转录。失败抛 RuntimeError,调用方负责面向用户的提示。"""
+ global _openai_client, _openai_warning_emitted
+
+ # 尺寸预检:放在 SDK 导入和实例化之前,确保超限文件绝不上传
+ size = os.path.getsize(wav_path)
+ if size > OPENAI_AUDIO_LIMIT_BYTES:
+ raise RuntimeError(
+ f"音频 {size / 1024 / 1024:.1f}MB 超过 OpenAI 25MB 上限,"
+ "提前拒绝以避免无谓上传"
+ )
+
+ try:
+ from openai import OpenAI
+ from openai import AuthenticationError, RateLimitError, APIError
+ except ImportError:
+ raise RuntimeError("缺少依赖: pip install openai")
+
+ if not _openai_warning_emitted:
+ print(
+ "[whisper] 已启用 OpenAI Whisper API,"
+ "语音将上传至 OpenAI 服务器进行转录",
+ file=sys.stderr, flush=True,
+ )
+ _openai_warning_emitted = True
+
+ if _openai_client is None:
+ _openai_client = OpenAI(api_key=OPENAI_API_KEY)
+
+ try:
+ with open(wav_path, "rb") as f:
+ result = _openai_client.audio.transcriptions.create(
+ model=OPENAI_WHISPER_MODEL,
+ file=f,
+ response_format="verbose_json",
+ )
+ except AuthenticationError:
+ raise RuntimeError("OpenAI 鉴权失败 (401):检查 openai_api_key")
+ except RateLimitError:
+ raise RuntimeError("OpenAI 限流 (429):稍后重试")
+ except APIError as e:
+ raise RuntimeError(f"OpenAI API 错误: {e}")
+
+ return {
+ "language": getattr(result, "language", "unknown"),
+ "text": (getattr(result, "text", "") or "").strip(),
+ }
+
+
+def _transcribe_whisper_cpp(wav_path):
+ """通过 whisper-cpp CLI(Metal GPU 加速)转录。失败抛 RuntimeError。"""
+ binary = _resolve_whisper_cpp_binary()
+ if not binary:
+ raise RuntimeError("whisper-cpp binary 未找到。安装: brew install whisper-cpp")
+ model = _resolve_whisper_cpp_model()
+ if not model:
+ raise RuntimeError(
+ "whisper.cpp 模型未找到。通过 config.json whisper_cpp_model 指定路径,"
+ "或下载: https://huggingface.co/ggerganov/whisper.cpp"
+ )
+
+ threads = WHISPER_CPP_THREADS
+ if not threads:
+ try:
+ threads = min(os.cpu_count() or 4, 8)
+ except Exception:
+ threads = 4
+
+ try:
+ cmd = [
+ binary,
+ "-m", model,
+ "-f", wav_path,
+ "-l", WHISPER_CPP_LANGUAGE,
+ "-t", str(threads),
+ "--no-fallback",
+ "-otxt",
+ ]
+ subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+
+ txt_path = f"{wav_path}.txt"
+ if os.path.isfile(txt_path):
+ with open(txt_path, encoding="utf-8") as f:
+ text = f.read().strip()
+ os.unlink(txt_path)
+ return {"language": WHISPER_CPP_LANGUAGE, "text": text or ""}
+ return {"language": WHISPER_CPP_LANGUAGE, "text": ""}
+ except subprocess.TimeoutExpired:
+ raise RuntimeError("whisper-cpp 超时 (120s)")
+ except Exception as e:
+ raise RuntimeError(f"whisper-cpp 转录失败: {e}")
+
+
+def _transcribe(wav_path, backend):
+ if backend == "openai":
+ return _transcribe_openai(wav_path)
+ if backend == "whisper_cpp":
+ return _transcribe_whisper_cpp(wav_path)
+ return _transcribe_local(wav_path)
+
+
+@mcp.tool()
+def transcribe_voice(chat_name: str, local_id: int) -> str:
+ """将微信语音消息转录为文字(自动检测语言,保留原语言)。
+
+ 首次转录会先解码 SILK 语音为 WAV,再用 Whisper 转录;结果缓存到
+ voice_transcriptions.json,重复调用直接返回缓存(跳过 SILK 解码
+ 和 Whisper 推理)。后端切换或本地模型升级(如 base → small)后,
+ 旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重。
+
+ 后端由 config.json 中 transcription_backend 字段控制(local/openai/whisper_cpp)。
+ 详见 README "语音转录隐私" 章节。
+
+ 依赖:
+ - 本地后端: pip install silk-python openai-whisper
+ (silk-python 的 import 名为 pysilk)
+ - OpenAI 后端: pip install silk-python openai
+ - whisper_cpp 后端: brew install whisper-cpp (macOS)
+
+ Args:
+ chat_name: 聊天对象的名字、备注名或wxid
+ local_id: 语音消息的 local_id(从 get_voice_messages 获取)
+ """
+ username = resolve_username(chat_name)
+ if not username:
+ return f"找不到聊天对象: {chat_name}"
+
+ sig = _cache_signature()
+ cache_key = _voice_transcription_cache_key(username, local_id)
+ cache = _load_voice_transcription_cache()
+ entry = cache.get(cache_key)
+ # 命中要求 backend + model_size 都匹配。
+ # 旧条目 (PR #58 schema) 缺 backend 字段,回填默认 "local" 保持向前兼容
+ # —— 那时唯一存在的后端就是 local,语义上等价。
+ if (
+ isinstance(entry, dict)
+ and "text" in entry
+ and entry.get("backend", "local") == sig["backend"]
+ and entry.get("model_size") == sig["model_size"]
+ ):
+ # 命中缓存:跳过 DB 查询、SILK 解码、转录。
+ # 条目里存了 create_time,即使源 DB 中消息已被清理仍能返回历史转录。
+ lang = entry.get("language", "unknown")
+ cached_ts = entry.get("create_time")
+ if isinstance(cached_ts, int):
+ time_label = datetime.fromtimestamp(cached_ts).strftime('%Y-%m-%d %H:%M')
+ else:
+ time_label = "-"
+ return f"[{time_label}] ({lang})\n{entry['text']}"
+
+ # 未命中:本地后端才需要 whisper 包,云后端在 _transcribe_openai 内单独检查
+ if sig["backend"] == "local":
+ try:
+ import whisper # noqa: F401
+ except ImportError:
+ return "缺少依赖: pip install openai-whisper"
+ # SILK 解码两条路径都需要
+ try:
+ import pysilk # noqa: F401
+ except ImportError:
+ return "缺少依赖: pip install silk-python"
+
+ row = _fetch_voice_row(username, local_id)
+ if row is None:
+ return f"找不到 local_id={local_id} 的语音消息"
+
+ voice_data, create_time = row
+ wav_path, _ = _silk_to_wav(voice_data, create_time, username, local_id)
+
+ try:
+ result = _transcribe(wav_path, sig["backend"])
+ except RuntimeError as e:
+ return str(e)
+ text = result["text"]
+ lang = result["language"]
+
+ # 写缓存:即使 text 为空也缓存(Whisper 偶尔对静音/极短片段返回空),
+ # 配合 backend + model_size 字段,切换后端或升级模型后会自动重转。
+ cache[cache_key] = {
+ "text": text,
+ "language": lang,
+ "create_time": int(create_time),
+ "backend": sig["backend"],
+ "model_size": sig["model_size"],
+ }
+ _save_voice_transcription_cache()
+
+ time_label = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
+ return f"[{time_label}] ({lang})\n{text}"
if __name__ == "__main__":
diff --git a/monitor_web.py b/monitor_web.py
index 41859e6..8132211 100644
--- a/monitor_web.py
+++ b/monitor_web.py
@@ -9,6 +9,7 @@ http://localhost:5678
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback
import hmac as hmac_mod
from concurrent.futures import ThreadPoolExecutor
+from contextlib import closing
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
@@ -435,10 +436,16 @@ def decrypt_wal_full(wal_path, out_path, enc_key):
return patched, ms
-def load_contact_names():
+def load_contact_names(db_path=None):
+ """加载联系人名字字典。
+
+ Args:
+ db_path: 指定的 contact.db 路径。None 则使用 CONTACT_CACHE(静态快照,可能过期)。
+ 实时场景应传入 db_cache.get("contact/contact.db") 返回的路径,确保数据最新。
+ """
names = {}
try:
- conn = sqlite3.connect(CONTACT_CACHE)
+ conn = sqlite3.connect(db_path or CONTACT_CACHE)
for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall():
names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0]
conn.close()
@@ -447,6 +454,96 @@ def load_contact_names():
return names
+def _extract_pb_field_30(data):
+ """从 extra_buffer (protobuf) 中提取 Field #30 的字符串值(联系人标签ID)"""
+ if not data:
+ return None
+ pos = 0
+ n = len(data)
+ while pos < n:
+ tag = 0
+ shift = 0
+ while pos < n:
+ b = data[pos]; pos += 1
+ tag |= (b & 0x7f) << shift
+ if not (b & 0x80):
+ break
+ shift += 7
+ field_num = tag >> 3
+ wire_type = tag & 0x07
+ if wire_type == 0:
+ while pos < n and data[pos] & 0x80:
+ pos += 1
+ pos += 1
+ elif wire_type == 2:
+ length = 0; shift = 0
+ while pos < n:
+ b = data[pos]; pos += 1
+ length |= (b & 0x7f) << shift
+ if not (b & 0x80):
+ break
+ shift += 7
+ if field_num == 30:
+ try:
+ return data[pos:pos + length].decode('utf-8')
+ except Exception:
+ return None
+ pos += length
+ elif wire_type == 1:
+ pos += 8
+ elif wire_type == 5:
+ pos += 4
+ else:
+ break
+ return None
+
+
+def load_contact_tags():
+ """加载联系人标签及其成员"""
+ try:
+ conn = sqlite3.connect(CONTACT_CACHE)
+ try:
+ label_rows = conn.execute(
+ "SELECT label_id_, label_name_, sort_order_ FROM contact_label ORDER BY sort_order_"
+ ).fetchall()
+ except Exception:
+ conn.close()
+ return []
+ if not label_rows:
+ conn.close()
+ return []
+
+ labels = {}
+ for lid, lname, sort_order in label_rows:
+ labels[lid] = {'id': lid, 'name': lname, 'sort_order': sort_order, 'members': []}
+
+ names = load_contact_names()
+ rows = conn.execute(
+ "SELECT username, extra_buffer FROM contact WHERE extra_buffer IS NOT NULL"
+ ).fetchall()
+ conn.close()
+
+ for username, buf in rows:
+ label_str = _extract_pb_field_30(buf)
+ if not label_str:
+ continue
+ display = names.get(username, username)
+ for lid_s in label_str.split(','):
+ try:
+ lid = int(lid_s.strip())
+ except (ValueError, AttributeError):
+ continue
+ if lid in labels:
+ labels[lid]['members'].append({'username': username, 'display_name': display})
+
+ result = sorted(labels.values(), key=lambda t: t['sort_order'])
+ for t in result:
+ t['member_count'] = len(t['members'])
+ return result
+ except Exception:
+ return []
+
+
def format_msg_type(t):
return {
1: '文本', 3: '图片', 34: '语音', 42: '名片',
@@ -531,6 +628,10 @@ def _convert_hevc_to_jpeg(hevc_path, jpeg_path):
# ============ 监听器 ============
class SessionMonitor:
+ # 改名/备注变更场景的刷新最小间隔(秒)。低于此间隔的 mtime 变化不触发
+ # 全量 reload,避免微信高频写 contact.db 时 CPU 抖动。30s 是经验值。
+ CONTACT_REFRESH_COOLDOWN = 30
+
def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
self.enc_key = enc_key
self.session_db = session_db
@@ -543,6 +644,43 @@ class SessionMonitor:
self.patched_pages = 0
# 已显示消息去重: {(username, timestamp, base_msg_type), ...}
self._shown_keys = set()
+ # contact.db mtime + 上次刷新时间,用于检测改名/备注变更
+ self._contact_db_mtime = 0
+ self._last_contact_refresh = 0
+
+ def _maybe_refresh_contacts(self):
+ """检测 contact.db mtime 变化时全量 reload 联系人缓存。
+
+ 覆盖三种变更场景:
+ - 新增联系人(之前 commit e86e00d 只覆盖了这种)
+ - 修改备注名(issue #67)
+ - 修改群名
+
+ 受 CONTACT_REFRESH_COOLDOWN 节流,避免 contact.db 高频变更时反复 reload。
+ """
+ if not self.db_cache:
+ return
+ try:
+ contact_path = self.db_cache.get(os.path.join("contact", "contact.db"))
+ except Exception as e:
+ print(f" [contact] 实时解密 contact.db 失败: {e}", flush=True)
+ return
+ if not contact_path:
+ return
+ try:
+ curr_mtime = os.path.getmtime(contact_path)
+ except OSError:
+ return
+ now = time.time()
+ if curr_mtime <= self._contact_db_mtime:
+ return # mtime 没变,跳过
+ if now - self._last_contact_refresh < self.CONTACT_REFRESH_COOLDOWN:
+ return # cooldown 中,等下次
+ refreshed = load_contact_names(contact_path)
+ if refreshed:
+ self.contact_names.update(refreshed)
+ self._contact_db_mtime = curr_mtime
+ self._last_contact_refresh = now
def resolve_image(self, username, timestamp):
"""解密图片: username+timestamp → 解密后的图片文件名,失败返回 None"""
@@ -797,6 +935,38 @@ class SessionMonitor:
except OSError:
pass
+ def _lookup_latest_local_id(self, username, timestamp):
+ """从 message_N.db 查指定 username 在 timestamp 的最大 local_id。
+
+ SessionTable 触发推送时调用此方法拿到对应 local_id,加到 _shown_keys 后
+ `_check_hidden_messages` 路径能用 (username, local_id) 精确去重,避免 issue #79
+ 的"同秒同类型多条消息 10 丢 4"。
+
+ 时机风险:SessionTable 写入比 message DB 早几毫秒,可能查不到。查不到时返回 None,
+ 调用方应选择跳过加 key(让 hidden 路径稍后补救并自己加 key)。
+ """
+ if not self.db_cache or not self.username_db_map:
+ return None
+ db_keys = self.username_db_map.get(username, [])
+ if not db_keys:
+ return None
+ table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
+ for db_key in db_keys:
+ dec_path = self.db_cache.get(db_key)
+ if not dec_path:
+ continue
+ try:
+ with closing(sqlite3.connect(f"file:{dec_path}?mode=ro&immutable=1", uri=True)) as conn:
+ row = conn.execute(
+ f"SELECT MAX(local_id) FROM [{table_name}] WHERE create_time = ?",
+ (timestamp,),
+ ).fetchone()
+ if row and row[0]:
+ return row[0]
+ except Exception:
+ continue
+ return None
+
def _check_hidden_messages(self, username, prev_ts, curr_ts, curr_msg_type, display, is_group, sender):
"""检查时间窗口内是否有被 session 摘要覆盖的消息(文字、图片、表情等)
@@ -827,10 +997,10 @@ class SessionMonitor:
try:
conn = sqlite3.connect(f"file:{dec_path}?mode=ro", uri=True)
rows = conn.execute(f"""
- SELECT create_time, local_type, message_content, WCDB_CT_message_content
+ SELECT local_id, create_time, local_type, message_content, WCDB_CT_message_content
FROM [{table_name}]
WHERE create_time >= ? AND create_time <= ?
- ORDER BY create_time ASC
+ ORDER BY create_time ASC, local_id ASC
""", (prev_ts, curr_ts)).fetchall()
conn.close()
all_rows.extend(rows)
@@ -839,7 +1009,8 @@ class SessionMonitor:
cache_failed = True
break
# 检查是否找到了 curr_ts 的消息(说明缓存是最新的)
- has_curr = any(r[0] == curr_ts for r in all_rows)
+ # 注: r[1] 是 create_time(新 schema:local_id, create_time, local_type, ...)
+ has_curr = any(r[1] == curr_ts for r in all_rows)
if has_curr or cache_failed:
break
# 缓存可能还没更新到最新数据,短暂等待后重试
@@ -860,11 +1031,13 @@ class SessionMonitor:
print(f" [hidden] 缓存查到 {len(all_rows)} 条", flush=True)
# 过滤出隐藏消息
+ # 去重 key 用 local_id(之前用 (username, ts, base) 太粗,同秒同类型多条会被
+ # 误判为重复,导致 issue #79 的 "10 丢 4")
hidden_msgs = []
- for ts, lt, mc, ct in all_rows:
+ for local_id, ts, lt, mc, ct in all_rows:
base = lt % 4294967296 if lt > 4294967296 else lt
- # 跳过已显示的消息(精确匹配 username+timestamp+type)
- if (username, ts, base) in self._shown_keys:
+ # 跳过已显示的消息(按 local_id 精确去重)
+ if (username, local_id) in self._shown_keys:
continue
# 解压 zstd
if isinstance(mc, bytes) and ct == 4:
@@ -874,7 +1047,7 @@ class SessionMonitor:
mc = mc.decode('utf-8', errors='replace') if isinstance(mc, bytes) else ''
elif isinstance(mc, bytes):
mc = mc.decode('utf-8', errors='replace')
- hidden_msgs.append((ts, base, mc or ''))
+ hidden_msgs.append((local_id, ts, base, mc or ''))
print(f" [hidden] 找到 {len(hidden_msgs)} 条隐藏消息", flush=True)
@@ -882,8 +1055,8 @@ class SessionMonitor:
return
global messages_log
- for ts, base, mc in hidden_msgs:
- self._shown_keys.add((username, ts, base))
+ for local_id, ts, base, mc in hidden_msgs:
+ self._shown_keys.add((username, local_id))
msg_data = {
'time': datetime.fromtimestamp(ts).strftime('%H:%M:%S'),
'timestamp': ts,
@@ -1144,6 +1317,26 @@ class SessionMonitor:
'des': des[:200] if des else '',
'items': items,
}
+ elif app_type == 2000:
+ # 微信转账 — 复用 mcp_server 已有的解析器,单一来源避免字段漂移
+ # (snake/camel 大小写、未来新 paysubtype 兜底)。
+ import mcp_server # 已被 chat_export_helpers 验证 import 安全
+ info = mcp_server._extract_transfer_info(appmsg) or {}
+ pay_memo = info.get('pay_memo', '')
+ paysubtype = info.get('paysubtype', '')
+ # 已知 paysubtype 显示中文 label;未知用空串而非"未知(paysubtype=N)",
+ # 避免 UI 出现内部诊断字串。日志侧若需要可看 chat history。
+ direction = (info.get('paysubtype_label', '')
+ if paysubtype in mcp_server._TRANSFER_PAYSUBTYPE_LABEL
+ else '')
+ return {
+ 'type': 'transfer',
+ 'title': title or '微信转账',
+ 'direction': direction,
+ 'paysubtype': paysubtype,
+ 'fee_desc': info.get('fee_desc', ''),
+ 'pay_memo': pay_memo[:200] if pay_memo else '',
+ }
else:
# 其他子类型: 用 title 显示
if title:
@@ -1273,13 +1466,11 @@ class SessionMonitor:
is_new = prev and (curr['timestamp'] > prev['timestamp'] or
(curr['timestamp'] == prev['timestamp'] and curr['msg_type'] != prev.get('msg_type')))
if is_new:
+ # contact.db mtime 变化时刷新缓存:覆盖新增联系人、改名、改备注、群名
+ # 修改等场景(issue #46, #67)。受 cooldown 节流。
+ self._maybe_refresh_contacts()
display = self.contact_names.get(username, username)
is_group = '@chatroom' in username
- # 新群/新联系人不在缓存中时,重新加载联系人
- if display == username and username not in self.contact_names:
- refreshed = load_contact_names()
- self.contact_names.update(refreshed)
- display = self.contact_names.get(username, username)
sender = ''
if is_group:
sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender'])
@@ -1309,7 +1500,13 @@ class SessionMonitor:
}
new_msgs.append(msg_data)
- self._shown_keys.add((username, curr['timestamp'], curr['msg_type']))
+ # _shown_keys 改用 (username, local_id) 精确去重(issue #79)。
+ # SessionTable 不带 local_id,去 message_N.db 查 max(local_id) WHERE create_time=curr_ts。
+ # 查不到时(message DB 写入滞后于 SessionTable)跳过加 key,让 _check_hidden_messages
+ # 1 秒后查到时自己 emit 并加 key。这种情况下偶发轻微重复,但比丢消息好。
+ latest_local_id = self._lookup_latest_local_id(username, curr['timestamp'])
+ if latest_local_id is not None:
+ self._shown_keys.add((username, latest_local_id))
# 图片消息: 后台异步解密(不阻塞轮询)
if curr['msg_type'] == 3:
@@ -1360,9 +1557,12 @@ class SessionMonitor:
self.prev_state = curr_state
- # 清理过期的去重 key(保留最近 5 分钟)
- cutoff = int(time.time()) - 300
- self._shown_keys = {k for k in self._shown_keys if k[1] > cutoff}
+ # 清理 _shown_keys(按数量上限):local_id 不是时间戳不能按时间 prune。
+ # 超过 10000 时保留 local_id 最大的 5000 条(最新消息优先)。
+ # 实际触发频率:~几小时一次,set lookup 仍是 O(1)。
+ if len(self._shown_keys) > 10000:
+ by_local_id = sorted(self._shown_keys, key=lambda k: k[1], reverse=True)
+ self._shown_keys = set(by_local_id[:5000])
def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map)
@@ -1477,6 +1677,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
.chatlog-item{font-size:12px;color:#999;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chatlog-item b{color:#bbb;font-weight:500}
.chatlog-more{font-size:11px;color:#555;margin-top:4px}
+.msg-transfer{display:inline-block;background:rgba(255,170,60,.1);border:1px solid rgba(255,170,60,.25);border-radius:8px;padding:8px 14px;margin-top:4px;min-width:180px}
+.msg-transfer-head{font-size:13px;color:#ffb84d;font-weight:500}
+.msg-transfer-amount{font-size:18px;color:#ffd28a;font-weight:600;margin-top:4px}
+.msg-transfer-memo{font-size:11px;color:#999;margin-top:4px}
a.msg-link{text-decoration:none;color:inherit}
#lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center}
#lightbox.show{display:flex}
@@ -1592,6 +1796,12 @@ function renderRich(r){
}
return `📋 ${esc(r.title)}${body}`;
}
+ if(r.type==='transfer') {
+ let dirLabel = r.direction || '微信转账';
+ let amount = r.fee_desc ? ''+esc(r.fee_desc)+'' : '';
+ let memo = r.pay_memo ? '备注: '+esc(r.pay_memo)+'' : '';
+ return `💸 ${esc(dirLabel)}${amount}${memo}`;
+ }
if(r.type==='voice') return `🎤 语音 ${r.duration}s`;
if(r.type==='video') return `🎬 视频${r.duration?' '+r.duration+'s':''}`;
return null;
@@ -1815,9 +2025,31 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(HTML_PAGE.encode('utf-8'))
- elif self.path == '/api/history':
+ elif self.path.startswith('/api/history'):
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+ filter_chat = params.get('chat', [''])[0].strip().lower()
+ since_ts = 0
+ try:
+ since_ts = int(params.get('since', ['0'])[0])
+ except (ValueError, TypeError):
+ pass
+ limit_val = 500
+ try:
+ limit_val = min(int(params.get('limit', ['500'])[0]), 2000)
+ except (ValueError, TypeError):
+ pass
+
with messages_lock:
data = sorted(messages_log, key=lambda m: m.get('timestamp', 0))
+
+ if since_ts:
+ data = [m for m in data if m.get('timestamp', 0) > since_ts]
+ if filter_chat:
+ data = [m for m in data if filter_chat in m.get('chat', '').lower()
+ or filter_chat in m.get('username', '').lower()]
+ data = data[-limit_val:]
+
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
@@ -1849,6 +2081,20 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
+ elif self.path.startswith('/api/tags'):
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+ name_filter = params.get('name', [''])[0].strip().lower()
+
+ tags = load_contact_tags()
+ if name_filter:
+ tags = [t for t in tags if name_filter in t['name'].lower()]
+
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
+ self.end_headers()
+ self.wfile.write(json.dumps(tags, ensure_ascii=False).encode('utf-8'))
+
elif self.path == '/stream':
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
@@ -1955,7 +2201,8 @@ def main():
print("Ctrl+C 停止\n", flush=True)
try:
- os.system(f'cmd.exe /c start http://localhost:{PORT}')
+ import webbrowser
+ webbrowser.open(f'http://localhost:{PORT}')
except Exception:
pass
diff --git a/requirements.txt b/requirements.txt
index ed397cf..c26a34f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@ zstandard>=0.22,<1
mcp>=1.0,<2
pilk>=0.2
pyinstaller>=6.0
+# 可选:进度条 (pip install tqdm)
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..83a73c5
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+"""
+WeChat Decrypt — 交互式配置向导
+
+检测微信数据目录、选择转录 backend、生成 config.json。
+
+用法:
+ python3 setup.py # 交互式配置
+ python3 setup.py --check # 仅检查环境,不修改文件
+"""
+
+import argparse
+import glob
+import json
+import os
+import platform
+import shutil
+import subprocess
+import sys
+
+
+def detect_wechat_dir():
+ """自动检测微信数据目录"""
+ system = platform.system().lower()
+
+ if system == "darwin":
+ containers = (
+ os.path.expanduser(
+ "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"
+ )
+ )
+ if os.path.exists(containers):
+ dirs = [
+ os.path.join(containers, d, "db_storage")
+ for d in os.listdir(containers)
+ if os.path.isdir(os.path.join(containers, d))
+ ]
+ dirs = [d for d in dirs if os.path.exists(d)]
+ # 按修改时间排序,最近活跃的排最前
+ dirs.sort(key=lambda d: os.path.getmtime(d), reverse=True)
+ return dirs if dirs else None
+
+ elif system == "linux":
+ home = os.path.expanduser("~")
+ candidates = [
+ os.path.join(home, "Documents", "xwechat_files"),
+ os.path.join(home, ".xwechat", "files"),
+ ]
+ for c in candidates:
+ if os.path.exists(c):
+ dirs = [
+ os.path.join(c, d, "db_storage")
+ for d in os.listdir(c)
+ if os.path.isdir(os.path.join(c, d))
+ ]
+ dirs = [d for d in dirs if os.path.exists(d)]
+ if dirs:
+ return dirs
+
+ elif system == "windows":
+ localappdata = os.environ.get("LOCALAPPDATA", "")
+ candidates = [
+ os.path.join(localappdata, "xwechat_files"),
+ os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "xwechat_files"),
+ ]
+ for c in candidates:
+ if os.path.exists(c):
+ dirs = [
+ os.path.join(c, d, "db_storage")
+ for d in os.listdir(c)
+ if os.path.isdir(os.path.join(c, d))
+ ]
+ dirs = [d for d in dirs if os.path.exists(d)]
+ if dirs:
+ return dirs
+
+ return None
+
+
+def detect_transcription_backends():
+ """检测可用的转录 backend"""
+ backends = {"local": True} # local 总是可用(如果安装了依赖)
+
+ # whisper-cpp
+ if shutil.which("whisper-cpp") or shutil.which("whisper-cli"):
+ backends["whisper_cpp"] = True
+ model_dirs = [
+ os.path.expanduser("~/Library/Application Support/whisper-cpp"),
+ os.path.expanduser("~/whisper-models"),
+ "/opt/homebrew/share/whisper-cpp/models",
+ ]
+ for md in model_dirs:
+ if os.path.exists(md):
+ models = [f for f in os.listdir(md) if f.startswith("ggml-") and f.endswith(".bin")]
+ if models:
+ backends["whisper_cpp_model"] = models[0]
+ break
+ else:
+ backends["whisper_cpp"] = False
+
+ # openai
+ try:
+ import openai # noqa
+ backends["openai"] = True
+ except ImportError:
+ backends["openai"] = False
+
+ return backends
+
+
+def check_environment():
+ """检查环境,返回状态信息"""
+ print("=== 环境检查 ===")
+ py_ver = sys.version.split()[0]
+ print(f"[Python] {py_ver}")
+
+ # venv
+ in_venv = sys.prefix != sys.base_prefix
+ print(f"[venv] {'是' if in_venv else '否'}")
+ if not in_venv:
+ print(" 建议使用: python3 -m venv .venv && source .venv/bin/activate")
+
+ # whisper-cpp
+ whisper_bin = shutil.which("whisper-cpp") or shutil.which("whisper-cli")
+ print(f"[whisper-cpp] {'✓ ' + whisper_bin if whisper_bin else '✗ 未安装 (brew install whisper-cpp)'}")
+
+ # config
+ if os.path.exists("config.json"):
+ with open("config.json") as f:
+ cfg = json.load(f)
+ db_dir = cfg.get("db_dir", "?")
+ backend = cfg.get("transcription_backend", "未设置")
+ print(f"[config.json] ✓ (db_dir = {db_dir}, backend = {backend})")
+ else:
+ print("[config.json] ✗ 未找到")
+
+ # 微信目录
+ dirs = detect_wechat_dir()
+ if dirs:
+ print(f"[微信目录] 找到 {len(dirs)} 个:")
+ for d in dirs:
+ age_days = (os.path.getmtime(__file__ if '__file__' in dir() else 0) - os.path.getmtime(d)) / 86400 if os.path.exists(d) else 0
+ print(f" {d}")
+ else:
+ print("[微信目录] 未找到自动检测路径")
+
+ return dirs
+
+
+def interactive_setup():
+ """交互式配置向导"""
+ print("\n=== 微信解密工具 — 配置向导 ===\n")
+
+ # 加载或创建配置
+ config = {}
+ if os.path.exists("config.json"):
+ with open("config.json") as f:
+ config = json.load(f)
+ print(f"现有 config.json 已加载 ({len(config)} 个字段)")
+ print()
+
+ # 微信数据目录
+ detected = detect_wechat_dir()
+ if detected:
+ if len(detected) == 1:
+ chosen = detected[0]
+ print(f"[1/3] 微信数据目录: 自动检测到")
+ print(f" {chosen}")
+ else:
+ print(f"[1/3] 检测到 {len(detected)} 个微信数据目录:")
+ for i, d in enumerate(detected, 1):
+ print(f" [{i}] {d}")
+ try:
+ sel = int(input("\n请选择 (1-{}): ".format(len(detected))) or "1")
+ chosen = detected[sel - 1]
+ except (ValueError, IndexError):
+ chosen = detected[0]
+ config["db_dir"] = chosen
+ else:
+ print("[1/3] 微信数据目录: 未能自动检测")
+ default_path = os.path.expanduser("~/Documents/xwechat_files/your_wxid/db_storage")
+ chosen = input(f" 请手动输入路径 [{default_path}]: ") or default_path
+ config["db_dir"] = chosen
+
+ # 转录 backend
+ backends = detect_transcription_backends()
+ print(f"\n[2/3] 语音转录 backend:")
+ print(f" [1] local — 本地 CPU 转录(默认,隐私最佳,速度较慢)")
+ status_w = "✓" if backends.get("whisper_cpp") else "✗ (brew install whisper-cpp)"
+ print(f" [2] whisper_cpp — GPU 加速 ({status_w})")
+ status_o = "✓" if backends.get("openai") else "✗ (pip install openai)"
+ print(f" [3] openai — API 转录 ({status_o})")
+
+ try:
+ sel = int(input("\n 请选择 (1-3) [1]: ") or "1")
+ if sel == 2:
+ config["transcription_backend"] = "whisper_cpp"
+ if "whisper_cpp_model" in backends:
+ config["whisper_cpp_model"] = backends["whisper_cpp_model"]
+ elif sel == 3:
+ config["transcription_backend"] = "openai"
+ key = input(" 输入 OpenAI API Key: ").strip()
+ if key:
+ config["openai_api_key"] = key
+ else:
+ config["transcription_backend"] = "local"
+ except (ValueError, IndexError):
+ config["transcription_backend"] = "local"
+
+ # 确认
+ print(f"\n[3/3] 即将写入 config.json:")
+ print(json.dumps(config, indent=4))
+ ans = input("\n 确认?(Y/n): ").strip().lower()
+ if ans in ("", "y", "yes"):
+ with open("config.json", "w", encoding="utf-8") as f:
+ json.dump(config, f, ensure_ascii=False, indent=4)
+ print(" config.json 已写入")
+ else:
+ print(" 已取消,config.json 未修改")
+
+ print("\n配置完成!下一步:")
+ print(" python main.py status — 查看状态")
+ print(" python main.py decrypt — 解密数据库")
+ print(" python main.py export — 解密 + 导出聊天记录")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="WeChat Decrypt — 配置向导",
+ )
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="仅检查环境,不修改文件",
+ )
+ args = parser.parse_args()
+
+ if args.check:
+ check_environment()
+ else:
+ interactive_setup()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.sh b/setup.sh
new file mode 100755
index 0000000..dae056b
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+# setup.sh — 一键安装所有依赖 + 编译 + 初始配置
+# 幂等(可重复运行)。适用 macOS / Linux / Windows (Git Bash / WSL)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+echo "========================================================"
+echo " WeChat Decrypt — 环境配置"
+echo "========================================================"
+
+# ── 检测平台 ──────────────────────────────────────────────────
+OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
+case "$OS" in
+ darwin) PLATFORM="macos" ;;
+ linux) PLATFORM="linux" ;;
+ mingw*|msys*|cygwin*) PLATFORM="windows" ;;
+ *) echo "未识别的平台: $OS"; exit 1 ;;
+esac
+echo "[平台] $PLATFORM"
+
+# ── Python / venv ────────────────────────────────────────────
+PYTHON="python3"
+if command -v python3 &>/dev/null; then
+ PYTHON="python3"
+elif command -v python &>/dev/null; then
+ PYTHON="python"
+else
+ echo "[错误] 未找到 Python 3。请安装: https://python.org"
+ exit 1
+fi
+
+if [ ! -d .venv ]; then
+ echo "[venv] 创建虚拟环境..."
+ "$PYTHON" -m venv .venv
+else
+ echo "[venv] 虚拟环境已存在"
+fi
+
+# 激活 venv(跨平台兼容)
+if [ "$PLATFORM" = "windows" ]; then
+ VENV_PY=".venv/Scripts/python.exe"
+else
+ VENV_PY=".venv/bin/python3"
+fi
+
+if [ ! -f "$VENV_PY" ]; then
+ echo "[错误] venv Python 未找到: $VENV_PY"
+ exit 1
+fi
+
+echo "[pip] 安装 Python 依赖..."
+"$VENV_PY" -m pip install --upgrade pip -q
+"$VENV_PY" -m pip install -r requirements.txt -q
+echo "[pip] 完成"
+
+# ── macOS 特有 ────────────────────────────────────────────────
+if [ "$PLATFORM" = "macos" ]; then
+ echo ""
+ echo "[macOS] ---"
+
+ # Xcode CLT
+ if ! xcode-select -p &>/dev/null; then
+ echo "[xcode] 安装 Command Line Tools..."
+ xcode-select --install || true
+ echo "[xcode] 安装完成后请重新运行 setup.sh"
+ exit 0
+ else
+ echo "[xcode] ✓"
+ fi
+
+ # whisper-cpp
+ if command -v brew &>/dev/null; then
+ if ! command -v whisper-cpp &>/dev/null; then
+ echo "[whisper-cpp] 通过 Homebrew 安装..."
+ brew install whisper-cpp
+ else
+ echo "[whisper-cpp] ✓ 已安装"
+ fi
+ else
+ echo "[brew] 未安装 Homebrew,跳过 whisper-cpp 自动安装"
+ echo " 手动安装: brew install whisper-cpp"
+ fi
+
+ # 编译 C 扫描器
+ if [ ! -f find_all_keys_macos ]; then
+ echo "[编译] find_all_keys_macos..."
+ cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation 2>/dev/null && \
+ codesign -s - find_all_keys_macos 2>/dev/null && \
+ echo "[编译] ✓" || echo "[编译] 跳过(c 源文件不存在?)"
+ else
+ echo "[编译] find_all_keys_macos 已存在(重新编译: make build)"
+ fi
+
+ # 微信重签名提示
+ echo ""
+ echo "[注意] 首次使用需要重签名微信:"
+ echo " killall WeChat"
+ echo " sudo codesign --force --deep --sign - /Applications/WeChat.app"
+fi
+
+# ── Linux 特有 ────────────────────────────────────────────────
+if [ "$PLATFORM" = "linux" ]; then
+ echo ""
+ echo "[Linux] 需要 root 或 CAP_SYS_PTRACE 来扫描微信进程内存。"
+ echo " 运行密钥提取时使用: sudo python3 find_all_keys.py"
+fi
+
+# ── config.json ───────────────────────────────────────────────
+if [ ! -f config.json ]; then
+ echo ""
+ echo "[config] 生成 config.json 模板..."
+ cat > config.json << 'CONFIG_EOF'
+{
+ "db_dir": "/path/to/your/wxid/db_storage",
+ "keys_file": "all_keys.json",
+ "decrypted_dir": "decrypted",
+ "wechat_process": "WeChat",
+ "__comment_db_dir": "各平台默认路径见 README.md"
+}
+CONFIG_EOF
+ echo "[config] 已生成,请编辑 config.json 中的 db_dir 路径"
+else
+ echo "[config] 已存在(跳过)"
+fi
+
+# ── 完成 ──────────────────────────────────────────────────────
+echo ""
+echo "========================================================"
+echo " 配置完成!下一步:"
+echo ""
+echo " 1. 编辑 config.json 确认 db_dir 路径"
+echo " 2. 提取密钥并解密:"
+echo " macOS: sudo ./find_all_keys_macos && $VENV_PY decrypt_db.py"
+echo " Linux: sudo $VENV_PY find_all_keys.py && $VENV_PY decrypt_db.py"
+echo " Windows: python find_all_keys.py && python decrypt_db.py"
+echo ""
+echo " 3. 批量导出聊天记录:"
+echo " $VENV_PY export_all_chats.py"
+echo ""
+echo " 或使用 Makefile: make decrypt / make all"
+echo "========================================================"
\ No newline at end of file
diff --git a/tests/test_chat_export_helpers.py b/tests/test_chat_export_helpers.py
new file mode 100644
index 0000000..e532879
--- /dev/null
+++ b/tests/test_chat_export_helpers.py
@@ -0,0 +1,104 @@
+"""Tests for `chat_export_helpers._extract_content` group prefix handling.
+
+Issue #88: 群聊里的引用回复 / appmsg 卡片在 export_chat / export_all_chats
+渲染成 link_or_file 且 content 为空。根因是 `_extract_content` 把带
+`wxid_xxx:\\n` 群前缀的原始 content 直接喂给 `_format_app_message_text`,
+XML 解析器在前缀文本上崩溃。
+
+修复后:
+- 检测到 chat_username 是 @chatroom,先用 `_parse_message_content` 剥前缀
+- 把 `is_group=True` 透传给 `_format_app_message_text` 让引用回复的发送者
+ 标签解析走群路径
+- 用真实的 contact names dict 而不是 `{}` 让 1-on-1 也能解出昵称
+"""
+import unittest
+from unittest.mock import patch
+
+import chat_export_helpers
+import mcp_server
+
+
+def _refer_appmsg(refer_content="hello world"):
+ """合成一条引用回复 appmsg。"""
+ return (
+ ''
+ 'quote reply '
+ '57 '
+ ''
+ '1 '
+ f'{refer_content} '
+ 'wxid_orig_sender '
+ 'Original Sender '
+ ' '
+ ' '
+ )
+
+
+class ExtractContentGroupPrefixTests(unittest.TestCase):
+ def setUp(self):
+ # Skip decompression
+ self._patch = patch.object(
+ mcp_server, '_decompress_content',
+ side_effect=lambda content, ct: content,
+ )
+ self._patch.start()
+ self._names_patch = patch.object(
+ mcp_server, 'get_contact_names',
+ return_value={'wxid_orig_sender': 'Alice'},
+ )
+ self._names_patch.start()
+
+ def tearDown(self):
+ self._patch.stop()
+ self._names_patch.stop()
+
+ def test_group_appmsg_with_prefix_renders_correctly(self):
+ """Issue #88: 群引用回复带 'wxid_xxx:\\n' 前缀,需要正确剥离后再解析。"""
+ prefixed = 'wxid_group_member:\n' + _refer_appmsg('hello group')
+ rendered, extras = chat_export_helpers._extract_content(
+ local_id=100, local_type=49, content=prefixed, ct=0,
+ chat_username='12345@chatroom', chat_display_name='Test Group',
+ )
+ self.assertIsNotNone(rendered, "群引用回复不应该解析失败返回 None")
+ self.assertIn('quote reply', rendered)
+ self.assertIn('hello group', rendered, "被引用内容应该出现在渲染结果里")
+
+ def test_one_on_one_appmsg_unaffected(self):
+ """1-on-1 场景没有前缀,行为应该保持不变。"""
+ rendered, _ = chat_export_helpers._extract_content(
+ local_id=100, local_type=49, content=_refer_appmsg('hi'), ct=0,
+ chat_username='wxid_friend', chat_display_name='Friend',
+ )
+ self.assertIsNotNone(rendered)
+ self.assertIn('hi', rendered)
+
+ def test_group_text_prefix_stripped(self):
+ """群里的 base=1 text 消息,content 也带前缀,应该被剥掉。"""
+ text, _ = chat_export_helpers._extract_content(
+ local_id=100, local_type=1, content='wxid_xx:\nhello group',
+ ct=0, chat_username='12345@chatroom', chat_display_name='Group',
+ )
+ self.assertEqual(text, 'hello group')
+
+ def test_one_on_one_text_unaffected(self):
+ """1-on-1 text 没有前缀概念,原样返回。"""
+ text, _ = chat_export_helpers._extract_content(
+ local_id=100, local_type=1, content='hello friend', ct=0,
+ chat_username='wxid_friend', chat_display_name='Friend',
+ )
+ self.assertEqual(text, 'hello friend')
+
+ def test_group_quote_uses_real_names(self):
+ """群引用回复的发送者标签应该用真实 contact names 解析。"""
+ prefixed = 'wxid_group_member:\n' + _refer_appmsg()
+ rendered, _ = chat_export_helpers._extract_content(
+ local_id=100, local_type=49, content=prefixed, ct=0,
+ chat_username='12345@chatroom', chat_display_name='Test Group',
+ )
+ # is_group=True 走 group 分支:用 ref_user (wxid_orig_sender) 查 names
+ # → 'Alice'。原先 names={} 会回退到 displayname。
+ self.assertIn('Alice', rendered, "应该用 names dict 解析出 'Alice'")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_chat_images_query_align.py b/tests/test_chat_images_query_align.py
new file mode 100644
index 0000000..c6cca2f
--- /dev/null
+++ b/tests/test_chat_images_query_align.py
@@ -0,0 +1,97 @@
+"""测试 get_chat_images 新增的 offset / start_time / end_time 参数。"""
+import os
+import sys
+from unittest.mock import patch
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import mcp_server
+
+
+def _img(local_id, create_time, md5=None, size=None):
+ info = {'local_id': local_id, 'create_time': create_time, 'md5': md5}
+ if size is not None:
+ info['size'] = size
+ return info
+
+
+def _run_with(shard_images_map, **kwargs):
+ """Helper: stub collaborators and call get_chat_images with new kwargs."""
+ shards = [{'db_path': k, 'table_name': 'Msg_x'} for k in shard_images_map]
+ captured = {'calls': []}
+
+ def fake_list(db_path, table_name, username, limit=20, start_ts=None, end_ts=None):
+ captured['calls'].append({
+ 'db_path': db_path, 'limit': limit, 'start_ts': start_ts, 'end_ts': end_ts,
+ })
+ return shard_images_map.get(db_path, [])
+
+ with patch.object(mcp_server, 'resolve_username', return_value='wxid_demo'), \
+ patch.object(mcp_server, 'get_contact_names', return_value={'wxid_demo': 'Demo'}), \
+ patch.object(mcp_server, '_find_msg_tables_for_user', return_value=shards), \
+ patch.object(mcp_server._image_resolver, 'list_chat_images', side_effect=fake_list):
+ return mcp_server.get_chat_images('Demo', **kwargs), captured
+
+
+def test_invalid_offset_returns_error():
+ out, _ = _run_with({}, offset=-1)
+ assert '错误' in out
+
+
+def test_invalid_time_range_returns_error():
+ """start_time 晚于 end_time 应报错。"""
+ out, _ = _run_with({}, start_time='2026-05-10', end_time='2026-05-01')
+ assert '错误' in out
+
+
+def test_candidate_limit_includes_offset():
+ """每 shard 拉 limit+offset 张候选, 保证全局分页能切到正确的页。"""
+ _, captured = _run_with({'/a': [_img(1, 1000)]}, limit=5, offset=10)
+ assert captured['calls'][0]['limit'] == 15
+
+
+def test_start_end_ts_forwarded_to_shard_query():
+ """start_time / end_time 解析为 unix 秒后透传给 shard 查询。"""
+ _, captured = _run_with(
+ {'/a': []},
+ start_time='2026-05-01',
+ end_time='2026-05-31',
+ )
+ call = captured['calls'][0]
+ assert call['start_ts'] is not None
+ assert call['end_ts'] is not None
+ assert call['start_ts'] < call['end_ts']
+
+
+def test_offset_slices_paged_window():
+ """offset=2, limit=2 取全局排序后第 3-4 张图片。"""
+ shard_a = [_img(1, 1100), _img(2, 1000)]
+ shard_b = [_img(3, 1300), _img(4, 1200)]
+ out, _ = _run_with({'/a': shard_a, '/b': shard_b}, limit=2, offset=2)
+ # 全局排序后顺序: 1300, 1200, 1100, 1000 → 第 3-4 是 1100, 1000 → local_id 1, 2
+ assert 'local_id=1' in out
+ assert 'local_id=2' in out
+ assert 'local_id=3' not in out
+ assert 'local_id=4' not in out
+
+
+def test_header_shows_time_range_when_given():
+ shard_a = [_img(1, 1000, md5='abc')]
+ out, _ = _run_with({'/a': shard_a}, start_time='2026-05-01')
+ assert '时间范围' in out
+ assert '2026-05-01' in out
+
+
+def test_header_shows_offset_limit():
+ shard_a = [_img(1, 1000, md5='abc')]
+ out, _ = _run_with({'/a': shard_a}, limit=10, offset=20)
+ assert 'offset=20' in out
+ assert 'limit=10' in out
+
+
+def test_default_behavior_unchanged():
+ """不传新参数时行为与旧接口一致 — offset=0 切片就是 [:limit]。"""
+ shard_a = [_img(1, 1100, md5='a1'), _img(2, 1000, md5='a2')]
+ out, _ = _run_with({'/a': shard_a})
+ assert 'local_id=1' in out
+ assert 'local_id=2' in out
diff --git a/tests/test_decode_image_v2.py b/tests/test_decode_image_v2.py
new file mode 100644
index 0000000..15e1132
--- /dev/null
+++ b/tests/test_decode_image_v2.py
@@ -0,0 +1,481 @@
+"""ImageResolver 在 V2 加密格式下的端到端解密测试。
+
+覆盖:
+- v2_decrypt_file 能正确还原 AES-ECB + XOR 混合加密的合成数据
+- decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径
+- ImageResolver 通过 __init__ 注入 aes_key/xor_key 后,能端到端解密 V2 .dat
+- 没传 aes_key 时遇到 V2 文件返回结构化错误,而不是 crash 或返回错误数据
+- 默认参数下老 XOR 路径不受影响,保持向后兼容
+"""
+import hashlib
+import os
+import sqlite3
+import struct
+import tempfile
+import unittest
+
+from Crypto.Cipher import AES
+from Crypto.Util import Padding
+
+from decode_image import (
+ V1_MAGIC_FULL,
+ V2_MAGIC_FULL,
+ ImageResolver,
+ decrypt_dat_file,
+ v2_decrypt_file,
+)
+
+
+# 测试用 16 字节 AES key (任意值,仅用于合成测试数据)
+TEST_AES_KEY = b'1234567890abcdef'
+TEST_XOR_KEY = 0x37
+# 最小可识别的 PNG payload (含 IHDR 和 IEND chunk),长度 88 字节
+TEST_PNG_PAYLOAD = (
+ b'\x89PNG\r\n\x1a\n'
+ + b'\x00\x00\x00\rIHDR'
+ + b'\x00' * 64
+ + b'IEND\xaeB`\x82'
+)
+
+
+def _build_v2_dat(plaintext, aes_size, xor_size,
+ aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ magic=V2_MAGIC_FULL):
+ """构造合成的 V2 / V1 .dat 字节串。
+
+ 布局: [6B magic][4B aes_size LE][4B xor_size LE][1B pad][AES-ECB][raw][XOR]
+ aes_size / xor_size 是明文字段长度,AES 段做 PKCS7 padding 后向上对齐到 16 倍数。
+ """
+ if aes_size + xor_size > len(plaintext):
+ raise ValueError("aes_size + xor_size 超过 plaintext 长度")
+ aes_plain = plaintext[:aes_size]
+ raw_plain = plaintext[aes_size:len(plaintext) - xor_size]
+ xor_plain = plaintext[len(plaintext) - xor_size:]
+
+ cipher = AES.new(aes_key[:16], AES.MODE_ECB)
+ aes_cipher = cipher.encrypt(Padding.pad(aes_plain, AES.block_size))
+ xor_cipher = bytes(b ^ xor_key for b in xor_plain)
+
+ header = magic + struct.pack(' chat_id 限定 + message_local_type=3 过滤图片。
+
+ packed_info 里嵌入 extract_md5_from_packed_info 期望的 protobuf marker
+ (\\x12\\x22\\x0a\\x20) 加 32 字节 ASCII hex MD5。
+
+ Args:
+ extra_rows: 额外 (chat_id, message_local_id, message_local_type,
+ message_create_time, file_md5) 元组列表, 用于构造同 local_id
+ 跨 chat / 同 chat 多版本的歧义场景。
+ """
+ marker = b'\x12\x22\x0a\x20'
+ def _packed(md5_hex):
+ return b'\x00' * 8 + marker + md5_hex.encode('ascii') + b'\x00' * 4
+
+ conn = sqlite3.connect(path)
+ try:
+ conn.execute("""
+ CREATE TABLE MessageResourceInfo (
+ message_id INTEGER PRIMARY KEY,
+ chat_id INTEGER,
+ sender_id INTEGER,
+ message_local_type INTEGER,
+ message_create_time INTEGER,
+ message_local_id INTEGER,
+ message_svr_id INTEGER,
+ message_origin_source INTEGER,
+ packed_info BLOB
+ )
+ """)
+ conn.execute(
+ "CREATE TABLE ChatName2Id (user_name TEXT PRIMARY KEY, update_time INTEGER)"
+ )
+ conn.execute(
+ "INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (?, ?, ?)",
+ (chat_id, username, message_create_time),
+ )
+ next_msg_id = 1
+ conn.execute(
+ "INSERT INTO MessageResourceInfo "
+ "(message_id, chat_id, sender_id, message_local_type, message_create_time, "
+ " message_local_id, message_svr_id, message_origin_source, packed_info) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (next_msg_id, chat_id, 0, message_local_type, message_create_time,
+ local_id, 0, 0, _packed(file_md5)),
+ )
+ next_msg_id += 1
+ for extra in extra_rows:
+ ex_chat_id, ex_local_id, ex_type, ex_ctime, ex_md5 = extra
+ conn.execute(
+ "INSERT INTO MessageResourceInfo "
+ "(message_id, chat_id, sender_id, message_local_type, message_create_time, "
+ " message_local_id, message_svr_id, message_origin_source, packed_info) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (next_msg_id, ex_chat_id, 0, ex_type, ex_ctime, ex_local_id, 0, 0, _packed(ex_md5)),
+ )
+ next_msg_id += 1
+ conn.commit()
+ finally:
+ conn.close()
+
+
+class TestV2DecryptSynthetic(unittest.TestCase):
+ """v2_decrypt_file / decrypt_dat_file 在合成数据上的正确性"""
+
+ def test_v2_round_trip_recovers_payload(self):
+ # 合成 V2 .dat 解密后字节级等于原始 payload
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+ with open(out_path, 'rb') as f:
+ self.assertEqual(f.read(), TEST_PNG_PAYLOAD)
+
+ def test_decrypt_dat_file_routes_v2_by_magic(self):
+ # decrypt_dat_file 看到 V2 magic 应自动走 V2 路径
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ out_path, fmt = decrypt_dat_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_decrypt_dat_file_v1_uses_fixed_key(self):
+ # V1 magic 走固定 key,即便外部不传 aes_key 也能解密
+ v1_fixed_key = b'cfcd208495d565ef' # md5("0")[:16],由 v2_decrypt_file 内部使用
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(
+ TEST_PNG_PAYLOAD, aes_size=32, xor_size=16,
+ aes_key=v1_fixed_key, magic=V1_MAGIC_FULL,
+ ))
+
+ out_path, fmt = decrypt_dat_file(
+ dat_path, aes_key=None, xor_key=TEST_XOR_KEY
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_decrypt_dat_file_legacy_xor_route(self):
+ # 老 XOR 格式 (无 V1/V2 magic),decrypt_dat_file 应回退到 xor_decrypt_file 不需要 aes_key
+ xor_key = 0x37
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(bytes(b ^ xor_key for b in TEST_PNG_PAYLOAD))
+
+ out_path, fmt = decrypt_dat_file(dat_path, aes_key=None)
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_v2_accepts_str_aes_key_from_config(self):
+ # 真实场景下 aes_key 来自 config.json,是 ASCII string 不是 bytes;
+ # v2_decrypt_file 内部应自行 encode,避免 TypeError
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ out_path, fmt = decrypt_dat_file(
+ dat_path, aes_key=TEST_AES_KEY.decode('ascii'), xor_key=TEST_XOR_KEY,
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_v2_accepts_str_xor_key_from_config(self):
+ # 与 aes_key 的 str 处理对称: config.json 里把 xor_key 写成 "0x88" / "136" 也应能正常解密
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ out_path, fmt = decrypt_dat_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=hex(TEST_XOR_KEY),
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_v2_wxgf_payload_returns_hevc_format(self):
+ # 微信 V2 动图 (wxgf 裸流 HEVC) 解密后 fmt='hevc',输出文件以 .hevc 结尾;
+ # 当前 ImageResolver 不再向 JPEG 转 (那是 monitor_web 的职责),保持原样输出。
+ wxgf_payload = b'wxgf' + b'\x00' * 84 # 88 字节,与 PNG payload 同长度,避免改 aes/xor sizes
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(wxgf_payload, aes_size=32, xor_size=16))
+
+ out_path, fmt = decrypt_dat_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'hevc')
+ self.assertTrue(out_path.endswith('.hevc'))
+
+ def test_v2_rejects_wrong_aes_key(self):
+ # AES key 错时 detect_image_format 返回 'bin' (magic 不识别),v2_decrypt_file
+ # 应拒绝写出 .bin 垃圾文件并返回 (None, None),让 caller 知道解密失败。
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ wrong_aes_key = b'wrongkey00000000'
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=wrong_aes_key, xor_key=TEST_XOR_KEY,
+ )
+ self.assertIsNone(out_path)
+ self.assertIsNone(fmt)
+
+ def test_v2_rejects_wrong_xor_key_jpg_trailer(self):
+ # JPG 必须以 FF D9 (EOI) 收尾。XOR key 错时尾部 16 字节乱码,
+ # FF D9 被破坏,触发尾部 magic 校验失败。
+ jpg_payload = b'\xff\xd8\xff' + b'\x00' * 83 + b'\xff\xd9' # 88 bytes, FF D9 在末尾
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(jpg_payload, aes_size=32, xor_size=16))
+
+ # 翻转所有 XOR 字节: TEST_XOR_KEY ^ 0xff 保证每字节都错位
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff,
+ )
+ self.assertIsNone(out_path)
+ self.assertIsNone(fmt)
+
+ def test_v2_rejects_wrong_xor_key_png_iend(self):
+ # PNG 末尾 12 字节必须含 IEND chunk。XOR key 错时 IEND 被破坏。
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff,
+ )
+ self.assertIsNone(out_path)
+ self.assertIsNone(fmt)
+
+ def test_v2_skip_xor_validation_when_xor_size_zero(self):
+ # xor_size < 2 时没有 XOR 段(或样本不足以验证),不应触发尾部 magic 校验。
+ # 构造 xor_size=0 的 PNG (整张图都在 AES + raw 段),xor_key 实际不参与解密。
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=0))
+
+ # xor_key 传 0 也应成功 (XOR 段长度 0)
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=0x00,
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'png')
+
+ def test_v2_wxgf_skips_trailer_validation(self):
+ # wxgf (HEVC 裸流) 没有强制 trailer signature,XOR key 错时也不应被尾部校验误杀
+ # (wxgf 路径在 elif 链前面命中,直接 fmt='hevc',不进入 XOR 校验分支)。
+ # 这里验证:即便 XOR key 错导致末尾字节乱码,只要 wxgf magic 在头部正确,
+ # 仍按 hevc 输出 — 因为我们只校验 jpg/png,其他格式跳过。
+ wxgf_payload = b'wxgf' + b'\x00' * 84
+ with tempfile.TemporaryDirectory() as td:
+ dat_path = os.path.join(td, "test.dat")
+ with open(dat_path, 'wb') as f:
+ f.write(_build_v2_dat(wxgf_payload, aes_size=32, xor_size=16))
+
+ out_path, fmt = v2_decrypt_file(
+ dat_path, aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY ^ 0xff,
+ )
+ self.assertIsNotNone(out_path)
+ self.assertEqual(fmt, 'hevc')
+
+
+class TestImageResolverV2(unittest.TestCase):
+ """ImageResolver 端到端:从 local_id 到解密文件,验证 V2 keys 注入路径"""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ tmp = self._tmp.name
+
+ self.wechat_base = os.path.join(tmp, "wechat")
+ self.out_dir = os.path.join(tmp, "decoded")
+ os.makedirs(self.out_dir, exist_ok=True)
+
+ self.username = "wxid_test123"
+ self.local_id = 42
+ self.file_md5 = "0123456789abcdef0123456789abcdef"
+
+ username_hash = hashlib.md5(self.username.encode()).hexdigest()
+ img_dir = os.path.join(
+ self.wechat_base, "msg", "attach", username_hash, "2025-08", "Img"
+ )
+ os.makedirs(img_dir, exist_ok=True)
+ self.dat_path = os.path.join(img_dir, f"{self.file_md5}.dat")
+ with open(self.dat_path, 'wb') as f:
+ f.write(_build_v2_dat(TEST_PNG_PAYLOAD, aes_size=32, xor_size=16))
+
+ self.db_path = os.path.join(tmp, "message_resource.db")
+ _make_resource_db(self.db_path, self.local_id, self.file_md5)
+ self.cache = _FakeCache({"message/message_resource.db": self.db_path})
+
+ def test_decode_image_v2_with_keys(self):
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache,
+ aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ )
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertTrue(result['success'], msg=result)
+ self.assertEqual(result['format'], 'png')
+ self.assertEqual(result['md5'], self.file_md5)
+ with open(result['path'], 'rb') as f:
+ self.assertEqual(f.read(), TEST_PNG_PAYLOAD)
+
+ def test_decode_image_v2_missing_aes_key_returns_error(self):
+ # 没传 aes_key 时遇到 V2 文件应返回 success=False,而不是 crash 或写入错误文件
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache, aes_key=None,
+ )
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertFalse(result['success'])
+ self.assertIn('AES key', result['error'])
+ self.assertEqual(result['md5'], self.file_md5)
+
+ def test_decode_image_default_args_preserve_legacy_xor(self):
+ # 默认参数 (aes_key=None) + 老 XOR .dat 应保持向后兼容
+ os.unlink(self.dat_path)
+ legacy_xor_key = 0x37
+ with open(self.dat_path, 'wb') as f:
+ f.write(bytes(b ^ legacy_xor_key for b in TEST_PNG_PAYLOAD))
+
+ resolver = ImageResolver(self.wechat_base, self.out_dir, self.cache)
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertTrue(result['success'], msg=result)
+ self.assertEqual(result['format'], 'png')
+
+ def test_decode_image_disambiguates_local_id_across_chats(self):
+ """同 local_id 跨 chat 重复时, 必须按 username -> chat_id 选对; 否则会拿到
+ 别的 chat 的 MD5 (或视频 type=43 的 packed_info), 解出错图。
+
+ 生产 DB 上同一个 message_local_id 实测会出现在 5+ 个不同 chat 里,
+ 其中混有图片 (type=3) / 视频 (type=43) / 群聊 / 私聊, 必须 chat-scoped
+ + type 过滤才能定位。
+ """
+ os.unlink(self.db_path)
+ other_md5 = "f" * 32
+ video_md5 = "a" * 32
+ _make_resource_db(
+ self.db_path, self.local_id, self.file_md5,
+ username=self.username, chat_id=7,
+ message_create_time=1778487726,
+ extra_rows=[
+ # 另一个 chat 同 local_id 同图片类型, MD5 不同 —— 选错就拿这个
+ (5, self.local_id, 3, 1700000000, other_md5),
+ # 又一个 chat 同 local_id 但是视频 (type=43), 应被 type 过滤
+ (132, self.local_id, 43, 1750000000, video_md5),
+ ],
+ )
+ # 给冲突 chat 也注册 user_name, 否则 chat-scope 等价
+ conn = sqlite3.connect(self.db_path)
+ conn.execute(
+ "INSERT INTO ChatName2Id (rowid, user_name, update_time) VALUES (5, ?, 0), (132, ?, 0)",
+ ("other_chat_wxid", "video_chat_wxid"),
+ )
+ conn.commit()
+ conn.close()
+
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache,
+ aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ )
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertTrue(result['success'], msg=result)
+ # 必须拿目标 chat 的 MD5, 不是 other_chat 也不是视频
+ self.assertEqual(result['md5'], self.file_md5)
+
+ def test_decode_image_picks_latest_when_same_chat_local_id_reused(self):
+ """活跃 chat 里 local_id 会被复用 (实测同 chat 同 local_id 最多 7 条);
+ 默认应返回 message_create_time 最新的那张, 对应用户最近一次 reference。
+ """
+ os.unlink(self.db_path)
+ old_md5 = "c" * 32
+ # self.file_md5 / self.local_id 在 _make_resource_db 默认插入为 "latest" 那条
+ _make_resource_db(
+ self.db_path, self.local_id, self.file_md5,
+ username=self.username, chat_id=1,
+ message_create_time=1778487726,
+ extra_rows=[
+ # 同 chat 同 local_id 但更早, 不应该被选中
+ (1, self.local_id, 3, 1700000000, old_md5),
+ ],
+ )
+
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache,
+ aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ )
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertTrue(result['success'], msg=result)
+ self.assertEqual(result['md5'], self.file_md5)
+
+ def test_decode_image_unknown_chat_returns_error(self):
+ """username 在 ChatName2Id 里找不到时, 应返回结构化错误而不是 crash 或乱选 row。"""
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache,
+ aes_key=TEST_AES_KEY, xor_key=TEST_XOR_KEY,
+ )
+ result = resolver.decode_image("wxid_does_not_exist", self.local_id)
+ self.assertFalse(result['success'])
+ self.assertIn('wxid_does_not_exist', result['error'])
+
+ def test_decode_image_v1_no_aes_key_uses_fixed_key(self):
+ # V1 magic 不会被 is_v2_format guard 拦截 (V1 magic 是 \x07\x08V1, V2 是 \x07\x08V2);
+ # 即便 ImageResolver(aes_key=None), V1 文件也应通过 decrypt_dat_file 内置固定 key 解密
+ os.unlink(self.dat_path)
+ v1_fixed_key = b'cfcd208495d565ef'
+ with open(self.dat_path, 'wb') as f:
+ f.write(_build_v2_dat(
+ TEST_PNG_PAYLOAD, aes_size=32, xor_size=16,
+ aes_key=v1_fixed_key, magic=V1_MAGIC_FULL,
+ ))
+
+ # xor_key 必须跟 _build_v2_dat 加密时用的一致,否则 XOR 段乱码,
+ # 触发新的尾部 magic 校验失败 (PNG IEND chunk 错位)。
+ resolver = ImageResolver(
+ self.wechat_base, self.out_dir, self.cache,
+ aes_key=None, xor_key=TEST_XOR_KEY,
+ )
+ result = resolver.decode_image(self.username, self.local_id)
+ self.assertTrue(result['success'], msg=result)
+ self.assertEqual(result['format'], 'png')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_decode_images_batch.py b/tests/test_decode_images_batch.py
new file mode 100644
index 0000000..6bb4180
--- /dev/null
+++ b/tests/test_decode_images_batch.py
@@ -0,0 +1,295 @@
+"""decode_image.decode_all_dats() batch CLI 行为测试。
+
+覆盖:
+- 路径扫描:glob 命中 attach///Img/*.dat
+- 路径解析:chat_hash / YYYY-MM 提取,_t / _h 后缀移除归并到原图 basename
+- 幂等性:目标 basename 已存在(任何扩展名)时跳过;--force 强制重解
+- 原子写:写到 tmp 再 os.replace;失败/异常路径不留 .tmp
+- V2 无 key:计入 skipped_no_key 而非 failed
+- 错误隔离:单文件异常不阻塞批次;返回失败计数
+
+decrypt_dat_file 用 mock 隔离(避免依赖真实加密图片);is_v2_format
+单独覆盖真实 magic 检测路径。
+"""
+import os
+import struct
+import tempfile
+import unittest
+from contextlib import redirect_stderr
+import io
+from unittest.mock import patch
+
+import decode_image
+
+
+def _write(path, data):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as f:
+ f.write(data)
+
+
+def _v2_magic_bytes():
+ # 仅用于让 is_v2_format() 返回 True
+ return decode_image.V2_MAGIC_FULL + struct.pack(" 形式只保留第一段下划线之内的内容
+ self.assertEqual(fkm.normalize_wxid("wxid_abc123_extra_more"), "wxid_abc123")
+
+ def test_wxid_no_extra_segments(self):
+ self.assertEqual(fkm.normalize_wxid("wxid_abc123"), "wxid_abc123")
+
+ def test_account_with_4char_alnum_suffix_stripped(self):
+ # macOS 路径常见:your_wxid_a1b2 → your_wxid
+ self.assertEqual(fkm.normalize_wxid("your_wxid_a1b2"), "your_wxid")
+
+ def test_account_without_recognizable_suffix_returned_asis(self):
+ self.assertEqual(fkm.normalize_wxid("simple"), "simple")
+ self.assertEqual(fkm.normalize_wxid("foo_bar_baz"), "foo_bar_baz") # baz 是 3 char
+
+ def test_empty_or_none_returns_empty(self):
+ self.assertEqual(fkm.normalize_wxid(""), "")
+ self.assertEqual(fkm.normalize_wxid(None), "")
+ self.assertEqual(fkm.normalize_wxid(" "), "")
+
+
+class DeriveImageKeysTests(unittest.TestCase):
+ def test_xor_is_low_byte_of_code(self):
+ xor, _ = fkm.derive_image_keys(0x12345678, "anything")
+ self.assertEqual(xor, 0x78)
+
+ def test_xor_handles_small_codes(self):
+ self.assertEqual(fkm.derive_image_keys(0xFF, "x")[0], 0xFF)
+ self.assertEqual(fkm.derive_image_keys(0x00, "x")[0], 0x00)
+
+ def test_aes_is_md5_hex_truncated_to_16(self):
+ # Golden value: 合成 fixture (uin=12345678) 派生; 算法正确性由公式
+ # md5(str(uin)+wxid)[:16] 决定, 测试值无需对应任何真实账号。
+ xor, aes = fkm.derive_image_keys(12345678, "your_wxid")
+ self.assertEqual(xor, 0x4E) # 12345678 & 0xFF
+ self.assertEqual(aes, "a0c093edddc98490")
+
+ def test_aes_does_not_normalize_wxid_internally(self):
+ # 归一化由调用方负责;不同 wxid 字符串产出不同 key
+ _, aes_full = fkm.derive_image_keys(12345678, "your_wxid_a1b2")
+ _, aes_norm = fkm.derive_image_keys(12345678, "your_wxid")
+ self.assertNotEqual(aes_full, aes_norm)
+
+
+class DeriveKvcommDirCandidatesTests(unittest.TestCase):
+ def test_canonical_macos_path_is_first_candidate(self):
+ db_dir = (
+ "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
+ "xwechat_files/wxid_abc/db_storage"
+ )
+ candidates = fkm.derive_kvcomm_dir_candidates(db_dir)
+ self.assertGreater(len(candidates), 0)
+ expected_primary = (
+ "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
+ "app_data/net/kvcomm"
+ )
+ self.assertEqual(candidates[0], expected_primary)
+
+ def test_returns_multiple_candidates(self):
+ # 多候选是 Round 1 review 的关键修复点:跨版本路径覆盖
+ db_dir = (
+ "/Users/x/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
+ "xwechat_files/wxid_abc/db_storage"
+ )
+ candidates = fkm.derive_kvcomm_dir_candidates(db_dir)
+ self.assertGreaterEqual(len(candidates), 3,
+ "应返回多个候选路径以覆盖不同微信版本布局")
+
+ def test_no_xwechat_files_still_returns_home_fallback(self):
+ # 即使无法从 db_dir 推算,也至少返回 HOME 默认路径作兜底
+ candidates = fkm.derive_kvcomm_dir_candidates("/random/path")
+ self.assertGreaterEqual(len(candidates), 1)
+ self.assertTrue(any("Containers/com.tencent.xinWeChat" in c
+ for c in candidates))
+
+ def test_candidates_are_unique(self):
+ db_dir = "/x/y/Documents/xwechat_files/wxid_abc/db_storage"
+ candidates = fkm.derive_kvcomm_dir_candidates(db_dir)
+ self.assertEqual(len(candidates), len(set(candidates)))
+
+
+class FindExistingKvcommDirTests(unittest.TestCase):
+ def test_returns_first_existing_candidate(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ # 构造合法 db_dir 路径,在第一个候选位置创建实际目录
+ base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x")
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+ kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+
+ self.assertEqual(fkm.find_existing_kvcomm_dir(db_dir), kvcomm)
+
+ def test_returns_none_when_no_candidate_exists(self):
+ # 即使 HOME fallback 候选也不存在时,应返回 None。
+ # 隔离测试不能依赖宿主机有/无微信安装;patch expanduser 指向 tmp。
+ with tempfile.TemporaryDirectory() as fake_home:
+ with patch("os.path.expanduser", return_value=fake_home):
+ self.assertIsNone(fkm.find_existing_kvcomm_dir("/nonexistent/x/y/z"))
+
+
+class CollectKvcommCodesTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self.kvdir = self._tmp.name
+
+ def _touch(self, name):
+ with open(os.path.join(self.kvdir, name), "w") as f:
+ f.write("")
+
+ def test_extracts_code_from_filename(self):
+ # 长格式: 模拟真实 kvcomm 缓存文件命名 (合成 ID/时间戳, 测 regex 提
+ # uin 的能力, 不绑定任何真实账号)
+ self._touch("key_12345678_1111111111_1_1700000000_22222_3600_input.statistic")
+ self._touch("key_99999999_yyy_zzz.statistic")
+ self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), [12345678, 99999999])
+
+ def test_ignores_files_with_non_numeric_first_segment(self):
+ self._touch("key_reportnow_12345678_xxx.statistic")
+ self._touch("key_abc_def.statistic")
+ self._touch("config.ini")
+ self._touch("monitordata_x")
+ self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), [])
+
+ def test_dedupes_same_code_across_files(self):
+ self._touch("key_42_a.statistic")
+ self._touch("key_42_b.statistic")
+ self.assertEqual(fkm.collect_kvcomm_codes(self.kvdir), [42])
+
+ def test_missing_dir_returns_empty(self):
+ self.assertEqual(fkm.collect_kvcomm_codes("/nonexistent/xxx"), [])
+
+ def test_none_dir_returns_empty(self):
+ self.assertEqual(fkm.collect_kvcomm_codes(None), [])
+
+
+class CollectWxidCandidatesTests(unittest.TestCase):
+ def test_returns_raw_and_normalized_when_different(self):
+ db_dir = "/x/Documents/xwechat_files/your_wxid_a1b2/db_storage"
+ self.assertEqual(fkm.collect_wxid_candidates(db_dir),
+ ["your_wxid_a1b2", "your_wxid"])
+
+ def test_returns_one_when_normalize_is_identity(self):
+ db_dir = "/x/Documents/xwechat_files/wxid_abc/db_storage"
+ self.assertEqual(fkm.collect_wxid_candidates(db_dir), ["wxid_abc"])
+
+ def test_no_xwechat_files_returns_empty(self):
+ self.assertEqual(fkm.collect_wxid_candidates("/random/path"), [])
+
+ def test_xwechat_files_at_end_returns_empty(self):
+ self.assertEqual(fkm.collect_wxid_candidates("/x/xwechat_files"), [])
+
+
+class VerifyAesKeyTests(unittest.TestCase):
+ KEY = "a0c093edddc98490"
+
+ def _encrypt(self, plaintext_16):
+ return AES.new(self.KEY.encode("ascii"), AES.MODE_ECB).encrypt(plaintext_16)
+
+ def test_jpeg_magic_passes(self):
+ ct = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12)
+ self.assertTrue(fkm.verify_aes_key(self.KEY, ct))
+
+ def test_png_magic_passes(self):
+ ct = self._encrypt(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
+ self.assertTrue(fkm.verify_aes_key(self.KEY, ct))
+
+ def test_gif_magic_passes(self):
+ ct = self._encrypt(b"GIF89a" + b"\x00" * 10)
+ self.assertTrue(fkm.verify_aes_key(self.KEY, ct))
+
+ def test_wxgf_magic_passes(self):
+ ct = self._encrypt(b"wxgf" + b"\x00" * 12)
+ self.assertTrue(fkm.verify_aes_key(self.KEY, ct))
+
+ def test_random_data_fails(self):
+ self.assertFalse(fkm.verify_aes_key(self.KEY, bytes(range(16))))
+
+ def test_wrong_length_template_fails(self):
+ self.assertFalse(fkm.verify_aes_key(self.KEY, b"short"))
+ self.assertFalse(fkm.verify_aes_key(self.KEY, b""))
+
+ def test_short_aes_key_fails(self):
+ self.assertFalse(fkm.verify_aes_key("short", b"\x00" * 16))
+
+ def test_empty_aes_key_fails(self):
+ self.assertFalse(fkm.verify_aes_key("", b"\x00" * 16))
+
+
+class VerifyAesKeyAgainstAllTests(unittest.TestCase):
+ """交叉验证:必须所有模板都通过才算命中(防短 magic 偶然碰撞)。"""
+
+ KEY = "a0c093edddc98490"
+
+ def _encrypt(self, plaintext_16):
+ return AES.new(self.KEY.encode("ascii"), AES.MODE_ECB).encrypt(plaintext_16)
+
+ def test_all_templates_pass(self):
+ ct1 = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12)
+ ct2 = self._encrypt(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
+ self.assertTrue(fkm.verify_aes_key_against_all(self.KEY, [ct1, ct2]))
+
+ def test_one_template_fails_overall_fails(self):
+ ct1 = self._encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12) # passes
+ ct2 = bytes(range(16)) # random, fails
+ self.assertFalse(fkm.verify_aes_key_against_all(self.KEY, [ct1, ct2]))
+
+ def test_empty_template_list_returns_false(self):
+ # 没模板就不能验证;不视为通过(防"零样本=自动通过"陷阱)
+ self.assertFalse(fkm.verify_aes_key_against_all(self.KEY, []))
+
+
+class FindV2TemplateCiphertextsTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self.dir = self._tmp.name
+
+ def _build_v2_dat(self, name, ciphertext_16, subdir=""):
+ target_dir = os.path.join(self.dir, subdir) if subdir else self.dir
+ os.makedirs(target_dir, exist_ok=True)
+ path = os.path.join(target_dir, name)
+ with open(path, "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ciphertext_16 + b"\x00\x00")
+ return path
+
+ def test_finds_one_template_in_v2_thumb(self):
+ ct = bytes(range(0xF, 0x1F))
+ self._build_v2_dat("abc_t.dat", ct)
+ result = fkm.find_v2_template_ciphertexts(self.dir)
+ self.assertEqual(result, [ct])
+
+ def test_finds_multiple_distinct_templates(self):
+ cts = [bytes([i] * 16) for i in (0x11, 0x22, 0x33)]
+ for i, ct in enumerate(cts):
+ self._build_v2_dat(f"chat{i}_t.dat", ct, subdir=f"chat{i}")
+ result = fkm.find_v2_template_ciphertexts(self.dir, max_templates=3)
+ self.assertEqual(set(result), set(cts))
+
+ def test_dedupes_identical_templates(self):
+ ct = b"\x42" * 16
+ self._build_v2_dat("a_t.dat", ct, subdir="a")
+ self._build_v2_dat("b_t.dat", ct, subdir="b")
+ result = fkm.find_v2_template_ciphertexts(self.dir)
+ self.assertEqual(result, [ct])
+
+ def test_falls_back_to_any_dat_if_no_thumb(self):
+ ct = b"\x33" * 16
+ self._build_v2_dat("only_full.dat", ct)
+ self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [ct])
+
+ def test_skips_non_v2_files(self):
+ path = os.path.join(self.dir, "abc_t.dat")
+ with open(path, "wb") as f:
+ f.write(b"\x00" * 100)
+ self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [])
+
+ def test_empty_dir_returns_empty(self):
+ self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [])
+
+ def test_missing_dir_returns_empty(self):
+ self.assertEqual(fkm.find_v2_template_ciphertexts("/nonexistent"), [])
+
+ def test_walks_into_subdirs(self):
+ ct = b"\x44" * 16
+ self._build_v2_dat("x_t.dat", ct, subdir="sub/deeper")
+ self.assertEqual(fkm.find_v2_template_ciphertexts(self.dir), [ct])
+
+ def test_respects_max_templates(self):
+ cts = [bytes([i] * 16) for i in range(10)]
+ for i, ct in enumerate(cts):
+ self._build_v2_dat(f"x{i}_t.dat", ct, subdir=f"d{i}")
+ result = fkm.find_v2_template_ciphertexts(self.dir, max_templates=2)
+ self.assertEqual(len(result), 2)
+
+
+class FindImageKeyMacosIntegrationTests(unittest.TestCase):
+ """端到端集成:合成 kvcomm 文件 + 合成 V2 模板 → 期望派生出已知 key。"""
+
+ def _build_test_env(self, tmpdir, code, wxid_raw, num_templates=2):
+ """构造测试环境,返回 (db_dir, expected_xor, expected_aes)。"""
+ wxid_norm = fkm.normalize_wxid(wxid_raw)
+ base = os.path.join(tmpdir, "Documents", "xwechat_files", wxid_raw)
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+
+ kvcomm = os.path.join(tmpdir, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+ with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f:
+ f.write("")
+
+ xor_expected, aes_expected = fkm.derive_image_keys(code, wxid_norm)
+ # 多个模板用不同的 plaintext 加密(仍是图像 magic 开头但内容不同)
+ plaintexts = [
+ b"\xff\xd8\xff\xe0" + b"\x00" * 12, # JPEG
+ b"\x89PNG\r\n\x1a\n" + b"\x00" * 8, # PNG
+ b"GIF89a" + b"\x01\x02" + b"\x00" * 8, # GIF
+ ]
+ for i in range(num_templates):
+ pt = plaintexts[i % len(plaintexts)]
+ ct = AES.new(aes_expected.encode("ascii"), AES.MODE_ECB).encrypt(pt)
+ attach = os.path.join(base, "msg", "attach", f"chat{i}")
+ os.makedirs(attach)
+ with open(os.path.join(attach, f"img{i}_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00")
+ return db_dir, xor_expected, aes_expected
+
+ def test_full_flow_succeeds_with_normalized_wxid(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir, xor_exp, aes_exp = self._build_test_env(
+ tmp, code=12345678, wxid_raw="your_wxid_a1b2", num_templates=3)
+ result = fkm.find_image_key_macos(db_dir)
+ self.assertIsNotNone(result, "派生应该成功")
+ self.assertEqual(result, (xor_exp, aes_exp))
+
+ def test_returns_none_when_no_kvcomm_codes(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x")
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+ self.assertIsNone(fkm.find_image_key_macos(db_dir))
+
+ def test_returns_none_when_no_v2_template(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x")
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+ kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+ with open(os.path.join(kvcomm, "key_42_x.statistic"), "w") as f:
+ f.write("")
+ self.assertIsNone(fkm.find_image_key_macos(db_dir))
+
+ def test_returns_none_when_no_combination_verifies(self):
+ # 有 code 也有 V2 .dat,但密文是随机的,没有任何 key 能解出
+ with tempfile.TemporaryDirectory() as tmp:
+ base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_x")
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+ kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+ with open(os.path.join(kvcomm, "key_42_x.statistic"), "w") as f:
+ f.write("")
+ attach = os.path.join(base, "msg", "attach", "x")
+ os.makedirs(attach)
+ with open(os.path.join(attach, "x_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + b"\xde\xad\xbe\xef" * 4 + b"\x00\x00")
+ self.assertIsNone(fkm.find_image_key_macos(db_dir))
+
+ def test_empty_db_dir_returns_none_without_crash(self):
+ # 防御:空字符串、不合理路径不应抛异常。
+ # patch expanduser 让 HOME fallback 也指向不存在的路径,避免
+ # 测试在装了真实微信的开发机上意外深入到 wxid 缺失分支。
+ with tempfile.TemporaryDirectory() as fake_home:
+ with patch("os.path.expanduser", return_value=fake_home):
+ self.assertIsNone(fkm.find_image_key_macos(""))
+
+
+class MainShortCircuitTests(unittest.TestCase):
+ """main() 短路:已有 image_aes_key 仍然有效时,不应重新派生 / 不应改写 config。"""
+
+ def test_existing_valid_key_skips_derivation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ wxid = "wxid_abc"
+ base = os.path.join(tmp, "Documents", "xwechat_files", wxid)
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+
+ # kvcomm 里放个 code,证明若真去派生也能算出 key
+ kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+ code = 42
+ with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f:
+ f.write("")
+
+ # 用真实派生的 key 加密 V2 模板,使现有 key 在该模板上能验证通过
+ xor_exp, aes_exp = fkm.derive_image_keys(code, wxid)
+ jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12
+ ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt)
+ attach = os.path.join(base, "msg", "attach", "x")
+ os.makedirs(attach)
+ with open(os.path.join(attach, "test_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00")
+
+ # 写入"已有有效 key"的 config
+ cfg_path = os.path.join(tmp, "config.json")
+ cfg_initial = {
+ "db_dir": db_dir,
+ "image_aes_key": aes_exp,
+ "image_xor_key": xor_exp,
+ "extra_field": "must_be_preserved", # 证明 main 不会重写
+ }
+ with open(cfg_path, "w", encoding="utf-8") as f:
+ json.dump(cfg_initial, f)
+ mtime_before = os.path.getmtime(cfg_path)
+
+ # 关键:patch find_image_key_macos 让它若被误调用立刻可见
+ with patch.object(fkm, "find_image_key_macos") as mock_derive:
+ fkm.main(config_path=cfg_path)
+
+ mock_derive.assert_not_called() # 短路应直接 return,不进派生
+ # config.json 不应被重写
+ self.assertEqual(os.path.getmtime(cfg_path), mtime_before)
+ with open(cfg_path, encoding="utf-8") as f:
+ self.assertEqual(json.load(f), cfg_initial)
+
+ def test_existing_invalid_key_falls_through_to_derivation(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ wxid = "wxid_abc"
+ base = os.path.join(tmp, "Documents", "xwechat_files", wxid)
+ db_dir = os.path.join(base, "db_storage")
+ os.makedirs(db_dir)
+
+ kvcomm = os.path.join(tmp, "Documents", "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm)
+ code = 42
+ with open(os.path.join(kvcomm, f"key_{code}_x.statistic"), "w") as f:
+ f.write("")
+
+ xor_exp, aes_exp = fkm.derive_image_keys(code, wxid)
+ jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12
+ ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt)
+ attach = os.path.join(base, "msg", "attach", "x")
+ os.makedirs(attach)
+ with open(os.path.join(attach, "test_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct + b"\x00\x00")
+
+ cfg_path = os.path.join(tmp, "config.json")
+ cfg_initial = {
+ "db_dir": db_dir,
+ "image_aes_key": "deadbeefdeadbeef", # 故意写一个错的
+ }
+ with open(cfg_path, "w", encoding="utf-8") as f:
+ json.dump(cfg_initial, f)
+
+ fkm.main(config_path=cfg_path)
+
+ # 短路应失败,进入派生路径,配置应被改写为正确的 key
+ with open(cfg_path, encoding="utf-8") as f:
+ cfg_after = json.load(f)
+ self.assertEqual(cfg_after["image_aes_key"], aes_exp)
+ self.assertEqual(cfg_after["image_xor_key"], xor_exp)
+
+
+# ---------- 方案2 (wxid 后缀候选搜索, fallback) 单元测试 ---------- #
+
+class ExtractWxidPartsTests(unittest.TestCase):
+ """extract_wxid_parts: 从 db_dir 提 (full, norm, suffix)。"""
+
+ def test_extracts_norm_and_suffix_from_alnum_suffix(self):
+ db_dir = "/foo/Documents/xwechat_files/your_wxid_25d5/db_storage"
+ self.assertEqual(
+ fkm.extract_wxid_parts(db_dir),
+ ("your_wxid_25d5", "your_wxid", "25d5"),
+ )
+
+ def test_wxid_format_with_4char_suffix(self):
+ db_dir = "/foo/Documents/xwechat_files/wxid_abc_e2f4/db_storage"
+ self.assertEqual(
+ fkm.extract_wxid_parts(db_dir),
+ ("wxid_abc_e2f4", "wxid_abc", "e2f4"),
+ )
+
+ def test_uppercase_suffix_lowercased(self):
+ db_dir = "/foo/Documents/xwechat_files/your_wxid_ABCD/db_storage"
+ result = fkm.extract_wxid_parts(db_dir)
+ self.assertIsNotNone(result)
+ self.assertEqual(result[2], "abcd")
+
+ def test_no_4char_suffix_returns_none(self):
+ # 6字符尾缀不匹配 _<4字符>$, 算法假设破灭
+ db_dir = "/foo/Documents/xwechat_files/wxid_simple/db_storage"
+ self.assertIsNone(fkm.extract_wxid_parts(db_dir))
+
+ def test_no_xwechat_files_returns_none(self):
+ self.assertIsNone(fkm.extract_wxid_parts("/random/path/db_storage"))
+
+
+class DeriveXorKeyFromV2DatTests(unittest.TestCase):
+ """derive_xor_key_from_v2_dat: 末字节投票反推 xor_key。"""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = self.tmp.name
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def _write_v2_dat(self, name, last_byte, subdir=""):
+ d = os.path.join(self.dir, subdir) if subdir else self.dir
+ os.makedirs(d, exist_ok=True)
+ body = (fkm.V2_MAGIC + b"\x00" * 9 + b"\x11" * 16
+ + b"\x00" * 4 + bytes([last_byte]))
+ with open(os.path.join(d, name), "wb") as f:
+ f.write(body)
+
+ def test_unanimous_vote(self):
+ # 全部末字节 = 0xA6 → xor_key = 0xA6 ^ 0xD9 = 0x7F
+ for i in range(10):
+ self._write_v2_dat(f"x{i}_t.dat", 0xA6)
+ self.assertEqual(fkm.derive_xor_key_from_v2_dat(self.dir),
+ (0x7F, 10, 10))
+
+ def test_majority_vote_with_dissent(self):
+ # 8 个 0xA6, 2 个 0x55: 多数 0x7F 胜出
+ for i in range(8):
+ self._write_v2_dat(f"good{i}_t.dat", 0xA6)
+ for i in range(2):
+ self._write_v2_dat(f"bad{i}_t.dat", 0x55)
+ result = fkm.derive_xor_key_from_v2_dat(self.dir)
+ self.assertEqual(result, (0x7F, 8, 10))
+
+ def test_no_v2_dat_returns_none(self):
+ self.assertIsNone(fkm.derive_xor_key_from_v2_dat(self.dir))
+
+ def test_below_min_samples_returns_none(self):
+ # 默认 min_samples=3, 仅有 2 个样本应被视为不可信
+ for i in range(2):
+ self._write_v2_dat(f"x{i}_t.dat", 0xA6)
+ self.assertIsNone(fkm.derive_xor_key_from_v2_dat(self.dir))
+
+ def test_missing_dir_returns_none(self):
+ self.assertIsNone(fkm.derive_xor_key_from_v2_dat("/nonexistent"))
+
+ def test_walks_into_subdirs(self):
+ for i in range(10):
+ self._write_v2_dat(f"x{i}_t.dat", 0xA6, subdir=f"deep/sub{i}")
+ result = fkm.derive_xor_key_from_v2_dat(self.dir)
+ self.assertIsNotNone(result)
+ self.assertEqual(result[0], 0x7F)
+
+ def test_skips_non_v2_files(self):
+ # 不是 V2 magic 的 .dat 不计入投票
+ with open(os.path.join(self.dir, "junk.dat"), "wb") as f:
+ f.write(b"NOT_V2" + b"\x00" * 30)
+ for i in range(10):
+ self._write_v2_dat(f"x{i}_t.dat", 0xA6)
+ result = fkm.derive_xor_key_from_v2_dat(self.dir)
+ self.assertEqual(result, (0x7F, 10, 10))
+
+
+class BruteforceUinCandidatesTests(unittest.TestCase):
+ """bruteforce_uin_candidates: 候选枚举 + md5 前缀匹配。
+
+ 注意:test_real_bruteforce_against_golden 单核 ~7-8 秒,全套测试耗时大头。
+ """
+
+ def test_real_bruteforce_against_golden(self):
+ # 真跑全空间 2^24 候选, 同时验证: (a) 合成 uin 在结果里
+ # (b) 候选数合理 (~256) (c) 候选都满足 xor_key 约束
+ # md5("12345678")[:4] == "25d5", 12345678 & 0xff == 0x4E
+ out = fkm.bruteforce_uin_candidates(0x4E, "25d5")
+ self.assertIn(12345678, out, "合成 uin 应在候选里")
+ self.assertTrue(200 <= len(out) <= 350,
+ f"候选数 {len(out)} 偏离 ~256 (理论 2^24/2^16)")
+ for uin in out[:20]:
+ self.assertEqual(uin & 0xFF, 0x4E,
+ f"uin {uin} 不满足 xor_key 约束")
+
+
+
+class FindViaBruteforceTests(unittest.TestCase):
+ """方案2 端到端 (合成 fixture, 多进程 worker 实跑)。
+
+ 注: parallel 路径在合成 uin (低数值, 在 worker 0 chunk 早期命中) 上
+ < 0.2s 完成, 不需要 mock 加速。worker spawn 开销是真实集成测试的合理代价。
+ """
+
+ def _build_bruteforce_env(self, tmp, uin, wxid_norm, suffix):
+ wxid_full = f"{wxid_norm}_{suffix}"
+ base = os.path.join(tmp, "Documents", "xwechat_files", wxid_full)
+ db_dir = os.path.join(base, "db_storage")
+ attach_dir = os.path.join(base, "msg", "attach")
+ os.makedirs(db_dir)
+ os.makedirs(attach_dir)
+ xor_exp, aes_exp = fkm.derive_image_keys(uin, wxid_norm)
+ last_byte = 0xD9 ^ xor_exp
+ jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12
+ ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt)
+ # 构造 10 个 V2 dat 让 derive_xor_key 投票稳定
+ for i in range(10):
+ with open(os.path.join(attach_dir, f"img{i}_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct
+ + b"\x00" * 4 + bytes([last_byte]))
+ return db_dir, attach_dir, xor_exp, aes_exp
+
+ def test_full_flow_finds_synthetic_uin(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir, attach_dir, xor_exp, aes_exp = self._build_bruteforce_env(
+ tmp, uin=12345678, wxid_norm="your_wxid", suffix="25d5")
+ templates = fkm.find_v2_template_ciphertexts(attach_dir)
+ result = fkm._find_via_bruteforce(db_dir, attach_dir, templates)
+ self.assertEqual(result, (xor_exp, aes_exp))
+
+ def test_returns_none_when_no_wxid_suffix(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ base = os.path.join(tmp, "Documents", "xwechat_files", "wxid_nosuffix")
+ db_dir = os.path.join(base, "db_storage")
+ attach_dir = os.path.join(base, "msg", "attach")
+ os.makedirs(db_dir)
+ os.makedirs(attach_dir)
+ self.assertIsNone(fkm._find_via_bruteforce(db_dir, attach_dir, []))
+
+ def test_returns_none_when_no_v2_dat(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ base = os.path.join(tmp, "Documents", "xwechat_files",
+ "your_wxid_25d5")
+ db_dir = os.path.join(base, "db_storage")
+ attach_dir = os.path.join(base, "msg", "attach")
+ os.makedirs(db_dir)
+ os.makedirs(attach_dir)
+ self.assertIsNone(fkm._find_via_bruteforce(db_dir, attach_dir, []))
+
+
+class DispatcherFallbackTests(unittest.TestCase):
+ """find_image_key_macos dispatcher: 方案1 失败 → fallback 方案2。"""
+
+ def test_kvcomm_missing_falls_back_to_bruteforce(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ uin, wxid_norm, suffix = 12345678, "your_wxid", "25d5"
+ wxid_full = f"{wxid_norm}_{suffix}"
+ base = os.path.join(tmp, "Documents", "xwechat_files", wxid_full)
+ db_dir = os.path.join(base, "db_storage")
+ attach_dir = os.path.join(base, "msg", "attach")
+ os.makedirs(db_dir)
+ os.makedirs(attach_dir)
+
+ # 不创建 kvcomm → 方案1 失败
+ xor_exp, aes_exp = fkm.derive_image_keys(uin, wxid_norm)
+ last_byte = 0xD9 ^ xor_exp
+ jpeg_pt = b"\xff\xd8\xff\xe0" + b"\x00" * 12
+ ct = AES.new(aes_exp.encode("ascii"), AES.MODE_ECB).encrypt(jpeg_pt)
+ for i in range(10):
+ with open(os.path.join(attach_dir, f"img{i}_t.dat"), "wb") as f:
+ f.write(fkm.V2_MAGIC + b"\x00" * 9 + ct
+ + b"\x00" * 4 + bytes([last_byte]))
+
+ # patch HOME 让兜底 kvcomm 路径也找不到, 强制走方案2
+ with patch("os.path.expanduser", return_value=tmp):
+ result = fkm.find_image_key_macos(db_dir)
+ self.assertEqual(result, (xor_exp, aes_exp))
+
+
+class BruteforceParallelTests(unittest.TestCase):
+ """方案2 多进程实现的两层覆盖:
+ - 算法核心 (_bruteforce_worker_chunk): 直接调用, 无 process spawn, 极快
+ - 集成 (_bruteforce_with_aes_parallel): workers=1 验证 spawn + pickle 链路
+
+ 多进程 e2e 由 FindViaBruteforceTests / DispatcherFallbackTests 间接覆盖
+ (cpu_count workers, 真实 fixture)。这里只测函数契约, 避免 spawn 开销
+ 被反复支付。
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ # 合成 fixture, 跨多个测试复用
+ cls.uin = 12345678
+ cls.xor_key = cls.uin & 0xFF # 0x4E
+ cls.wxid_norm = "your_wxid"
+ cls.suffix_hex = hashlib.md5(str(cls.uin).encode()).hexdigest()[:4]
+ cls.suffix_bytes = bytes.fromhex(cls.suffix_hex)
+ cls.aes_hex = hashlib.md5(
+ f"{cls.uin}{cls.wxid_norm}".encode()
+ ).hexdigest()[:16]
+ cls.template = AES.new(
+ cls.aes_hex.encode("ascii"), AES.MODE_ECB
+ ).encrypt(b"\xff\xd8\xff\xe0" + b"\x00" * 12)
+ # i = (uin - xor_key) >> 8: worker 用 i 索引, 主进程倒推区间
+ cls.target_i = (cls.uin - cls.xor_key) >> 8
+
+ # 注: multiprocessing.Queue.put() 通过 feeder thread 异步刷到 pipe,
+ # get_nowait() 读取会 race。所有 queue 读用 get(timeout=...):
+ # - 命中场景: timeout=2s 给 feeder 充足时间 (实际 ~ms 级)
+ # - 不命中场景: timeout=0.5s 既证空又不拖慢测试
+
+ def test_worker_finds_known_uin_in_chunk(self):
+ q = multiprocessing.Queue()
+ fkm._bruteforce_worker_chunk(
+ self.target_i - 50, self.target_i + 50,
+ self.xor_key, self.suffix_bytes,
+ self.wxid_norm.encode("ascii"),
+ [self.template], q,
+ )
+ result = q.get(timeout=2)
+ self.assertEqual(result, (self.uin, self.aes_hex))
+
+ def test_worker_no_match_returns_silently(self):
+ # 区间不含 target_i (~48k), worker 扫完, queue 应保持空
+ q = multiprocessing.Queue()
+ fkm._bruteforce_worker_chunk(
+ 0, 1000,
+ self.xor_key, self.suffix_bytes,
+ self.wxid_norm.encode("ascii"),
+ [self.template], q,
+ )
+ with self.assertRaises(_queue_mod.Empty):
+ q.get(timeout=0.5)
+
+ def test_worker_skips_when_aes_fails(self):
+ # md5 prefix 命中但 AES 模板错: 不入队 (防止 md5 单 gate 假阳)
+ q = multiprocessing.Queue()
+ wrong_template = b"\x00" * 16 # AES 解出来非图像 magic
+ fkm._bruteforce_worker_chunk(
+ self.target_i - 50, self.target_i + 50,
+ self.xor_key, self.suffix_bytes,
+ self.wxid_norm.encode("ascii"),
+ [wrong_template], q,
+ )
+ with self.assertRaises(_queue_mod.Empty):
+ q.get(timeout=0.5)
+
+ def test_parallel_workers_1_finds_synthetic_uin(self):
+ # 集成: workers=1 验证 process spawn + pickle + queue 跨进程通信
+ result = fkm._bruteforce_with_aes_parallel(
+ self.xor_key, self.suffix_hex, self.wxid_norm,
+ [self.template], workers=1, timeout=30,
+ )
+ self.assertEqual(result, (self.uin, self.aes_hex))
+
+
+class SaveConfigAtomicTests(unittest.TestCase):
+ """原子写测试:os.replace 保证 config.json 不会被半截覆盖。"""
+
+ def test_roundtrip_writes_pretty_utf8(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg_path = os.path.join(tmp, "config.json")
+ cfg = {"db_dir": "/x", "image_aes_key": "中文测试key"}
+ fkm._save_config_atomic(cfg_path, cfg)
+ with open(cfg_path, encoding="utf-8") as f:
+ self.assertEqual(json.load(f), cfg)
+ # ensure_ascii=False:中文应直接落盘,不被转义
+ with open(cfg_path, "rb") as f:
+ self.assertIn("中文测试key".encode("utf-8"), f.read())
+
+ def test_failed_replace_leaves_original_intact(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg_path = os.path.join(tmp, "config.json")
+ with open(cfg_path, "w", encoding="utf-8") as f:
+ json.dump({"original": True}, f)
+ with patch.object(os, "replace",
+ side_effect=OSError("disk full during rename")):
+ with self.assertRaises(OSError):
+ fkm._save_config_atomic(cfg_path, {"new": True})
+ # 原文件应保持不变
+ with open(cfg_path, encoding="utf-8") as f:
+ self.assertEqual(json.load(f), {"original": True})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_get_chat_images_multishard.py b/tests/test_get_chat_images_multishard.py
new file mode 100644
index 0000000..75afa09
--- /dev/null
+++ b/tests/test_get_chat_images_multishard.py
@@ -0,0 +1,133 @@
+"""Tests for `get_chat_images` multi-shard scanning.
+
+WeChat rolls a chat's messages over to the next `message_N.db` shard once
+the current one fills up, so any chat older than the current shard window
+has its history split across multiple shards. The other query tools
+(`get_chat_history`, `search_messages`, `decode_image`) already scan all
+shards via `_find_msg_tables_for_user`; before this fix `get_chat_images`
+used the single-shard `_find_msg_table_for_user`, so it silently dropped
+every image that lived in a non-first shard.
+
+These tests pin the corrected behaviour: results come from all matching
+shards, are sorted by `create_time` DESC across shards, and respect the
+`limit` cap.
+"""
+import unittest
+from unittest.mock import patch
+
+import mcp_server
+
+
+class GetChatImagesMultiShardTests(unittest.TestCase):
+ def setUp(self):
+ # `resolve_username` / `get_contact_names` would hit real DBs; stub them.
+ self._patches = [
+ patch.object(mcp_server, "resolve_username",
+ side_effect=lambda x: "wxid_demo"),
+ patch.object(mcp_server, "get_contact_names",
+ return_value={"wxid_demo": "Demo"}),
+ ]
+ for p in self._patches:
+ p.start()
+ self.addCleanup(p.stop)
+
+ def _run(self, shards, shard_images_map, limit=20):
+ """Helper: stub the two collaborators and call the tool."""
+ def fake_list(db_path, table_name, username, limit=20, start_ts=None, end_ts=None):
+ return shard_images_map.get(db_path, [])
+
+ with patch.object(mcp_server, "_find_msg_tables_for_user",
+ return_value=shards), \
+ patch.object(mcp_server._image_resolver, "list_chat_images",
+ side_effect=fake_list):
+ return mcp_server.get_chat_images("Demo", limit=limit)
+
+ def test_collects_images_from_every_shard(self):
+ shards = [
+ {"db_path": "/m/message_1.db", "table_name": "Msg_x",
+ "max_create_time": 1_800_000_000},
+ {"db_path": "/m/message_2.db", "table_name": "Msg_x",
+ "max_create_time": 1_700_000_000},
+ ]
+ shard_images = {
+ "/m/message_1.db": [
+ {"local_id": 11, "create_time": 1_800_000_000, "md5": "a" * 32, "size": 1024},
+ ],
+ "/m/message_2.db": [
+ {"local_id": 22, "create_time": 1_700_000_000, "md5": "b" * 32, "size": 2048},
+ ],
+ }
+ out = self._run(shards, shard_images)
+ # Both shards' images must appear; before the fix the message_2.db
+ # image was silently dropped.
+ self.assertIn("local_id=11", out)
+ self.assertIn("local_id=22", out)
+ self.assertIn("2 张图片", out)
+
+ def test_global_sort_by_create_time_desc(self):
+ # Older shard happens to contain a NEWER image (e.g. when shards are
+ # ordered by max_create_time but individual rows interleave): the
+ # output must still be globally sorted, not per-shard concatenated.
+ shards = [
+ {"db_path": "/m/message_1.db", "table_name": "Msg_x",
+ "max_create_time": 1_800_000_000},
+ {"db_path": "/m/message_2.db", "table_name": "Msg_x",
+ "max_create_time": 1_700_000_000},
+ ]
+ shard_images = {
+ "/m/message_1.db": [
+ {"local_id": 11, "create_time": 1_750_000_000, "md5": "a" * 32},
+ ],
+ "/m/message_2.db": [
+ # Older shard, but this single image is newer than the one above.
+ {"local_id": 22, "create_time": 1_799_000_000, "md5": "b" * 32},
+ ],
+ }
+ out = self._run(shards, shard_images)
+ pos_22 = out.find("local_id=22")
+ pos_11 = out.find("local_id=11")
+ self.assertGreaterEqual(pos_22, 0)
+ self.assertGreaterEqual(pos_11, 0)
+ self.assertLess(pos_22, pos_11) # newer first
+
+ def test_limit_truncates_globally_across_shards(self):
+ shards = [
+ {"db_path": "/m/message_1.db", "table_name": "Msg_x",
+ "max_create_time": 1_800_000_000},
+ {"db_path": "/m/message_2.db", "table_name": "Msg_x",
+ "max_create_time": 1_700_000_000},
+ ]
+ shard_images = {
+ "/m/message_1.db": [
+ {"local_id": i, "create_time": 1_800_000_000 - i}
+ for i in range(0, 5)
+ ],
+ "/m/message_2.db": [
+ {"local_id": 100 + i, "create_time": 1_700_000_000 - i}
+ for i in range(0, 5)
+ ],
+ }
+ out = self._run(shards, shard_images, limit=3)
+ # 3 newest overall = local_id=0, 1, 2 (all from shard 1, but the
+ # decision is global, not "first shard wins").
+ self.assertIn("3 张图片", out)
+ self.assertIn("local_id=0", out)
+ self.assertIn("local_id=1", out)
+ self.assertIn("local_id=2", out)
+ self.assertNotIn("local_id=100", out)
+
+ def test_no_shards_returns_not_found(self):
+ out = self._run(shards=[], shard_images_map={})
+ self.assertIn("找不到", out)
+
+ def test_all_shards_empty_returns_no_images(self):
+ shards = [
+ {"db_path": "/m/message_1.db", "table_name": "Msg_x",
+ "max_create_time": 0},
+ ]
+ out = self._run(shards, shard_images_map={"/m/message_1.db": []})
+ self.assertIn("无图片消息", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_msg_types_filter.py b/tests/test_msg_types_filter.py
new file mode 100644
index 0000000..788e14c
--- /dev/null
+++ b/tests/test_msg_types_filter.py
@@ -0,0 +1,85 @@
+"""测试 _resolve_msg_types 和 _build_message_filters 的 type_filter 路径。"""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import mcp_server
+
+
+def test_resolve_none_returns_no_filter():
+ assert mcp_server._resolve_msg_types(None) == (None, None)
+
+
+def test_resolve_empty_returns_no_filter():
+ assert mcp_server._resolve_msg_types([]) == (None, None)
+
+
+def test_resolve_single_text_type():
+ type_filter, err = mcp_server._resolve_msg_types(['text'])
+ assert err is None
+ assert type_filter == [1]
+
+
+def test_resolve_multiple_types():
+ type_filter, err = mcp_server._resolve_msg_types(['image', 'voice', 'video'])
+ assert err is None
+ assert sorted(type_filter) == [3, 34, 43]
+
+
+def test_file_alias_maps_to_app():
+ """'file' 是常见叫法, 实际是 type=49 (app message)。"""
+ type_filter, err = mcp_server._resolve_msg_types(['file'])
+ assert err is None
+ assert type_filter == [49]
+
+
+def test_case_insensitive_and_strip():
+ type_filter, err = mcp_server._resolve_msg_types([' Text ', 'IMAGE'])
+ assert err is None
+ assert sorted(type_filter) == [1, 3]
+
+
+def test_unknown_type_returns_error():
+ type_filter, err = mcp_server._resolve_msg_types(['unknown'])
+ assert type_filter is None
+ assert err is not None
+ assert 'unknown' in err
+ assert 'text' in err # 错误提示列出可选值
+
+
+def test_partial_unknown_aborts_whole():
+ """混入一个未知类型时整体失败, 不偷偷过滤合法的。"""
+ type_filter, err = mcp_server._resolve_msg_types(['text', 'invalid_type'])
+ assert type_filter is None
+ assert 'invalid_type' in err
+
+
+def test_build_filters_without_type_filter():
+ """type_filter=None 时 SQL 不包含 local_type 子句。"""
+ clauses, params = mcp_server._build_message_filters()
+ assert not any('local_type' in c for c in clauses)
+
+
+def test_build_filters_with_single_type():
+ clauses, params = mcp_server._build_message_filters(type_filter=[1])
+ assert any('local_type IN (?)' == c for c in clauses)
+ assert 1 in params
+
+
+def test_build_filters_with_multiple_types():
+ clauses, params = mcp_server._build_message_filters(type_filter=[1, 3, 34])
+ type_clause = [c for c in clauses if 'local_type' in c][0]
+ assert type_clause == 'local_type IN (?,?,?)'
+ assert params == [1, 3, 34]
+
+
+def test_build_filters_combines_with_time_and_keyword():
+ clauses, params = mcp_server._build_message_filters(
+ start_ts=1000, end_ts=2000, keyword='hello', type_filter=[1]
+ )
+ assert 'create_time >= ?' in clauses
+ assert 'create_time <= ?' in clauses
+ assert 'message_content LIKE ?' in clauses
+ assert any('local_type' in c for c in clauses)
+ assert params == [1000, 2000, '%hello%', 1]
diff --git a/tests/test_namecard_format.py b/tests/test_namecard_format.py
new file mode 100644
index 0000000..e61184d
--- /dev/null
+++ b/tests/test_namecard_format.py
@@ -0,0 +1,73 @@
+"""Tests for `_format_namecard_text` (msg_type=42 鉴定).
+
+Before this helper, type=42 messages fell through the generic non-text branch
+and emitted `[名片] `, dumping the full ` ` element including
+antispamticket, biznamecardinfo and head-image URLs. Those tokens are PII that
+should not be piped to downstream LLM / log systems.
+
+These tests pin the new behaviour: a compact `[名片] : ` line,
+without any source-only XML fields.
+"""
+import unittest
+
+import mcp_server
+
+
+# Realistic-shape sample with the noisy / sensitive attrs that used to leak.
+_REAL_NAMECARD = (
+ ' '
+)
+
+
+class FormatNamecardTextTests(unittest.TestCase):
+ def test_compact_line_for_real_namecard(self):
+ out = mcp_server._format_namecard_text(_REAL_NAMECARD)
+ self.assertEqual(out, "[名片] 李雷: 搬砖工人 / 业余摄影")
+
+ def test_no_pii_or_url_in_output(self):
+ out = mcp_server._format_namecard_text(_REAL_NAMECARD)
+ self.assertNotIn("antispamticket", out)
+ self.assertNotIn("v2_abc123def456", out)
+ self.assertNotIn("qlogo.cn", out)
+ self.assertNotIn("brandIconUrl", out)
+ self.assertNotIn("headimgurl", out)
+
+ def test_official_account_marked(self):
+ xml = (
+ ' '
+ )
+ out = mcp_server._format_namecard_text(xml)
+ self.assertEqual(
+ out, "[名片] Some Official Account (公众号 gh_some_official): 一个公众号"
+ )
+
+ def test_no_certinfo_falls_back_to_head_only(self):
+ xml = ' '
+ out = mcp_server._format_namecard_text(xml)
+ self.assertEqual(out, "[名片] 韩梅梅")
+
+ def test_only_username_when_nickname_missing(self):
+ xml = ' '
+ out = mcp_server._format_namecard_text(xml)
+ self.assertEqual(out, "[名片] wxid_demo")
+
+ def test_missing_both_identifiers_returns_none(self):
+ xml = ' '
+ self.assertIsNone(mcp_server._format_namecard_text(xml))
+
+ def test_broken_xml_returns_none(self):
+ self.assertIsNone(mcp_server._format_namecard_text(""))
+ self.assertIsNone(mcp_server._format_namecard_text(" 25MB 在调用 OpenAI SDK 之前就被拒绝(保证不会无意上传)
+2. 缓存正确性: backend 不匹配的旧条目不会被命中(避免切后端时返回错后端结果)
+
+其余路径要么琐碎(默认值读取)、要么坏掉时声音很大(SDK 错误、ImportError),
+不再单独覆盖。
+"""
+import os
+import sys
+import tempfile
+import unittest
+from unittest.mock import MagicMock, patch
+
+import mcp_server
+
+
+class _CacheIsolationMixin:
+ """与 test_voice_transcription_cache.py 同款隔离:避免污染 module-level 缓存状态。"""
+
+ def setUp(self):
+ self._saved_cache = mcp_server._voice_transcription_cache
+ self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE
+ self._saved_warned = mcp_server._voice_transcription_save_warned
+
+ mcp_server._voice_transcription_cache = None
+ mcp_server._voice_transcription_save_warned = False
+
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join(
+ self._tmp.name, "voice_transcriptions.json"
+ )
+
+ def tearDown(self):
+ mcp_server._voice_transcription_cache = self._saved_cache
+ mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path
+ mcp_server._voice_transcription_save_warned = self._saved_warned
+
+
+class OpenAIBackendPrivacyTests(unittest.TestCase):
+ """隐私契约:超限文件必须在 OpenAI SDK 实例化之前就被拒绝。
+
+ 若有人把 size check 移到 OpenAI(api_key=...) 之后(即便仍在 upload 前),
+ 本测试会失败 —— 这层防御边界值得守住。
+ """
+
+ def test_oversize_audio_rejected_before_sdk_call(self):
+ # 写一个 26MB 临时 WAV (用稀疏写法快速生成)
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
+ f.seek(26 * 1024 * 1024)
+ f.write(b"\0")
+ big_path = f.name
+ self.addCleanup(os.unlink, big_path)
+
+ # 注入一个假的 openai 模块,保证 import 成功;OpenAI 构造函数若被调用即测试失败
+ fake_openai = MagicMock()
+ fake_openai.OpenAI = MagicMock(
+ side_effect=AssertionError("OpenAI() must not be instantiated for oversize files")
+ )
+ fake_openai.AuthenticationError = type("AuthenticationError", (Exception,), {})
+ fake_openai.RateLimitError = type("RateLimitError", (Exception,), {})
+ fake_openai.APIError = type("APIError", (Exception,), {})
+
+ with patch.dict(sys.modules, {"openai": fake_openai}):
+ with self.assertRaises(RuntimeError) as ctx:
+ mcp_server._transcribe_openai(big_path)
+
+ self.assertIn("25MB", str(ctx.exception))
+ fake_openai.OpenAI.assert_not_called()
+
+
+class CacheBackendMatchTests(_CacheIsolationMixin, unittest.TestCase):
+ """缓存正确性:backend 不匹配 → 视为 miss,避免切后端时返回错后端结果。"""
+
+ def test_cache_hit_requires_backend_match(self):
+ # 种入一条 openai 后端的缓存条目
+ key = mcp_server._voice_transcription_cache_key("wxid_test", 42)
+ cache = mcp_server._load_voice_transcription_cache()
+ cache[key] = {
+ "text": "openai-result",
+ "language": "zh",
+ "create_time": 1700000000,
+ "backend": "openai",
+ "model_size": "whisper-1",
+ }
+ mcp_server._save_voice_transcription_cache()
+
+ # 当前后端是 local,应当 miss → 走转录流程而非返回 "openai-result"
+ with patch.object(mcp_server, "TRANSCRIPTION_BACKEND", "local"), \
+ patch.object(mcp_server, "OPENAI_API_KEY", ""), \
+ patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
+ patch.object(mcp_server, "_fetch_voice_row",
+ return_value=(b"\x02fake-silk-blob", 1700000001)), \
+ patch.object(mcp_server, "_silk_to_wav",
+ return_value=("/tmp/fake.wav", 24000 * 2)), \
+ patch.object(mcp_server, "_transcribe_local",
+ return_value={"text": "local-result", "language": "zh"}), \
+ patch.dict(sys.modules, {"whisper": MagicMock(), "pysilk": MagicMock()}):
+ result = mcp_server.transcribe_voice("test_contact", 42)
+
+ # 没返回旧 openai 缓存,而是走了 local 转录流程
+ self.assertNotIn("openai-result", result)
+ self.assertIn("local-result", result)
+
+ # 落盘的新条目应记录当前后端
+ mcp_server._voice_transcription_cache = None
+ reloaded = mcp_server._load_voice_transcription_cache()
+ self.assertEqual(reloaded[key]["backend"], "local")
+ self.assertEqual(reloaded[key]["text"], "local-result")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_pagination_hint.py b/tests/test_pagination_hint.py
new file mode 100644
index 0000000..9c96411
--- /dev/null
+++ b/tests/test_pagination_hint.py
@@ -0,0 +1,36 @@
+"""测试分页提示语 _pagination_hint() 的边界行为。"""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import mcp_server
+
+
+def test_no_hint_when_count_less_than_limit():
+ """count < limit 表示已读完当前条件下全部结果,不提示。"""
+ assert mcp_server._pagination_hint(count=10, limit=50, offset=0) == ""
+
+
+def test_hint_when_count_equals_limit():
+ """count == limit 时无法判断是否还有更多,提示下一页 offset。"""
+ hint = mcp_server._pagination_hint(count=50, limit=50, offset=0)
+ assert "可能还有更多" in hint
+ assert "offset=50" in hint
+
+
+def test_hint_advances_offset_by_limit():
+ """连续翻页时 offset 累加。"""
+ hint = mcp_server._pagination_hint(count=20, limit=20, offset=100)
+ assert "offset=120" in hint
+
+
+def test_no_hint_when_limit_zero():
+ """limit=0 是非法分页 (上游有 _validate_pagination 兜底);防御性返回空。"""
+ assert mcp_server._pagination_hint(count=0, limit=0, offset=0) == ""
+
+
+def test_no_hint_when_count_exceeds_limit():
+ """理论上 count > limit 不该发生 (调用方已 limit), 但若发生仍要提示。"""
+ hint = mcp_server._pagination_hint(count=51, limit=50, offset=0)
+ assert "可能还有更多" in hint
diff --git a/tests/test_record_decoders.py b/tests/test_record_decoders.py
new file mode 100644
index 0000000..0221b95
--- /dev/null
+++ b/tests/test_record_decoders.py
@@ -0,0 +1,470 @@
+"""Helper-level regression tests for the recorditem / decoder additions.
+
+Focused on locking in the bugs fixed across PR #65's many review rounds so
+they don't regress. Covers helpers that are easy to call in isolation:
+
+- `_safe_basename` path-traversal sanitize (round-4 high #1)
+- `_md5_file_chunked` streaming hash + size cap (round-6 medium #3)
+- `_parse_message_content` group prefix stripping for both `:\n` and
+ `:19 ` content (round-5 medium #3)
+- `_format_record_message_text` end-to-end expansion of a >20KB outer
+ type-19 message (round-5 high #1, round-2 P2-1)
+- `_format_record_dataitem` per-datatype rendering for the 14 known
+ types incl. text / file / image / 视频号 etc.
+
+The two MCP-tool wrappers (decode_file_message / decode_record_item) lean
+heavily on module globals (WECHAT_BASE_DIR, _cache, MSG_DB_KEYS) and the
+real wechat cache layout. They are exercised by real-data smoke runs in
+the PR description rather than mocked here — mocking the entire wechat
+cache tree would dwarf the actual logic under test.
+"""
+
+import hashlib
+import os
+import tempfile
+import unittest
+
+import mcp_server
+
+
+# -------- _safe_basename ----------------------------------------------------
+
+
+class SafeBasenameTests(unittest.TestCase):
+ def test_normal_filename_passes(self):
+ self.assertEqual(mcp_server._safe_basename('normal.pdf'), 'normal.pdf')
+ self.assertEqual(
+ mcp_server._safe_basename('Lec 4- 零和.pdf'), 'Lec 4- 零和.pdf'
+ )
+ self.assertEqual(
+ mcp_server._safe_basename('file (1).pdf'), 'file (1).pdf'
+ )
+
+ def test_absolute_path_rejected(self):
+ self.assertEqual(mcp_server._safe_basename('/etc/passwd'), '')
+
+ def test_parent_dir_rejected(self):
+ # Strict reject — should not return the basename 'sensitive'.
+ self.assertEqual(mcp_server._safe_basename('../../sensitive'), '')
+ self.assertEqual(mcp_server._safe_basename('..'), '')
+
+ def test_path_separator_rejected(self):
+ self.assertEqual(mcp_server._safe_basename('subdir/x.pdf'), '')
+ self.assertEqual(mcp_server._safe_basename('a\\b\\c.pdf'), '')
+
+ def test_nul_rejected(self):
+ self.assertEqual(mcp_server._safe_basename('with\x00nul.pdf'), '')
+
+ def test_empty_or_dot_rejected(self):
+ self.assertEqual(mcp_server._safe_basename(''), '')
+ self.assertEqual(mcp_server._safe_basename('.'), '')
+
+ def test_inner_dots_pass(self):
+ # 'file...with..dots.pdf' has no separator → fine.
+ self.assertEqual(
+ mcp_server._safe_basename('file...with..dots.pdf'),
+ 'file...with..dots.pdf',
+ )
+
+
+# -------- _md5_file_chunked -------------------------------------------------
+
+
+class Md5FileChunkedTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.NamedTemporaryFile(delete=False)
+ self.tmp.write(b'x' * 1000)
+ self.tmp.close()
+ self.addCleanup(lambda: os.unlink(self.tmp.name))
+
+ def test_happy_path_matches_hashlib(self):
+ md5, err = mcp_server._md5_file_chunked(self.tmp.name)
+ self.assertIsNone(err)
+ self.assertEqual(md5, hashlib.md5(b'x' * 1000).hexdigest())
+
+ def test_size_cap_rejects_oversized_file(self):
+ md5, err = mcp_server._md5_file_chunked(self.tmp.name, max_size=500)
+ self.assertIsNone(md5)
+ self.assertIn('超过 md5 校验上限', err)
+
+ def test_missing_file_returns_error(self):
+ md5, err = mcp_server._md5_file_chunked('/tmp/no/such/path/here_xxx')
+ self.assertIsNone(md5)
+ self.assertIsNotNone(err)
+
+
+# -------- _parse_message_content --------------------------------------------
+
+
+class ParseMessageContentTests(unittest.TestCase):
+ def test_legacy_newline_prefix_in_group(self):
+ sender, text = mcp_server._parse_message_content(
+ 'wxid_abc:\nhi ', 1, is_group=True
+ )
+ self.assertEqual(sender, 'wxid_abc')
+ self.assertEqual(text, 'hi ')
+
+ def test_xml_decl_inline_prefix_in_group(self):
+ # round-7 high #1: 'sender:x ', 1, is_group=True
+ )
+ self.assertEqual(sender, 'wxid_abc')
+ self.assertTrue(text.startswith('x ', 1, is_group=True
+ )
+ self.assertEqual(sender, 'wxid_abc')
+ self.assertEqual(text, 'x ')
+
+ def test_private_chat_does_not_strip(self):
+ sender, text = mcp_server._parse_message_content(
+ 'wxid_abc:x ', 1, is_group=False
+ )
+ self.assertEqual(sender, '')
+ self.assertEqual(text, 'wxid_abc:x ')
+
+ def test_bytes_content_returns_marker(self):
+ sender, text = mcp_server._parse_message_content(b'\x00\x01', 1, is_group=False)
+ self.assertEqual(sender, '')
+ self.assertEqual(text, '(二进制内容)')
+
+
+# -------- _parse_app_message_outer ------------------------------------------
+
+
+class ParseAppMessageOuterTests(unittest.TestCase):
+ def test_small_xml_uses_default_path(self):
+ outer = '5 x '
+ root = mcp_server._parse_app_message_outer(outer)
+ self.assertIsNotNone(root)
+
+ def test_oversized_non_record_xml_short_circuits(self):
+ # round-5 medium #3: only 19 content should retry under
+ # the wider 500K cap. A 25KB non-type-19 message must NOT be parsed
+ # under the wider limit.
+ outer = '5 ' + 'X' * 25000 + ' '
+ root = mcp_server._parse_app_message_outer(outer)
+ self.assertIsNone(root)
+
+ def test_oversized_record_xml_retries(self):
+ # type=19 content > 20KB should succeed under the wider cap.
+ big_desc = 'A' * 25000
+ outer = (
+ '19 x '
+ f'x '
+ f''
+ f']]> '
+ )
+ self.assertGreater(len(outer), 20000)
+ root = mcp_server._parse_app_message_outer(outer)
+ self.assertIsNotNone(root)
+
+
+# -------- _format_record_dataitem ------------------------------------------
+
+
+class FormatRecordDataitemTests(unittest.TestCase):
+ def _item(self, xml):
+ import xml.etree.ElementTree as ET
+ return ET.fromstring(xml)
+
+ def test_text(self):
+ item = self._item(
+ 'hello world '
+ )
+ self.assertEqual(mcp_server._format_record_dataitem(item), 'hello world')
+
+ def test_file_with_title(self):
+ item = self._item(
+ 'report.pdf '
+ )
+ self.assertEqual(
+ mcp_server._format_record_dataitem(item), '[文件] report.pdf'
+ )
+
+ def test_image(self):
+ item = self._item(' ')
+ self.assertEqual(mcp_server._format_record_dataitem(item), '[图片]')
+
+ def test_finder_feed(self):
+ # round-2 datatype 22 视频号
+ item = self._item(
+ 'video desc '
+ )
+ self.assertEqual(
+ mcp_server._format_record_dataitem(item), '[视频号] video desc'
+ )
+
+ def test_music(self):
+ item = self._item(
+ 'song artist '
+ )
+ self.assertEqual(
+ mcp_server._format_record_dataitem(item), '[音乐] song - artist'
+ )
+
+ def test_unknown_datatype_falls_back_to_desc(self):
+ item = self._item(
+ 'fallback content '
+ )
+ self.assertEqual(
+ mcp_server._format_record_dataitem(item), 'fallback content'
+ )
+
+ def test_unknown_datatype_with_no_desc_uses_label(self):
+ item = self._item(' ')
+ self.assertEqual(
+ mcp_server._format_record_dataitem(item), '[未知类型 999]'
+ )
+
+
+# -------- _format_record_message_text end-to-end ---------------------------
+
+
+class FormatRecordMessageTextTests(unittest.TestCase):
+ def _outer_with_items(self, items_xml, title='Big card', is_chatroom=False):
+ chatroom = '1 ' if is_chatroom else ''
+ recordinfo = (
+ f'{title} {chatroom}'
+ f''
+ f' '
+ )
+ return (
+ 'x 19 '
+ f' '
+ ' '
+ )
+
+ def test_large_outer_expands_via_app_message_path(self):
+ # round-2 P2-1 + round-5 high #1: 大 outer 端到端必须能展开
+ items_xml = ''.join(
+ f'S{i} '
+ f'2025-01-01 00:00 '
+ f'{"X" * 600} '
+ for i in range(40)
+ )
+ outer = self._outer_with_items(items_xml)
+ self.assertGreater(len(outer), 20000)
+ out = mcp_server._format_app_message_text(
+ outer,
+ (19 << 32) | 49,
+ False,
+ 'wxid_dummy',
+ 'dummy',
+ {},
+ )
+ self.assertIsNotNone(out)
+ self.assertIn('[聊天记录]', out)
+ self.assertIn('共 40 条', out)
+ # 每行带 0-based index
+ self.assertIn('[0] ', out)
+ self.assertIn('[1] ', out)
+
+ def test_empty_datalist_marks_loading(self):
+ # 空 datalist 应展示"(待加载)"而非"共 0 条"
+ outer = (
+ 'x 19 '
+ 'x '
+ '0 ]]> '
+ ' '
+ )
+ out = mcp_server._format_app_message_text(
+ outer, (19 << 32) | 49, False, 'd', 'd', {}
+ )
+ self.assertIn('待加载', out)
+
+ def test_chatroom_marker_appended(self):
+ items_xml = (
+ 'A '
+ 'hi '
+ )
+ outer = self._outer_with_items(items_xml, title='G', is_chatroom=True)
+ out = mcp_server._format_app_message_text(
+ outer, (19 << 32) | 49, True, 'd', 'd', {}
+ )
+ self.assertIn('群聊转发', out)
+
+ def test_overflow_truncation_marker(self):
+ # > _RECORD_MAX_ITEMS dataitems should produce a
+ # "…(还有 N 条未显示)" line.
+ original_max = mcp_server._RECORD_MAX_ITEMS
+ try:
+ mcp_server._RECORD_MAX_ITEMS = 3
+ items_xml = ''.join(
+ f'm{i} '
+ for i in range(7)
+ )
+ outer = self._outer_with_items(items_xml)
+ out = mcp_server._format_app_message_text(
+ outer, (19 << 32) | 49, False, 'd', 'd', {}
+ )
+ self.assertIn('还有 4 条未显示', out)
+ finally:
+ mcp_server._RECORD_MAX_ITEMS = original_max
+
+
+# -------- WCPay transfer (appmsg type=2000) --------------------------------
+#
+# All fixtures use synthetic placeholder values — no real wxid / fee / id /
+# memo. paysubtype semantics are community consensus from open-source wechat
+# tooling; treat any "未识别" branch as forward-compatible degradation.
+
+
+class TransferPaysubTypeLabelTests(unittest.TestCase):
+ def test_known_subtypes_present(self):
+ labels = mcp_server._TRANSFER_PAYSUBTYPE_LABEL
+ self.assertEqual(labels['1'], '发起转账')
+ self.assertEqual(labels['3'], '已收款')
+ self.assertEqual(labels['4'], '已退还')
+ # 5/7/8 are version-dependent variants — locked to current text so a
+ # silent rename in mcp_server.py would surface here.
+ self.assertEqual(labels['5'], '过期已退还')
+ self.assertEqual(labels['7'], '待领取')
+ self.assertEqual(labels['8'], '已领取')
+
+
+def _transfer_appmsg(
+ paysubtype='1',
+ fee_desc='¥100.00',
+ pay_memo='',
+ payer='wxid_payer_synth',
+ receiver='wxid_recv_synth',
+ transferid='1' + '0' * 27,
+ transcationid='1' + '0' * 27,
+ begin_ts='1746528000',
+ invalid_ts='1746614400',
+ title='微信转账',
+ des='请收钱',
+ feedesc_tag='feedesc',
+ paymemo_tag='pay_memo',
+):
+ """Build a synthetic appmsg type=2000 root element. All values are
+ placeholder; tests never run against real wechat data."""
+ import xml.etree.ElementTree as ET
+ fee_node = f'<{feedesc_tag}>{fee_desc}{feedesc_tag}>' if fee_desc else ''
+ memo_node = f'<{paymemo_tag}>{pay_memo}{paymemo_tag}>' if pay_memo else ''
+ xml_text = (
+ f'{title} {des} '
+ f'2000 '
+ f''
+ f'{paysubtype} '
+ f'{fee_node}{memo_node}'
+ f'{transferid} '
+ f'{transcationid} '
+ f'{begin_ts} '
+ f'{invalid_ts} '
+ f'{payer} '
+ f'{receiver} '
+ f' '
+ )
+ return ET.fromstring(xml_text), xml_text
+
+
+class ExtractTransferInfoTests(unittest.TestCase):
+ def test_full_fields_round_trip(self):
+ root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch split')
+ appmsg = root.find('.//appmsg')
+ info = mcp_server._extract_transfer_info(appmsg)
+ self.assertIsNotNone(info)
+ self.assertEqual(info['paysubtype'], '3')
+ self.assertEqual(info['paysubtype_label'], '已收款')
+ self.assertEqual(info['fee_desc'], '¥100.00')
+ self.assertEqual(info['pay_memo'], 'lunch split')
+ self.assertEqual(info['payer_username'], 'wxid_payer_synth')
+ self.assertEqual(info['receiver_username'], 'wxid_recv_synth')
+ self.assertEqual(info['begin_transfer_time'], '1746528000')
+ self.assertEqual(info['invalid_time'], '1746614400')
+ self.assertTrue(info['transfer_id'].startswith('1'))
+ self.assertTrue(info['transcation_id'].startswith('1'))
+
+ def test_missing_wcpayinfo_returns_none(self):
+ import xml.etree.ElementTree as ET
+ root = ET.fromstring(
+ 'x 2000 '
+ )
+ appmsg = root.find('.//appmsg')
+ self.assertIsNone(mcp_server._extract_transfer_info(appmsg))
+
+ def test_camelcase_feedesc_falls_back(self):
+ # 部分微信版本字段名为 feeDesc 而非 feedesc
+ root, _ = _transfer_appmsg(feedesc_tag='feeDesc')
+ appmsg = root.find('.//appmsg')
+ info = mcp_server._extract_transfer_info(appmsg)
+ self.assertEqual(info['fee_desc'], '¥100.00')
+
+ def test_camelcase_paymemo_falls_back(self):
+ # paymemo (无下划线) 也是已知变体
+ root, _ = _transfer_appmsg(pay_memo='note', paymemo_tag='paymemo')
+ appmsg = root.find('.//appmsg')
+ info = mcp_server._extract_transfer_info(appmsg)
+ self.assertEqual(info['pay_memo'], 'note')
+
+ def test_unknown_paysubtype_label_degraded(self):
+ root, _ = _transfer_appmsg(paysubtype='99')
+ appmsg = root.find('.//appmsg')
+ info = mcp_server._extract_transfer_info(appmsg)
+ self.assertEqual(info['paysubtype'], '99')
+ self.assertIn('99', info['paysubtype_label'])
+
+ def test_empty_paysubtype_label_empty(self):
+ root, _ = _transfer_appmsg(paysubtype='')
+ appmsg = root.find('.//appmsg')
+ info = mcp_server._extract_transfer_info(appmsg)
+ self.assertEqual(info['paysubtype_label'], '')
+
+
+class FormatTransferMessageTextTests(unittest.TestCase):
+ def test_initiate_with_amount(self):
+ root, _ = _transfer_appmsg(paysubtype='1')
+ appmsg = root.find('.//appmsg')
+ out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
+ self.assertIn('[转账·发起转账]', out)
+ self.assertIn('¥100.00', out)
+
+ def test_received_with_memo(self):
+ root, _ = _transfer_appmsg(paysubtype='3', pay_memo='lunch')
+ appmsg = root.find('.//appmsg')
+ out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
+ self.assertIn('[转账·已收款]', out)
+ self.assertIn('备注: lunch', out)
+
+ def test_missing_wcpayinfo_falls_back_to_title(self):
+ import xml.etree.ElementTree as ET
+ root = ET.fromstring(
+ '微信转账 2000 '
+ )
+ appmsg = root.find('.//appmsg')
+ out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
+ self.assertEqual(out, '[转账] 微信转账')
+
+ def test_missing_fee_desc_safe(self):
+ # 没有金额时也要给一行能看的输出,不能崩
+ root, _ = _transfer_appmsg(paysubtype='4', fee_desc='')
+ appmsg = root.find('.//appmsg')
+ out = mcp_server._format_transfer_message_text(appmsg, '微信转账')
+ self.assertIn('[转账·已退还]', out)
+
+
+class AppMessageDispatchTransferTests(unittest.TestCase):
+ """type=2000 must route through _format_transfer_message_text via
+ _format_app_message_text (so get_chat_history / export_chat both pick it up)."""
+
+ def test_dispatch_calls_transfer_helper(self):
+ _, xml_text = _transfer_appmsg(paysubtype='3', pay_memo='dinner')
+ out = mcp_server._format_app_message_text(
+ xml_text, 49, False, 'wxid_dummy', 'dummy', {}
+ )
+ self.assertIsNotNone(out)
+ self.assertIn('[转账·已收款]', out)
+ self.assertIn('¥100.00', out)
+ self.assertIn('备注: dinner', out)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_refer_message.py b/tests/test_refer_message.py
new file mode 100644
index 0000000..fc763f5
--- /dev/null
+++ b/tests/test_refer_message.py
@@ -0,0 +1,219 @@
+"""微信引用回复消息(appmsg type=57)解析鉴定测试。
+
+旧逻辑直接把 refermsg/content 按 [:160] 截断当摘要,对 type=3 (图片) /
+34 (语音) / 47 (动画表情) / 49 (嵌套卡片) 这些"二进制"被引用消息会渲染
+成 cdnurl + aeskey + md5 一坨乱码 (issue #44 #45)。本组测试 pin 新行为:
+按 refer_type 给 schema-aware 摘要,cdnurl / aeskey / md5 / cdnthumb /
+voiceurl / externurl 全部不再泄漏到聊天历史。
+
+合成 fixture:wxid_synth_a / wxid_synth_b / 12345@chatroom / Sender A/B /
+svrid 1 + 0*18,无真实 PII。
+"""
+import unittest
+import xml.etree.ElementTree as ET
+
+import mcp_server
+
+
+# ---------- 合成 fixture ----------
+
+def _appmsg(refermsg_xml='', title='我的回复'):
+ """组装一个最小 type=57 appmsg 元素。"""
+ xml = (
+ f'57 {title} '
+ f'{refermsg_xml} '
+ )
+ root = ET.fromstring(xml)
+ return root.find('.//appmsg')
+
+
+def _refermsg(refer_type, content, fromusr='wxid_synth_a',
+ displayname='Sender A', svrid='1' + '0' * 18,
+ chatusr='', createtime='1700000000'):
+ return (
+ ''
+ f'{refer_type} '
+ f'{svrid} '
+ f'{fromusr} '
+ f'{chatusr} '
+ f'{displayname} '
+ f'{createtime} '
+ f'{content} '
+ ' '
+ )
+
+
+# ---------- 标签映射 ----------
+
+class ReferInnerTypeLabelTests(unittest.TestCase):
+ def test_known_refer_inner_labels(self):
+ self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['3'], '图片')
+ self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['34'], '语音')
+ self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['47'], '动画表情')
+ self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['49'], '链接/卡片')
+
+ def test_known_inner_appmsg_labels(self):
+ self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['5'], '链接')
+ self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['6'], '文件')
+ self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['19'], '聊天记录')
+
+
+# ---------- _extract_refer_info ----------
+
+class ExtractReferInfoTests(unittest.TestCase):
+ def test_full_fields_round_trip(self):
+ appmsg = _appmsg(_refermsg('1', '原文本'), title='回复正文')
+ info = mcp_server._extract_refer_info(appmsg)
+ self.assertEqual(info['reply_text'], '回复正文')
+ self.assertEqual(info['refer_type'], '1')
+ self.assertEqual(info['refer_fromusr'], 'wxid_synth_a')
+ self.assertEqual(info['refer_displayname'], 'Sender A')
+ self.assertEqual(info['refer_svrid'], '1' + '0' * 18)
+ self.assertEqual(info['refer_content'], '原文本')
+
+ def test_missing_refermsg_returns_none(self):
+ appmsg = _appmsg(refermsg_xml='', title='孤儿回复')
+ self.assertIsNone(mcp_server._extract_refer_info(appmsg))
+
+
+# ---------- _summarize_refer_content ----------
+
+class SummarizeReferContentTests(unittest.TestCase):
+ def test_text_returns_original(self):
+ self.assertEqual(mcp_server._summarize_refer_content('1', '你好'), '你好')
+
+ def test_text_truncates_to_max_len(self):
+ long = '中' * 200
+ out = mcp_server._summarize_refer_content('1', long, max_len=160)
+ self.assertEqual(len(out), 161) # 160 + '…'
+ self.assertTrue(out.endswith('…'))
+
+ def test_image_returns_label_not_xml(self):
+ v2_image_xml = (
+ '![]()
'
+ )
+ out = mcp_server._summarize_refer_content('3', v2_image_xml)
+ self.assertEqual(out, '[图片]')
+ # PII / 二进制元数据不能泄漏到摘要
+ for leak in ('cdnurl', 'aeskey', 'md5', 'cdnthumb', 'leak_main'):
+ self.assertNotIn(leak, out)
+
+ def test_voice_returns_label(self):
+ v_xml = ' '
+ out = mcp_server._summarize_refer_content('34', v_xml)
+ self.assertEqual(out, '[语音]')
+ self.assertNotIn('voiceurl', out)
+
+ def test_emoji_returns_label(self):
+ out = mcp_server._summarize_refer_content(
+ '47', ' '
+ )
+ self.assertEqual(out, '[动画表情]')
+ self.assertNotIn('externurl', out)
+ self.assertNotIn('leak', out)
+
+ def test_nested_link_card_summary(self):
+ nested = '5 分享标题 '\
+ 'http://example.com/leak '
+ out = mcp_server._summarize_refer_content('49', nested)
+ self.assertEqual(out, '[链接] 分享标题')
+ self.assertNotIn('http', out)
+ self.assertNotIn('url', out)
+
+ def test_nested_record_card_summary(self):
+ nested = '19 群聊天记录 '
+ out = mcp_server._summarize_refer_content('49', nested)
+ self.assertEqual(out, '[聊天记录] 群聊天记录')
+
+ def test_nested_invalid_xml_falls_back_to_card(self):
+ self.assertEqual(
+ mcp_server._summarize_refer_content('49', ']>'
+ '5 &x; '
+ )
+ out = mcp_server._summarize_refer_content('49', xxe)
+ self.assertEqual(out, '[卡片]')
+
+
+# ---------- _format_refer_message_text ----------
+
+class FormatReferMessageTextTests(unittest.TestCase):
+ def _names(self):
+ return {'wxid_synth_a': 'Sender A', 'wxid_synth_b': 'Sender B'}
+
+ def test_text_refer_in_1v1(self):
+ appmsg = _appmsg(_refermsg('1', '你吃了吗'), title='吃了')
+ out = mcp_server._format_refer_message_text(
+ appmsg, is_group=False, chat_username='wxid_synth_a',
+ chat_display_name='Sender A', names=self._names(),
+ )
+ self.assertEqual(out, '吃了\n ↳ 回复 Sender A: 你吃了吗')
+
+ def test_image_refer_uses_label_not_xml_payload(self):
+ v2_image = (
+ '<msg><img cdnurl="leak" aeskey="leak" md5="leak"/></msg>'
+ )
+ appmsg = _appmsg(_refermsg('3', v2_image), title='这张?')
+ out = mcp_server._format_refer_message_text(
+ appmsg, is_group=False, chat_username='wxid_synth_a',
+ chat_display_name='Sender A', names=self._names(),
+ )
+ self.assertIn('[图片]', out)
+ for leak in ('cdnurl', 'aeskey', 'md5'):
+ self.assertNotIn(leak, out)
+
+ def test_missing_refermsg_falls_back_to_title(self):
+ appmsg = _appmsg(refermsg_xml='', title='孤儿回复')
+ out = mcp_server._format_refer_message_text(
+ appmsg, is_group=False, chat_username='wxid_synth_a',
+ chat_display_name='Sender A', names={},
+ )
+ self.assertEqual(out, '孤儿回复')
+
+ def test_empty_reply_uses_placeholder(self):
+ appmsg = _appmsg(_refermsg('1', 'hi'), title='')
+ out = mcp_server._format_refer_message_text(
+ appmsg, is_group=False, chat_username='wxid_synth_a',
+ chat_display_name='Sender A', names=self._names(),
+ )
+ self.assertTrue(out.startswith('[引用消息]'))
+
+
+# ---------- 调度入口 ----------
+
+class AppMessageDispatchReferTests(unittest.TestCase):
+ def test_type57_dispatches_to_helper(self):
+ # _format_app_message_text 的 type=57 分支必须走 _format_refer_message_text,
+ # 不再走旧的 inline [:160] 截断。
+ v2_image = '<msg><img cdnurl="leak_main"/></msg>'
+ content = (
+ f'57 看这个 '
+ f'{_refermsg("3", v2_image)} '
+ )
+ out = mcp_server._format_app_message_text(
+ content, local_type=49, is_group=False,
+ chat_username='wxid_synth_a', chat_display_name='Sender A', names={},
+ )
+ self.assertIn('[图片]', out)
+ self.assertNotIn('leak_main', out)
+ self.assertNotIn('cdnurl', out)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_voice_format.py b/tests/test_voice_format.py
new file mode 100644
index 0000000..0012a7c
--- /dev/null
+++ b/tests/test_voice_format.py
@@ -0,0 +1,82 @@
+"""Tests for `_format_voice_text` (msg_type=34 鉴定).
+
+Voice messages previously rendered as a bare `[语音]` from the generic
+non-text branch. LLMs reading chat history had no way to (a) judge whether a
+clip was worth transcribing, or (b) call `decode_voice` without first round-
+tripping through `get_voice_messages` to look up the `local_id`.
+
+These tests pin the new behaviour: `[语音 Ns]` (duration to 1 decimal) when
+the embedded `` is parseable, with graceful
+fallback to `[语音]` on missing / zero / malformed length.
+"""
+import unittest
+
+import mcp_server
+
+
+def _voice_xml(length_ms):
+ return (
+ f' '
+ )
+
+
+class FormatVoiceTextTests(unittest.TestCase):
+ def test_renders_duration_with_one_decimal(self):
+ self.assertEqual(mcp_server._format_voice_text(_voice_xml(3300)), "[语音 3.3s]")
+
+ def test_subsecond_voice(self):
+ self.assertEqual(mcp_server._format_voice_text(_voice_xml(800)), "[语音 0.8s]")
+
+ def test_long_clip(self):
+ self.assertEqual(mcp_server._format_voice_text(_voice_xml(62000)), "[语音 62.0s]")
+
+ def test_missing_voicelength_falls_back(self):
+ xml = ' '
+ self.assertEqual(mcp_server._format_voice_text(xml), "[语音]")
+
+ def test_zero_voicelength_falls_back(self):
+ self.assertEqual(mcp_server._format_voice_text(_voice_xml(0)), "[语音]")
+
+ def test_non_numeric_voicelength_falls_back(self):
+ xml = ' '
+ self.assertEqual(mcp_server._format_voice_text(xml), "[语音]")
+
+ def test_empty_content(self):
+ self.assertEqual(mcp_server._format_voice_text(""), "[语音]")
+ self.assertEqual(mcp_server._format_voice_text(None), "[语音]")
+
+ def test_missing_voicemsg_tag(self):
+ self.assertEqual(mcp_server._format_voice_text(" "), "[语音]")
+
+ def test_malformed_xml(self):
+ self.assertEqual(mcp_server._format_voice_text("]>'
+ ' '
+ )
+ self.assertEqual(mcp_server._format_voice_text(xxe), "[语音]")
+
+ def test_end_to_end_format_message_text_with_voicelength(self):
+ xml = _voice_xml(3300)
+ _, text = mcp_server._format_message_text(
+ local_id=72481, local_type=34, content=xml, is_group=False,
+ chat_username="wxid_synth_a", chat_display_name="A", names={},
+ create_time=1700000000,
+ )
+ self.assertEqual(text, "[语音 3.3s] (local_id=72481, ts=1700000000)")
+
+ def test_end_to_end_without_voicelength(self):
+ _, text = mcp_server._format_message_text(
+ local_id=99, local_type=34, content=" ", is_group=False,
+ chat_username="wxid_synth_a", chat_display_name="A", names={},
+ create_time=0,
+ )
+ self.assertEqual(text, "[语音] (local_id=99)")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_voice_transcription_cache.py b/tests/test_voice_transcription_cache.py
new file mode 100644
index 0000000..abd53ce
--- /dev/null
+++ b/tests/test_voice_transcription_cache.py
@@ -0,0 +1,274 @@
+import json
+import os
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+
+import mcp_server
+
+
+class _CacheIsolationMixin:
+ """所有测试共享:隔离 module-level 缓存状态 + 指向 tempdir 的 cache 文件。"""
+
+ def setUp(self):
+ self._saved_cache = mcp_server._voice_transcription_cache
+ self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE
+ self._saved_warned = mcp_server._voice_transcription_save_warned
+
+ mcp_server._voice_transcription_cache = None
+ mcp_server._voice_transcription_save_warned = False
+
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join(
+ self._tmp.name, "voice_transcriptions.json"
+ )
+
+ def tearDown(self):
+ mcp_server._voice_transcription_cache = self._saved_cache
+ mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path
+ mcp_server._voice_transcription_save_warned = self._saved_warned
+
+
+class VoiceTranscriptionCachePersistenceTests(_CacheIsolationMixin, unittest.TestCase):
+ """_load_voice_transcription_cache / _save_voice_transcription_cache 的持久化行为。"""
+
+ def test_load_missing_file_returns_empty_dict(self):
+ self.assertEqual(mcp_server._load_voice_transcription_cache(), {})
+
+ def test_save_and_reload_roundtrip(self):
+ cache = mcp_server._load_voice_transcription_cache()
+ cache["wxid_foo:42"] = {
+ "text": "你好",
+ "language": "zh",
+ "create_time": 1700000000,
+ "model_size": "base",
+ }
+ mcp_server._save_voice_transcription_cache()
+
+ # 强制下一次 load 从磁盘读
+ mcp_server._voice_transcription_cache = None
+ reloaded = mcp_server._load_voice_transcription_cache()
+ self.assertEqual(reloaded["wxid_foo:42"]["text"], "你好")
+ self.assertEqual(reloaded["wxid_foo:42"]["language"], "zh")
+
+ def test_corrupt_file_returns_empty_dict(self):
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f:
+ f.write("{{ not valid json")
+ self.assertEqual(mcp_server._load_voice_transcription_cache(), {})
+
+ def test_non_dict_payload_returns_empty_dict(self):
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "w", encoding="utf-8") as f:
+ json.dump(["not", "a", "dict"], f)
+ self.assertEqual(mcp_server._load_voice_transcription_cache(), {})
+
+ def test_utf8_preserved_on_disk(self):
+ # ensure_ascii=False 必须生效,否则中文会被转义成 \uXXXX
+ cache = mcp_server._load_voice_transcription_cache()
+ cache["wxid_bar:1"] = {"text": "中文测试", "language": "zh"}
+ mcp_server._save_voice_transcription_cache()
+
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, "rb") as f:
+ raw = f.read()
+ self.assertIn("中文测试".encode("utf-8"), raw)
+
+ def test_save_without_prior_load_persists_empty_dict(self):
+ # 从未 load 过就直接 save:应落盘一个空 dict,而不是静默丢弃。
+ mcp_server._voice_transcription_cache = None
+ mcp_server._save_voice_transcription_cache()
+ self.assertTrue(os.path.exists(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE))
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f:
+ self.assertEqual(json.load(f), {})
+
+
+class VoiceTranscriptionCacheAtomicityTests(_CacheIsolationMixin, unittest.TestCase):
+ """原子写 + crash-during-save 行为。"""
+
+ def test_write_is_atomic_via_rename(self):
+ # 先写入一份已有缓存
+ cache = mcp_server._load_voice_transcription_cache()
+ cache["wxid_x:1"] = {"text": "initial", "language": "zh", "model_size": "base"}
+ mcp_server._save_voice_transcription_cache()
+
+ # 模拟:写 .tmp 正常但 os.replace 阶段失败
+ original_replace = os.replace
+
+ def flaky_replace(src, dst):
+ raise OSError("disk full during rename")
+
+ cache["wxid_x:1"] = {"text": "MUTATED", "language": "zh", "model_size": "base"}
+ with patch.object(os, "replace", side_effect=flaky_replace):
+ mcp_server._save_voice_transcription_cache() # 不应抛
+
+ # 磁盘上应仍然是 initial,不是 MUTATED,也不是损坏的半截文件
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f:
+ disk = json.load(f)
+ self.assertEqual(disk["wxid_x:1"]["text"], "initial")
+
+ # .tmp 应该被清理,避免污染目录
+ tmp_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE + ".tmp"
+ # 注:patch 生效期间 os.replace 失败,finally 里会尝试 unlink
+ _ = original_replace # 防 lint 警告
+ self.assertFalse(os.path.exists(tmp_path))
+
+ def test_early_save_error_preserves_existing_file(self):
+ # json.dump 在 .tmp 上抛异常时(模拟磁盘满 / 权限问题),主文件应保持原样;
+ # 注意此测试不是"写到一半中断"而是"写前就失败"的场景。
+ cache = mcp_server._load_voice_transcription_cache()
+ cache["wxid_y:1"] = {"text": "survives", "language": "zh", "model_size": "base"}
+ mcp_server._save_voice_transcription_cache()
+
+ cache["wxid_y:1"] = {"text": "DO NOT SEE", "language": "zh", "model_size": "base"}
+
+ def boom(*args, **kwargs):
+ raise OSError("disk full")
+
+ with patch.object(mcp_server.json, "dump", side_effect=boom):
+ mcp_server._save_voice_transcription_cache() # 静默降级,不抛
+
+ # 主文件没被破坏:仍然可 json.load 出原先内容
+ mcp_server._voice_transcription_cache = None
+ reloaded = mcp_server._load_voice_transcription_cache()
+ self.assertEqual(reloaded["wxid_y:1"]["text"], "survives")
+
+
+class VoiceTranscriptionCacheConcurrencyTests(_CacheIsolationMixin, unittest.TestCase):
+ """多线程下的 load/save 行为。"""
+
+ def test_concurrent_load_returns_same_dict_instance(self):
+ # 16 个线程同时触发首次 load,应当只实际化一份 dict(lock 生效)
+ barrier = threading.Barrier(16)
+ results = []
+ results_lock = threading.Lock()
+
+ def worker():
+ barrier.wait()
+ d = mcp_server._load_voice_transcription_cache()
+ with results_lock:
+ results.append(id(d))
+
+ threads = [threading.Thread(target=worker) for _ in range(16)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ self.assertEqual(len(set(results)), 1, "并发 load 应返回同一个 dict 对象")
+
+ def test_concurrent_save_does_not_corrupt(self):
+ # 多个线程同时 save,磁盘上最终文件必须是合法 JSON(原子写 + lock 保障)
+ cache = mcp_server._load_voice_transcription_cache()
+ for i in range(100):
+ cache[f"wxid_z:{i}"] = {
+ "text": f"msg-{i}",
+ "language": "zh",
+ "model_size": "base",
+ }
+
+ def worker():
+ mcp_server._save_voice_transcription_cache()
+
+ threads = [threading.Thread(target=worker) for _ in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ with open(mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE, encoding="utf-8") as f:
+ disk = json.load(f) # 必须能解析
+ self.assertEqual(len(disk), 100)
+
+
+class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase):
+ """transcribe_voice 的缓存命中 / 失效路径。"""
+
+ def _seed(self, key, entry):
+ cache = mcp_server._load_voice_transcription_cache()
+ cache[key] = entry
+ mcp_server._save_voice_transcription_cache()
+
+ def test_cache_hit_skips_fetch_and_transcribe(self):
+ key = mcp_server._voice_transcription_cache_key("wxid_test", 7)
+ self._seed(key, {
+ "text": "缓存命中文本",
+ "language": "zh",
+ "create_time": 1700000000,
+ "model_size": mcp_server.LOCAL_WHISPER_MODEL,
+ })
+
+ with patch.object(mcp_server, "resolve_username", return_value="wxid_test") as mock_resolve, \
+ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch, \
+ patch.object(mcp_server, "_silk_to_wav") as mock_silk, \
+ patch.object(mcp_server, "_get_whisper_model") as mock_model:
+ result = mcp_server.transcribe_voice("test_contact", 7)
+
+ mock_resolve.assert_called_once_with("test_contact")
+ mock_fetch.assert_not_called()
+ mock_silk.assert_not_called()
+ mock_model.assert_not_called()
+ self.assertIn("缓存命中文本", result)
+ self.assertIn("(zh)", result)
+
+ def test_cache_hit_uses_placeholder_when_create_time_missing(self):
+ # 旧条目若没有 create_time 字段,不应崩溃
+ key = mcp_server._voice_transcription_cache_key("wxid_test", 8)
+ self._seed(key, {
+ "text": "历史条目",
+ "language": "zh",
+ "model_size": mcp_server.LOCAL_WHISPER_MODEL,
+ })
+
+ with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
+ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch:
+ result = mcp_server.transcribe_voice("test_contact", 8)
+
+ mock_fetch.assert_not_called()
+ self.assertIn("历史条目", result)
+
+ def test_cache_hit_returns_empty_text_without_retranscribing(self):
+ # Whisper 返回空也要缓存;再次调用应直接返回空,不进入 miss 路径
+ key = mcp_server._voice_transcription_cache_key("wxid_test", 9)
+ self._seed(key, {
+ "text": "",
+ "language": "zh",
+ "create_time": 1700000000,
+ "model_size": mcp_server.LOCAL_WHISPER_MODEL,
+ })
+
+ with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
+ patch.object(mcp_server, "_fetch_voice_row") as mock_fetch:
+ result = mcp_server.transcribe_voice("test_contact", 9)
+
+ mock_fetch.assert_not_called()
+ self.assertIn("(zh)", result)
+
+ def test_model_mismatch_is_treated_as_miss(self):
+ # 缓存条目的 model_size 和当前 LOCAL_WHISPER_MODEL 不一致时,
+ # 不应命中;进入 miss 路径(这里无 whisper 依赖,应落到"缺少依赖"分支)。
+ key = mcp_server._voice_transcription_cache_key("wxid_test", 10)
+ self._seed(key, {
+ "text": "旧模型结果",
+ "language": "zh",
+ "create_time": 1700000000,
+ "model_size": "OUTDATED_MODEL",
+ })
+
+ with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
+ patch.dict("sys.modules", {"whisper": None}):
+ # whisper=None 时 `import whisper` 触发 ImportError
+ result = mcp_server.transcribe_voice("test_contact", 10)
+
+ # 走了 miss 路径 → 返回缺依赖提示,而不是返回旧缓存文本
+ self.assertNotIn("旧模型结果", result)
+ self.assertIn("缺少依赖", result)
+
+ def test_cache_key_handles_colon_in_username(self):
+ # 若上游未来的 resolve_username 放出带 ':' 的 username,也不会和其他条目冲突
+ key_a = mcp_server._voice_transcription_cache_key("wxid:foo", 1)
+ key_b = mcp_server._voice_transcription_cache_key("wxid", 1)
+ self.assertNotEqual(key_a, key_b)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/transcribe_chat.py b/transcribe_chat.py
new file mode 100644
index 0000000..ca55f61
--- /dev/null
+++ b/transcribe_chat.py
@@ -0,0 +1,111 @@
+"""
+为聊天导出 JSON 中的语音消息补齐转录文本。
+
+用法:
+ .venv/bin/python3 transcribe_chat.py [output.json]
+
+参数:
+ 由 export_chat.py 产出的 JSON。
+ [output.json] 可选输出路径,默认 "_transcribed.json"。
+
+完整流程示例:
+ .venv/bin/python3 export_chat.py /tmp/chat.json
+ .venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json
+
+行为说明:
+ - 后端由 config.json 中 transcription_backend 字段控制 (local/openai/whisper_cpp),
+ 与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。
+ - 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。
+ - 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。
+ - 切到 whisper_cpp: 使用 whisper-cpp CLI (Metal GPU 加速,仅 macOS)。
+ - 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。
+ - 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。
+
+需要 WeChat DB 仍然在线/已解密 —— 语音 blob 是从 DB 现场按 local_id 读取的,
+不从 JSON 读。
+"""
+import json
+import os
+import sys
+from datetime import datetime
+
+import mcp_server
+
+
+def _transcribe_local_id(username, local_id, backend):
+ row = mcp_server._fetch_voice_row(username, local_id)
+ if row is None:
+ return "[not found]"
+
+ voice_data, create_time = row
+ try:
+ wav_path, _ = mcp_server._silk_to_wav(voice_data, create_time, username, local_id)
+ except Exception as e:
+ return f"[decode error: {e}]"
+
+ try:
+ result = mcp_server._transcribe(wav_path, backend)
+ return result["text"]
+ except Exception as e:
+ return f"[transcribe error: {e}]"
+
+
+def transcribe_export(input_path, output_path):
+ with open(input_path, encoding="utf-8") as f:
+ data = json.load(f)
+
+ # 优先使用导出 JSON 中已记录的 username,避免重新模糊匹配导致同名联系人漂移。
+ username = data.get("username")
+ chat_name = data.get("chat", "")
+ if not username:
+ username = mcp_server.resolve_username(chat_name)
+ if not username:
+ print(f"Could not resolve username for: {chat_name}")
+ sys.exit(1)
+
+ messages = data["messages"]
+ # Compact format: type is absent for text; transcription is only present when filled.
+ pending = [m for m in messages if m.get("type") == "voice" and not m.get("transcription")]
+ total = len(pending)
+
+ if total == 0:
+ print("No voice messages to transcribe.")
+ return
+
+ backend = mcp_server._resolve_active_backend()
+ print(f"Found {total} voice messages to transcribe.")
+ print(f"Backend: {backend}")
+ if backend == "local":
+ print("Loading Whisper model (first run downloads ~145MB)...")
+ mcp_server._get_whisper_model()
+ print("Model ready.\n")
+ elif backend == "whisper_cpp":
+ print("Using whisper-cpp with Metal GPU acceleration\n")
+ else:
+ print("")
+
+ for i, msg in enumerate(pending, 1):
+ local_id = msg["local_id"]
+ ts = msg["timestamp"]
+ ts_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if isinstance(ts, (int, float)) else ts
+ print(f"[{i}/{total}] local_id={local_id} ({ts_str}) ... ", end="", flush=True)
+ result = _transcribe_local_id(username, local_id, backend)
+ msg["transcription"] = result
+ print(repr(result[:60]) if result else '""')
+
+ # Save after each transcription so progress isn't lost on crash
+ with open(output_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+ print(f"\nDone. Written to {output_path}")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python3 transcribe_chat.py [output.json]")
+ sys.exit(1)
+
+ inp = sys.argv[1]
+ base, ext = os.path.splitext(inp)
+ out = sys.argv[2] if len(sys.argv) > 2 else f"{base}_transcribed{ext}"
+ transcribe_export(inp, out)