# -*- coding: utf-8 -*- """现有企业微信解密库 -> 新聊天归档的独立增量适配器。 不改 ``wxwork_db.py`` 的客服监听行为;这里直接读取它生成的明文 SQLite 副本, 保留群聊、自己发送、非文本类型等归档数据。每个账号按 ``send_time + rowid`` 保存水位,中断后可继续。 """ from __future__ import annotations import argparse import base64 import hashlib import json import sqlite3 from pathlib import Path from typing import Any import admin_backend from archive_store import ArchiveStore from wxwork_db import ( connect_sqlite, decrypt_with_keys, get_msg_type_name, load_keys, parse_content, ) def _safe_raw(value: Any) -> Any: """保留原表字段,但不把大二进制素材塞进数据库。""" if isinstance(value, bytes): if len(value) <= 64 * 1024: return { "encoding": "base64", "data": base64.b64encode(value).decode("ascii"), } return { "encoding": "external-binary", "size": len(value), "sha256": hashlib.sha256(value).hexdigest(), } if value is None or isinstance(value, (bool, float, int, str)): return value return str(value) class WxworkArchiveAdapter: def __init__( self, store: ArchiveStore, source_root: Path | str, *, keys_map: dict[str, str] | None = None, cache_dir: Path | str | None = None, corp_scopes: dict[str, str] | None = None, ): self.store = store self.source_root = Path(source_root).resolve() self.keys_map = keys_map if keys_map is not None else load_keys() self.cache_dir = Path(cache_dir or (self.source_root.parent / "wxwork_archive_cache")) self.corp_scopes = corp_scopes or {} @staticmethod def _columns(connection: sqlite3.Connection, table: str) -> list[str]: return [str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")')] @staticmethod def _tables(connection: sqlite3.Connection) -> set[str]: return { str(row[0]) for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) } def _metadata( self, decrypted: list[tuple[str, str, str]] ) -> tuple[dict[tuple[str, str], str], dict[tuple[str, str], str]]: users: dict[tuple[str, str], str] = {} conversations: dict[tuple[str, str], str] = {} for path, name, account in decrypted: if name not in {"user.db", "session.db"}: continue connection = connect_sqlite(path) try: tables = self._tables(connection) if name == "user.db" and "user_table" in tables: columns = set(self._columns(connection, "user_table")) wanted = [ key for key in ("id", "name", "real_name", "account") if key in columns ] if "id" in wanted: for row in connection.execute( f"SELECT {','.join(wanted)} FROM user_table" ): item = dict(zip(wanted, row)) uid = str(item.get("id") or "") label = str( item.get("name") or item.get("real_name") or item.get("account") or uid ) if uid: users[(account, uid)] = label if name == "session.db" and "conversation_table" in tables: columns = set(self._columns(connection, "conversation_table")) wanted = [ key for key in ("id", "name", "roomname_remark", "session_id") if key in columns ] if "id" in wanted: for row in connection.execute( f"SELECT {','.join(wanted)} FROM conversation_table" ): item = dict(zip(wanted, row)) cid = str(item.get("id") or "") label = str( item.get("roomname_remark") or item.get("name") or item.get("session_id") or cid ) if cid: conversations[(account, cid)] = label finally: connection.close() return users, conversations @staticmethod def _value(row: dict[str, Any], *names: str) -> Any: for name in names: if row.get(name) not in (None, ""): return row[name] return None def _normalized_message( self, account: str, row: dict[str, Any], users: dict[tuple[str, str], str], conversations: dict[tuple[str, str], str], ) -> dict[str, Any]: conversation_id = str(row.get("conversation_id") or "unknown") sender_id = str(row.get("sender_id") or "") content_type = self._value(row, "content_type", "msg_type", "type") status = "normal" if self._value(row, "is_revoke", "revoke_status"): status = "revoked" elif self._value(row, "is_deleted", "delete_status"): status = "deleted" return { "source_table": "message_table", "source_message_id": str( self._value(row, "server_id", "client_id") or f"rowid:{row['__archive_rowid']}" ), "server_id": str(row.get("server_id") or ""), "client_id": str(row.get("client_id") or ""), "sequence_no": self._value( row, "message_seq", "sequence_no", "seq", "local_id" ), "conversation": { "external_id": conversation_id, "name": conversations.get((account, conversation_id), conversation_id), }, "sender": { "external_id": sender_id, "display_name": users.get((account, sender_id), sender_id), "identity_type": "wecom_local_uid", "scope_id": self.corp_scopes.get(account) or account, "source": "wxwork_db", }, "sent_at": row.get("send_time"), "message_type": get_msg_type_name(content_type), "direction": "outbound" if sender_id and sender_id == account else "inbound", "status": status, "content": parse_content(row.get("content")), "raw_fields": {key: _safe_raw(value) for key, value in row.items()}, } def import_all( self, user_id: int, ip: str = "local", *, batch_size: int = 1000 ) -> dict[str, Any]: batch_size = max(1, min(int(batch_size), 5000)) decrypted = decrypt_with_keys( str(self.source_root), str(self.cache_dir), self.keys_map, use_cache=True ) users, conversations = self._metadata(decrypted) result = {"accounts": 0, "batches": 0, "inserted": 0, "duplicates": 0} for path, name, account in decrypted: if name != "message.db": continue connection = connect_sqlite(path) try: if "message_table" not in self._tables(connection): continue columns = self._columns(connection, "message_table") if "send_time" not in columns: continue checkpoint = self.store.source_checkpoint(account, "message_table") sent_at = float(checkpoint.get("send_time") or 0) rowid = int(checkpoint.get("rowid") or 0) result["accounts"] += 1 while True: cursor = connection.execute( """SELECT rowid AS __archive_rowid,* FROM message_table WHERE send_time>? OR (send_time=? AND rowid>?) ORDER BY send_time,rowid LIMIT ?""", (sent_at, sent_at, rowid, batch_size), ) names = [item[0] for item in cursor.description] rows = [dict(zip(names, values)) for values in cursor.fetchall()] if not rows: break last = rows[-1] batch = self.store.import_messages( { "source_account": { "external_account_id": account, "display_name": users.get((account, account), account), "corp_scope_id": self.corp_scopes.get(account) or account, }, "source_table": "message_table", "checkpoint": { "send_time": float(last["send_time"]), "rowid": int(last["__archive_rowid"]), }, "messages": [ self._normalized_message( account, row, users, conversations ) for row in rows ], }, user_id, ip, ) result["batches"] += 1 result["inserted"] += int(batch["inserted"]) result["duplicates"] += int(batch["duplicates"]) sent_at = float(last["send_time"]) rowid = int(last["__archive_rowid"]) finally: connection.close() return result def main() -> None: parser = argparse.ArgumentParser(description="将现有企微解密库增量导入聊天归档") parser.add_argument("--source-root", required=True, help="企微账号数据根目录") parser.add_argument("--backend-db", default="backend.db") parser.add_argument("--cache-dir", default="") parser.add_argument("--admin-user", default="admin") parser.add_argument("--batch-size", type=int, default=1000) args = parser.parse_args() database = admin_backend.Database(Path(args.backend_db).resolve()) database.migrate() with database.connect() as connection: user = connection.execute( "SELECT id FROM users WHERE username=? AND active=1", (args.admin_user,) ).fetchone() if user is None: raise SystemExit("找不到可用的审计用户") store = ArchiveStore(database) store.initialize() adapter = WxworkArchiveAdapter( store, args.source_root, cache_dir=args.cache_dir or None ) print( json.dumps( adapter.import_all(int(user["id"]), batch_size=args.batch_size), ensure_ascii=False, ) ) if __name__ == "__main__": main()