fix(realtime): 内置 WCDB sidecar 并切换项目内 DLL

- 桌面端启动时自动拉起 WCDB sidecar,并向后端注入连接参数

- 打包附带 sidecar 脚本与 koffi 运行时

- 改为通过项目内置 WeFlow DLL 处理 realtime 调用,规避宿主校验失败和连接超时
This commit is contained in:
2977094657
2026-04-11 16:56:42 +08:00
Unverified
parent 1e7c02863d
commit 604e01eb50
10 changed files with 1876 additions and 41 deletions
+8
View File
@@ -57,6 +57,14 @@
{
"from": "resources/backend",
"to": "backend"
},
{
"from": "src/wcdb-sidecar.cjs",
"to": "wcdb-sidecar.cjs"
},
{
"from": "vendor/koffi",
"to": "koffi"
}
],
"win": {
+161
View File
@@ -17,6 +17,7 @@ try {
autoUpdaterLoadError = err;
}
const { spawn, spawnSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const net = require("net");
@@ -33,6 +34,10 @@ const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1")
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
let backendProc = null;
let wcdbSidecarProc = null;
let wcdbSidecarPort = null;
let wcdbSidecarUrl = "";
let wcdbSidecarToken = "";
let resolvedDataDir = null;
let mainWindow = null;
let tray = null;
@@ -1452,8 +1457,159 @@ function getPackagedWcdbDllPath() {
return path.join(process.resourcesPath, "backend", "native", "wcdb_api.dll");
}
function getDevWcdbDllPath() {
return path.join(repoRoot(), "src", "wechat_decrypt_tool", "native", "wcdb_api.dll");
}
function getWcdbDllPath() {
return app.isPackaged ? getPackagedWcdbDllPath() : getDevWcdbDllPath();
}
function getWcdbDllDir() {
return path.dirname(getWcdbDllPath());
}
function getWcdbSidecarScriptPath() {
return app.isPackaged
? path.join(process.resourcesPath, "wcdb-sidecar.cjs")
: path.join(__dirname, "wcdb-sidecar.cjs");
}
function getKoffiDir() {
return app.isPackaged ? path.join(process.resourcesPath, "koffi") : path.join(repoRoot(), "desktop", "vendor", "koffi");
}
function getWcdbSidecarStdioLogPath(dataDir) {
return path.join(dataDir, "wcdb-sidecar-stdio.log");
}
function getWcdbSidecarPort() {
if (parsePort(wcdbSidecarPort) != null) return wcdbSidecarPort;
const envPort = parsePort(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT);
wcdbSidecarPort = envPort ?? Math.min(65535, getBackendPort() + 101);
return wcdbSidecarPort;
}
async function prepareWcdbSidecarPort() {
if (parsePort(wcdbSidecarPort) != null) return wcdbSidecarPort;
const envPort = parsePort(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT);
if (envPort != null) {
wcdbSidecarPort = envPort;
return wcdbSidecarPort;
}
const preferred = Math.min(65535, getBackendPort() + 101);
wcdbSidecarPort = await chooseAvailablePort(preferred, "127.0.0.1");
if (wcdbSidecarPort == null) wcdbSidecarPort = preferred;
return wcdbSidecarPort;
}
function getWcdbResourcePaths() {
const out = [];
const seen = new Set();
const add = (value) => {
const raw = String(value || "").trim();
if (!raw) return;
const resolved = path.resolve(raw);
const key = resolved.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(resolved);
};
const dllDir = getWcdbDllDir();
add(dllDir);
add(path.dirname(dllDir));
add(repoRoot());
add(path.join(repoRoot(), "resources"));
const dataDir = resolveDataDir();
if (dataDir) {
add(dataDir);
add(path.join(dataDir, "resources"));
}
return out;
}
function ensureWcdbSidecarEnv(env) {
if (!wcdbSidecarUrl || !wcdbSidecarToken) return env;
env.WECHAT_TOOL_WCDB_SIDECAR_URL = wcdbSidecarUrl;
env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN = wcdbSidecarToken;
return env;
}
function startWcdbSidecar() {
if (process.env.WECHAT_TOOL_WCDB_SIDECAR === "0") return null;
if (wcdbSidecarProc && wcdbSidecarProc.exitCode == null) return wcdbSidecarProc;
if (process.platform !== "win32") return null;
const dllPath = getWcdbDllPath();
const sidecarScript = getWcdbSidecarScriptPath();
const koffiDir = getKoffiDir();
if (!fs.existsSync(dllPath)) {
logMain(`[wcdb-sidecar] skip: missing wcdb_api.dll ${dllPath}`);
return null;
}
if (!fs.existsSync(sidecarScript)) {
logMain(`[wcdb-sidecar] skip: missing sidecar script ${sidecarScript}`);
return null;
}
if (!fs.existsSync(koffiDir)) {
logMain(`[wcdb-sidecar] skip: missing koffi runtime ${koffiDir}`);
return null;
}
const port = getWcdbSidecarPort();
const host = "127.0.0.1";
wcdbSidecarUrl = `http://${host}:${port}`;
wcdbSidecarToken = wcdbSidecarToken || crypto.randomBytes(24).toString("hex");
const env = {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
WECHAT_TOOL_WCDB_SIDECAR_HOST: host,
WECHAT_TOOL_WCDB_SIDECAR_PORT: String(port),
WECHAT_TOOL_WCDB_SIDECAR_TOKEN: wcdbSidecarToken,
WECHAT_TOOL_WCDB_API_DLL_PATH: dllPath,
WECHAT_TOOL_WCDB_DLL_DIR: getWcdbDllDir(),
WECHAT_TOOL_WCDB_RESOURCE_PATHS: JSON.stringify(getWcdbResourcePaths()),
WECHAT_TOOL_KOFFI_DIR: koffiDir,
};
logMain(`[wcdb-sidecar] starting url=${wcdbSidecarUrl} dll=${dllPath}`);
wcdbSidecarProc = spawn(process.execPath, [sidecarScript], {
cwd: path.dirname(sidecarScript),
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const dataDir = resolveDataDir() || getUserDataDir() || repoRoot();
attachBackendStdio(wcdbSidecarProc, getWcdbSidecarStdioLogPath(dataDir));
const proc = wcdbSidecarProc;
proc.on("exit", (code, signal) => {
if (wcdbSidecarProc === proc) wcdbSidecarProc = null;
logMain(`[wcdb-sidecar] exited code=${code} signal=${signal}`);
});
process.env.WECHAT_TOOL_WCDB_SIDECAR_URL = wcdbSidecarUrl;
process.env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN = wcdbSidecarToken;
return wcdbSidecarProc;
}
function stopWcdbSidecar() {
if (!wcdbSidecarProc) return;
const pid = wcdbSidecarProc.pid;
logMain(`[wcdb-sidecar] stop pid=${pid || "?"}`);
try {
wcdbSidecarProc.kill();
} catch {}
wcdbSidecarProc = null;
}
function startBackend() {
if (backendProc) return backendProc;
startWcdbSidecar();
const env = {
...process.env,
@@ -1462,6 +1618,7 @@ function startBackend() {
// Make sure Python prints UTF-8 to stdout/stderr.
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
};
ensureWcdbSidecarEnv(env);
// In packaged mode we expect to provide the generated Nuxt output dir via env.
if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) {
@@ -2135,6 +2292,7 @@ async function main() {
await applyPendingOutputDirOnStartup();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
await prepareWcdbSidecarPort();
logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`);
@@ -2190,6 +2348,7 @@ async function main() {
app.on("window-all-closed", () => {
stopBackend();
stopWcdbSidecar();
if (process.platform !== "darwin") app.quit();
});
@@ -2203,6 +2362,7 @@ app.on("before-quit", () => {
isQuitting = true;
destroyTray();
stopBackend();
stopWcdbSidecar();
});
if (gotSingleInstanceLock) {
@@ -2211,6 +2371,7 @@ if (gotSingleInstanceLock) {
console.error(err);
logMain(`[main] fatal: ${err?.stack || String(err)}`);
stopBackend();
stopWcdbSidecar();
try {
const dir = getUserDataDir();
const outputDir = resolveOutputDir();
+545
View File
@@ -0,0 +1,545 @@
"use strict";
const fs = require("fs");
const http = require("http");
const path = require("path");
const HOST = String(process.env.WECHAT_TOOL_WCDB_SIDECAR_HOST || "127.0.0.1").trim() || "127.0.0.1";
const PORT = Number.parseInt(String(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT || "0").trim(), 10);
const TOKEN = String(process.env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN || "").trim();
let koffi = null;
let nativeLib = null;
let nativeDllPath = "";
let initialized = false;
let protectionResults = [];
const preloadedLibs = [];
const funcs = Object.create(null);
class ApiError extends Error {
constructor(message, rc = 0, details = null) {
super(message);
this.name = "ApiError";
this.rc = rc;
this.details = details;
}
}
function log(message) {
process.stderr.write(`[wcdb-sidecar] ${new Date().toISOString()} ${message}\n`);
}
function jsonResponse(res, statusCode, payload) {
const body = Buffer.from(JSON.stringify(payload), "utf8");
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": String(body.length),
"Cache-Control": "no-store",
});
res.end(body);
}
function readRequestJson(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > 16 * 1024 * 1024) {
reject(new ApiError("request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8").trim();
resolve(raw ? JSON.parse(raw) : {});
} catch (err) {
reject(new ApiError(`invalid json: ${err?.message || err}`));
}
});
req.on("error", reject);
});
}
function parseResourcePaths() {
const out = [];
const seen = new Set();
function add(value) {
const raw = String(value || "").trim();
if (!raw) return;
const resolved = path.resolve(raw);
const key = resolved.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(resolved);
}
const raw = String(process.env.WECHAT_TOOL_WCDB_RESOURCE_PATHS || "").trim();
if (raw) {
try {
const decoded = JSON.parse(raw);
if (Array.isArray(decoded)) {
for (const item of decoded) add(item);
}
} catch {
for (const item of raw.split(path.delimiter)) add(item);
}
}
const dllDir = getDllDir();
add(dllDir);
add(path.dirname(dllDir));
add(process.cwd());
return out;
}
function getDllDir() {
const fromEnv = String(process.env.WECHAT_TOOL_WCDB_DLL_DIR || "").trim();
if (fromEnv) return path.resolve(fromEnv);
const dllPath = String(process.env.WECHAT_TOOL_WCDB_API_DLL_PATH || "").trim();
if (dllPath) return path.dirname(path.resolve(dllPath));
return process.cwd();
}
function getDllPath() {
const fromEnv = String(process.env.WECHAT_TOOL_WCDB_API_DLL_PATH || "").trim();
if (fromEnv) return path.resolve(fromEnv);
return path.join(getDllDir(), "wcdb_api.dll");
}
function loadKoffi() {
if (koffi) return koffi;
const koffiDir = String(process.env.WECHAT_TOOL_KOFFI_DIR || "").trim();
if (koffiDir) {
koffi = require(path.resolve(koffiDir));
} else {
koffi = require("koffi");
}
return koffi;
}
function tryFunc(signature) {
try {
return nativeLib.func(signature);
} catch {
return null;
}
}
function loadNative() {
if (nativeLib) return nativeLib;
const ffi = loadKoffi();
const dllDir = getDllDir();
nativeDllPath = getDllPath();
if (!fs.existsSync(nativeDllPath)) {
throw new ApiError(`wcdb_api.dll not found: ${nativeDllPath}`);
}
try {
process.chdir(dllDir);
} catch {}
for (const dep of ["WCDB.dll", "SDL2.dll", "VoipEngine.dll"]) {
const depPath = path.join(dllDir, dep);
if (!fs.existsSync(depPath)) continue;
try {
preloadedLibs.push(ffi.load(depPath));
log(`preloaded dependency ${depPath}`);
} catch (err) {
log(`preload dependency failed ${depPath}: ${err?.message || err}`);
}
}
nativeLib = ffi.load(nativeDllPath);
funcs.InitProtection = tryFunc("int32 InitProtection(const char* resourcePath)");
funcs.wcdb_init = tryFunc("int32 wcdb_init()");
funcs.wcdb_shutdown = tryFunc("int32 wcdb_shutdown()");
funcs.wcdb_open_account = tryFunc("int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)");
funcs.wcdb_close_account = tryFunc("int32 wcdb_close_account(int64 handle)");
funcs.wcdb_set_my_wxid = tryFunc("int32 wcdb_set_my_wxid(int64 handle, const char* wxid)");
funcs.wcdb_get_sessions = tryFunc("int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)");
funcs.wcdb_get_messages = tryFunc(
"int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)"
);
funcs.wcdb_get_message_count = tryFunc(
"int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* count)"
);
funcs.wcdb_get_display_names = tryFunc(
"int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)"
);
funcs.wcdb_get_avatar_urls = tryFunc(
"int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)"
);
funcs.wcdb_get_group_member_count = tryFunc(
"int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* count)"
);
funcs.wcdb_get_group_members = tryFunc(
"int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)"
);
funcs.wcdb_get_group_nicknames = tryFunc(
"int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)"
);
funcs.wcdb_exec_query = tryFunc(
"int32 wcdb_exec_query(int64 handle, const char* kind, const char* dbPath, const char* sql, _Out_ void** outJson)"
);
funcs.wcdb_update_message = tryFunc(
"int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)"
);
funcs.wcdb_delete_message = tryFunc(
"int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)"
);
funcs.wcdb_get_sns_timeline = tryFunc(
"int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* usernamesJson, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)"
);
funcs.wcdb_decrypt_sns_image = tryFunc(
"int32 wcdb_decrypt_sns_image(void* encryptedData, int32 len, const char* key, _Out_ void** outHex)"
);
funcs.wcdb_get_logs = tryFunc("int32 wcdb_get_logs(_Out_ void** outJson)");
funcs.wcdb_free_string = tryFunc("void wcdb_free_string(void* ptr)");
if (!funcs.wcdb_init || !funcs.wcdb_open_account || !funcs.wcdb_get_logs || !funcs.wcdb_free_string) {
throw new ApiError("wcdb_api.dll is missing required exports");
}
log(`loaded ${nativeDllPath}`);
return nativeLib;
}
function requireFunc(name) {
loadNative();
const fn = funcs[name];
if (!fn) throw new ApiError(`${name} not exported`, -404);
return fn;
}
function ptrToString(ptr) {
if (!ptr) return "";
return loadKoffi().decode(ptr, "char", -1);
}
function freeStringPtr(ptr) {
if (!ptr || !funcs.wcdb_free_string) return;
try {
funcs.wcdb_free_string(ptr);
} catch {}
}
function getLogs() {
try {
loadNative();
const out = [null];
const rc = Number(funcs.wcdb_get_logs(out));
try {
if (rc !== 0 || !out[0]) return [];
const payload = ptrToString(out[0]);
const decoded = JSON.parse(payload || "[]");
return Array.isArray(decoded) ? decoded.map((x) => String(x)) : [];
} finally {
freeStringPtr(out[0]);
}
} catch {
return [];
}
}
function callOutJson(name, args) {
const fn = requireFunc(name);
const out = [null];
const rc = Number(fn(...args, out));
try {
if (rc !== 0) throw new ApiError(`${name} failed`, rc);
return ptrToString(out[0]);
} finally {
freeStringPtr(out[0]);
}
}
function callOutError(name, args) {
const fn = requireFunc(name);
const out = [null];
const rc = Number(fn(...args, out));
try {
if (rc !== 0) {
const message = ptrToString(out[0]) || `${name} failed`;
throw new ApiError(message, rc);
}
return null;
} finally {
freeStringPtr(out[0]);
}
}
function ensureInitialized() {
loadNative();
if (initialized) {
return { initialized: true, dllPath: nativeDllPath, protection: protectionResults };
}
protectionResults = [];
if (funcs.InitProtection) {
for (const resourcePath of parseResourcePaths()) {
try {
const rc = Number(funcs.InitProtection(resourcePath));
protectionResults.push({ path: resourcePath, rc });
log(`InitProtection rc=${rc} path=${resourcePath}`);
if (rc === 0) break;
} catch (err) {
protectionResults.push({ path: resourcePath, error: String(err?.message || err) });
}
}
}
const rc = Number(funcs.wcdb_init());
if (rc !== 0) {
throw new ApiError("wcdb_init failed", rc, { protection: protectionResults });
}
initialized = true;
return { initialized: true, dllPath: nativeDllPath, protection: protectionResults };
}
function normalizeHandle(value) {
const n = Number(value || 0);
if (!Number.isFinite(n) || n <= 0) throw new ApiError("invalid handle");
return n;
}
function handleAction(action, payload) {
const data = payload && typeof payload === "object" ? payload : {};
switch (action) {
case "init":
return ensureInitialized();
case "get_logs":
loadNative();
return { logs: getLogs() };
case "open_account": {
ensureInitialized();
const dbPath = String(data.path || "").trim();
const key = String(data.key || "").trim();
if (!dbPath) throw new ApiError("missing account path");
if (key.length !== 64) throw new ApiError("invalid db key");
const out = [0];
const rc = Number(requireFunc("wcdb_open_account")(dbPath, key, out));
const handle = Number(out[0] || 0);
if (rc !== 0 || handle <= 0) throw new ApiError("wcdb_open_account failed", rc);
return { handle };
}
case "close_account": {
const handle = normalizeHandle(data.handle);
try {
requireFunc("wcdb_close_account")(handle);
} catch {}
return { closed: true };
}
case "set_my_wxid": {
const fn = requireFunc("wcdb_set_my_wxid");
const rc = Number(fn(normalizeHandle(data.handle), String(data.wxid || "").trim()));
return { success: rc === 0, rc };
}
case "get_sessions":
return { payload: callOutJson("wcdb_get_sessions", [normalizeHandle(data.handle)]) };
case "get_messages":
return {
payload: callOutJson("wcdb_get_messages", [
normalizeHandle(data.handle),
String(data.username || "").trim(),
Number.parseInt(String(data.limit || 0), 10) || 0,
Number.parseInt(String(data.offset || 0), 10) || 0,
]),
};
case "get_message_count": {
const out = [0];
const rc = Number(
requireFunc("wcdb_get_message_count")(normalizeHandle(data.handle), String(data.username || "").trim(), out)
);
return { count: rc === 0 ? Number(out[0] || 0) : 0, rc };
}
case "get_display_names":
return {
payload: callOutJson("wcdb_get_display_names", [
normalizeHandle(data.handle),
JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []),
]),
};
case "get_avatar_urls":
return {
payload: callOutJson("wcdb_get_avatar_urls", [
normalizeHandle(data.handle),
JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []),
]),
};
case "get_group_members":
return {
payload: callOutJson("wcdb_get_group_members", [
normalizeHandle(data.handle),
String(data.chatroom_id || "").trim(),
]),
};
case "get_group_nicknames":
return {
payload: callOutJson("wcdb_get_group_nicknames", [
normalizeHandle(data.handle),
String(data.chatroom_id || "").trim(),
]),
};
case "exec_query":
return {
payload: callOutJson("wcdb_exec_query", [
normalizeHandle(data.handle),
String(data.kind || "").trim(),
data.path == null ? null : String(data.path || "").trim(),
String(data.sql || ""),
]),
};
case "update_message":
callOutError("wcdb_update_message", [
normalizeHandle(data.handle),
String(data.session_id || "").trim(),
Number(data.local_id || 0),
Number.parseInt(String(data.create_time || 0), 10) || 0,
String(data.new_content || ""),
]);
return { success: true };
case "delete_message":
callOutError("wcdb_delete_message", [
normalizeHandle(data.handle),
String(data.session_id || "").trim(),
Number(data.local_id || 0),
Number.parseInt(String(data.create_time || 0), 10) || 0,
String(data.db_path_hint || ""),
]);
return { success: true };
case "get_sns_timeline":
return {
payload: callOutJson("wcdb_get_sns_timeline", [
normalizeHandle(data.handle),
Number.parseInt(String(data.limit || 0), 10) || 0,
Number.parseInt(String(data.offset || 0), 10) || 0,
JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []),
String(data.keyword || ""),
Number.parseInt(String(data.start_time || 0), 10) || 0,
Number.parseInt(String(data.end_time || 0), 10) || 0,
]),
};
case "decrypt_sns_image": {
ensureInitialized();
const raw = Buffer.from(String(data.data_b64 || ""), "base64");
if (!raw.length) return { data_b64: "" };
const key = String(data.key || "").trim();
if (!key) return { data_b64: raw.toString("base64") };
const out = [null];
const rc = Number(requireFunc("wcdb_decrypt_sns_image")(raw, raw.length, key, out));
try {
if (rc !== 0 || !out[0]) return { data_b64: raw.toString("base64"), rc };
const hex = ptrToString(out[0]).replace(/[^0-9a-f]/gi, "");
if (!hex) return { data_b64: raw.toString("base64"), rc };
return { data_b64: Buffer.from(hex, "hex").toString("base64"), rc };
} finally {
freeStringPtr(out[0]);
}
}
case "shutdown": {
if (nativeLib && initialized && funcs.wcdb_shutdown) {
try {
funcs.wcdb_shutdown();
} finally {
initialized = false;
}
}
return { shutdown: true };
}
default:
throw new ApiError(`unknown action: ${action}`);
}
}
async function handleRequest(req, res) {
try {
if (req.method === "GET" && req.url === "/health") {
jsonResponse(res, 200, { ok: true, initialized, dllPath: nativeDllPath || getDllPath() });
return;
}
if (req.method !== "POST" || req.url !== "/call") {
jsonResponse(res, 404, { ok: false, error: "not found" });
return;
}
if (TOKEN && String(req.headers["x-wcdb-sidecar-token"] || "") !== TOKEN) {
jsonResponse(res, 401, { ok: false, error: "unauthorized" });
return;
}
const body = await readRequestJson(req);
const action = String(body?.action || "").trim();
const result = handleAction(action, body?.payload || {});
jsonResponse(res, 200, { ok: true, result });
} catch (err) {
const rc = Number.isFinite(err?.rc) ? Number(err.rc) : 0;
const error = err?.message || String(err);
log(`error rc=${rc} message=${error}`);
jsonResponse(res, 200, {
ok: false,
error,
rc,
details: err?.details || null,
logs: getLogs().slice(-20),
});
}
}
if (!Number.isInteger(PORT) || PORT <= 0 || PORT > 65535) {
log(`invalid sidecar port: ${process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT || ""}`);
process.exit(2);
}
const server = http.createServer((req, res) => {
void handleRequest(req, res);
});
server.listen(PORT, HOST, () => {
log(`listening http://${HOST}:${PORT} dll=${getDllPath()} koffi=${process.env.WECHAT_TOOL_KOFFI_DIR || "node_modules"}`);
});
process.on("SIGTERM", () => {
try {
server.close(() => process.exit(0));
} catch {
process.exit(0);
}
});
process.on("SIGINT", () => {
try {
server.close(() => process.exit(0));
} catch {
process.exit(0);
}
});
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (C) 2025 Niels Martignène <niels.martignene@protonmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
Binary file not shown.
+634
View File
@@ -0,0 +1,634 @@
"use strict";
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod3) => function __require() {
return mod3 || (0, cb[__getOwnPropNames(cb)[0]])((mod3 = { exports: {} }).exports, mod3), mod3.exports;
};
// package/src/cnoke/src/tools.js
var require_tools = __commonJS({
"package/src/cnoke/src/tools.js"(exports2, module2) {
"use strict";
var crypto = require("crypto");
var fs2 = require("fs");
var http = require("https");
var path2 = require("path");
var zlib = require("zlib");
async function download_http(url, dest) {
console.log(">> Downloading " + url);
let [tmp_name, file] = open_temporary_stream(dest);
try {
await new Promise((resolve, reject) => {
let request = http.get(url, (response) => {
if (response.statusCode != 200) {
let err = new Error(`Download failed: ${response.statusMessage} [${response.statusCode}]`);
err.code = response.statusCode;
reject(err);
return;
}
response.pipe(file);
file.on("finish", () => file.close(() => {
try {
fs2.renameSync(file.path, dest);
} catch (err) {
if (!fs2.existsSync(dest))
reject(err);
}
resolve();
}));
});
request.on("error", reject);
file.on("error", reject);
});
} catch (err) {
file.close();
try {
fs2.unlinkSync(tmp_name);
} catch (err2) {
if (err2.code != "ENOENT")
throw err2;
}
throw err;
}
}
function open_temporary_stream(prefix) {
let buf = Buffer.allocUnsafe(4);
for (; ; ) {
try {
crypto.randomFillSync(buf);
let suffix = buf.toString("hex").padStart(8, "0");
let filename2 = `${prefix}.${suffix}`;
let file = fs2.createWriteStream(filename2, { flags: "wx", mode: 420 });
return [filename2, file];
} catch (err) {
if (err.code != "EEXIST")
throw err;
}
}
}
function extract_targz(filename2, dest_dir, strip = 0) {
let reader = fs2.createReadStream(filename2).pipe(zlib.createGunzip());
return new Promise((resolve, reject) => {
let header = null;
let extended = {};
reader.on("readable", () => {
try {
for (; ; ) {
if (header == null) {
let buf = reader.read(512);
if (buf == null)
break;
if (!buf[0])
continue;
header = {
filename: buf.toString("utf-8", 0, 100).replace(/\0/g, ""),
mode: parseInt(buf.toString("ascii", 100, 109), 8),
size: parseInt(buf.toString("ascii", 124, 137), 8),
type: String.fromCharCode(buf[156])
};
Object.assign(header, extended);
extended = {};
header.filename = header.filename.replace(/\\/g, "/");
if (!header.filename.length)
throw new Error(`Insecure empty filename inside TAR archive`);
if (path_is_absolute(header.filename[0]))
throw new Error(`Insecure filename starting with / inside TAR archive`);
if (path_has_dotdot(header.filename))
throw new Error(`Insecure filename containing '..' inside TAR archive`);
for (let i = 0; i < strip; i++)
header.filename = header.filename.substr(header.filename.indexOf("/") + 1);
}
let aligned = Math.floor((header.size + 511) / 512) * 512;
let data = header.size ? reader.read(aligned) : null;
if (data == null) {
if (header.size)
break;
data = Buffer.alloc(0);
}
data = data.subarray(0, header.size);
if (header.type == "0" || header.type == "7") {
let filename3 = dest_dir + "/" + header.filename;
let dirname = path2.dirname(filename3);
fs2.mkdirSync(dirname, { recursive: true, mode: 493 });
fs2.writeFileSync(filename3, data, { mode: header.mode });
} else if (header.type == "5") {
let filename3 = dest_dir + "/" + header.filename;
fs2.mkdirSync(filename3, { recursive: true, mode: header.mode });
} else if (header.type == "L") {
extended.filename = data.toString("utf-8").replace(/\0/g, "");
} else if (header.type == "x") {
let str = data.toString("utf-8");
try {
while (str.length) {
let matches = str.match(/^([0-9]+) ([a-zA-Z0-9\._]+)=(.*)\n/);
let skip = parseInt(matches[1], 10);
let key = matches[2];
let value = matches[3];
switch (key) {
case "path":
{
extended.filename = value;
}
break;
case "size":
{
extended.size = parseInt(value, 10);
}
break;
}
str = str.substr(skip).trimStart();
}
} catch (err) {
throw new Error("Malformed PAX entry");
}
}
header = null;
}
} catch (err) {
reject(err);
}
});
reader.on("error", reject);
reader.on("end", resolve);
});
}
function path_is_absolute(path3) {
if (process.platform == "win32" && path3.match(/^[a-zA-Z]:/))
path3 = path3.substr(2);
return is_path_separator(path3[0]);
}
function path_has_dotdot(path3) {
let start = 0;
for (; ; ) {
let offset = path3.indexOf("..", start);
if (offset < 0)
break;
start = offset + 2;
if (offset && !is_path_separator(path3[offset - 1]))
continue;
if (offset + 2 < path3.length && !is_path_separator(path3[offset + 2]))
continue;
return true;
}
return false;
}
function is_path_separator(c) {
if (c == "/")
return true;
if (process.platform == "win32" && c == "\\")
return true;
return false;
}
function sync_files(src_dir, dest_dir) {
let keep = /* @__PURE__ */ new Set();
{
let entries = fs2.readdirSync(src_dir, { withFileTypes: true });
for (let entry of entries) {
if (!entry.isFile())
continue;
keep.add(entry.name);
fs2.copyFileSync(src_dir + `/${entry.name}`, dest_dir + `/${entry.name}`);
}
}
{
let entries = fs2.readdirSync(dest_dir, { withFileTypes: true });
for (let entry of entries) {
if (!entry.isFile())
continue;
if (keep.has(entry.name))
continue;
fs2.unlinkSync(dest_dir + `/${entry.name}`);
}
}
}
function determine_arch2() {
let arch = process.arch;
if (arch == "riscv32" || arch == "riscv64") {
let buf = read_file_header(process.execPath, 512);
let header = decode_elf_header(buf);
let float_abi = header.e_flags & 6;
switch (float_abi) {
case 0:
{
}
break;
case 2:
{
arch += "f";
}
break;
case 4:
{
arch += "d";
}
break;
case 6:
{
arch += "q";
}
break;
}
} else if (arch == "arm") {
let buf = read_file_header(process.execPath, 512);
let header = decode_elf_header(buf);
if (header.e_flags & 1024) {
arch += "hf";
} else if (header.e_flags & 512) {
arch += "sf";
} else {
throw new Error("Unknown ARM floating-point ABI");
}
}
return arch;
}
function read_file_header(filename2, read) {
let fd = null;
try {
let fd2 = fs2.openSync(filename2);
let buf = Buffer.allocUnsafe(read);
let len = fs2.readSync(fd2, buf);
return buf.subarray(0, len);
} finally {
if (fd != null)
fs2.closeSync(fd);
}
}
function decode_elf_header(buf) {
let header = {};
if (buf.length < 16)
throw new Error("Truncated header");
if (buf[0] != 127 || buf[1] != 69 || buf[2] != 76 || buf[3] != 70)
throw new Error("Invalid magic number");
if (buf[6] != 1)
throw new Error("Invalid ELF version");
if (buf[5] != 1)
throw new Error("Big-endian architectures are not supported");
let machine = buf.readUInt16LE(18);
switch (machine) {
case 3:
{
header.e_machine = "ia32";
}
break;
case 40:
{
header.e_machine = "arm";
}
break;
case 62:
{
header.e_machine = "amd64";
}
break;
case 183:
{
header.e_machine = "arm64";
}
break;
case 243:
{
switch (buf[4]) {
case 1:
{
header.e_machine = "riscv32";
}
break;
case 2:
{
header.e_machine = "riscv64";
}
break;
}
}
break;
case 248:
{
switch (buf[4]) {
case 1:
{
header.e_machine = "loong32";
}
break;
case 2:
{
header.e_machine = "loong64";
}
break;
}
}
break;
default:
throw new Error("Unknown ELF machine type");
}
switch (buf[4]) {
case 1:
{
buf = buf.subarray(0, 68);
if (buf.length < 68)
throw new Error("Truncated ELF header");
header.ei_class = 32;
header.e_flags = buf.readUInt32LE(36);
}
break;
case 2:
{
buf = buf.subarray(0, 120);
if (buf.length < 120)
throw new Error("Truncated ELF header");
header.ei_class = 64;
header.e_flags = buf.readUInt32LE(48);
}
break;
default:
throw new Error("Invalid ELF class");
}
return header;
}
function unlink_recursive(path3) {
try {
fs2.rmSync(path3, { recursive: true, maxRetries: process.platform == "win32" ? 3 : 0 });
} catch (err) {
if (err.code !== "ENOENT")
throw err;
}
}
function get_napi_version2(napi, major) {
if (napi > 8)
return null;
const support = {
6: ["6.14.2", "6.14.2", "6.14.2"],
8: ["8.6.0", "8.10.0", "8.11.2"],
9: ["9.0.0", "9.3.0", "9.11.0"],
10: ["10.0.0", "10.0.0", "10.0.0", "10.16.0", "10.17.0", "10.20.0", "10.23.0"],
11: ["11.0.0", "11.0.0", "11.0.0", "11.8.0"],
12: ["12.0.0", "12.0.0", "12.0.0", "12.0.0", "12.11.0", "12.17.0", "12.19.0", "12.22.0"],
13: ["13.0.0", "13.0.0", "13.0.0", "13.0.0", "13.0.0"],
14: ["14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.12.0", "14.17.0"],
15: ["15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.12.0"]
};
const max = Math.max(...Object.keys(support).map((k) => parseInt(k, 10)));
if (major > max)
return major + ".0.0";
if (support[major] == null)
return null;
let required = support[major][napi - 1] || null;
return required;
}
function cmp_version(ver1, ver2) {
ver1 = String(ver1).replace(/-.*$/, "").split(".").reduce((acc, v, idx) => acc + parseInt(v, 10) * Math.pow(10, 2 * (5 - idx)), 0);
ver2 = String(ver2).replace(/-.*$/, "").split(".").reduce((acc, v, idx) => acc + parseInt(v, 10) * Math.pow(10, 2 * (5 - idx)), 0);
let cmp = Math.min(Math.max(ver1 - ver2, -1), 1);
return cmp;
}
module2.exports = {
download_http,
extract_targz,
path_is_absolute,
path_has_dotdot,
sync_files,
determine_arch: determine_arch2,
unlink_recursive,
get_napi_version: get_napi_version2,
cmp_version
};
}
});
// package/src/koffi/package.json
var require_package = __commonJS({
"package/src/koffi/package.json"(exports2, module2) {
module2.exports = {
name: "koffi",
version: "2.15.2",
description: "Fast and simple C FFI (foreign function interface) for Node.js",
keywords: [
"foreign",
"function",
"interface",
"ffi",
"binding",
"c",
"napi"
],
repository: {
type: "git",
url: "https://github.com/Koromix/koffi"
},
homepage: "https://koffi.dev/",
author: {
name: "Niels Martign\xE8ne",
email: "niels.martignene@protonmail.com",
url: "https://koromix.dev/"
},
main: "./index.js",
types: "./index.d.ts",
scripts: {
test: "node tools/koffi.js test",
prepack: `echo 'Use "npm run package" instead' && false`,
prepublishOnly: `echo 'Use "npm run package" instead' && false`,
package: "node tools/koffi.js build"
},
license: "MIT",
cnoke: {
api: "../../vendor/node-api-headers",
output: "../../bin/Koffi/{{ toolchain }}",
node: 16,
napi: 8,
require: "./index.js"
},
funding: "https://liberapay.com/Koromix"
};
}
});
// package/src/koffi/src/init.js
var require_init = __commonJS({
"package/src/koffi/src/init.js"(exports, module) {
var fs = require("fs");
var path = require("path");
var util = require("util");
var { get_napi_version, determine_arch } = require_tools();
var pkg = require_package();
function detect() {
if (process.versions.napi == null || process.versions.napi < pkg.cnoke.napi) {
let major = parseInt(process.versions.node, 10);
let required = get_napi_version(pkg.cnoke.napi, major);
if (required != null) {
throw new Error(`This engine is based on Node ${process.versions.node}, but ${pkg.name} requires Node >= ${required} in the Node ${major}.x branch (N-API >= ${pkg.cnoke.napi})`);
} else {
throw new Error(`This engine is based on Node ${process.versions.node}, but ${pkg.name} does not support the Node ${major}.x branch (N-API < ${pkg.cnoke.napi})`);
}
}
let arch = determine_arch();
let triplet3 = `${process.platform}_${arch}`;
return triplet3;
}
function init(triplet, native) {
if (native == null) {
let roots = [path.join(__dirname, "..")];
let triplets = [triplet];
if (process.resourcesPath != null)
roots.push(process.resourcesPath);
if (triplet.startsWith("linux_")) {
let musl = triplet.replace(/^linux_/, "musl_");
triplets.push(musl);
}
let filenames = roots.flatMap((root) => triplets.flatMap((triplet3) => [
`${root}/koffi/build/koffi/${triplet3}/koffi.node`,
`${root}/build/koffi/${triplet3}/koffi.node`,
`${root}/koffi/${triplet3}/koffi.node`,
`${root}/node_modules/koffi/build/koffi/${triplet3}/koffi.node`,
`${root}/../../bin/Koffi/${triplet3}/koffi.node`
]));
let first_err = null;
for (let filename of filenames) {
if (!fs.existsSync(filename))
continue;
try {
native = eval("require")(filename);
} catch (err) {
if (first_err == null)
first_err = err;
continue;
}
break;
}
if (first_err != null)
throw first_err;
}
if (native == null)
throw new Error("Cannot find the native Koffi module; did you bundle it correctly?");
if (native.version != pkg.version)
throw new Error("Mismatched native Koffi modules");
let mod = wrap(native);
return mod;
}
function wrap(native3) {
let obj = {
...native3,
// Deprecated functions
handle: util.deprecate(native3.opaque, "The koffi.handle() function was deprecated in Koffi 2.1, use koffi.opaque() instead", "KOFFI001"),
callback: util.deprecate(native3.proto, "The koffi.callback() function was deprecated in Koffi 2.4, use koffi.proto() instead", "KOFFI002")
};
obj.load = (...args) => {
let lib = native3.load(...args);
lib.cdecl = util.deprecate((...args2) => lib.func("__cdecl", ...args2), "The koffi.cdecl() function was deprecated in Koffi 2.7, use koffi.func(...) instead", "KOFFI003");
lib.stdcall = util.deprecate((...args2) => lib.func("__stdcall", ...args2), 'The koffi.stdcall() function was deprecated in Koffi 2.7, use koffi.func("__stdcall", ...) instead', "KOFFI004");
lib.fastcall = util.deprecate((...args2) => lib.func("__fastcall", ...args2), 'The koffi.fastcall() function was deprecated in Koffi 2.7, use koffi.func("__fastcall", ...) instead', "KOFFI005");
lib.thiscall = util.deprecate((...args2) => lib.func("__thiscall", ...args2), 'The koffi.thiscall() function was deprecated in Koffi 2.7, use koffi.func("__thiscall", ...) instead', "KOFFI006");
return lib;
};
return obj;
}
module.exports = {
detect,
init
};
}
});
// package/src/koffi/index.js
var { detect: detect2, init: init2 } = require_init();
var triplet2 = detect2();
var native2 = null;
try {
switch (triplet2) {
case "darwin_arm64":
{
native2 = require("./build/koffi/darwin_arm64/koffi.node");
}
break;
case "darwin_x64":
{
native2 = require("./build/koffi/darwin_x64/koffi.node");
}
break;
case "freebsd_arm64":
{
native2 = require("./build/koffi/freebsd_arm64/koffi.node");
}
break;
case "freebsd_ia32":
{
native2 = require("./build/koffi/freebsd_ia32/koffi.node");
}
break;
case "freebsd_x64":
{
native2 = require("./build/koffi/freebsd_x64/koffi.node");
}
break;
case "linux_armhf":
{
native2 = require("./build/koffi/linux_armhf/koffi.node");
}
break;
case "linux_arm64":
{
native2 = require("./build/koffi/linux_arm64/koffi.node");
}
break;
case "linux_ia32":
{
native2 = require("./build/koffi/linux_ia32/koffi.node");
}
break;
case "linux_loong64":
{
native2 = require("./build/koffi/linux_loong64/koffi.node");
}
break;
case "linux_riscv64d":
{
native2 = require("./build/koffi/linux_riscv64d/koffi.node");
}
break;
case "linux_x64":
{
native2 = require("./build/koffi/linux_x64/koffi.node");
}
break;
case "openbsd_ia32":
{
native2 = require("./build/koffi/openbsd_ia32/koffi.node");
}
break;
case "openbsd_x64":
{
native2 = require("./build/koffi/openbsd_x64/koffi.node");
}
break;
case "win32_arm64":
{
native2 = require("./build/koffi/win32_arm64/koffi.node");
}
break;
case "win32_ia32":
{
native2 = require("./build/koffi/win32_ia32/koffi.node");
}
break;
case "win32_x64":
{
native2 = require("./build/koffi/win32_x64/koffi.node");
}
break;
}
} catch {
try {
switch (triplet2) {
case "linux_arm64":
{
native2 = require("./build/koffi/musl_arm64/koffi.node");
}
break;
case "linux_x64":
{
native2 = require("./build/koffi/musl_x64/koffi.node");
}
break;
}
} catch {
}
}
var mod2 = init2(triplet2, native2);
module.exports = mod2;
+26
View File
@@ -0,0 +1,26 @@
{
"name": "koffi",
"version": "2.15.2",
"description": "Fast and simple C FFI (foreign function interface) for Node.js",
"repository": {
"type": "git",
"url": "https://github.com/Koromix/koffi"
},
"homepage": "https://koffi.dev/",
"author": {
"name": "Niels Martignène",
"email": "niels.martignene@protonmail.com",
"url": "https://koromix.dev/"
},
"main": "./index.js",
"types": "./index.d.ts",
"license": "MIT",
"cnoke": {
"api": "../../vendor/node-api-headers",
"output": "build/koffi/{{ toolchain }}",
"node": 16,
"napi": 8,
"require": "./index.js"
},
"funding": "https://liberapay.com/Koromix"
}
Binary file not shown.
Binary file not shown.
+480 -41
View File
@@ -1,4 +1,5 @@
import ctypes
import base64
import binascii
import json
import os
@@ -6,6 +7,8 @@ import re
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
@@ -121,14 +124,188 @@ def _resolve_wcdb_api_dll_path() -> Path:
_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
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 _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
global _lib, _loaded_wcdb_api_dll
with _lib_lock:
if _lib is not None:
return _lib
@@ -146,6 +323,7 @@ def _load_wcdb_lib() -> ctypes.CDLL:
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)
@@ -291,20 +469,50 @@ def _load_wcdb_lib() -> ctypes.CDLL:
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
global _initialized, _loaded_wcdb_api_dll, _protection_result
if _sidecar_enabled():
with _lib_lock:
if _initialized:
return
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
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 = f" logs={logs[:6]}" if logs else ""
hint = _format_protection_hint()
if logs:
hint += f" logs={logs[:6]}"
raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}")
_initialized = True
@@ -368,6 +576,21 @@ def _call_out_error(fn, *args) -> None:
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()
@@ -404,6 +627,20 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
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)))
@@ -421,15 +658,22 @@ def set_my_wxid(handle: int, wxid: str) -> bool:
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
w = str(wxid or "").strip()
if not w:
return False
try:
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
except Exception:
@@ -449,6 +693,12 @@ def close_account(handle: int) -> None:
_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))
@@ -458,6 +708,13 @@ def close_account(handle: int) -> None:
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)
@@ -472,10 +729,26 @@ def get_sessions(handle: int) -> list[dict[str, Any]]:
def get_messages(handle: int, username: str, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
_ensure_initialized()
lib = _load_wcdb_lib()
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)),
@@ -495,10 +768,21 @@ def get_messages(handle: int, username: str, *, limit: int = 50, offset: int = 0
def get_message_count(handle: int, username: str) -> int:
_ensure_initialized()
lib = _load_wcdb_lib()
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:
@@ -508,11 +792,22 @@ def get_message_count(handle: int, username: str) -> int:
def get_display_names(handle: int, usernames: list[str]) -> dict[str, str]:
_ensure_initialized()
lib = _load_wcdb_lib()
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)
@@ -523,11 +818,22 @@ def get_display_names(handle: int, usernames: list[str]) -> dict[str, str]:
def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
_ensure_initialized()
lib = _load_wcdb_lib()
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)
@@ -538,10 +844,21 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]:
_ensure_initialized()
lib = _load_wcdb_lib()
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):
@@ -555,15 +872,29 @@ def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]:
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 {}
cid = str(chatroom_id or "").strip()
if not cid:
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):
@@ -577,11 +908,6 @@ def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
"""
_ensure_initialized()
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.")
k = str(kind or "").strip()
if not k:
raise WCDBRealtimeError("Missing kind for exec_query.")
@@ -592,6 +918,27 @@ def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list
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)),
@@ -615,15 +962,29 @@ def update_message(handle: int, *, session_id: str, local_id: int, create_time:
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.")
sid = str(session_id or "").strip()
if not sid:
raise WCDBRealtimeError("Missing session_id for update_message.")
_call_out_error(
fn,
ctypes.c_int64(int(handle)),
@@ -647,16 +1008,30 @@ def delete_message(
Requires wcdb_delete_message export in wcdb_api.dll.
"""
_ensure_initialized()
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.")
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)),
@@ -682,11 +1057,6 @@ def get_sns_timeline(
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
"""
_ensure_initialized()
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.")
lim = max(0, int(limit or 0))
off = max(0, int(offset or 0))
@@ -696,6 +1066,30 @@ def get_sns_timeline(
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)),
@@ -724,11 +1118,6 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
- 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""
@@ -737,6 +1126,28 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
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
@@ -775,6 +1186,17 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
def shutdown() -> None:
global _initialized
if _sidecar_enabled():
with _lib_lock:
if not _initialized:
return
try:
_sidecar_call("shutdown", timeout=5.0)
finally:
with _lib_lock:
_initialized = False
return
lib = _load_wcdb_lib()
with _lib_lock:
if not _initialized:
@@ -893,6 +1315,7 @@ class WCDBRealtimeManager:
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
@@ -926,9 +1349,11 @@ class WCDBRealtimeManager:
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)
@@ -952,14 +1377,27 @@ class WCDBRealtimeManager:
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]
@@ -980,7 +1418,8 @@ class WCDBRealtimeManager:
with self._mu:
self._conns[account] = conn
logger.info(f"[wcdb] connected account={account} session_db={session_db_path}")
self._failed.pop(account, None)
logger.info("[wcdb] connected account=%s handle=%s session_db=%s", account, int(handle), session_db_path)
return conn
finally:
with self._mu: