604 lines
23 KiB
Python
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
|