mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(sns): 增强朋友圈时间线/媒体获取与实时同步
- 新增 /api/sns/users:按发圈数统计联系人(支持 keyword/limit) - 新增 /api/sns/realtime/sync_latest:WCDB 实时增量同步到解密库(append-only),并持久化 sync state - 朋友圈媒体优先走“远程下载+解密”:图片支持 wcdb_decrypt_sns_image,视频/实况支持 ISAAC64(WeFlow 逻辑) - 增加 WeFlow WASM keystream(Node) 优先 + Python ISAAC64 fallback,提升兼容性 - wcdb_api.dll 支持多路径自动发现/环境变量覆盖,并在状态信息中回传实际使用路径
This commit is contained in:
169
src/wechat_decrypt_tool/isaac64.py
Normal file
169
src/wechat_decrypt_tool/isaac64.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""ISAAC-64 PRNG (WeFlow compatible).
|
||||||
|
|
||||||
|
WeChat SNS live photo/video decryption uses a keystream generated by ISAAC-64 and
|
||||||
|
XORs the first 128KB of the mp4 file. WeFlow's implementation reverses the
|
||||||
|
generated byte array, so we mirror that behavior for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_MASK_64 = 0xFFFFFFFFFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(v: int) -> int:
|
||||||
|
return int(v) & _MASK_64
|
||||||
|
|
||||||
|
|
||||||
|
class Isaac64:
|
||||||
|
def __init__(self, seed: Any):
|
||||||
|
seed_text = str(seed).strip()
|
||||||
|
if not seed_text:
|
||||||
|
seed_val = 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# WeFlow seeds with BigInt(seed), where seed is usually a decimal string.
|
||||||
|
seed_val = int(seed_text, 0)
|
||||||
|
except Exception:
|
||||||
|
seed_val = 0
|
||||||
|
|
||||||
|
self.mm = [_u64(0) for _ in range(256)]
|
||||||
|
self.aa = _u64(0)
|
||||||
|
self.bb = _u64(0)
|
||||||
|
self.cc = _u64(0)
|
||||||
|
self.randrsl = [_u64(0) for _ in range(256)]
|
||||||
|
self.randrsl[0] = _u64(seed_val)
|
||||||
|
self.randcnt = 0
|
||||||
|
self._init(True)
|
||||||
|
|
||||||
|
def _init(self, flag: bool) -> None:
|
||||||
|
a = b = c = d = e = f = g = h = _u64(0x9E3779B97F4A7C15)
|
||||||
|
|
||||||
|
def mix() -> tuple[int, int, int, int, int, int, int, int]:
|
||||||
|
nonlocal a, b, c, d, e, f, g, h
|
||||||
|
a = _u64(a - e)
|
||||||
|
f = _u64(f ^ (h >> 9))
|
||||||
|
h = _u64(h + a)
|
||||||
|
|
||||||
|
b = _u64(b - f)
|
||||||
|
g = _u64(g ^ _u64(a << 9))
|
||||||
|
a = _u64(a + b)
|
||||||
|
|
||||||
|
c = _u64(c - g)
|
||||||
|
h = _u64(h ^ (b >> 23))
|
||||||
|
b = _u64(b + c)
|
||||||
|
|
||||||
|
d = _u64(d - h)
|
||||||
|
a = _u64(a ^ _u64(c << 15))
|
||||||
|
c = _u64(c + d)
|
||||||
|
|
||||||
|
e = _u64(e - a)
|
||||||
|
b = _u64(b ^ (d >> 14))
|
||||||
|
d = _u64(d + e)
|
||||||
|
|
||||||
|
f = _u64(f - b)
|
||||||
|
c = _u64(c ^ _u64(e << 20))
|
||||||
|
e = _u64(e + f)
|
||||||
|
|
||||||
|
g = _u64(g - c)
|
||||||
|
d = _u64(d ^ (f >> 17))
|
||||||
|
f = _u64(f + g)
|
||||||
|
|
||||||
|
h = _u64(h - d)
|
||||||
|
e = _u64(e ^ _u64(g << 14))
|
||||||
|
g = _u64(g + h)
|
||||||
|
return a, b, c, d, e, f, g, h
|
||||||
|
|
||||||
|
for _ in range(4):
|
||||||
|
mix()
|
||||||
|
|
||||||
|
for i in range(0, 256, 8):
|
||||||
|
if flag:
|
||||||
|
a = _u64(a + self.randrsl[i])
|
||||||
|
b = _u64(b + self.randrsl[i + 1])
|
||||||
|
c = _u64(c + self.randrsl[i + 2])
|
||||||
|
d = _u64(d + self.randrsl[i + 3])
|
||||||
|
e = _u64(e + self.randrsl[i + 4])
|
||||||
|
f = _u64(f + self.randrsl[i + 5])
|
||||||
|
g = _u64(g + self.randrsl[i + 6])
|
||||||
|
h = _u64(h + self.randrsl[i + 7])
|
||||||
|
mix()
|
||||||
|
self.mm[i] = a
|
||||||
|
self.mm[i + 1] = b
|
||||||
|
self.mm[i + 2] = c
|
||||||
|
self.mm[i + 3] = d
|
||||||
|
self.mm[i + 4] = e
|
||||||
|
self.mm[i + 5] = f
|
||||||
|
self.mm[i + 6] = g
|
||||||
|
self.mm[i + 7] = h
|
||||||
|
|
||||||
|
if flag:
|
||||||
|
for i in range(0, 256, 8):
|
||||||
|
a = _u64(a + self.mm[i])
|
||||||
|
b = _u64(b + self.mm[i + 1])
|
||||||
|
c = _u64(c + self.mm[i + 2])
|
||||||
|
d = _u64(d + self.mm[i + 3])
|
||||||
|
e = _u64(e + self.mm[i + 4])
|
||||||
|
f = _u64(f + self.mm[i + 5])
|
||||||
|
g = _u64(g + self.mm[i + 6])
|
||||||
|
h = _u64(h + self.mm[i + 7])
|
||||||
|
mix()
|
||||||
|
self.mm[i] = a
|
||||||
|
self.mm[i + 1] = b
|
||||||
|
self.mm[i + 2] = c
|
||||||
|
self.mm[i + 3] = d
|
||||||
|
self.mm[i + 4] = e
|
||||||
|
self.mm[i + 5] = f
|
||||||
|
self.mm[i + 6] = g
|
||||||
|
self.mm[i + 7] = h
|
||||||
|
|
||||||
|
self._isaac64()
|
||||||
|
self.randcnt = 256
|
||||||
|
|
||||||
|
def _isaac64(self) -> None:
|
||||||
|
self.cc = _u64(self.cc + 1)
|
||||||
|
self.bb = _u64(self.bb + self.cc)
|
||||||
|
|
||||||
|
for i in range(256):
|
||||||
|
x = self.mm[i]
|
||||||
|
if (i & 3) == 0:
|
||||||
|
# aa ^= ~(aa << 21)
|
||||||
|
self.aa = _u64(self.aa ^ (_u64(self.aa << 21) ^ _MASK_64))
|
||||||
|
elif (i & 3) == 1:
|
||||||
|
self.aa = _u64(self.aa ^ (self.aa >> 5))
|
||||||
|
elif (i & 3) == 2:
|
||||||
|
self.aa = _u64(self.aa ^ _u64(self.aa << 12))
|
||||||
|
else:
|
||||||
|
self.aa = _u64(self.aa ^ (self.aa >> 33))
|
||||||
|
|
||||||
|
self.aa = _u64(self.mm[(i + 128) & 255] + self.aa)
|
||||||
|
y = _u64(self.mm[(x >> 3) & 255] + self.aa + self.bb)
|
||||||
|
self.mm[i] = y
|
||||||
|
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
|
||||||
|
self.randrsl[i] = self.bb
|
||||||
|
|
||||||
|
def get_next(self) -> int:
|
||||||
|
if self.randcnt == 0:
|
||||||
|
self._isaac64()
|
||||||
|
self.randcnt = 256
|
||||||
|
idx = 256 - self.randcnt
|
||||||
|
self.randcnt -= 1
|
||||||
|
return _u64(self.randrsl[idx])
|
||||||
|
|
||||||
|
def generate_keystream(self, size: int) -> bytes:
|
||||||
|
"""Generate a keystream of `size` bytes (must be multiple of 8)."""
|
||||||
|
if size <= 0:
|
||||||
|
return b""
|
||||||
|
if size % 8 != 0:
|
||||||
|
raise ValueError("ISAAC64 keystream size must be multiple of 8 bytes.")
|
||||||
|
|
||||||
|
out = bytearray()
|
||||||
|
count = size // 8
|
||||||
|
for _ in range(count):
|
||||||
|
out.extend(int(self.get_next()).to_bytes(8, "little", signed=False))
|
||||||
|
|
||||||
|
# WeFlow reverses the entire byte array (Uint8Array.reverse()).
|
||||||
|
out.reverse()
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
274
src/wechat_decrypt_tool/sns_realtime_autosync.py
Normal file
274
src/wechat_decrypt_tool/sns_realtime_autosync.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""SNS (Moments) realtime -> decrypted sqlite incremental sync.
|
||||||
|
|
||||||
|
Why:
|
||||||
|
- We can read the latest Moments via WCDB realtime, but the decrypted snapshot (`output/databases/{account}/sns.db`)
|
||||||
|
can lag behind or miss data (e.g. you viewed it when it was visible, then it became "only last 3 days").
|
||||||
|
- For export/offline browsing, we want to keep a local append-only cache of Moments that were visible at some point.
|
||||||
|
|
||||||
|
This module runs a lightweight background poller that watches db_storage/sns*.db mtime changes and triggers a cheap
|
||||||
|
incremental sync of the latest N Moments into the decrypted snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .chat_helpers import _list_decrypted_accounts, _resolve_account_dir
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .wcdb_realtime import WCDB_REALTIME
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
raw = str(os.environ.get(name, "") or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
return raw not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
|
||||||
|
raw = str(os.environ.get(name, "") or "").strip()
|
||||||
|
try:
|
||||||
|
v = int(raw)
|
||||||
|
except Exception:
|
||||||
|
v = int(default)
|
||||||
|
if v < min_v:
|
||||||
|
v = min_v
|
||||||
|
if v > max_v:
|
||||||
|
v = max_v
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _mtime_ns(path: Path) -> int:
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
m_ns = int(getattr(st, "st_mtime_ns", 0) or 0)
|
||||||
|
if m_ns <= 0:
|
||||||
|
m_ns = int(float(getattr(st, "st_mtime", 0.0) or 0.0) * 1_000_000_000)
|
||||||
|
return int(m_ns)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_sns_db_mtime_ns(db_storage_dir: Path) -> int:
|
||||||
|
"""Best-effort "latest mtime" signal for sns.db buckets."""
|
||||||
|
base = Path(db_storage_dir)
|
||||||
|
candidates: list[Path] = [
|
||||||
|
base / "sns" / "sns.db",
|
||||||
|
base / "sns" / "sns.db-wal",
|
||||||
|
base / "sns" / "sns.db-shm",
|
||||||
|
base / "sns.db",
|
||||||
|
base / "sns.db-wal",
|
||||||
|
base / "sns.db-shm",
|
||||||
|
]
|
||||||
|
max_ns = 0
|
||||||
|
for p in candidates:
|
||||||
|
v = _mtime_ns(p)
|
||||||
|
if v > max_ns:
|
||||||
|
max_ns = v
|
||||||
|
return int(max_ns)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AccountState:
|
||||||
|
last_mtime_ns: int = 0
|
||||||
|
due_at: float = 0.0
|
||||||
|
last_sync_end_at: float = 0.0
|
||||||
|
thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SnsRealtimeAutoSyncService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._enabled = _env_bool("WECHAT_TOOL_SNS_AUTOSYNC", True)
|
||||||
|
self._interval_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_INTERVAL_MS", 2000, min_v=500, max_v=60_000)
|
||||||
|
self._debounce_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_DEBOUNCE_MS", 800, min_v=0, max_v=60_000)
|
||||||
|
self._min_sync_interval_ms = _env_int(
|
||||||
|
"WECHAT_TOOL_SNS_AUTOSYNC_MIN_SYNC_INTERVAL_MS", 5000, min_v=0, max_v=300_000
|
||||||
|
)
|
||||||
|
self._workers = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_WORKERS", 1, min_v=1, max_v=4)
|
||||||
|
self._max_scan = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_MAX_SCAN", 200, min_v=20, max_v=2000)
|
||||||
|
|
||||||
|
self._mu = threading.Lock()
|
||||||
|
self._states: dict[str, _AccountState] = {}
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
logger.info("[sns-autosync] disabled by env WECHAT_TOOL_SNS_AUTOSYNC=0")
|
||||||
|
return
|
||||||
|
with self._mu:
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
th = threading.Thread(target=self._run, name="sns-realtime-autosync", daemon=True)
|
||||||
|
self._thread = th
|
||||||
|
th.start()
|
||||||
|
logger.info(
|
||||||
|
"[sns-autosync] started interval_ms=%s debounce_ms=%s min_sync_interval_ms=%s max_scan=%s workers=%s",
|
||||||
|
int(self._interval_ms),
|
||||||
|
int(self._debounce_ms),
|
||||||
|
int(self._min_sync_interval_ms),
|
||||||
|
int(self._max_scan),
|
||||||
|
int(self._workers),
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
with self._mu:
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
tick_t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
self._tick()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[sns-autosync] tick failed")
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - tick_t0) * 1000.0
|
||||||
|
sleep_ms = max(200.0, float(self._interval_ms) - elapsed_ms)
|
||||||
|
self._stop.wait(timeout=sleep_ms / 1000.0)
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
accounts = _list_decrypted_accounts()
|
||||||
|
now = time.time()
|
||||||
|
if not accounts:
|
||||||
|
return
|
||||||
|
|
||||||
|
for acc in accounts:
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
account_dir = _resolve_account_dir(acc)
|
||||||
|
except HTTPException:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
info = WCDB_REALTIME.get_status(account_dir)
|
||||||
|
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
|
||||||
|
if not available:
|
||||||
|
continue
|
||||||
|
|
||||||
|
db_storage_dir = Path(str(info.get("db_storage_dir") or "").strip())
|
||||||
|
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
mtime_ns = _scan_sns_db_mtime_ns(db_storage_dir)
|
||||||
|
with self._mu:
|
||||||
|
st = self._states.setdefault(acc, _AccountState())
|
||||||
|
if mtime_ns and mtime_ns != st.last_mtime_ns:
|
||||||
|
st.last_mtime_ns = int(mtime_ns)
|
||||||
|
st.due_at = now + (float(self._debounce_ms) / 1000.0)
|
||||||
|
|
||||||
|
# Schedule daemon threads.
|
||||||
|
to_start: list[threading.Thread] = []
|
||||||
|
with self._mu:
|
||||||
|
keep = set(accounts)
|
||||||
|
for acc in list(self._states.keys()):
|
||||||
|
if acc not in keep:
|
||||||
|
self._states.pop(acc, None)
|
||||||
|
|
||||||
|
running = 0
|
||||||
|
for st in self._states.values():
|
||||||
|
th = st.thread
|
||||||
|
if th is not None and th.is_alive():
|
||||||
|
running += 1
|
||||||
|
elif th is not None and (not th.is_alive()):
|
||||||
|
st.thread = None
|
||||||
|
|
||||||
|
for acc, st in self._states.items():
|
||||||
|
if running >= int(self._workers):
|
||||||
|
break
|
||||||
|
if st.due_at <= 0 or st.due_at > now:
|
||||||
|
continue
|
||||||
|
if st.thread is not None and st.thread.is_alive():
|
||||||
|
continue
|
||||||
|
|
||||||
|
since = now - float(st.last_sync_end_at or 0.0)
|
||||||
|
min_interval = float(self._min_sync_interval_ms) / 1000.0
|
||||||
|
if min_interval > 0 and since < min_interval:
|
||||||
|
st.due_at = now + (min_interval - since)
|
||||||
|
continue
|
||||||
|
|
||||||
|
st.due_at = 0.0
|
||||||
|
th = threading.Thread(
|
||||||
|
target=self._sync_account_runner,
|
||||||
|
args=(acc,),
|
||||||
|
name=f"sns-autosync-{acc}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
st.thread = th
|
||||||
|
to_start.append(th)
|
||||||
|
running += 1
|
||||||
|
|
||||||
|
for th in to_start:
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
th.start()
|
||||||
|
except Exception:
|
||||||
|
with self._mu:
|
||||||
|
for acc, st in self._states.items():
|
||||||
|
if st.thread is th:
|
||||||
|
st.thread = None
|
||||||
|
break
|
||||||
|
|
||||||
|
def _sync_account_runner(self, account: str) -> None:
|
||||||
|
account = str(account or "").strip()
|
||||||
|
try:
|
||||||
|
if self._stop.is_set() or (not account):
|
||||||
|
return
|
||||||
|
res = self._sync_account(account)
|
||||||
|
upserted = int((res or {}).get("upserted") or 0)
|
||||||
|
logger.info("[sns-autosync] sync done account=%s upserted=%s", account, upserted)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[sns-autosync] sync failed account=%s", account)
|
||||||
|
finally:
|
||||||
|
with self._mu:
|
||||||
|
st = self._states.get(account)
|
||||||
|
if st is not None:
|
||||||
|
st.thread = None
|
||||||
|
st.last_sync_end_at = time.time()
|
||||||
|
|
||||||
|
def _sync_account(self, account: str) -> dict[str, Any]:
|
||||||
|
account = str(account or "").strip()
|
||||||
|
if not account:
|
||||||
|
return {"status": "skipped", "reason": "missing account"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
account_dir = _resolve_account_dir(account)
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "skipped", "reason": f"resolve account failed: {e}"}
|
||||||
|
|
||||||
|
info = WCDB_REALTIME.get_status(account_dir)
|
||||||
|
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
|
||||||
|
if not available:
|
||||||
|
return {"status": "skipped", "reason": "realtime not available"}
|
||||||
|
|
||||||
|
# Import lazily to avoid startup import ordering issues.
|
||||||
|
from .routers.sns import sync_sns_realtime_timeline_latest
|
||||||
|
|
||||||
|
try:
|
||||||
|
return sync_sns_realtime_timeline_latest(
|
||||||
|
account=account,
|
||||||
|
max_scan=int(self._max_scan),
|
||||||
|
force=0,
|
||||||
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
return {"status": "error", "error": str(e.detail or "")}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService()
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -20,7 +22,51 @@ class WCDBRealtimeError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
|
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
|
||||||
_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
||||||
|
_WCDB_API_DLL_SELECTED: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_wcdb_api_dll_paths() -> list[Path]:
|
||||||
|
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
|
||||||
|
cands: list[Path] = []
|
||||||
|
|
||||||
|
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
|
||||||
|
if env:
|
||||||
|
cands.append(Path(env))
|
||||||
|
|
||||||
|
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
|
||||||
|
try:
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
except Exception:
|
||||||
|
repo_root = Path.cwd()
|
||||||
|
|
||||||
|
for p in [
|
||||||
|
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
|
||||||
|
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
|
||||||
|
_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_lock = threading.Lock()
|
||||||
_lib: Optional[ctypes.CDLL] = None
|
_lib: Optional[ctypes.CDLL] = None
|
||||||
@@ -40,16 +86,18 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
|||||||
if not _is_windows():
|
if not _is_windows():
|
||||||
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
|
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
|
||||||
|
|
||||||
if not _WCDB_API_DLL.exists():
|
wcdb_api_dll = _resolve_wcdb_api_dll_path()
|
||||||
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {_WCDB_API_DLL}")
|
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.
|
# Ensure dependent DLLs (e.g. WCDB.dll) can be found.
|
||||||
try:
|
try:
|
||||||
os.add_dll_directory(str(_NATIVE_DIR))
|
os.add_dll_directory(str(wcdb_api_dll.parent))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
lib = ctypes.CDLL(str(_WCDB_API_DLL))
|
lib = ctypes.CDLL(str(wcdb_api_dll))
|
||||||
|
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
|
||||||
|
|
||||||
# Signatures
|
# Signatures
|
||||||
lib.wcdb_init.argtypes = []
|
lib.wcdb_init.argtypes = []
|
||||||
@@ -144,6 +192,19 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
|||||||
# Older wcdb_api.dll may not expose this export.
|
# Older wcdb_api.dll may not expose this export.
|
||||||
pass
|
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.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
|
||||||
lib.wcdb_get_logs.restype = ctypes.c_int
|
lib.wcdb_get_logs.restype = ctypes.c_int
|
||||||
|
|
||||||
@@ -488,6 +549,63 @@ def get_sns_timeline(
|
|||||||
return []
|
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()
|
||||||
|
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.")
|
||||||
|
|
||||||
|
raw = bytes(encrypted_data or b"")
|
||||||
|
if not raw:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
k = str(key or "").strip()
|
||||||
|
if not k:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
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:
|
def shutdown() -> None:
|
||||||
global _initialized
|
global _initialized
|
||||||
lib = _load_wcdb_lib()
|
lib = _load_wcdb_lib()
|
||||||
@@ -573,11 +691,16 @@ class WCDBRealtimeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e)
|
err = str(e)
|
||||||
|
|
||||||
dll_ok = _WCDB_API_DLL.exists()
|
dll_path = _resolve_wcdb_api_dll_path()
|
||||||
|
try:
|
||||||
|
dll_ok = bool(dll_path.exists())
|
||||||
|
except Exception:
|
||||||
|
dll_ok = False
|
||||||
connected = self.is_connected(account)
|
connected = self.is_connected(account)
|
||||||
return {
|
return {
|
||||||
"account": account,
|
"account": account,
|
||||||
"dll_present": bool(dll_ok),
|
"dll_present": bool(dll_ok),
|
||||||
|
"wcdb_api_dll": str(dll_path),
|
||||||
"key_present": bool(key_ok),
|
"key_present": bool(key_ok),
|
||||||
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
|
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
|
||||||
"session_db_path": str(session_db_path) if session_db_path else "",
|
"session_db_path": str(session_db_path) if session_db_path else "",
|
||||||
|
|||||||
122
tools/weflow_wasm_keystream.js
Normal file
122
tools/weflow_wasm_keystream.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node tools/weflow_wasm_keystream.js <key> <size>
|
||||||
|
//
|
||||||
|
// Prints a base64-encoded keystream to stdout (no extra logs).
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const vm = require('vm')
|
||||||
|
|
||||||
|
function usageAndExit() {
|
||||||
|
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(process.argv[2] || '').trim()
|
||||||
|
const size = Number(process.argv[3] || 0)
|
||||||
|
|
||||||
|
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
|
||||||
|
|
||||||
|
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
|
||||||
|
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
|
||||||
|
const jsPath = path.join(basePath, 'wasm_video_decode.js')
|
||||||
|
|
||||||
|
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||||
|
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasmBinary = fs.readFileSync(wasmPath)
|
||||||
|
const jsContent = fs.readFileSync(jsPath, 'utf8')
|
||||||
|
|
||||||
|
let capturedKeystream = null
|
||||||
|
let resolveInit
|
||||||
|
let rejectInit
|
||||||
|
const initPromise = new Promise((res, rej) => {
|
||||||
|
resolveInit = res
|
||||||
|
rejectInit = rej
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockGlobal = {
|
||||||
|
console: { log: () => {}, error: () => {} }, // keep stdout clean
|
||||||
|
Buffer,
|
||||||
|
Uint8Array,
|
||||||
|
Int8Array,
|
||||||
|
Uint16Array,
|
||||||
|
Int16Array,
|
||||||
|
Uint32Array,
|
||||||
|
Int32Array,
|
||||||
|
Float32Array,
|
||||||
|
Float64Array,
|
||||||
|
BigInt64Array,
|
||||||
|
BigUint64Array,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
Function,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
Error,
|
||||||
|
Promise,
|
||||||
|
require,
|
||||||
|
process,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval,
|
||||||
|
clearInterval,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGlobal.Module = {
|
||||||
|
onRuntimeInitialized: () => resolveInit(),
|
||||||
|
wasmBinary,
|
||||||
|
print: () => {},
|
||||||
|
printErr: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGlobal.self = mockGlobal
|
||||||
|
mockGlobal.self.location = { href: jsPath }
|
||||||
|
mockGlobal.WorkerGlobalScope = function () {}
|
||||||
|
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
|
||||||
|
|
||||||
|
mockGlobal.wasm_isaac_generate = (ptr, n) => {
|
||||||
|
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
|
||||||
|
capturedKeystream = new Uint8Array(buf) // copy view
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = vm.createContext(mockGlobal)
|
||||||
|
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
|
||||||
|
} catch (e) {
|
||||||
|
rejectInit(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await initPromise
|
||||||
|
|
||||||
|
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
|
||||||
|
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
|
||||||
|
}
|
||||||
|
if (!mockGlobal.Module.WxIsaac64) {
|
||||||
|
throw new Error('WxIsaac64 not found in WASM module')
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedKeystream = null
|
||||||
|
const isaac = new mockGlobal.Module.WxIsaac64(key)
|
||||||
|
isaac.generate(size)
|
||||||
|
if (isaac.delete) isaac.delete()
|
||||||
|
|
||||||
|
if (!capturedKeystream) throw new Error('Failed to capture keystream')
|
||||||
|
|
||||||
|
const out = Buffer.from(capturedKeystream)
|
||||||
|
// Match WeFlow worker logic: reverse the captured Uint8Array.
|
||||||
|
out.reverse()
|
||||||
|
process.stdout.write(out.toString('base64'))
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
Reference in New Issue
Block a user