1112 lines
46 KiB
Python
1112 lines
46 KiB
Python
# -*- 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()
|