# -*- 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()