Files
kefu/wechat_rpa/backups/loading-performance-20260917/wxwork_message_browser.py
T
2026-09-21 10:34:06 +08:00

604 lines
23 KiB
Python

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