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/
This commit is contained in:
zlab
2026-04-22 15:10:18 -04:00
parent 69a2f44240
commit ea1d1157f8
4 changed files with 223 additions and 7 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/

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
.PHONY: keys decrypt web build
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:
.venv/bin/python3 main.py decrypt
web:
.venv/bin/python3 main.py

12
main.py
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()
@@ -44,6 +48,14 @@ def ensure_keys(keys_file, db_dir):
print(f"[+] 已有 {len(keys)} 个数据库密钥") print(f"[+] 已有 {len(keys)} 个数据库密钥")
return return
if platform.system().lower() == "darwin":
print("[!] macOS 请先运行 C 版扫描器提取密钥:")
print()
print(" sudo ./find_all_keys_macos")
print()
print(" 完成后再运行 python main.py decrypt")
sys.exit(1)
print("[*] 密钥文件不存在,正在从微信进程提取...") print("[*] 密钥文件不存在,正在从微信进程提取...")
print() print()
from find_all_keys import main as extract_keys from find_all_keys import main as extract_keys

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,185 @@ 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")
def _get_media_db_path():
return _cache.get("message/media_0.db")
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=None):
"""Query VoiceInfo from media_0.db. Returns (voice_data, create_time) or None."""
media_db = _get_media_db_path()
if not media_db:
return None
with closing(sqlite3.connect(media_db)) as conn:
chat_name_id = _get_chat_name_id(conn, username)
if chat_name_id is None:
return None
if local_id is not None:
return conn.execute(
"SELECT voice_data, create_time FROM VoiceInfo "
"WHERE chat_name_id = ? AND local_id = ?",
(chat_name_id, local_id),
).fetchone()
return None
def _silk_to_wav(voice_data, create_time, username):
"""Decode SILK voice blob to WAV file, return output path."""
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}.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)
media_db = _get_media_db_path()
if not media_db:
return "找不到 media_0.db"
with closing(sqlite3.connect(media_db)) as conn:
chat_name_id = _get_chat_name_id(conn, username)
if chat_name_id is None:
return f"{display_name} 无语音消息"
rows = 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} 无语音消息"
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/ 目录。
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)
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
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)
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()