Files
kefu/wechat_rpa/wxwork_db.py
T
2026-09-21 10:34:06 +08:00

1112 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 math
import os
import re
import sqlite3
import struct
import time
import threading
from functools import wraps
import unicodedata
from datetime import datetime
from wxwork_crypto import (
decrypt_db_to_file,
decrypt_page,
read_page_header,
SQLITE_FILE_HEADER,
verify_key,
)
from runtime_paths import application_data_dir
_SCRIPT_DIR = str(application_data_dir())
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(path=None) -> dict:
"""读取本机密钥文件,兼容 UTF-8 BOM;错误信息不包含密钥内容。"""
path = os.fspath(path) if path is not None else KEYS_FILE
if not os.path.exists(path):
return {}
try:
with open(path, encoding="utf-8-sig") as f:
data = json.load(f)
if not isinstance(data, dict) or not isinstance(data.get("keys", {}), dict):
raise ValueError()
keys = dict(data.get("keys", {}))
if data.get("global_key"):
keys.setdefault("*", data["global_key"])
result = {}
for account, value in keys.items():
text = str(value or "").strip().removeprefix("x'").removesuffix("'")
key = bytes.fromhex(text)
if len(key) not in (16, 32) or not str(account).strip():
raise ValueError()
result[str(account)] = key.hex()
return result
except (OSError, ValueError, TypeError) as exc:
raise ValueError("数据库密钥文件无效,请重新导入当前电脑对应的密钥文件") from exc
def _windows_documents_dirs():
"""读取文档重定向位置,支持注册表中的环境变量。"""
values = []
try:
import winreg
for name in ("User Shell Folders", "Shell Folders"):
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\" + name) as key:
raw, _ = winreg.QueryValueEx(key, "Personal")
if raw:
values.append(os.path.expandvars(str(raw)))
except OSError:
continue
except ImportError:
pass
return values
def detect_wxwork_dir(candidates=None) -> str | None:
"""优先保存位置,再找当前用户文档、OneDrive 和其他用户的常见位置。"""
def valid(base):
try:
return any(os.path.isfile(os.path.join(base, d, "Data", "message.db"))
for d in os.listdir(base))
except OSError:
return False
configured = list(candidates or [])
environment_path = os.environ.get("WECOM_ARCHIVE_SOURCE_DIR", "").strip()
if environment_path:
configured.append(environment_path)
try:
with open(os.path.join(_SCRIPT_DIR, "wxwork_gui_config.json"), encoding="utf-8-sig") as f:
settings = json.load(f)
if isinstance(settings, dict) and settings.get("db_dir"):
configured.append(str(settings["db_dir"]))
except (OSError, ValueError, TypeError):
pass
# 重定向后的文档先于旧的 Documents,避免误选迁移前遗留的数据。
configured.extend(os.path.join(folder, "WXWork") for folder in _windows_documents_dirs())
for variable in ("OneDrive", "OneDriveCommercial", "OneDriveConsumer"):
cloud = os.environ.get(variable, "").strip()
if cloud:
configured.extend(os.path.join(cloud, folder, "WXWork") for folder in ("Documents", "文档"))
configured.append(os.path.join(os.path.expanduser("~"), "Documents", "WXWork"))
users_root = os.path.join(os.environ.get("SystemDrive", "C:") + os.sep, "Users")
try:
configured.extend(os.path.join(users_root, user, "Documents", "WXWork")
for user in sorted(os.listdir(users_root)))
except OSError:
pass
seen = set()
for candidate in configured:
if not candidate:
continue
candidate = os.path.abspath(os.path.expanduser(os.path.expandvars(os.fspath(candidate))))
identity = os.path.normcase(candidate)
if identity not in seen and valid(candidate):
return candidate
seen.add(identity)
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, *, committed_only=False) -> 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
if committed_only and (magic not in (0x377f0682, 0x377f0683) or pgsz not in (512,1024,2048,4096,8192,16384,32768,65536)):
return {}, meta
pending = {}
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:
if committed_only: break
continue
meta["salt_ok"] += 1
if pgno == 0 or pgno > (1 << 31):
if committed_only: break
continue
dec = decrypt_page(key, pgno, data, pgsz)
if dec is None:
if committed_only: break
continue
if committed_only:
# Customer relationship recovery must not see a partially
# written transaction that the WeCom client never committed.
pending[pgno] = dec
if commit:
pages.update(pending)
pages = {number: page for number, page in pages.items() if number <= commit}
meta["db_size"] = commit
meta["applied"] += len(pending)
pending.clear()
else:
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, *, committed_only=False) -> 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, committed_only=committed_only)
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 committed_only and meta.get("db_size"):
n_pages = meta["db_size"]
f.truncate(n_pages * pgsz)
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:
source_connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
target_connection = sqlite3.connect(out_path)
try:
source_connection.backup(target_connection)
finally:
target_connection.close()
source_connection.close()
decrypted_dbs.append((out_path, db_name, user_dir))
continue
except (OSError, sqlite3.Error):
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
# 消息与客户关系必须包含 WAL 中已写入的更新。
if db_name in ("message.db", "user.db") and wal_mtime > 0:
if db_name == "user.db":
ok = merge_wal_into_db(db_path, wal_path, key, out_path, committed_only=True)
else:
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, *, cross_thread: bool = False) -> sqlite3.Connection:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=not cross_thread)
conn.text_factory = lambda b: b.decode("utf-8", errors="replace")
return conn
# ---------------------------------------------------------------------------
# WXWorkDB:多账号增量消息读取器
# ---------------------------------------------------------------------------
class DatabaseReadError(RuntimeError):
"""数据库暂不可读,调用方可明确进入备用读取路径。"""
def _db_locked(method):
@wraps(method)
def synchronized(self, *args, **kwargs):
with self._lock:
return method(self, *args, **kwargs)
return synchronized
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):
if not db_base or not os.path.isdir(db_base):
raise DatabaseReadError("未找到企业微信数据目录")
self._lock = threading.RLock()
self._message_cursors = {}
self._bootstrap_since = None
self._source_signature = None
self._closed = False
self.db_base = db_base
self._voice_reader = None
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()
# ── 生命周期 ────────────────────────────────────────────────────────────
@_db_locked
def _open(self, *, refresh=False):
before = self._source_files_state()
decrypted = decrypt_with_keys(self.db_base, self.cache_dir, self.keys_map, use_cache=not refresh)
self._source_signature = before
self.user_cache.clear()
self.conv_cache.clear()
self._conversation_index = None
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, cross_thread=True)
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 _source_files_state(self):
result = []
try:
for account in sorted(os.listdir(self.db_base)):
for name in _RELEVANT_DBS:
path = os.path.join(self.db_base, account, "Data", name)
for candidate in (path, path + "-wal"):
try:
info = os.stat(candidate)
result.append((candidate, info.st_mtime_ns, info.st_size))
except OSError:
pass
except OSError as exc:
raise DatabaseReadError("企业微信数据目录暂不可访问") from exc
return tuple(result)
@_db_locked
def _refresh_if_needed(self):
if self._closed:
raise DatabaseReadError("数据库读取已停止")
current = self._source_files_state()
if current == self._source_signature and self._conns:
return
for connection in self._conns.values():
connection.close()
self._conns.clear()
self._paths.clear()
# 重新打开前关闭旧连接,避免后台重解密与另一个线程读同一副本冲突。
self._open(refresh=True)
if not self._conns:
raise DatabaseReadError(self.last_error or "未找到可读取的消息数据库")
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
# ── 消息读取 ────────────────────────────────────────────────────────────
@_db_locked
def get_new_messages(self, since_ts: float) -> list[dict]:
"""按账号 rowid 增量读取;同秒消息、迟到写入和分批读取互不跳过。"""
self._refresh_if_needed()
if self._bootstrap_since is None:
self._bootstrap_since = max(0.0, float(since_ts or 0.0))
# Resolve names before advancing any cursor; schema errors must not consume messages.
self._matching_conversations("")
result = []
errors = []
successful = 0
for account, connection in self._conns.items():
try:
columns = [row[1] for row in connection.execute("PRAGMA table_info(message_table)")]
if not {"send_time", "conversation_id"}.issubset(columns):
raise DatabaseReadError("消息表结构不可用")
maximum = int(connection.execute("SELECT COALESCE(MAX(rowid),0) FROM message_table").fetchone()[0])
cursor = self._message_cursors.get(account)
if cursor is None or maximum < cursor["rowid"]:
cursor = {"rowid": 0, "initial_end": maximum, "initial": True}
if cursor["initial"]:
rows = connection.execute(
"SELECT rowid,* FROM message_table WHERE rowid>? AND rowid<=? "
"AND (CASE WHEN send_time>1000000000000 THEN send_time/1000.0 ELSE send_time END)>=? "
"ORDER BY rowid LIMIT 2000",
(cursor["rowid"], cursor["initial_end"], self._bootstrap_since),
).fetchall()
else:
rows = connection.execute(
"SELECT rowid,* FROM message_table WHERE rowid>? ORDER BY rowid LIMIT 2000",
(cursor["rowid"],),
).fetchall()
for row in rows:
parsed = self._parse_message(account, dict(zip(["__rowid", *columns], row)))
if parsed:
result.append(parsed)
if rows:
cursor["rowid"] = int(rows[-1][0])
if cursor["initial"] and len(rows) < 2000:
cursor["rowid"] = cursor["initial_end"]
cursor["initial"] = False
self._message_cursors[account] = cursor
successful += 1
except (sqlite3.Error, DatabaseReadError) as exc:
errors.append(f"{account}: {exc}")
self.last_error = "; ".join(errors)
if not successful:
raise DatabaseReadError(self.last_error or "没有可读取的消息数据库")
for item in result:
item["ambiguous_name"] = len(self._matching_conversations(item["display_name"])) != 1
result.sort(key=lambda item: (item["send_time"], item["account"], item["rowid"]))
return result
@staticmethod
def _title_key(value):
return re.sub(r"@(?:微信|微信联系人)$", "", normalize_name(value))
def _matching_conversations(self, name):
if self._conversation_index is None:
index = {}
for account, connection in self._conns.items():
for (conv_id,) in connection.execute("SELECT DISTINCT conversation_id FROM message_table"):
if not is_personal_chat(str(conv_id)):
continue
display = self._conv_display_name(account, conv_id)
index.setdefault(self._title_key(display), []).append((account, str(conv_id), display))
self._conversation_index = index
return self._conversation_index.get(self._title_key(name), [])
def _contact_relation(self, account, conv_id):
from pathlib import Path
from contact_relation import read_contact_relation, unknown_relation
unknown = unknown_relation(account, conv_id)
try:
# Only the current reader's refreshed snapshot may authorize a
# recheck. Never select another cache directory by newest mtime.
expected = (Path(self.cache_dir) / str(account) / "user.db").resolve()
paths = [Path(path) for path, name, owner in getattr(self, "_decrypted", [])
if name == "user.db" and str(owner) == str(account)]
if len(paths) != 1 or paths[0].resolve() != expected:
return unknown
before = self._source_files_state()
if before != self._source_signature:
return {**unknown, "reason": "source_changed"}
result = read_contact_relation(paths[0], account, conv_id)
if self._source_files_state() != before:
return {**unknown, "reason": "source_changed"}
return result
except (OSError, AttributeError, DatabaseReadError):
return unknown
@_db_locked
def get_conversation_context(self, display_name: str, *, account="", conv_id="", limit=100):
"""仅返回名称唯一匹配的当前会话;同名跨账号或跨会话时不猜测。"""
self._refresh_if_needed()
if not normalize_name(display_name):
return None
matches = self._matching_conversations(display_name)
# 屏幕标题无法区分同名的两个账号;即使任务携带账号 ID,也不能猜测。
if len(matches) != 1:
return None
found_account, found_conv, name = matches[0]
if (account and str(account) != found_account) or (conv_id and str(conv_id) != found_conv):
return None
account, conv_id = found_account, found_conv
connection = self._conns[account]
columns = [row[1] for row in connection.execute("PRAGMA table_info(message_table)")]
rows = connection.execute(
"SELECT rowid,* FROM message_table WHERE conversation_id=? ORDER BY rowid DESC LIMIT ?",
(conv_id, max(1, min(500, int(limit)))),
).fetchall()
messages = []
for row in reversed(rows):
parsed = self._parse_message(account, dict(zip(["__rowid", *columns], row)))
if parsed:
messages.append(parsed)
if not messages:
return None
# ASR runs on a separate worker. Only this unanswered customer batch is queued.
if any(int(m.get("content_type") or 0) in (4, 16) for m in messages):
if getattr(self, "_voice_reader", None) is None and getattr(self, "db_base", None):
from voice_messages import VoiceMessageReader
self._voice_reader = VoiceMessageReader(self.db_base)
if getattr(self, "_voice_reader", None) is not None:
self._voice_reader.prepare(messages)
lines = []
for message in messages:
speaker = "我" if message["is_self"] else self.user_cache.get((account, message["sender_id"]), name)
timestamp = datetime.fromtimestamp(message["send_time"]).strftime("%Y/%m/%d %H:%M:%S")
lines.extend((f"{speaker} {timestamp}", message["content"]))
return {"account": account, "conv_id": conv_id, "display_name": name,
"text": "\n".join(lines), "messages": messages, "last_message": messages[-1],
"contact_relation": self._contact_relation(account, conv_id)}
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)
if send_time > 10 ** 12:
send_time /= 1000.0
if not math.isfinite(send_time) or send_time <= 0:
return None
# Context rendering uses local datetime conversion. Reject corrupt
# rows here so neither polling order nor later formatting can crash.
datetime.fromtimestamp(send_time)
except (TypeError, ValueError, OverflowError, OSError):
return None
# 保留我方消息供上下文和最后一条方向判断;检测引擎不将它作为新客户任务。
is_self = bool(sender_id) and sender_id == str(user_dir)
from voice_messages import is_voice, cached_voice_fields
voice_fields = {}
if is_voice(msg):
voice_fields = cached_voice_fields(getattr(self, "_conns", {}).get(user_dir),
str(user_dir), conv_id, msg.get("server_id"), msg.get("content"))
from archive_content_parser import parse_message_content
decoded = parse_message_content(msg.get("content"), msg.get("content_type"),
extra_content=msg.get("extra_content"))
content = voice_fields.get("content", decoded["text"])
if not content.strip():
if int(msg.get("content_type") or 0) in {3,4,5,6,7,8,9,10,14,16,17,20,111,123}:
content = "[" + get_msg_type_name(msg.get("content_type")) + "]"
else:
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 "")
stable_id = "server:" + server_id if server_id not in {"", "0", "None"} else "row:" + str(msg.get("__rowid") or send_time)
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"),
"content_status": decoded["status"],
"content_kind": decoded["kind"],
"send_time": send_time,
"server_id": server_id,
"rowid": int(msg.get("__rowid") or 0),
"dedup_key": f"{user_dir}:{conv_id}:{stable_id}",
**voice_fields,
}
@_db_locked
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
@_db_locked
def close(self):
self._closed = True
voice_reader = getattr(self, "_voice_reader", None)
if voice_reader is not None:
voice_reader.close()
self._voice_reader = None
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()