# -*- coding: utf-8 -*- """Read-only browser model for locally decrypted WeCom message databases. The auto-reply engine and archive uploader already maintain decrypted SQLite copies. This module only reads those copies and therefore never writes to the live WeCom databases. A manual refresh may update the existing decrypted cache through ``wxwork_db.decrypt_with_keys`` before the snapshot is built. """ from __future__ import annotations import sqlite3 import time from datetime import datetime from pathlib import Path from typing import Any, Iterable from runtime_paths import application_data_dir from wxwork_db import ( connect_sqlite, decrypt_with_keys, detect_wxwork_dir, get_msg_type_name, load_keys, parse_content, ) DATABASE_TABLES = { "message.db": "message_table", "session.db": "conversation_table", "user.db": "user_table", "company.db": "company_table", } def _format_time(value: Any) -> str: try: timestamp = float(value or 0) except (TypeError, ValueError): return "" if timestamp > 10**12: timestamp /= 1000 if timestamp <= 0: return "" try: return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") except (OSError, OverflowError, ValueError): return "" def _size_label(size: int) -> str: if size < 1024: return f"{size} B" if size < 1024 * 1024: return f"{size / 1024:.1f} KB" if size < 1024 * 1024 * 1024: return f"{size / (1024 * 1024):.1f} MB" return f"{size / (1024 * 1024 * 1024):.2f} GB" def _columns(connection: sqlite3.Connection, table: str) -> set[str]: try: return { str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")').fetchall() } except sqlite3.Error: return set() def _count(connection: sqlite3.Connection, table: str) -> int: try: return int(connection.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]) except (sqlite3.Error, TypeError, ValueError): return 0 def default_cache_roots() -> list[Path]: data_root = application_data_dir() source_root = Path(__file__).resolve().parent candidates = [ data_root / "archive_auto_backup" / "decrypted", data_root / "wxwork_decrypted", source_root / "archive_auto_backup" / "decrypted", source_root / "wxwork_decrypted", ] result: list[Path] = [] seen: set[str] = set() for candidate in candidates: identity = str(candidate.resolve()).casefold() if identity in seen: continue seen.add(identity) result.append(candidate) return result def _discover_databases(cache_roots: Iterable[Path]) -> dict[str, dict[str, Path]]: """Choose the newest readable copy of every database for each account.""" discovered: dict[str, dict[str, Path]] = {} for root in cache_roots: if not root.is_dir(): continue try: account_dirs = list(root.iterdir()) except OSError: continue for account_dir in account_dirs: if not account_dir.is_dir(): continue account = account_dir.name for database_path in account_dir.glob("*.db"): current = discovered.setdefault(account, {}).get(database_path.name) try: newer = current is None or database_path.stat().st_mtime > current.stat().st_mtime except OSError: continue if newer: discovered[account][database_path.name] = database_path return discovered def _refresh_cache(*, auto_acquire: bool = False) -> tuple[str, str]: """先复用密钥解密;自动初始化时,仅在无法读取后获取一次本机密钥。""" source_root = detect_wxwork_dir() if not source_root: return "", "自动检测未找到企业微信数据库。请先在本机登录企业微信后刷新,或手动选择数据目录" from wxwork_local_setup import acquire_local_keys, select_source_directory try: # 让独立 EXE 密钥工作进程使用同一个已识别的目录。 if auto_acquire: select_source_directory(source_root) expected = {p.parent.parent.name for p in Path(source_root).glob("*/Data/message.db")} def decrypt() -> tuple[set[str], dict]: try: keys = load_keys() except ValueError: keys = {} output = str(application_data_dir() / "wxwork_decrypted") decrypted = decrypt_with_keys(source_root, output, keys, use_cache=True) readable = set() invalid = False for path, name, account in decrypted: if name != "message.db": continue try: connection = connect_sqlite(path) try: connection.execute("SELECT conversation_id FROM message_table LIMIT 1").fetchone() readable.add(account) finally: connection.close() except sqlite3.Error: invalid = True # 缓存大小/时间正常也可能已经损坏;重建后再次检查。 if invalid: decrypted = decrypt_with_keys(source_root, output, keys, use_cache=False) readable.clear() for path, name, account in decrypted: if name != "message.db": continue try: connection = connect_sqlite(path) try: connection.execute("SELECT conversation_id FROM message_table LIMIT 1").fetchone() readable.add(account) finally: connection.close() except sqlite3.Error: continue return readable, keys readable, keys = decrypt() if expected and expected <= readable: return source_root, "" if auto_acquire: try: acquire_local_keys() except (OSError, ValueError) as exc: return source_root, f"自动解密未完成:{exc}。可重新刷新自动检测,或使用下方手动设置" readable, keys = decrypt() if expected and expected <= readable: return source_root, "" return source_root, "自动获取密钥后仍有数据库无法解密,请确认企业微信已登录,或使用下方手动设置" if not keys: return source_root, "已找到企业微信数据库,但本机尚未配置密钥。请点击刷新自动获取,或导入当前电脑的密钥文件" return source_root, "已找到企业微信数据库,但现有密钥无法解密全部数据库。请刷新重新获取本机密钥或导入匹配的密钥文件" except Exception as exc: return source_root, f"更新解密缓存失败:{exc}" def _load_names(databases: dict[str, Path], account: str) -> tuple[dict[str, str], dict[str, str], str]: users: dict[str, str] = {} conversations: dict[str, str] = {} account_name = account user_path = databases.get("user.db") if user_path: try: connection = connect_sqlite(str(user_path)) try: columns = _columns(connection, "user_table") wanted = [name for name in ("id", "name", "real_name", "account") if name in columns] if "id" in wanted: cursor = connection.execute(f"SELECT {','.join(wanted)} FROM user_table") for values in cursor.fetchall(): item = dict(zip(wanted, values)) user_id = str(item.get("id") or "") label = str( item.get("name") or item.get("real_name") or item.get("account") or user_id ) if user_id: users[user_id] = label account_name = users.get(account) or account_name finally: connection.close() except sqlite3.Error: pass session_path = databases.get("session.db") if session_path: try: connection = connect_sqlite(str(session_path)) try: columns = _columns(connection, "conversation_table") wanted = [ name for name in ("id", "name", "roomname_remark", "session_id") if name in columns ] if "id" in wanted: cursor = connection.execute( f"SELECT {','.join(wanted)} FROM conversation_table" ) for values in cursor.fetchall(): item = dict(zip(wanted, values)) conversation_id = str(item.get("id") or "") label = str( item.get("roomname_remark") or item.get("name") or item.get("session_id") or "" ) if conversation_id and label: conversations[conversation_id] = label finally: connection.close() except sqlite3.Error: pass return users, conversations, account_name def _conversation_name( account: str, conversation_id: str, users: dict[str, str], conversations: dict[str, str], ) -> str: label = str(conversations.get(conversation_id) or "").strip() if label and label != conversation_id: return label if conversation_id.startswith("M:"): peer_id = conversation_id[2:] return users.get(peer_id) or f"微信用户 {peer_id}" if conversation_id.startswith("S:"): peers = [item for item in conversation_id[2:].split("_") if item] peer_id = next((item for item in peers if item != account), peers[0] if peers else "") return users.get(peer_id) or f"企微用户 {peer_id}" if conversation_id.startswith("R:"): return label or f"群聊 {conversation_id[2:]}" if conversation_id.startswith("Y:"): return label or f"应用 {conversation_id[2:]}" return label or conversation_id or "未知会话" def _conversation_kind(conversation_id: str) -> str: if conversation_id.startswith("M:"): return "微信客户" if conversation_id.startswith("S:"): return "企业微信" if conversation_id.startswith("R:"): return "群聊" if conversation_id.startswith("Y:"): return "应用" return "其他" def _content_label(content: Any, content_type: Any) -> str: parsed = str(parse_content(content) or "").strip() if parsed: return parsed[:2000] return f"[{get_msg_type_name(content_type)}]" def _database_files(databases: dict[str, Path]) -> tuple[list[dict[str, Any]], int]: files: list[dict[str, Any]] = [] total_size = 0 preferred_order = { name: index for index, name in enumerate( ("message.db", "session.db", "user.db", "company.db", "message_lookup.db", "user_extend.db") ) } for name, path in sorted( databases.items(), key=lambda item: (preferred_order.get(item[0], 99), item[0]) ): try: stat = path.stat() size = int(stat.st_size) modified = _format_time(stat.st_mtime) except OSError: size, modified = 0, "" total_size += size row_count = 0 status = "可读取" table = DATABASE_TABLES.get(name) try: connection = connect_sqlite(str(path)) try: if table: row_count = _count(connection, table) connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone() finally: connection.close() except sqlite3.Error: status = "读取失败" files.append( { "name": name, "path": str(path), "size": size, "sizeLabel": _size_label(size), "modified": modified, "rows": row_count, "status": status, } ) return files, total_size def _conversation_rows( message_path: Path, account: str, users: dict[str, str], conversations: dict[str, str], query: str, limit: int, ) -> tuple[list[dict[str, Any]], int, int]: connection = connect_sqlite(str(message_path)) try: columns = _columns(connection, "message_table") required = {"conversation_id", "send_time"} if not required.issubset(columns): return [], 0, 0 total_messages = _count(connection, "message_table") try: total_conversations = int( connection.execute( "SELECT COUNT(DISTINCT conversation_id) FROM message_table " "WHERE conversation_id IS NOT NULL AND conversation_id<>'' " "AND conversation_id NOT LIKE 'Y:%'" ).fetchone()[0] ) except (sqlite3.Error, TypeError, ValueError): total_conversations = 0 sender_expr = "sender_id" if "sender_id" in columns else "''" type_expr = "content_type" if "content_type" in columns else "NULL" content_expr = "content" if "content" in columns else "''" fetch_limit = 1000 if str(query or "").strip() else max(limit * 4, limit) cursor = connection.execute( f"""SELECT conversation_id,COUNT(*) AS message_count, MAX(send_time) AS last_time,{sender_expr} AS sender_id, {type_expr} AS content_type,{content_expr} AS content FROM message_table WHERE conversation_id IS NOT NULL AND conversation_id<>'' AND conversation_id NOT LIKE 'Y:%' GROUP BY conversation_id ORDER BY last_time DESC LIMIT ?""", (fetch_limit,), ) needle = str(query or "").strip().casefold() result: list[dict[str, Any]] = [] for conversation_id, count, last_time, sender_id, content_type, content in cursor.fetchall(): conversation_id = str(conversation_id or "") name = _conversation_name(account, conversation_id, users, conversations) preview = _content_label(content, content_type).replace("\n", " ") if needle and needle not in f"{name} {conversation_id} {preview}".casefold(): continue result.append( { "id": conversation_id, "name": name, "kind": _conversation_kind(conversation_id), "messageCount": int(count or 0), "lastTime": _format_time(last_time), "lastTimestamp": float(last_time or 0), "preview": preview[:160], "lastDirection": "发出" if str(sender_id or "") == account else "收到", } ) if len(result) >= limit: break return result, total_conversations, total_messages finally: connection.close() def _messages( message_path: Path, account: str, conversation_id: str, users: dict[str, str], limit: int, ) -> list[dict[str, Any]]: if not conversation_id: return [] connection = connect_sqlite(str(message_path)) try: columns = _columns(connection, "message_table") if "conversation_id" not in columns: return [] wanted = [ name for name in ( "sender_id", "send_time", "content_type", "content", "server_id", "client_id", ) if name in columns ] if not wanted: return [] cursor = connection.execute( f"SELECT {','.join(wanted)} FROM message_table " "WHERE conversation_id=? ORDER BY send_time DESC,rowid DESC LIMIT ?", (conversation_id, max(1, min(int(limit), 1000))), ) result: list[dict[str, Any]] = [] for values in reversed(cursor.fetchall()): item = dict(zip(wanted, values)) sender_id = str(item.get("sender_id") or "") content_type = item.get("content_type") result.append( { "id": str(item.get("server_id") or item.get("client_id") or ""), "senderId": sender_id, "sender": users.get(sender_id) or ("当前账号" if sender_id == account else sender_id), "direction": "outbound" if sender_id == account else "inbound", "time": _format_time(item.get("send_time")), "timestamp": float(item.get("send_time") or 0), "type": get_msg_type_name(content_type), "content": _content_label(item.get("content"), content_type), } ) return result finally: connection.close() def load_browser_snapshot( *, selected_account: str = "", selected_conversation: str = "", query: str = "", refresh_cache: bool = False, auto_initialize: bool = False, cache_roots: Iterable[Path] | None = None, conversation_limit: int = 200, message_limit: int = 500, ) -> dict[str, Any]: """Build the JSON-ready state used by the desktop message-library page.""" source_root = "" warning = "" roots = [Path(item) for item in (cache_roots or default_cache_roots())] discovered = _discover_databases(roots) if refresh_cache or auto_initialize or not discovered: source_root, warning = _refresh_cache(auto_acquire=True) if auto_initialize else _refresh_cache() discovered = _discover_databases(roots) state: dict[str, Any] = { "loading": False, "error": "", "warning": warning, "manualSetupRequired": bool(warning), "initializationAttempted": bool(auto_initialize), "sourceRoot": source_root, "refreshedAt": _format_time(time.time()), "query": str(query or ""), "selectedAccount": "", "selectedConversation": "", "accountCount": len(discovered), "databaseCount": 0, "conversationCount": 0, "messageCount": 0, "accounts": [], "conversations": [], "messages": [], "files": [], } if not discovered: state["error"] = warning or "尚未找到可读取的企业微信数据库,请刷新自动检测,或使用手动设置" state["manualSetupRequired"] = True return state account_models: list[dict[str, Any]] = [] account_metadata: dict[str, tuple[dict[str, str], dict[str, str], str]] = {} for account, databases in discovered.items(): files, total_size = _database_files(databases) users, conversations, account_name = _load_names(databases, account) account_metadata[account] = (users, conversations, account_name) message_count = 0 conversation_count = 0 message_path = databases.get("message.db") if message_path: try: connection = connect_sqlite(str(message_path)) try: message_count = _count(connection, "message_table") conversation_count = int( connection.execute( "SELECT COUNT(DISTINCT conversation_id) FROM message_table " "WHERE conversation_id IS NOT NULL AND conversation_id<>'' " "AND conversation_id NOT LIKE 'Y:%'" ).fetchone()[0] ) finally: connection.close() except (sqlite3.Error, TypeError, ValueError): pass updated = max( (item.get("modified") or "" for item in files), default="", ) account_models.append( { "id": account, "name": account_name, "databaseCount": len(files), "messageCount": message_count, "conversationCount": conversation_count, "size": total_size, "sizeLabel": _size_label(total_size), "updated": updated, "files": files, } ) account_models.sort(key=lambda item: (item["updated"], item["messageCount"]), reverse=True) account_ids = {str(item["id"]) for item in account_models} account = str(selected_account or "") if account not in account_ids: account = str(account_models[0]["id"]) state["selectedAccount"] = account state["accounts"] = account_models state["databaseCount"] = sum(int(item["databaseCount"]) for item in account_models) state["messageCount"] = sum(int(item["messageCount"]) for item in account_models) state["conversationCount"] = sum(int(item["conversationCount"]) for item in account_models) current = next(item for item in account_models if str(item["id"]) == account) state["files"] = current["files"] users, conversation_names, _account_name = account_metadata[account] message_path = discovered[account].get("message.db") if not message_path: state["error"] = "当前账号没有可读取的 message.db" state["manualSetupRequired"] = True return state try: conversations, _account_conversations, _account_messages = _conversation_rows( message_path, account, users, conversation_names, str(query or ""), max(1, min(int(conversation_limit), 1000)), ) state["conversations"] = conversations available = {str(item["id"]) for item in conversations} conversation = str(selected_conversation or "") if conversation not in available: conversation = str(conversations[0]["id"]) if conversations else "" state["selectedConversation"] = conversation state["messages"] = _messages( message_path, account, conversation, users, message_limit, ) except sqlite3.Error as exc: state["error"] = f"读取消息数据库失败:{exc}" state["manualSetupRequired"] = True return state