feat: 新增语音 MCP 工具 + macOS 密钥提取修复 (#53)

* feat: 新增语音 MCP 工具 + macOS 密钥提取修复

- 新增 get_voice_messages / decode_voice / transcribe_voice MCP 工具
  - 语音数据存储在 media_0.db VoiceInfo 表(SILK v3 格式)
  - decode_voice 解码为 WAV 文件(saved to decoded_voices/)
  - transcribe_voice 通过 Whisper 自动识别语言转录
- 新增 get_chat_history oldest_first 参数,支持从最早消息开始分页
- 修复 macOS 下 check_wechat_running / ensure_keys 逻辑
  - 改用 pgrep 检测微信进程,绕过不支持 macOS 的 Python 扫描器
  - 无 all_keys.json 时打印清晰引导,提示运行 C 版扫描器
- 新增 Makefile(build / keys / decrypt / web 快捷命令)
- .gitignore 补充 find_all_keys_macos 二进制和 decoded_voices/

* fix: 语音查询支持多分片 media DB + 文件名唯一化

解决 PR #53 review 的阻塞项 #1,顺手修 #3、#6。

#1 `_get_media_db_path()` 硬编码 `media_0.db`
  - 新增模块级 `MEDIA_DB_KEYS`,镜像 `MSG_DB_KEYS` 的分片发现逻辑
  - `_fetch_voice_row` 遍历所有分片,按 `(chat_name_id, local_id)`
    首个命中即返回;单条语音在 media DB 家族内唯一,命中即可停
  - `get_voice_messages` 从每个分片各取 `LIMIT limit`,合并排序后
    截断到 `limit`。选择"每分片取 limit 条再合并"而非"按
    max(create_time) 排序后逐个取到 limit 即停止":后者假设分片
    间时间不重叠,一旦 WeChat 改分片策略就会静默丢消息;前者工作
    量 O(N 分片 × limit),在任何分片布局下都正确

#3 输出文件名冲突
  - `_silk_to_wav` 增加 `local_id` 参数,输出 `{user}_{time}_{lid}.wav`,
    同一秒内两条语音不会互相覆盖;两个调用方都已在作用域内持有
    `local_id`

#6 `_fetch_voice_row` 的 `local_id=None` 死分支
  - 随 #1 的重写一并删除,`local_id` 改为必填位置参数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: macOS 密钥提取分层下沉到 find_all_keys.py

解决 PR #53 review 的阻塞项 #2。

review 里提到"跟 PR #51 冲突"实测不存在 —— PR #51 当前 0 文件改动
(fork 分支已与上游同步),但架构建议本身是对的:macOS 处理应集中
在 `find_all_keys.py`,而不是在 `main.py` 提前 return 截胡。

- `main.py:ensure_keys()` 移除 darwin 专属提前返回分支,macOS 走
  和其他平台相同的 `extract_keys()` 路径
- `find_all_keys.py:_load_impl()` 在 darwin 分支抛出带
  `sudo ./find_all_keys_macos` 操作指引的 RuntimeError;非 macOS
  的平台兜底分支保留
- `main.py` 里已有 `except RuntimeError` 会打印并 `sys.exit(1)`,
  用户可见行为不变

未来若有 PR 在 `find_all_keys.py` 加 macOS 自动编译 / dispatch,
直接替换这段 RuntimeError 即可,不再需要改 `main.py`。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: Makefile 支持 PYTHON 变量覆盖

解决 PR #53 review 的非阻塞项 #7。

原 Makefile 硬编码 `.venv/bin/python3`,没有 venv 的用户跑 `make
decrypt` 直接报错。引入 `PYTHON ?= .venv/bin/python3`:默认行为
不变(仍走 venv),想用系统 Python 的用户 `PYTHON=python3 make
decrypt` 即可。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 回应 PR #53 review #4 — 澄清 silk-python 与 pysilk 包名关系

验证:本项目 import 的 `pysilk` 实际由 `pip install silk-python`
(synodriver/pysilk) 提供;pypi 上另有同名 `pysilk==0.0.1` 是无内容
的占位包,不可用。错误消息里 `pip install silk-python` 已经是对的,
但 reader 看到 `import pysilk` 仍会困惑,所以:

- `_silk_to_wav` 的 import 处加一行注释,点名所用的是
  synodriver 版本,并提醒 pypi 上还有 pilk / pysilk 两个同类包
- `decode_voice` / `transcribe_voice` 的 docstring 加 "依赖:" 行,
  明确 "pip install silk-python (import 名为 pysilk)",MCP 客户端
  读 tool 描述就能看到正确的安装命令

未新增 requirements.txt 条目:voice 支持是可选功能(tool 内
try/except ImportError 懒加载),保持非必需依赖的语义。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
btc-z
2026-04-23 02:10:22 -04:00
committed by GitHub
parent e86e00df87
commit 02bc9c1840
5 changed files with 248 additions and 9 deletions

4
.gitignore vendored
View File

@@ -23,3 +23,7 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Compiled binaries and output
find_all_keys_macos
decoded_voices/

16
Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: keys decrypt web build
PYTHON ?= .venv/bin/python3
build:
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
codesign -s - find_all_keys_macos
keys:
sudo ./find_all_keys_macos
decrypt:
$(PYTHON) main.py decrypt
web:
$(PYTHON) main.py

View File

@@ -12,9 +12,16 @@ def _load_impl():
if system == "linux": if system == "linux":
import find_all_keys_linux as impl import find_all_keys_linux as impl
return impl return impl
if system == "darwin":
raise RuntimeError(
"macOS 请先运行 C 版扫描器提取密钥:\n"
"\n"
" sudo ./find_all_keys_macos\n"
"\n"
" 完成后再运行 python main.py decrypt"
)
raise RuntimeError( raise RuntimeError(
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n" f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}"
f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)"
) )

View File

@@ -6,7 +6,9 @@ python main.py decrypt # 提取密钥 + 解密全部数据库
""" """
import json import json
import os import os
import platform
import sys import sys
import subprocess
import functools import functools
print = functools.partial(print, flush=True) print = functools.partial(print, flush=True)
@@ -16,6 +18,8 @@ from key_utils import strip_key_metadata
def check_wechat_running(): def check_wechat_running():
"""检查微信是否在运行,返回 True/False""" """检查微信是否在运行,返回 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 from find_all_keys import get_pids
try: try:
get_pids() get_pids()

View File

@@ -5,7 +5,9 @@ Based on FastMCP (stdio transport), reuses existing decryption.
Runs on Windows Python (needs access to D:\ WeChat databases). Runs on Windows Python (needs access to D:\ WeChat databases).
""" """
import io
import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re
import wave
import hmac as hmac_mod import hmac as hmac_mod
from contextlib import closing from contextlib import closing
from datetime import datetime from datetime import datetime
@@ -803,18 +805,19 @@ def _build_message_filters(start_ts=None, end_ts=None, keyword=''):
return clauses, params return clauses, params
def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0): def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0, oldest_first=False):
if not _is_safe_msg_table_name(table_name): if not _is_safe_msg_table_name(table_name):
raise ValueError(f'非法消息表名: {table_name}') raise ValueError(f'非法消息表名: {table_name}')
clauses, params = _build_message_filters(start_ts, end_ts, keyword) clauses, params = _build_message_filters(start_ts, end_ts, keyword)
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else '' where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ''
order = 'ASC' if oldest_first else 'DESC'
sql = f""" sql = f"""
SELECT local_id, local_type, create_time, real_sender_id, message_content, SELECT local_id, local_type, create_time, real_sender_id, message_content,
WCDB_CT_message_content WCDB_CT_message_content
FROM [{table_name}] FROM [{table_name}]
{where_sql} {where_sql}
ORDER BY create_time DESC ORDER BY create_time {order}
""" """
if limit is None: if limit is None:
return conn.execute(sql, params).fetchall() return conn.execute(sql, params).fetchall()
@@ -994,14 +997,14 @@ def _history_query_batch_size(candidate_limit):
return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE) return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE)
def _page_ranked_entries(entries, limit, offset): def _page_ranked_entries(entries, limit, offset, oldest_first=False):
ordered = sorted(entries, key=lambda item: item[0], reverse=True) ordered = sorted(entries, key=lambda item: item[0], reverse=not oldest_first)
paged = ordered[offset:offset + limit] paged = ordered[offset:offset + limit]
paged.sort(key=lambda item: item[0]) paged.sort(key=lambda item: item[0])
return paged return paged
def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0): def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0, oldest_first=False):
collected = [] collected = []
failures = [] failures = []
candidate_limit = _candidate_page_size(limit, offset) candidate_limit = _candidate_page_size(limit, offset)
@@ -1022,6 +1025,7 @@ def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20
end_ts=end_ts, end_ts=end_ts,
limit=batch_size, limit=batch_size,
offset=fetch_offset, offset=fetch_offset,
oldest_first=oldest_first,
) )
if not rows: if not rows:
break break
@@ -1042,7 +1046,7 @@ def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20
except Exception as e: except Exception as e:
failures.append(f"{table_ctx['db_path']}: {e}") failures.append(f"{table_ctx['db_path']}: {e}")
paged = _page_ranked_entries(collected, limit, offset) paged = _page_ranked_entries(collected, limit, offset, oldest_first=oldest_first)
return [line for _, line in paged], failures return [line for _, line in paged], failures
@@ -1348,7 +1352,7 @@ def get_recent_sessions(limit: int = 20) -> str:
@mcp.tool() @mcp.tool()
def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str: def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "", oldest_first: bool = False) -> str:
"""获取指定聊天的消息记录。 """获取指定聊天的消息记录。
Args: Args:
@@ -1357,6 +1361,7 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim
offset: 分页偏移量默认0 offset: 分页偏移量默认0
start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS 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 end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
oldest_first: 为 True 时返回最早的消息(默认 False 返回最新消息)
""" """
try: try:
_validate_pagination(limit, offset, limit_max=None) _validate_pagination(limit, offset, limit_max=None)
@@ -1378,6 +1383,7 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim
end_ts=end_ts, end_ts=end_ts,
limit=limit, limit=limit,
offset=offset, offset=offset,
oldest_first=oldest_first,
) )
if not lines: if not lines:
@@ -1734,5 +1740,207 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
return f"{display_name}{len(lines)} 张图片:\n\n" + "\n".join(lines) return f"{display_name}{len(lines)} 张图片:\n\n" + "\n".join(lines)
# ============ 语音解密 ============
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-pythonimport 名 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) -> str:
"""列出某个聊天中的语音消息。
返回语音的时间、local_id 和大小,可配合 decode_voice 工具解码。
Args:
chat_name: 聊天对象的名字、备注名或wxid
limit: 返回数量默认20
"""
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 条后合并再截断:分片若有时间重叠也不会漏最新消息
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
rows.extend(conn.execute(
"SELECT local_id, create_time, length(voice_data) FROM VoiceInfo "
"WHERE chat_name_id = ? ORDER BY create_time DESC LIMIT ?",
(chat_name_id, limit),
).fetchall())
if not rows:
return f"{display_name} 无语音消息"
rows.sort(key=lambda r: r[1], reverse=True)
rows = rows[:limit]
lines = []
for local_id, create_time, size in rows:
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")
return f"{display_name}{len(lines)} 条语音消息:\n\n" + "\n".join(lines)
@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_model = None
def _get_whisper_model(model_size="base"):
global _whisper_model
if _whisper_model is None:
import whisper
_whisper_model = whisper.load_model(model_size)
return _whisper_model
@mcp.tool()
def transcribe_voice(chat_name: str, local_id: int) -> str:
"""将微信语音消息转录为文字(自动检测语言,保留原语言)。
会先解码 SILK 语音为 WAV再用 Whisper 转录。
首次运行会下载 Whisper 模型(约 145MB
依赖: pip install silk-python openai-whisper (silk-python 的 import 名为 pysilk)
Args:
chat_name: 聊天对象的名字、备注名或wxid
local_id: 语音消息的 local_id从 get_voice_messages 获取)
"""
try:
import whisper # noqa: F401
except ImportError:
return "缺少依赖: pip install openai-whisper"
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
wav_path, _ = _silk_to_wav(voice_data, create_time, username, local_id)
model = _get_whisper_model()
result = model.transcribe(wav_path)
lang = result.get("language", "unknown")
text = result.get("text", "").strip()
time_label = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
return f"[{time_label}] ({lang})\n{text}"
if __name__ == "__main__": if __name__ == "__main__":
mcp.run() mcp.run()