gengx
This commit is contained in:
@@ -0,0 +1,917 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
wxwork_db.py - 企业微信本地数据库读取模块(引擎 B 升级数据源)
|
||||
=================================================================
|
||||
基于 wxwork_crypto 解密 message.db / session.db / user.db 为标准 SQLite,
|
||||
提供增量消息轮询接口,供 engine_b 使用。
|
||||
|
||||
参考实现(C:\\Users\\pc\\WorkBuddy\\2026-08-19-18-27-34\\,密钥已验证正确):
|
||||
- wxwork_keys.json 已复制的验证过密钥(本项目 wxwork_keys.json)
|
||||
- wxwork_export_final.py 移植:parse_content / extract_protobuf_texts /
|
||||
clean_text / MSG_TYPE_MAP / is_personal_chat /
|
||||
is_blocked_conv_name / format_timestamp /
|
||||
load_keys / detect_wxwork_dir / connect_sqlite /
|
||||
decrypt_with_keys(WAL 感知增量解密缓存)/
|
||||
会话名解析(user_cache / conv_cache / S: 对端推导)
|
||||
- wxwork_crypto.py 解密算法与本项目 wxwork_crypto.py 完全一致(互相印证)
|
||||
|
||||
相对参考实现的增强:
|
||||
- WAL 帧合并:企微运行时新消息在 message.db-wal(未 checkpoint),参考实现仅
|
||||
以 wal_mtime 触发重解密主库(仍缺 WAL 内最新消息)。本项目直接解密 WAL 帧
|
||||
(帧数据与主库同算法加密,帧头明文),按页号覆盖合并进主库解密副本,
|
||||
经 PRAGMA quick_check 验证(见 test_wal_merge.py)。
|
||||
- 会话指纹:复用 wechat_bot.WeChatBot._fp_from_name 的名称哈希算法,
|
||||
使 DB 直读的 fp_hex 与引擎 A(identity_by_name 模式)完全一致,
|
||||
引擎 A 可在会话列表按名称重新定位窗口。
|
||||
|
||||
典型流程:
|
||||
keys = load_keys()
|
||||
db = WXWorkDB(detect_wxwork_dir(), keys)
|
||||
msgs = db.get_new_messages(since_ts=...)
|
||||
db.close()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import struct
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
||||
from wxwork_crypto import (
|
||||
decrypt_db_to_file,
|
||||
decrypt_page,
|
||||
read_page_header,
|
||||
SQLITE_FILE_HEADER,
|
||||
verify_key,
|
||||
)
|
||||
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
KEYS_FILE = os.path.join(_SCRIPT_DIR, "wxwork_keys.json")
|
||||
DEFAULT_CACHE_DIR = os.path.join(_SCRIPT_DIR, "wxwork_decrypted")
|
||||
|
||||
# 企微数据库文件名(与聊天相关的库)
|
||||
_RELEVANT_DBS = (
|
||||
"message.db", "session.db", "user.db", "company.db",
|
||||
"message_lookup.db", "user_extend.db",
|
||||
)
|
||||
|
||||
# 引擎 A 会话指纹字节数(wechat_bot.py: _AVATAR_FP_BYTES=8 + _NAME_FP_BYTES=32)
|
||||
SESSION_FP_BYTES = 40
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 消息类型映射 / 会话过滤(移植自 wxwork_export_final.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
MSG_TYPE_MAP = {
|
||||
0: "文本", 1: "文本", 2: "文本",
|
||||
3: "图片", 4: "语音", 5: "表情",
|
||||
6: "链接", 7: "文件", 8: "视频",
|
||||
9: "位置", 10: "名片", 11: "系统",
|
||||
12: "引用", 13: "红包", 14: "图片",
|
||||
15: "转账", 16: "语音", 17: "视频号",
|
||||
20: "合并转发", 21: "日程", 22: "接龙",
|
||||
23: "文件回复", 26: "位置共享", 29: "视频通话",
|
||||
31: "图文链接", 38: "系统通知", 40: "待办",
|
||||
42: "文件预览", 46: "话题", 47: "图文",
|
||||
49: "待办", 51: "收藏", 53: "订阅通知",
|
||||
55: "文件分享", 56: "群公告", 57: "投票",
|
||||
59: "回执", 61: "文件编辑", 62: "文件评论",
|
||||
63: "收藏合并", 64: "卡片", 65: "群文件",
|
||||
66: "工作台", 67: "位置共享", 68: "视频通话",
|
||||
69: "音频通话", 70: "企业微信应用", 73: "微信好友",
|
||||
74: "回复", 76: "邀请", 77: "移除",
|
||||
78: "修改群名", 79: "修改群公告", 80: "加入群聊",
|
||||
81: "退出群聊", 82: "解散群聊", 83: "群主转让",
|
||||
101: "系统", 111: "文件", 123: "截图",
|
||||
132: "系统",
|
||||
503: "应用消息", 529: "朋友圈", 561: "应用消息",
|
||||
565: "应用消息", 573: "应用消息",
|
||||
1002: "安全通知", 1011: "系统消息", 1012: "系统消息",
|
||||
1017: "系统消息", 1022: "系统消息", 1025: "系统消息",
|
||||
1043: "系统消息",
|
||||
1988: "系统消息",
|
||||
}
|
||||
|
||||
|
||||
def get_msg_type_name(ct):
|
||||
"""消息类型名"""
|
||||
try:
|
||||
ct = int(ct)
|
||||
except (ValueError, TypeError):
|
||||
return f"类型{ct}"
|
||||
return MSG_TYPE_MAP.get(ct, f"类型{ct}")
|
||||
|
||||
|
||||
def is_personal_chat(conv_id):
|
||||
"""判断会话是否为人与人之间的单聊(移植自 wxwork_export_final.py)。
|
||||
|
||||
保留: M:xxx(微信单聊)、S:UID_UID(企微单聊)
|
||||
过滤: R:xxx 群聊 / Y:xxx 应用 / O:xxx 第三方 / MAIL/APPROVAL 系统虚拟会话
|
||||
"""
|
||||
c = str(conv_id or "")
|
||||
if c.startswith("M:"):
|
||||
return True
|
||||
if c.startswith("S:"):
|
||||
parts = c[2:].split("_")
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# 按会话名称过滤的官方/系统账号
|
||||
DEFAULT_BLOCKED_CONV_NAMES = ("企业微信团队", "微信团队", "微信支付", "腾讯客服", "腾讯新闻")
|
||||
|
||||
|
||||
def is_blocked_conv_name(conv_name, blocked=None):
|
||||
"""按会话名称判断是否命中过滤名单(官方/系统账号)。"""
|
||||
if not conv_name:
|
||||
return False
|
||||
blocked = DEFAULT_BLOCKED_CONV_NAMES if blocked is None else tuple(blocked)
|
||||
return any(kw and kw in str(conv_name) for kw in blocked)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 消息内容解析(移植自 wxwork_export_final.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
def clean_text(text):
|
||||
"""清洗提取的文本: 去除控制字符和 protobuf 残留。"""
|
||||
if not text:
|
||||
return ""
|
||||
cleaned = "".join(c for c in text if c.isprintable() or c in "\n\t")
|
||||
cleaned = cleaned.strip()
|
||||
cleaned = re.sub(r"[ \t]+", " ", cleaned)
|
||||
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def extract_protobuf_texts(data):
|
||||
"""从 protobuf 编码的 content 中递归提取可读文本,返回去重后的文本列表。"""
|
||||
if not data or not isinstance(data, bytes):
|
||||
return []
|
||||
|
||||
texts = []
|
||||
seen = set()
|
||||
|
||||
def add_text(t):
|
||||
t = clean_text(t)
|
||||
if len(t) >= 2 and t not in seen:
|
||||
has_chinese = any("\u4e00" <= c <= "\u9fff" for c in t)
|
||||
alpha_count = sum(1 for c in t if c.isalpha())
|
||||
if has_chinese or alpha_count >= 6:
|
||||
seen.add(t)
|
||||
texts.append(t)
|
||||
|
||||
def parse(buf, depth):
|
||||
if depth > 6 or not buf:
|
||||
return
|
||||
i = 0
|
||||
while i < len(buf):
|
||||
try:
|
||||
tag = 0
|
||||
shift = 0
|
||||
while i < len(buf) and shift < 70:
|
||||
b = buf[i]
|
||||
tag |= (b & 0x7f) << shift
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
if shift >= 70:
|
||||
break
|
||||
field_num = tag >> 3
|
||||
wire_type = tag & 7
|
||||
if field_num == 0:
|
||||
break
|
||||
|
||||
if wire_type == 0: # varint
|
||||
while i < len(buf):
|
||||
b = buf[i]
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
elif wire_type == 1: # 64-bit
|
||||
i += 8
|
||||
elif wire_type == 2: # length-delimited
|
||||
length = 0
|
||||
shift = 0
|
||||
while i < len(buf) and shift < 70:
|
||||
b = buf[i]
|
||||
length |= (b & 0x7f) << shift
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
if shift >= 70:
|
||||
break
|
||||
chunk = buf[i:i + length]
|
||||
i += length
|
||||
try:
|
||||
text = chunk.decode("utf-8")
|
||||
if all(ord(c) >= 32 or c in "\n\t\r" for c in text):
|
||||
add_text(text)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
if length < 8192:
|
||||
parse(chunk, depth + 1)
|
||||
elif wire_type == 5: # 32-bit
|
||||
i += 4
|
||||
else:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
|
||||
parse(data, 0)
|
||||
|
||||
if not any("\u4e00" <= c <= "\u9fff" for t in texts for c in t[:50]):
|
||||
try:
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
readable = re.findall(
|
||||
r"[\u4e00-\u9fff\w\s,。!?、;:“”‘’()《》【】\-—.,;:!?()\[\]/\\+*=<>@#$%^&~`]{2,}",
|
||||
text)
|
||||
for r in readable:
|
||||
add_text(r.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return texts
|
||||
|
||||
|
||||
def parse_content(content):
|
||||
"""解析消息内容, 返回可读文本(移植自 wxwork_export_final.py)。"""
|
||||
if content is None:
|
||||
return ""
|
||||
if not isinstance(content, bytes):
|
||||
content = str(content).encode("utf-8", errors="replace")
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
# 情况 1: 直接是 UTF-8 文本
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
if text and all(ord(c) >= 32 or c in "\n\r\t" for c in text[:500]):
|
||||
return clean_text(text)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 情况 2: protobuf 编码
|
||||
texts = extract_protobuf_texts(content)
|
||||
if texts:
|
||||
texts.sort(key=len, reverse=True)
|
||||
meaningful = [t for t in texts
|
||||
if not (t.startswith("{") and "}" in t)
|
||||
and not t.startswith("http")
|
||||
and len(t) > 1]
|
||||
if meaningful:
|
||||
cleaned_msgs = []
|
||||
for t in meaningful:
|
||||
t = t.strip()
|
||||
m = re.match(r"^(.{6,}?)[?zxa-zA-Z]{1,4}\1$", t)
|
||||
if m:
|
||||
t = m.group(1)
|
||||
t = re.sub(
|
||||
r"^[?zxa-zA-Z]{1,4}(?=[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef])", "", t)
|
||||
t = re.sub(
|
||||
r"^[0-9](?=[\u4e00-\u9fff])(?![\u4e00-\u9fff]{0,2}"
|
||||
r"(?:月|日|年|个|天|点|号|楼|人|次|分|秒|周|元|块|台|家|辆|条|件|张|本|层|岁|万|亿|美元|室|房|折))",
|
||||
"", t)
|
||||
t = t.strip()
|
||||
if len(t) >= 3:
|
||||
dup = False
|
||||
for c in cleaned_msgs:
|
||||
if len(c) >= 4 and (t in c or c in t):
|
||||
dup = True
|
||||
break
|
||||
if dup:
|
||||
continue
|
||||
if t and t not in cleaned_msgs:
|
||||
cleaned_msgs.append(t)
|
||||
if cleaned_msgs:
|
||||
return " | ".join(cleaned_msgs[:2])
|
||||
return texts[0]
|
||||
|
||||
# 情况 3: JSON
|
||||
try:
|
||||
obj = json.loads(content.decode("utf-8", errors="replace"))
|
||||
return json.dumps(obj, ensure_ascii=False)[:2000]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 情况 4: 二进制数据
|
||||
if len(content) <= 64:
|
||||
return content.hex()
|
||||
return f"[二进制数据 {len(content)} 字节]"
|
||||
|
||||
|
||||
def format_timestamp(ts):
|
||||
"""Unix 时间戳转可读格式(>10^12 视为毫秒)。"""
|
||||
if ts is None or ts == "":
|
||||
return ""
|
||||
try:
|
||||
ts = int(ts)
|
||||
if ts > 10 ** 12:
|
||||
ts = ts // 1000
|
||||
if ts < 0:
|
||||
return str(ts)
|
||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, OSError, OverflowError):
|
||||
return str(ts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话指纹(与 wechat_bot.WeChatBot._fp_from_name 算法一致)
|
||||
# ---------------------------------------------------------------------------
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalize_name(value: str) -> str:
|
||||
"""昵称归一化(与 session_name.normalize_name 完全一致)。
|
||||
|
||||
实测企微标题读出来是「高瑞 @微信」、列表行是「高瑞@微信」,中间多个空格;
|
||||
不归一的话同一个人会算出两个 md5。仅用标准库实现(session_name 顶层
|
||||
依赖 numpy,DB 模块不引入视觉依赖)。
|
||||
"""
|
||||
text = unicodedata.normalize("NFKC", str(value or ""))
|
||||
text = _WHITESPACE_RE.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def session_fp_from_name(name: str) -> str:
|
||||
"""会话名 → 40 字节会话指纹 hex。
|
||||
|
||||
与 wechat_bot.py::_fp_from_name 完全一致:前 16 字节 = md5(归一化昵称),
|
||||
后 24 字节 = blake2b(md5)。引擎 A 在 identity_by_name 模式下用同一算法
|
||||
从屏幕 OCR 的昵称生成指纹,因此 DB 直读投递的 fp_hex 可被引擎 A
|
||||
在会话列表中按名称重新定位窗口。
|
||||
"""
|
||||
normalized = normalize_name(name)
|
||||
if not normalized:
|
||||
return ""
|
||||
digest = hashlib.md5(normalized.encode("utf-8")).digest()
|
||||
tail = hashlib.blake2b(digest, digest_size=SESSION_FP_BYTES - len(digest)).digest()
|
||||
return (digest + tail).hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 密钥与解密(移植自 wxwork_export_final.py + WAL 帧合并增强)
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_keys() -> dict:
|
||||
"""加载密钥映射 {user_dir: key_hex},支持 global_key/* 通配。"""
|
||||
if not os.path.exists(KEYS_FILE):
|
||||
return {}
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
keys = data.get("keys", {})
|
||||
if "global_key" in data:
|
||||
keys.setdefault("*", data["global_key"])
|
||||
return keys
|
||||
|
||||
|
||||
def detect_wxwork_dir(candidates=None) -> str | None:
|
||||
"""自动检测企业微信数据根目录(移植自 wxwork_export_final.py)。
|
||||
|
||||
依次尝试: 配置候选 → 当前用户 Documents\\WXWork →
|
||||
注册表 Personal 下的 WXWork → 扫描所有用户。
|
||||
返回第一个含账号 Data/message.db 的有效目录。
|
||||
"""
|
||||
def _valid(base):
|
||||
if not base or not os.path.isdir(base):
|
||||
return False
|
||||
try:
|
||||
for d in os.listdir(base):
|
||||
if os.path.exists(os.path.join(base, d, "Data", "message.db")):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
for c in (candidates or []):
|
||||
if _valid(c):
|
||||
return c
|
||||
default = os.path.join(os.path.expanduser("~"), "Documents", "WXWork")
|
||||
if _valid(default):
|
||||
return default
|
||||
try:
|
||||
import winreg
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
|
||||
) as k:
|
||||
personal, _ = winreg.QueryValueEx(k, "Personal")
|
||||
if personal:
|
||||
reg_path = os.path.join(personal, "WXWork")
|
||||
if _valid(reg_path):
|
||||
return reg_path
|
||||
except Exception:
|
||||
pass
|
||||
users_root = os.path.join(os.environ.get("SystemDrive", "C:"), os.sep, "Users")
|
||||
if os.path.isdir(users_root):
|
||||
try:
|
||||
for u in sorted(os.listdir(users_root)):
|
||||
p = os.path.join(users_root, u, "Documents", "WXWork")
|
||||
if _valid(p):
|
||||
return p
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _is_plain_sqlite(path: str) -> bool:
|
||||
"""文件头是否为明文 SQLite。"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(16) == SQLITE_FILE_HEADER
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def wal_read_frames(wal_path: str, key: bytes) -> tuple[dict, dict]:
|
||||
"""读取 WAL 帧并解密,返回 ({pgno: bytes}, meta)。
|
||||
|
||||
帧数据与主库同算法加密(wxSQLite3 aes128cbc,帧头 24 字节明文)。
|
||||
仅应用 salt 与 WAL 头一致的帧(跳过已回滚/旧事务帧),后帧覆盖前帧。
|
||||
"""
|
||||
pages: dict[int, bytes] = {}
|
||||
meta = {"pgsz": 0, "frames": 0, "applied": 0, "salt_ok": 0}
|
||||
try:
|
||||
with open(wal_path, "rb") as f:
|
||||
hdr = f.read(32)
|
||||
if len(hdr) < 32:
|
||||
return pages, meta
|
||||
magic, ver, pgsz, ckpt, salt1, salt2, _, _ = struct.unpack(">IIIIIIII", hdr)
|
||||
meta["pgsz"] = pgsz
|
||||
while True:
|
||||
fh = f.read(24)
|
||||
if len(fh) < 24:
|
||||
break
|
||||
pgno, commit, fs1, fs2, _, _ = struct.unpack(">IIIIII", fh)
|
||||
data = f.read(pgsz)
|
||||
if len(data) < pgsz:
|
||||
break
|
||||
meta["frames"] += 1
|
||||
if fs1 != salt1 or fs2 != salt2:
|
||||
continue
|
||||
meta["salt_ok"] += 1
|
||||
if pgno == 0 or pgno > (1 << 31):
|
||||
continue
|
||||
dec = decrypt_page(key, pgno, data, pgsz)
|
||||
if dec is None:
|
||||
continue
|
||||
pages[pgno] = dec
|
||||
meta["applied"] += 1
|
||||
except OSError:
|
||||
pass
|
||||
return pages, meta
|
||||
|
||||
|
||||
def merge_wal_into_db(db_path: str, wal_path: str, key: bytes, out_path: str) -> bool:
|
||||
"""解密主库 + 合并 WAL 帧 → out_path。返回是否成功。
|
||||
|
||||
主库与 WAL 帧都解密后,按页号覆盖(等价于 checkpoint 后的状态),
|
||||
并更新 SQLite 头的页数字段。WAL 无有效帧时退化为仅主库。
|
||||
"""
|
||||
if not decrypt_db_to_file(db_path, key, out_path):
|
||||
return False
|
||||
pages, meta = wal_read_frames(wal_path, key)
|
||||
if not pages:
|
||||
return True
|
||||
pgsz = meta["pgsz"] or 4096
|
||||
try:
|
||||
with open(out_path, "r+b") as f:
|
||||
size = os.path.getsize(out_path)
|
||||
n_pages = (size + pgsz - 1) // pgsz
|
||||
for pgno, data in pages.items():
|
||||
if pgno > n_pages:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.write(b"\x00" * ((pgno - n_pages) * pgsz))
|
||||
n_pages = pgno
|
||||
f.seek((pgno - 1) * pgsz)
|
||||
f.write(data)
|
||||
if n_pages > 0:
|
||||
f.seek(28)
|
||||
f.write(struct.pack(">I", n_pages))
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def decrypt_with_keys(db_base: str, out_dir: str, keys_map: dict, use_cache: bool = True) -> list:
|
||||
"""解密所有可解密的账号数据库(移植自 wxwork_export_final.py)。
|
||||
|
||||
返回 [(out_path, db_name, user_dir), ...]。
|
||||
- 增量缓存: 解密副本 mtime >= max(db, wal) 且大小正常则复用
|
||||
- WAL 增强: message.db 的 WAL 非空时合并帧(拿 checkpoint 前的最新消息)
|
||||
- 密钥: 优先目录密钥,失败尝试全部密钥
|
||||
"""
|
||||
decrypted_dbs = []
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
all_keys = set()
|
||||
for k in keys_map.values():
|
||||
try:
|
||||
all_keys.add(bytes.fromhex(k.replace("x'", "").replace("'", "")))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not os.path.isdir(db_base):
|
||||
return decrypted_dbs
|
||||
|
||||
for user_dir in sorted(os.listdir(db_base)):
|
||||
data_dir = os.path.join(db_base, user_dir, "Data")
|
||||
if not os.path.isdir(data_dir):
|
||||
continue
|
||||
db_files = [f for f in os.listdir(data_dir)
|
||||
if f.endswith(".db") and not f.endswith("-wal") and not f.endswith("-shm")]
|
||||
if not db_files:
|
||||
continue
|
||||
|
||||
user_out = os.path.join(out_dir, user_dir)
|
||||
os.makedirs(user_out, exist_ok=True)
|
||||
|
||||
dir_key = None
|
||||
if user_dir in keys_map:
|
||||
try:
|
||||
dir_key = bytes.fromhex(keys_map[user_dir])
|
||||
except ValueError:
|
||||
dir_key = None
|
||||
|
||||
for db_name in _RELEVANT_DBS:
|
||||
db_path = os.path.join(data_dir, db_name)
|
||||
if not os.path.isfile(db_path):
|
||||
continue
|
||||
out_path = os.path.join(user_out, db_name)
|
||||
|
||||
# 增量缓存判断(WAL 感知)
|
||||
wal_path = db_path + "-wal"
|
||||
wal_mtime = 0
|
||||
try:
|
||||
if os.path.exists(wal_path) and os.path.getsize(wal_path) > 0:
|
||||
wal_mtime = os.path.getmtime(wal_path)
|
||||
except OSError:
|
||||
wal_mtime = 0
|
||||
src_mtime = max(os.path.getmtime(db_path), wal_mtime)
|
||||
|
||||
if use_cache and os.path.exists(out_path):
|
||||
try:
|
||||
if (os.path.getmtime(out_path) >= src_mtime
|
||||
and os.path.getsize(out_path) > 4096):
|
||||
decrypted_dbs.append((out_path, db_name, user_dir))
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 明文库直接复制
|
||||
if _is_plain_sqlite(db_path):
|
||||
try:
|
||||
with open(db_path, "rb") as fin, open(out_path, "wb") as fout:
|
||||
fout.write(fin.read())
|
||||
decrypted_dbs.append((out_path, db_name, user_dir))
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 加密库:目录密钥 → 全部密钥
|
||||
if read_page_header(db_path) is None:
|
||||
continue # 不是企微加密格式
|
||||
candidate_keys = [dir_key] if dir_key else []
|
||||
candidate_keys += [k for k in all_keys if k != dir_key]
|
||||
decrypted_ok = False
|
||||
for key in candidate_keys:
|
||||
if key is None:
|
||||
continue
|
||||
try:
|
||||
if not verify_key(key, db_path):
|
||||
continue
|
||||
# message.db 且 WAL 非空 → 合并;否则整库解密
|
||||
if db_name == "message.db" and wal_mtime > 0:
|
||||
ok = merge_wal_into_db(db_path, wal_path, key, out_path)
|
||||
else:
|
||||
ok = decrypt_db_to_file(db_path, key, out_path)
|
||||
if ok:
|
||||
decrypted_dbs.append((out_path, db_name, user_dir))
|
||||
decrypted_ok = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not decrypted_ok:
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return decrypted_dbs
|
||||
|
||||
|
||||
def connect_sqlite(path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
conn.text_factory = lambda b: b.decode("utf-8", errors="replace")
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WXWorkDB:多账号增量消息读取器
|
||||
# ---------------------------------------------------------------------------
|
||||
class WXWorkDB:
|
||||
"""企微数据根目录下的加密数据库读取器(只读,多账号)。
|
||||
|
||||
- 构造时: 验证密钥 → 确保解密缓存(WAL 感知)→ 加载元数据 → 打开各账号
|
||||
message.db 解密副本连接
|
||||
- 轮询: get_new_messages(since_ts) 返回 since_ts 之后的新客户消息;
|
||||
内部按 mtime 检查 WAL/主库变化,必要时重解密并重开连接
|
||||
"""
|
||||
|
||||
def __init__(self, db_base: str, keys_map: dict | None = None,
|
||||
cache_dir: str | None = None, poll_interval: float = 2.0):
|
||||
self.db_base = db_base
|
||||
self.keys_map = keys_map if keys_map is not None else load_keys()
|
||||
self.cache_dir = cache_dir or DEFAULT_CACHE_DIR
|
||||
self.poll_interval = max(0.5, float(poll_interval))
|
||||
self.last_error = ""
|
||||
# (user_dir) -> sqlite3.Connection(message.db 解密副本)
|
||||
self._conns: dict[str, sqlite3.Connection] = {}
|
||||
# (user_dir) -> 解密副本路径
|
||||
self._paths: dict[str, str] = {}
|
||||
# 元数据缓存
|
||||
self.user_cache: dict = {}
|
||||
self.conv_cache: dict = {}
|
||||
self._open()
|
||||
|
||||
# ── 生命周期 ────────────────────────────────────────────────────────────
|
||||
def _open(self):
|
||||
decrypted = decrypt_with_keys(self.db_base, self.cache_dir, self.keys_map)
|
||||
self._decrypted = decrypted
|
||||
if not decrypted:
|
||||
self.last_error = "没有可用密钥解密的账号数据库"
|
||||
return
|
||||
self._load_metadata()
|
||||
for out_path, db_name, user_dir in decrypted:
|
||||
if db_name != "message.db":
|
||||
continue
|
||||
try:
|
||||
conn = connect_sqlite(out_path)
|
||||
self._conns[user_dir] = conn
|
||||
self._paths[user_dir] = out_path
|
||||
except sqlite3.Error as exc:
|
||||
self.last_error = f"打开 {user_dir}/message.db 失败: {exc}"
|
||||
|
||||
def _load_metadata(self):
|
||||
"""加载 user_cache / conv_cache(移植自 wxwork_export_final.py 阶段 1)。"""
|
||||
for out_path, db_name, user_dir in self._decrypted:
|
||||
try:
|
||||
conn = connect_sqlite(out_path)
|
||||
cur = conn.cursor()
|
||||
if db_name == "user.db":
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(user_table)")
|
||||
cols = [r[1] for r in cur.fetchall()]
|
||||
if cols:
|
||||
cur.execute(
|
||||
"SELECT id, name, real_name, account FROM user_table")
|
||||
for uid, name, real_name, account in cur.fetchall():
|
||||
if uid is not None:
|
||||
nm = name or real_name or account or str(uid)
|
||||
self.user_cache[(user_dir, str(uid))] = str(nm)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
elif db_name == "session.db":
|
||||
try:
|
||||
cur.execute("PRAGMA table_info(conversation_table)")
|
||||
cols = [r[1] for r in cur.fetchall()]
|
||||
if cols:
|
||||
cur.execute(
|
||||
"SELECT id, name, roomname_remark, session_id "
|
||||
"FROM conversation_table")
|
||||
for cid, name, remark, sid in cur.fetchall():
|
||||
if cid:
|
||||
nm = remark or name or sid
|
||||
self.conv_cache[(user_dir, str(cid))] = (
|
||||
str(nm) if nm else "")
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
continue
|
||||
|
||||
def _conv_display_name(self, user_dir: str, conv_id) -> str:
|
||||
"""会话显示名(移植自 wxwork_export_final.py 的 conv_name 解析)。"""
|
||||
conv_id = str(conv_id or "")
|
||||
name = self.conv_cache.get((user_dir, conv_id), "")
|
||||
if name:
|
||||
return name
|
||||
if conv_id.startswith("M:"):
|
||||
uid = conv_id[2:]
|
||||
return self.user_cache.get((user_dir, uid), uid)
|
||||
if conv_id.startswith("S:"):
|
||||
parts = conv_id[2:].split("_")
|
||||
peer_uid = ""
|
||||
for p in parts:
|
||||
if p != user_dir:
|
||||
peer_uid = p
|
||||
break
|
||||
if not peer_uid:
|
||||
peer_uid = parts[0] if parts else ""
|
||||
peer_name = self.user_cache.get((user_dir, peer_uid), "")
|
||||
return peer_name if peer_name else ("单聊 " + conv_id)
|
||||
if conv_id.startswith("O:"):
|
||||
return conv_id[2:]
|
||||
if conv_id.startswith("Y:"):
|
||||
return "应用 " + conv_id[2:]
|
||||
return conv_id
|
||||
|
||||
def _refresh_if_needed(self):
|
||||
"""WAL/主库比解密副本新 → 重解密并重开连接。"""
|
||||
for out_path, db_name, user_dir in self._decrypted:
|
||||
if db_name != "message.db":
|
||||
continue
|
||||
src = os.path.join(self.db_base, user_dir, "Data", db_name)
|
||||
wal = src + "-wal"
|
||||
try:
|
||||
src_mtime = os.path.getmtime(src)
|
||||
wal_mtime = 0
|
||||
if os.path.exists(wal) and os.path.getsize(wal) > 0:
|
||||
wal_mtime = os.path.getmtime(wal)
|
||||
src_mtime = max(src_mtime, wal_mtime)
|
||||
if os.path.getmtime(out_path) >= src_mtime:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# 重解密
|
||||
key = None
|
||||
for candidate in self._keys_for(user_dir):
|
||||
try:
|
||||
if verify_key(candidate, src):
|
||||
key = candidate
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if key is None:
|
||||
continue
|
||||
try:
|
||||
if wal_mtime > 0:
|
||||
ok = merge_wal_into_db(src, wal, key, out_path)
|
||||
else:
|
||||
ok = decrypt_db_to_file(src, key, out_path)
|
||||
if ok:
|
||||
old = self._conns.pop(user_dir, None)
|
||||
if old is not None:
|
||||
old.close()
|
||||
self._conns[user_dir] = connect_sqlite(out_path)
|
||||
self._paths[user_dir] = out_path
|
||||
except Exception as exc:
|
||||
self.last_error = f"重解密 {user_dir} 失败: {exc}"
|
||||
|
||||
def _keys_for(self, user_dir: str) -> list:
|
||||
keys = []
|
||||
if user_dir in self.keys_map:
|
||||
try:
|
||||
keys.append(bytes.fromhex(self.keys_map[user_dir]))
|
||||
except ValueError:
|
||||
pass
|
||||
if "*" in self.keys_map:
|
||||
try:
|
||||
keys.append(bytes.fromhex(self.keys_map["*"]))
|
||||
except ValueError:
|
||||
pass
|
||||
return keys
|
||||
|
||||
# ── 消息读取 ────────────────────────────────────────────────────────────
|
||||
def get_new_messages(self, since_ts: float) -> list[dict]:
|
||||
"""返回 since_ts(秒)之后的新客户消息列表。
|
||||
|
||||
返回字段:
|
||||
account 账号目录名
|
||||
conv_id 企微会话 ID(M:/S:/R:...)
|
||||
fp_hex 会话名派生的 40 字节指纹 hex(引擎 A identity_by_name 兼容)
|
||||
display_name 会话显示名
|
||||
sender_id 发送者 UID
|
||||
is_self 是否本账号自己发送
|
||||
content 解析后的可读文本(空文本不返回)
|
||||
content_type 原始消息类型
|
||||
send_time 秒级时间戳
|
||||
server_id 服务器消息 ID(去重键)
|
||||
dedup_key f"{fp_hex}:{send_time}"
|
||||
"""
|
||||
self._refresh_if_needed()
|
||||
result: list[dict] = []
|
||||
try:
|
||||
since = float(since_ts or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
since = 0.0
|
||||
for user_dir, conn in self._conns.items():
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"PRAGMA table_info(message_table)")
|
||||
columns = [row[1] for row in cur.fetchall()]
|
||||
if "send_time" not in columns or "conversation_id" not in columns:
|
||||
continue
|
||||
# 增量查询(send_time 为秒级 Unix 时间戳)
|
||||
cur.execute(
|
||||
"SELECT * FROM message_table WHERE send_time > ? "
|
||||
"ORDER BY send_time ASC LIMIT 2000",
|
||||
(since,))
|
||||
for row in cur.fetchall():
|
||||
msg = dict(zip(columns, row))
|
||||
m = self._parse_message(user_dir, msg)
|
||||
if m:
|
||||
result.append(m)
|
||||
except sqlite3.Error as exc:
|
||||
self.last_error = f"查询 {user_dir} 失败: {exc}"
|
||||
result.sort(key=lambda m: m["send_time"])
|
||||
return result
|
||||
|
||||
def _parse_message(self, user_dir: str, msg: dict) -> dict | None:
|
||||
conv_id = str(msg.get("conversation_id") or "")
|
||||
# 仅人与人单聊(过滤群聊/应用/第三方/系统虚拟会话)
|
||||
if not is_personal_chat(conv_id):
|
||||
return None
|
||||
sender_id = str(msg.get("sender_id") or "")
|
||||
send_time = msg.get("send_time") or 0
|
||||
try:
|
||||
send_time = float(send_time)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if send_time <= 0:
|
||||
return None
|
||||
# 本账号自己发送的消息不投递(引擎 B 只管客户消息)
|
||||
is_self = bool(sender_id) and sender_id == str(user_dir)
|
||||
content = parse_content(msg.get("content"))
|
||||
if not content.strip():
|
||||
return None
|
||||
display_name = self._conv_display_name(user_dir, conv_id)
|
||||
if is_blocked_conv_name(display_name) or is_blocked_conv_name(conv_id):
|
||||
return None
|
||||
fp_hex = session_fp_from_name(display_name)
|
||||
if not fp_hex:
|
||||
return None
|
||||
server_id = str(msg.get("server_id") or "")
|
||||
return {
|
||||
"account": user_dir,
|
||||
"conv_id": conv_id,
|
||||
"fp_hex": fp_hex,
|
||||
"display_name": display_name,
|
||||
"sender_id": sender_id,
|
||||
"is_self": is_self,
|
||||
"content": content,
|
||||
"content_type": msg.get("content_type"),
|
||||
"send_time": send_time,
|
||||
"server_id": server_id,
|
||||
"dedup_key": f"{fp_hex}:{send_time}",
|
||||
}
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""连接与密钥有效性自检。"""
|
||||
if not self._conns:
|
||||
return False
|
||||
try:
|
||||
for conn in self._conns.values():
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM sqlite_master")
|
||||
cur.fetchone()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
for conn in self._conns.values():
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._conns.clear()
|
||||
|
||||
|
||||
def open_account_db(account_dir: str, key: bytes) -> WXWorkDB | None:
|
||||
"""便捷工厂(兼容旧接口):单账号目录 + 单密钥。"""
|
||||
try:
|
||||
user_dir = os.path.basename(os.path.normpath(account_dir))
|
||||
return WXWorkDB(
|
||||
os.path.dirname(os.path.normpath(account_dir)),
|
||||
{user_dir: key.hex()},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 冒烟测试:真实数据目录 + 密钥,打印可解密账号与最近消息
|
||||
keys = load_keys()
|
||||
print(f"[*] 密钥文件: {KEYS_FILE},账号密钥 {len(keys)} 个")
|
||||
base = detect_wxwork_dir()
|
||||
print(f"[*] 企微数据目录: {base}")
|
||||
if not keys or not base:
|
||||
print("[-] 缺少密钥或数据目录,无法冒烟")
|
||||
raise SystemExit(1)
|
||||
db = WXWorkDB(base, keys)
|
||||
print(f"[*] 可读账号: {sorted(db._conns.keys())}")
|
||||
print(f"[*] health_check: {db.health_check()}")
|
||||
msgs = db.get_new_messages(0)
|
||||
print(f"[*] 全量消息 {len(msgs)} 条,最近 5 条:")
|
||||
for m in msgs[-5:]:
|
||||
print(f" {m['send_time']} [{m['account']}] {m['display_name']}: "
|
||||
f"{m['content'][:40]}")
|
||||
db.close()
|
||||
Reference in New Issue
Block a user