Files
WeChatDataAnalysis/src/wechat_decrypt_tool/wcdb_realtime.py
T
2977094657 26c8f78d5b fix(wcdb): 传给 native 的 wxid 与 db_storage 解析对齐 WeFlow
- 调用 wcdb_set_my_wxid 前去掉导出目录名的 4 位随机后缀(如 Murderers_0e5d -> Murderers),否则 native 会话/消息查询可能只返回极少结果。

- media_helpers 的 db_storage 路径解析改用 WeFlow 的 resolveDbStoragePath 规则:直接子目录、wxid 子目录、带后缀的 wxid 目录、向上回查、有限深度递归兜底,保证用户选到任意层级都能正确定位。

- 连接日志额外打印 native_wxid,便于排查匹配错位问题。
2026-04-29 17:25:46 +08:00

1740 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ctypes
import base64
import binascii
import json
import os
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from .key_store import get_account_keys_from_store
from .logging_config import get_logger
from .media_helpers import _resolve_account_db_storage_dir
logger = get_logger(__name__)
class WCDBRealtimeError(RuntimeError):
pass
def _clean_weflow_account_dir_name(dir_name: str) -> str:
"""调用 WCDB 前使用与 WeFlow 相同的账号/wxid 清理规则。"""
trimmed = str(dir_name or "").strip()
if not trimmed:
return trimmed
if trimmed.lower().startswith("wxid_"):
match = re.match(r"^(wxid_[^_]+)", trimmed, flags=re.IGNORECASE)
if match:
return match.group(1)
return trimmed
suffix_match = re.match(r"^(.+)_([a-zA-Z0-9]{4})$", trimmed)
return suffix_match.group(1) if suffix_match else trimmed
def _derive_weflow_wcdb_wxid(account: str, db_storage_dir: Optional[Path] = None) -> str:
"""推导传给 native WCDB 的 wxid,语义对齐 WeFlow。
output 账号目录可能带随机后缀,例如 `Murderers_0e5d`。
WeFlow 在调用 `wcdb_set_my_wxid` 前会去掉这个后缀;如果传带后缀的名字,
native 会话/消息查询可能只返回很少结果。
"""
candidates: list[str] = []
if db_storage_dir is not None:
try:
parent_name = Path(db_storage_dir).parent.name
if parent_name:
candidates.append(parent_name)
except Exception:
pass
candidates.append(str(account or ""))
for item in candidates:
cleaned = _clean_weflow_account_dir_name(item)
if cleaned:
return cleaned
return str(account or "").strip()
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _iter_runtime_wcdb_api_dll_paths() -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[str] = set()
def add_anchor(anchor: str | Path | None) -> None:
if not anchor:
return
try:
base = Path(anchor).resolve()
except Exception:
base = Path(anchor)
candidate = base / "native" / "wcdb_api.dll"
key = str(candidate).replace("/", "\\").rstrip("\\").lower()
if key in seen:
return
seen.add(key)
candidates.append(candidate)
add_anchor(os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip())
add_anchor(Path.cwd())
if getattr(sys, "frozen", False):
add_anchor(Path(sys.executable).resolve().parent)
return tuple(candidates)
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
try:
resolved = path.resolve(strict=False)
except Exception:
resolved = path
try:
default_resolved = _DEFAULT_WCDB_API_DLL.resolve(strict=False)
except Exception:
default_resolved = _DEFAULT_WCDB_API_DLL
if resolved == default_resolved:
return True
for candidate in _iter_runtime_wcdb_api_dll_paths():
try:
if resolved == candidate.resolve(strict=False):
return True
except Exception:
if resolved == candidate:
return True
parts = tuple(str(part).lower() for part in resolved.parts)
allowed_suffixes = (
("backend", "native", "wcdb_api.dll"),
("wechat_decrypt_tool", "native", "wcdb_api.dll"),
)
return any(parts[-len(suffix) :] == suffix for suffix in allowed_suffixes)
def _candidate_wcdb_api_dll_paths() -> list[Path]:
"""Return allowed locations for wcdb_api.dll."""
cands: list[Path] = []
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
if env:
env_path = Path(env)
if _is_project_wcdb_api_dll_path(env_path):
cands.append(env_path)
else:
logger.warning("[wcdb] ignore external wcdb_api.dll override: %s", env_path)
for p in (_DEFAULT_WCDB_API_DLL,):
if p not in cands:
cands.append(p)
return cands
def _resolve_wcdb_api_dll_path() -> Path:
global _WCDB_API_DLL_SELECTED
if _WCDB_API_DLL_SELECTED is not None:
return _WCDB_API_DLL_SELECTED
for p in _candidate_wcdb_api_dll_paths():
try:
if p.exists() and p.is_file():
_WCDB_API_DLL_SELECTED = p
return p
except Exception:
continue
# Fall back to the default path even if it doesn't exist; caller will raise a clear error.
_WCDB_API_DLL_SELECTED = _DEFAULT_WCDB_API_DLL
return _WCDB_API_DLL_SELECTED
_lib_lock = threading.Lock()
_lib: Optional[ctypes.CDLL] = None
_initialized = False
_loaded_wcdb_api_dll: Optional[Path] = None
_preloaded_native_libs: list[ctypes.CDLL] = []
_protection_checked = False
_protection_result: Optional[tuple[int, str]] = None
_AUTO_SIDECAR_LOCK = threading.Lock()
_AUTO_SIDECAR_PROC: Optional[subprocess.Popen] = None
_AUTO_SIDECAR_URL = ""
_AUTO_SIDECAR_TOKEN = ""
def _is_windows() -> bool:
return sys.platform.startswith("win")
def _iter_wcdb_resource_paths(wcdb_api_dll: Path) -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[str] = set()
def add(path: str | Path | None) -> None:
if not path:
return
try:
resolved = Path(path).resolve()
except Exception:
resolved = Path(path)
key = str(resolved).replace("/", "\\").rstrip("\\").lower()
if key in seen:
return
seen.add(key)
candidates.append(resolved)
dll_dir = wcdb_api_dll.parent
add(dll_dir)
add(dll_dir.parent)
add(_NATIVE_DIR)
add(_NATIVE_DIR.parent)
cwd = Path.cwd()
add(cwd)
add(cwd / "resources")
data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip()
if data_dir:
add(data_dir)
add(Path(data_dir) / "resources")
if getattr(sys, "frozen", False):
try:
exe_dir = Path(sys.executable).resolve().parent
except Exception:
exe_dir = Path(sys.executable).parent
add(exe_dir)
add(exe_dir / "resources")
return tuple(candidates)
def _preload_wcdb_dependencies(wcdb_api_dll: Path) -> None:
dll_dir = wcdb_api_dll.parent
for name in ("WCDB.dll", "SDL2.dll", "VoipEngine.dll"):
dep_path = dll_dir / name
if not dep_path.exists():
continue
try:
_preloaded_native_libs.append(ctypes.CDLL(str(dep_path)))
logger.info("[wcdb] preloaded dependency: %s", dep_path)
except Exception as exc:
logger.warning("[wcdb] preload dependency failed: %s err=%s", dep_path, exc)
def _run_init_protection(lib: ctypes.CDLL, wcdb_api_dll: Path) -> None:
global _protection_checked, _protection_result
if _protection_checked:
return
_protection_checked = True
fn = getattr(lib, "InitProtection", None)
if not fn:
logger.info("[wcdb] InitProtection not exported: %s", wcdb_api_dll)
return
try:
fn.argtypes = [ctypes.c_char_p]
fn.restype = ctypes.c_int32
except Exception:
pass
best: Optional[tuple[int, str]] = None
for resource_path in _iter_wcdb_resource_paths(wcdb_api_dll):
try:
rc = int(fn(str(resource_path).encode("utf-8")))
logger.info("[wcdb] InitProtection rc=%s path=%s", rc, resource_path)
if rc == 0:
_protection_result = (rc, str(resource_path))
return
if best is None:
best = (rc, str(resource_path))
except Exception as exc:
logger.warning("[wcdb] InitProtection exception path=%s err=%s", resource_path, exc)
_protection_result = best
def _format_protection_hint() -> str:
if not _protection_result:
return ""
rc, resource_path = _protection_result
return f" protection_rc={rc} protection_path={resource_path}"
def _sidecar_url() -> str:
return str(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL", "") or "").strip().rstrip("/")
def _sidecar_enabled() -> bool:
return bool(_sidecar_url())
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _source_sidecar_assets() -> tuple[Path, Path, Path] | None:
if getattr(sys, "frozen", False):
return None
repo_root = _repo_root()
electron_exe = repo_root / "desktop" / "node_modules" / "electron" / "dist" / "electron.exe"
sidecar_script = repo_root / "desktop" / "src" / "wcdb-sidecar.cjs"
koffi_dir = repo_root / "desktop" / "vendor" / "koffi"
try:
if electron_exe.is_file() and sidecar_script.is_file() and koffi_dir.exists():
return electron_exe, sidecar_script, koffi_dir
except Exception:
return None
return None
def _auto_sidecar_started_here() -> bool:
with _AUTO_SIDECAR_LOCK:
return bool(_AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN)
def _parse_port(value: object) -> Optional[int]:
try:
raw = str(value or "").strip()
if not raw:
return None
port = int(raw, 10)
except Exception:
return None
if 1 <= port <= 65535:
return port
return None
def _pick_free_port() -> int:
requested = _parse_port(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_PORT"))
if requested is not None:
return requested
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.listen(1)
return int(sock.getsockname()[1])
def _build_auto_sidecar_resource_paths(wcdb_api_dll: Path) -> list[str]:
items: list[str] = []
seen: set[str] = set()
def add(path: str | Path | None) -> None:
if not path:
return
try:
resolved = Path(path).resolve()
except Exception:
resolved = Path(path)
key = str(resolved).replace("/", "\\").rstrip("\\").lower()
if not key or key in seen:
return
seen.add(key)
items.append(str(resolved))
repo_root = _repo_root()
dll_dir = wcdb_api_dll.parent
add(dll_dir)
add(dll_dir.parent)
add(repo_root)
add(repo_root / "resources")
data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip()
if data_dir:
add(data_dir)
add(Path(data_dir) / "resources")
else:
add(Path.cwd())
add(Path.cwd() / "resources")
return items
def _stop_auto_sidecar() -> None:
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
with _AUTO_SIDECAR_LOCK:
proc = _AUTO_SIDECAR_PROC
owned_url = _AUTO_SIDECAR_URL
owned_token = _AUTO_SIDECAR_TOKEN
_AUTO_SIDECAR_PROC = None
_AUTO_SIDECAR_URL = ""
_AUTO_SIDECAR_TOKEN = ""
if owned_url and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL") == owned_url:
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_URL", None)
if owned_token and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN") == owned_token:
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", None)
if proc is None:
return
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5.0)
except Exception:
proc.kill()
except Exception:
pass
def _maybe_start_auto_sidecar() -> bool:
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
if _sidecar_enabled() or not _is_windows():
return False
assets = _source_sidecar_assets()
if not assets:
return False
wcdb_api_dll = _resolve_wcdb_api_dll_path()
try:
if not wcdb_api_dll.exists():
return False
except Exception:
return False
electron_exe, sidecar_script, koffi_dir = assets
repo_root = _repo_root()
with _AUTO_SIDECAR_LOCK:
proc = _AUTO_SIDECAR_PROC
if proc is not None and proc.poll() is None and _AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN:
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = _AUTO_SIDECAR_URL
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = _AUTO_SIDECAR_TOKEN
return True
if proc is not None and proc.poll() is not None:
_AUTO_SIDECAR_PROC = None
_AUTO_SIDECAR_URL = ""
_AUTO_SIDECAR_TOKEN = ""
port = _pick_free_port()
token = os.urandom(24).hex()
url = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env.update(
{
"ELECTRON_RUN_AS_NODE": "1",
"WECHAT_TOOL_WCDB_SIDECAR_HOST": "127.0.0.1",
"WECHAT_TOOL_WCDB_SIDECAR_PORT": str(port),
"WECHAT_TOOL_WCDB_SIDECAR_TOKEN": token,
"WECHAT_TOOL_WCDB_API_DLL_PATH": str(wcdb_api_dll),
"WECHAT_TOOL_WCDB_DLL_DIR": str(wcdb_api_dll.parent),
"WECHAT_TOOL_WCDB_RESOURCE_PATHS": json.dumps(
_build_auto_sidecar_resource_paths(wcdb_api_dll), ensure_ascii=False
),
"WECHAT_TOOL_KOFFI_DIR": str(koffi_dir),
}
)
creationflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
try:
proc = subprocess.Popen(
[str(electron_exe), str(sidecar_script)],
cwd=str(repo_root),
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
)
except Exception as exc:
logger.warning("[wcdb] auto sidecar start failed: %s", exc)
return False
_AUTO_SIDECAR_PROC = proc
_AUTO_SIDECAR_URL = url
_AUTO_SIDECAR_TOKEN = token
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = url
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = token
logger.info("[wcdb] auto-started electron sidecar url=%s dll=%s", _AUTO_SIDECAR_URL, wcdb_api_dll)
return True
def _sidecar_call(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> dict[str, Any]:
base_url = _sidecar_url()
if not base_url:
raise WCDBRealtimeError("WCDB sidecar is not configured.")
token = str(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", "") or "").strip()
body = json.dumps(
{
"action": str(action or "").strip(),
"payload": payload or {},
},
ensure_ascii=False,
).encode("utf-8")
deadline = time.monotonic() + max(1.0, float(timeout or 30.0))
last_err: Exception | None = None
attempts = 20 if action == "init" else 3
for attempt in range(attempts):
headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
}
if token:
headers["X-WCDB-Sidecar-Token"] = token
req = urllib.request.Request(
f"{base_url}/call",
data=body,
headers=headers,
method="POST",
)
try:
remaining = max(0.5, deadline - time.monotonic())
with urllib.request.urlopen(req, timeout=min(remaining, max(0.5, timeout))) as resp:
raw = resp.read()
decoded = json.loads(raw.decode("utf-8", errors="replace") or "{}")
if not isinstance(decoded, dict):
raise WCDBRealtimeError("WCDB sidecar returned invalid response.")
if decoded.get("ok"):
result = decoded.get("result")
return result if isinstance(result, dict) else {}
rc = decoded.get("rc")
err = str(decoded.get("error") or "WCDB sidecar call failed")
logs = decoded.get("logs")
hint = ""
if isinstance(logs, list) and logs:
hint = f" logs={[str(x) for x in logs[:6]]}"
raise WCDBRealtimeError(f"{err} rc={rc}.{hint}")
except WCDBRealtimeError:
raise
except (urllib.error.URLError, TimeoutError, OSError) as exc:
last_err = exc
if attempt >= attempts - 1 or time.monotonic() >= deadline:
break
time.sleep(0.15)
except Exception as exc:
last_err = exc
break
raise WCDBRealtimeError(f"WCDB sidecar unavailable: {last_err}")
def _sidecar_payload(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> str:
result = _sidecar_call(action, payload, timeout=timeout)
return str(result.get("payload") or "")
def _load_wcdb_lib() -> ctypes.CDLL:
global _lib, _loaded_wcdb_api_dll
with _lib_lock:
if _lib is not None:
return _lib
if not _is_windows():
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
wcdb_api_dll = _resolve_wcdb_api_dll_path()
if not wcdb_api_dll.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {wcdb_api_dll}")
# Ensure dependent DLLs (e.g. WCDB.dll) can be found.
try:
os.add_dll_directory(str(wcdb_api_dll.parent))
except Exception:
pass
_preload_wcdb_dependencies(wcdb_api_dll)
lib = ctypes.CDLL(str(wcdb_api_dll))
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
# Signatures
lib.wcdb_init.argtypes = []
lib.wcdb_init.restype = ctypes.c_int
lib.wcdb_shutdown.argtypes = []
lib.wcdb_shutdown.restype = ctypes.c_int
lib.wcdb_open_account.argtypes = [
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_int64),
]
lib.wcdb_open_account.restype = ctypes.c_int
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
lib.wcdb_close_account.restype = ctypes.c_int
# Optional: wcdb_set_my_wxid(handle, wxid)
try:
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
lib.wcdb_set_my_wxid.restype = ctypes.c_int
except Exception:
pass
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_sessions.restype = ctypes.c_int
lib.wcdb_get_messages.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_int32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_messages.restype = ctypes.c_int
lib.wcdb_get_message_count.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int32)]
lib.wcdb_get_message_count.restype = ctypes.c_int
lib.wcdb_get_display_names.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_display_names.restype = ctypes.c_int
lib.wcdb_get_avatar_urls.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_avatar_urls.restype = ctypes.c_int
lib.wcdb_get_group_member_count.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int32)]
lib.wcdb_get_group_member_count.restype = ctypes.c_int
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_group_members.restype = ctypes.c_int
# Optional (newer DLLs): wcdb_get_group_nicknames(handle, chatroom_id, out_json)
try:
lib.wcdb_get_group_nicknames.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_group_nicknames.restype = ctypes.c_int
except Exception:
pass
# Optional: execute arbitrary SQL on a selected database kind/path.
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
try:
lib.wcdb_exec_query.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_exec_query.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): update a single message content in message db.
# Signature: wcdb_update_message(handle, sessionId, localId, createTime, newContent, outError)
try:
lib.wcdb_update_message.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_update_message.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): delete a single message in message db.
# Signature: wcdb_delete_message(handle, sessionId, localId, createTime, dbPathHint, outError)
try:
lib.wcdb_delete_message.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_delete_message.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
try:
lib.wcdb_get_sns_timeline.argtypes = [
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_int32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
except Exception:
# Older wcdb_api.dll may not expose this export.
pass
# Optional (newer DLLs): wcdb_decrypt_sns_image(encrypted_data, len, key, out_hex)
# WeFlow uses this to decrypt Moments CDN images.
try:
lib.wcdb_decrypt_sns_image.argtypes = [
ctypes.c_void_p,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_void_p),
]
lib.wcdb_decrypt_sns_image.restype = ctypes.c_int32
except Exception:
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
lib.wcdb_free_string.argtypes = [ctypes.c_char_p]
lib.wcdb_free_string.restype = None
_loaded_wcdb_api_dll = wcdb_api_dll
_lib = lib
return lib
def _ensure_initialized() -> None:
global _initialized, _loaded_wcdb_api_dll, _protection_result
_maybe_start_auto_sidecar()
if _sidecar_enabled():
with _lib_lock:
if _initialized:
return
try:
result = _sidecar_call("init", timeout=30.0)
dll_path = str(result.get("dllPath") or "").strip()
if dll_path:
try:
_loaded_wcdb_api_dll = Path(dll_path)
except Exception:
pass
protection = result.get("protection")
if isinstance(protection, list):
for item in protection:
if isinstance(item, dict) and "rc" in item:
try:
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
if int(item.get("rc")) == 0:
break
except Exception:
continue
with _lib_lock:
_initialized = True
return
except Exception:
if not _auto_sidecar_started_here():
raise
logger.warning("[wcdb] auto sidecar init failed, fallback to in-process wcdb")
_stop_auto_sidecar()
lib = _load_wcdb_lib()
with _lib_lock:
if _initialized:
return
wcdb_api_dll = _loaded_wcdb_api_dll or _resolve_wcdb_api_dll_path()
_run_init_protection(lib, wcdb_api_dll)
rc = int(lib.wcdb_init())
if rc != 0:
logs = get_native_logs(require_initialized=False)
hint = _format_protection_hint()
if logs:
hint += f" logs={logs[:6]}"
raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}")
_initialized = True
def _safe_load_json(payload: str) -> Any:
try:
return json.loads(payload)
except Exception:
return None
def _call_out_json(fn, *args) -> str:
lib = _load_wcdb_lib()
out = ctypes.c_char_p()
rc = int(fn(*args, ctypes.byref(out)))
try:
if rc != 0:
logs = get_native_logs()
hint = ""
if logs:
hint = f" logs={logs[:6]}"
raise WCDBRealtimeError(f"wcdb api call failed: {rc}.{hint}")
raw = out.value or b""
try:
return raw.decode("utf-8", errors="replace")
except Exception:
return ""
finally:
try:
if out.value:
lib.wcdb_free_string(out)
except Exception:
pass
def _call_out_error(fn, *args) -> None:
lib = _load_wcdb_lib()
out = ctypes.c_char_p()
rc = int(fn(*args, ctypes.byref(out)))
try:
if rc != 0:
err = ""
try:
if out.value:
err = (out.value or b"").decode("utf-8", errors="replace")
except Exception:
err = ""
logs = get_native_logs()
hint = f" logs={logs[:6]}" if logs else ""
if err:
raise WCDBRealtimeError(f"wcdb api call failed: {rc}. error={err}.{hint}")
raise WCDBRealtimeError(f"wcdb api call failed: {rc}.{hint}")
finally:
try:
if out.value:
lib.wcdb_free_string(out)
except Exception:
pass
def get_native_logs(*, require_initialized: bool = True) -> list[str]:
if _sidecar_enabled():
if require_initialized:
try:
_ensure_initialized()
except Exception:
return []
try:
result = _sidecar_call("get_logs", timeout=5.0)
logs = result.get("logs")
if isinstance(logs, list):
return [str(x) for x in logs]
return []
except Exception:
return []
if require_initialized:
try:
_ensure_initialized()
except Exception:
return []
lib = _load_wcdb_lib()
out = ctypes.c_char_p()
rc = int(lib.wcdb_get_logs(ctypes.byref(out)))
try:
if rc != 0 or not out.value:
return []
payload = out.value.decode("utf-8", errors="replace")
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
return [str(x) for x in decoded]
return []
except Exception:
return []
finally:
try:
if out.value:
lib.wcdb_free_string(out)
except Exception:
pass
def open_account(session_db_path: Path, key_hex: str) -> int:
_ensure_initialized()
p = Path(session_db_path)
if not p.exists():
raise WCDBRealtimeError(f"session db not found: {p}")
key = str(key_hex or "").strip()
if len(key) != 64:
raise WCDBRealtimeError("Invalid db key (must be 64 hex chars).")
if _sidecar_enabled():
result = _sidecar_call(
"open_account",
{
"path": str(p),
"key": key,
},
timeout=30.0,
)
handle = int(result.get("handle") or 0)
if handle <= 0:
raise WCDBRealtimeError("wcdb_open_account failed: invalid sidecar handle.")
return handle
lib = _load_wcdb_lib()
out_handle = ctypes.c_int64(0)
rc = int(lib.wcdb_open_account(str(p).encode("utf-8"), key.encode("utf-8"), ctypes.byref(out_handle)))
if rc != 0 or int(out_handle.value) <= 0:
logs = get_native_logs()
hint = f" logs={logs[:6]}" if logs else ""
raise WCDBRealtimeError(f"wcdb_open_account failed: {rc}.{hint}")
return int(out_handle.value)
def set_my_wxid(handle: int, wxid: str) -> bool:
"""Best-effort set the "my wxid" context for some WCDB APIs."""
try:
_ensure_initialized()
except Exception:
return False
w = str(wxid or "").strip()
if not w:
return False
if _sidecar_enabled():
try:
result = _sidecar_call("set_my_wxid", {"handle": int(handle), "wxid": w}, timeout=10.0)
return bool(result.get("success"))
except Exception:
return False
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_set_my_wxid", None)
if not fn:
return False
try:
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
except Exception:
return False
return rc == 0
def close_account(handle: int) -> None:
try:
h = int(handle)
except Exception:
return
if h <= 0:
return
try:
_ensure_initialized()
except Exception:
return
if _sidecar_enabled():
try:
_sidecar_call("close_account", {"handle": h}, timeout=5.0)
except Exception:
pass
return
lib = _load_wcdb_lib()
try:
lib.wcdb_close_account(ctypes.c_int64(h))
except Exception:
return
def get_sessions(handle: int) -> list[dict[str, Any]]:
_ensure_initialized()
if _sidecar_enabled():
payload = _sidecar_payload("get_sessions", {"handle": int(handle)}, timeout=30.0)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
return [x for x in decoded if isinstance(x, dict)]
return []
lib = _load_wcdb_lib()
payload = _call_out_json(lib.wcdb_get_sessions, ctypes.c_int64(int(handle)))
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_messages(handle: int, username: str, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
_ensure_initialized()
u = str(username or "").strip()
if not u:
return []
if _sidecar_enabled():
payload = _sidecar_payload(
"get_messages",
{
"handle": int(handle),
"username": u,
"limit": int(limit),
"offset": int(offset),
},
timeout=30.0,
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
return [x for x in decoded if isinstance(x, dict)]
return []
lib = _load_wcdb_lib()
payload = _call_out_json(
lib.wcdb_get_messages,
ctypes.c_int64(int(handle)),
u.encode("utf-8"),
ctypes.c_int32(int(limit)),
ctypes.c_int32(int(offset)),
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_message_count(handle: int, username: str) -> int:
_ensure_initialized()
u = str(username or "").strip()
if not u:
return 0
if _sidecar_enabled():
result = _sidecar_call(
"get_message_count",
{"handle": int(handle), "username": u},
timeout=30.0,
)
try:
return int(result.get("count") or 0)
except Exception:
return 0
lib = _load_wcdb_lib()
out_count = ctypes.c_int32(0)
rc = int(lib.wcdb_get_message_count(ctypes.c_int64(int(handle)), u.encode("utf-8"), ctypes.byref(out_count)))
if rc != 0:
return 0
return int(out_count.value)
def get_display_names(handle: int, usernames: list[str]) -> dict[str, str]:
_ensure_initialized()
uniq = [str(u or "").strip() for u in usernames if str(u or "").strip()]
uniq = list(dict.fromkeys(uniq))
if not uniq:
return {}
if _sidecar_enabled():
out_json = _sidecar_payload(
"get_display_names",
{"handle": int(handle), "usernames": uniq},
timeout=30.0,
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
lib = _load_wcdb_lib()
payload = json.dumps(uniq, ensure_ascii=False).encode("utf-8")
out_json = _call_out_json(lib.wcdb_get_display_names, ctypes.c_int64(int(handle)), payload)
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
_ensure_initialized()
uniq = [str(u or "").strip() for u in usernames if str(u or "").strip()]
uniq = list(dict.fromkeys(uniq))
if not uniq:
return {}
if _sidecar_enabled():
out_json = _sidecar_payload(
"get_avatar_urls",
{"handle": int(handle), "usernames": uniq},
timeout=30.0,
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
lib = _load_wcdb_lib()
payload = json.dumps(uniq, ensure_ascii=False).encode("utf-8")
out_json = _call_out_json(lib.wcdb_get_avatar_urls, ctypes.c_int64(int(handle)), payload)
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]:
_ensure_initialized()
cid = str(chatroom_id or "").strip()
if not cid:
return []
if _sidecar_enabled():
out_json = _sidecar_payload(
"get_group_members",
{"handle": int(handle), "chatroom_id": cid},
timeout=30.0,
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
return [x for x in decoded if isinstance(x, dict)]
return []
lib = _load_wcdb_lib()
out_json = _call_out_json(lib.wcdb_get_group_members, ctypes.c_int64(int(handle)), cid.encode("utf-8"))
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_group_nicknames(handle: int, chatroom_id: str) -> dict[str, str]:
_ensure_initialized()
cid = str(chatroom_id or "").strip()
if not cid:
return {}
if _sidecar_enabled():
try:
out_json = _sidecar_payload(
"get_group_nicknames",
{"handle": int(handle), "chatroom_id": cid},
timeout=30.0,
)
except Exception:
return {}
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_get_group_nicknames", None)
if not fn:
return {}
out_json = _call_out_json(fn, ctypes.c_int64(int(handle)), cid.encode("utf-8"))
decoded = _safe_load_json(out_json)
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
return {}
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
"""Execute raw SQL on a specific db kind/path via WCDB.
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
"""
_ensure_initialized()
k = str(kind or "").strip()
if not k:
raise WCDBRealtimeError("Missing kind for exec_query.")
s = str(sql or "").strip()
if not s:
return []
p = None if path is None else str(path or "").strip()
if _sidecar_enabled():
out_json = _sidecar_payload(
"exec_query",
{
"handle": int(handle),
"kind": k,
"path": p,
"sql": s,
},
timeout=60.0,
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
return [x for x in decoded if isinstance(x, dict)]
return []
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_exec_query", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
out_json = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
k.encode("utf-8"),
None if p is None else p.encode("utf-8"),
s.encode("utf-8"),
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def update_message(handle: int, *, session_id: str, local_id: int, create_time: int, new_content: str) -> None:
"""Update a single message content in the live encrypted db_storage via WCDB.
Requires wcdb_update_message export in wcdb_api.dll.
"""
_ensure_initialized()
sid = str(session_id or "").strip()
if not sid:
raise WCDBRealtimeError("Missing session_id for update_message.")
if _sidecar_enabled():
_sidecar_call(
"update_message",
{
"handle": int(handle),
"session_id": sid,
"local_id": int(local_id or 0),
"create_time": int(create_time or 0),
"new_content": str(new_content or ""),
},
timeout=30.0,
)
return
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_update_message", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support update_message.")
_call_out_error(
fn,
ctypes.c_int64(int(handle)),
sid.encode("utf-8"),
ctypes.c_int64(int(local_id or 0)),
ctypes.c_int32(int(create_time or 0)),
str(new_content or "").encode("utf-8"),
)
def delete_message(
handle: int,
*,
session_id: str,
local_id: int,
create_time: int,
db_path_hint: str | None = None,
) -> None:
"""Delete a single message in the live encrypted db_storage via WCDB.
Requires wcdb_delete_message export in wcdb_api.dll.
"""
_ensure_initialized()
sid = str(session_id or "").strip()
if not sid:
raise WCDBRealtimeError("Missing session_id for delete_message.")
hint = str(db_path_hint or "").strip()
if _sidecar_enabled():
_sidecar_call(
"delete_message",
{
"handle": int(handle),
"session_id": sid,
"local_id": int(local_id or 0),
"create_time": int(create_time or 0),
"db_path_hint": hint,
},
timeout=30.0,
)
return
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_delete_message", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support delete_message.")
_call_out_error(
fn,
ctypes.c_int64(int(handle)),
sid.encode("utf-8"),
ctypes.c_int64(int(local_id or 0)),
ctypes.c_int32(int(create_time or 0)),
hint.encode("utf-8"),
)
def get_sns_timeline(
handle: int,
*,
limit: int = 20,
offset: int = 0,
usernames: Optional[list[str]] = None,
keyword: str | None = None,
start_time: int = 0,
end_time: int = 0,
) -> list[dict[str, Any]]:
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
"""
_ensure_initialized()
lim = max(0, int(limit or 0))
off = max(0, int(offset or 0))
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
users = list(dict.fromkeys(users))
users_json = json.dumps(users, ensure_ascii=False) if users else ""
kw = str(keyword or "").strip()
if _sidecar_enabled():
payload = _sidecar_payload(
"get_sns_timeline",
{
"handle": int(handle),
"limit": lim,
"offset": off,
"usernames": users,
"keyword": kw,
"start_time": int(start_time or 0),
"end_time": int(end_time or 0),
},
timeout=60.0,
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
return [x for x in decoded if isinstance(x, dict)]
return []
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_get_sns_timeline", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
payload = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
ctypes.c_int32(lim),
ctypes.c_int32(off),
users_json.encode("utf-8"),
kw.encode("utf-8"),
ctypes.c_int32(int(start_time or 0)),
ctypes.c_int32(int(end_time or 0)),
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
"""Decrypt Moments CDN image bytes using WCDB DLL (WeFlow compatible).
Notes:
- Requires a newer wcdb_api.dll export: wcdb_decrypt_sns_image.
- On failure, returns the original encrypted_data (best-effort behavior like WeFlow).
"""
_ensure_initialized()
raw = bytes(encrypted_data or b"")
if not raw:
return b""
k = str(key or "").strip()
if not k:
return raw
if _sidecar_enabled():
result = _sidecar_call(
"decrypt_sns_image",
{
"data_b64": base64.b64encode(raw).decode("ascii"),
"key": k,
},
timeout=60.0,
)
data_b64 = str(result.get("data_b64") or "")
if not data_b64:
return raw
try:
return base64.b64decode(data_b64)
except Exception:
return raw
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_decrypt_sns_image", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.")
out_ptr = ctypes.c_void_p()
buf = ctypes.create_string_buffer(raw, len(raw))
rc = 0
try:
rc = int(
fn(
ctypes.cast(buf, ctypes.c_void_p),
ctypes.c_int32(len(raw)),
k.encode("utf-8"),
ctypes.byref(out_ptr),
)
)
if rc != 0 or not out_ptr.value:
return raw
hex_bytes = ctypes.cast(out_ptr, ctypes.c_char_p).value or b""
if not hex_bytes:
return raw
# Defensive: keep only hex chars (some builds may include whitespace).
hex_clean = re.sub(rb"[^0-9a-fA-F]", b"", hex_bytes)
if not hex_clean:
return raw
try:
return binascii.unhexlify(hex_clean)
except Exception:
return raw
finally:
try:
if out_ptr.value:
lib.wcdb_free_string(ctypes.cast(out_ptr, ctypes.c_char_p))
except Exception:
pass
def shutdown() -> None:
global _initialized
if _sidecar_enabled():
with _lib_lock:
should_shutdown = bool(_initialized)
try:
if should_shutdown:
_sidecar_call("shutdown", timeout=5.0)
finally:
with _lib_lock:
_initialized = False
if _auto_sidecar_started_here():
_stop_auto_sidecar()
return
lib = _load_wcdb_lib()
with _lib_lock:
if not _initialized:
return
try:
lib.wcdb_shutdown()
finally:
_initialized = False
if _auto_sidecar_started_here():
_stop_auto_sidecar()
def _resolve_session_db_path(db_storage_dir: Path) -> Path:
# Prefer current WeChat 4.x naming/layout:
# - db_storage/session/session.db
# - (fallback) db_storage/session.db
candidates = [
db_storage_dir / "session" / "session.db",
db_storage_dir / "session.db",
db_storage_dir / "Session.db",
db_storage_dir / "MicroMsg.db",
]
for c in candidates:
try:
if c.exists() and c.is_file():
return c
except Exception:
continue
# Fallback: recursive search (some installs keep DBs in subfolders).
try:
for p in db_storage_dir.rglob("session.db"):
try:
if p.exists() and p.is_file():
return p
except Exception:
continue
except Exception:
pass
try:
for p in db_storage_dir.rglob("MicroMsg.db"):
try:
if p.exists() and p.is_file():
return p
except Exception:
continue
except Exception:
pass
raise WCDBRealtimeError(f"Cannot find session db in: {db_storage_dir}")
@dataclass(frozen=True)
class WCDBRealtimeConnection:
account: str
native_wxid: str
handle: int
db_storage_dir: Path
session_db_path: Path
connected_at: float
lock: threading.Lock
class WCDBRealtimeManager:
_FAILED_TTL = 60.0 # seconds before retrying a failed connection
def __init__(self) -> None:
self._mu = threading.Lock()
self._conns: dict[str, WCDBRealtimeConnection] = {}
self._connecting: dict[str, threading.Event] = {}
# Negative cache: accounts that failed to connect recently (avoids repeated timeouts).
self._failed: dict[str, float] = {} # account -> monotonic timestamp of failure
def get_status(self, account_dir: Path) -> dict[str, Any]:
account = str(account_dir.name)
key_item = get_account_keys_from_store(account)
key_hex = str((key_item or {}).get("db_key") or "").strip()
key_ok = len(key_hex) == 64
db_storage_dir = None
session_db_path = None
native_wxid = ""
err = ""
try:
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir is not None:
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
session_db_path = _resolve_session_db_path(db_storage_dir)
except Exception as e:
err = str(e)
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
dll_path = _resolve_wcdb_api_dll_path()
try:
dll_ok = bool(dll_path.exists())
except Exception:
dll_ok = False
connected = self.is_connected(account)
return {
"account": account,
"dll_present": bool(dll_ok),
"wcdb_api_dll": str(dll_path),
"key_present": bool(key_ok),
"native_wxid": native_wxid,
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
"session_db_path": str(session_db_path) if session_db_path else "",
"connected": bool(connected),
"error": err,
}
def is_connected(self, account: str) -> bool:
with self._mu:
conn = self._conns.get(str(account))
return bool(conn and conn.handle > 0)
def ensure_connected(
self, account_dir: Path, *, key_hex: Optional[str] = None, timeout: float = 5.0
) -> WCDBRealtimeConnection:
account = str(account_dir.name)
# Fast-reject if this account failed recently to avoid repeated timeouts.
with self._mu:
failed_at = self._failed.get(account)
if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL:
logger.warning("[wcdb] recent failure cache hit account=%s ttl=%ss", account, int(self._FAILED_TTL))
raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.")
deadline = time.monotonic() + timeout
while True:
with self._mu:
existing = self._conns.get(account)
if existing is not None and existing.handle > 0:
return existing
waiter = self._connecting.get(account)
if waiter is None:
waiter = threading.Event()
self._connecting[account] = waiter
break
# Another thread is connecting; wait a bit and retry.
remaining = deadline - time.monotonic()
if remaining <= 0:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
waiter.wait(timeout=min(remaining, 10.0))
if time.monotonic() >= deadline:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
key = str(key_hex or "").strip()
if not key:
key_item = get_account_keys_from_store(account)
key = str((key_item or {}).get("db_key") or "").strip()
try:
if len(key) != 64:
with self._mu:
self._failed[account] = time.monotonic()
logger.warning("[wcdb] missing/invalid db key account=%s key_len=%s", account, len(key))
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir is None:
logger.warning("[wcdb] db_storage resolve failed account=%s account_dir=%s", account, account_dir)
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
session_db_path = _resolve_session_db_path(db_storage_dir)
native_wxid = _derive_weflow_wcdb_wxid(account, db_storage_dir)
# Run open_account in a daemon thread with a timeout to avoid
# blocking indefinitely when the native library hangs (locked DB).
_handle_box: list[int] = []
_open_err: list[Exception] = []
def _do_open() -> None:
try:
_handle_box.append(open_account(session_db_path, key))
except Exception as exc:
_open_err.append(exc)
remaining = max(0.1, deadline - time.monotonic())
open_thread = threading.Thread(target=_do_open, daemon=True)
open_thread.start()
open_thread.join(timeout=remaining)
if open_thread.is_alive():
with self._mu:
self._failed[account] = time.monotonic()
logger.warning(
"[wcdb] open_account timeout account=%s timeout=%ss session_db=%s",
account,
int(timeout),
session_db_path,
)
raise WCDBRealtimeError(
f"open_account timed out after {timeout:.0f}s for {session_db_path}"
)
if _open_err:
with self._mu:
self._failed[account] = time.monotonic()
logger.warning(
"[wcdb] open_account failed account=%s session_db=%s error=%s",
account,
session_db_path,
_open_err[0],
)
raise _open_err[0]
if not _handle_box:
logger.warning("[wcdb] open_account returned no handle account=%s session_db=%s", account, session_db_path)
raise WCDBRealtimeError("open_account returned no handle.")
handle = _handle_box[0]
# 对齐 WeFlow:传清理后的 wxid/account 名称给 native WCDB
# 不传带 4 位随机后缀的导出目录名。
try:
set_my_wxid(handle, native_wxid)
except Exception:
pass
conn = WCDBRealtimeConnection(
account=account,
native_wxid=native_wxid,
handle=handle,
db_storage_dir=db_storage_dir,
session_db_path=session_db_path,
connected_at=time.time(),
lock=threading.Lock(),
)
with self._mu:
self._conns[account] = conn
self._failed.pop(account, None)
logger.info(
"[wcdb] connected account=%s native_wxid=%s handle=%s session_db=%s",
account,
native_wxid,
int(handle),
session_db_path,
)
return conn
finally:
with self._mu:
ev = self._connecting.pop(account, None)
if ev is not None:
ev.set()
def disconnect(self, account: str) -> None:
a = str(account or "").strip()
if not a:
return
with self._mu:
conn = self._conns.pop(a, None)
self._failed.pop(a, None) # clear negative cache on explicit disconnect
if conn is None:
return
try:
with conn.lock:
close_account(conn.handle)
except Exception:
pass
def close_all(self, *, lock_timeout_s: float | None = None) -> bool:
"""Close all known WCDB realtime connections.
When `lock_timeout_s` is None, this waits indefinitely for per-connection locks.
When provided, this will skip busy connections after the timeout and return False.
"""
with self._mu:
conns = list(self._conns.values())
self._conns.clear()
ok = True
for conn in conns:
try:
if lock_timeout_s is None:
with conn.lock:
close_account(conn.handle)
continue
acquired = conn.lock.acquire(timeout=float(lock_timeout_s))
if not acquired:
ok = False
logger.warning("[wcdb] close_all skip busy conn account=%s", conn.account)
continue
try:
close_account(conn.handle)
finally:
conn.lock.release()
except Exception:
ok = False
continue
return ok
WCDB_REALTIME = WCDBRealtimeManager()