feat: wire wechat archive through langbot and n8n

This commit is contained in:
Codex
2026-06-13 04:32:52 +00:00
parent e47eca77ab
commit c818d0cd44
8 changed files with 653 additions and 78 deletions
+142
View File
@@ -15,6 +15,7 @@ interface RuntimeConfig {
providerToken: string | null;
sshClientToken: string | null;
sshClientRouteAllowlist: string[];
wechatArchiveToken: string | null;
sessionSecret: string;
sessionTtlSeconds: number;
logFile: string;
@@ -211,6 +212,8 @@ function readConfig(): RuntimeConfig {
sshClientToken: optionalEnv("UNIDESK_SSH_CLIENT_TOKEN")
?? optionalFileEnv("UNIDESK_SSH_CLIENT_TOKEN_FILE", "/run/secrets/unidesk_ssh_client_token"),
sshClientRouteAllowlist: parseRouteAllowlist(optionalEnv("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*"),
wechatArchiveToken: optionalEnv("UNIDESK_WECHAT_ARCHIVE_TOKEN")
?? optionalFileEnv("UNIDESK_WECHAT_ARCHIVE_TOKEN_FILE", "/tmp/unidesk-wechat-archive-token"),
sessionSecret: requiredEnv("SESSION_SECRET"),
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
logFile: requiredEnv("LOG_FILE"),
@@ -319,6 +322,7 @@ function trimPerformanceBuffers(): void {
function classifyRequestComponent(pathname: string): string {
if (pathname === "/api/frontend-performance") return "webui_performance";
if (pathname === "/webhooks/wechat-archive") return "wechat_archive_webhook";
if (pathname.startsWith("/api/") || pathname === "/logs") return "webui_api_proxy";
if (pathname === "/login" || pathname === "/logout" || pathname === "/api/session") return "webui_auth";
if (pathname === "/app.js" || pathname.startsWith("/vendor/") || /\/[^/]+\.[a-z0-9]+$/iu.test(pathname)) return "webui_static";
@@ -789,6 +793,143 @@ function sessionResponse(req: Request): Response {
return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() });
}
async function readJsonRecord(req: Request, maxBytes: number): Promise<Record<string, unknown>> {
const bytes = await req.arrayBuffer();
if (bytes.byteLength > maxBytes) throw new Error(`request body exceeds ${maxBytes} bytes`);
const text = Buffer.from(bytes).toString("utf8");
const parsed = JSON.parse(text) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
return parsed as Record<string, unknown>;
}
function recordFrom(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringFrom(value: unknown): string {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function wechatArchiveTokenFromRequest(req: Request): string | null {
return bearerTokenFromRequest(req) ?? req.headers.get("x-unidesk-wechat-archive-token")?.trim() ?? null;
}
function isValidArchiveRemotePath(path: string): boolean {
return path.startsWith("/UniDesk/WeChatArchive/")
&& !path.includes("\0")
&& !path.split("/").includes("..")
&& path.length <= 1024;
}
function archiveContentPayload(body: Record<string, unknown>): Record<string, unknown> {
const message = recordFrom(body.message);
const archive = recordFrom(body.archive);
const media = recordFrom(message.media);
const messageType = stringFrom(message.messageType || body.messageType || "text").toLowerCase();
const remotePath = stringFrom(archive.remotePath);
if (!isValidArchiveRemotePath(remotePath)) throw new Error("archive.remotePath is outside the WeChat archive root");
const filename = stringFrom(archive.filename || remotePath.split("/").filter(Boolean).pop() || "wechat-archive.txt");
if (messageType === "image") {
const dataBase64 = stringFrom(media.dataBase64 || archive.dataBase64 || body.dataBase64);
if (!dataBase64) throw new Error("image archive payload is missing media.dataBase64");
return { dataBase64, filename, remotePath, maxBytes: 10 * 1024 * 1024 };
}
const text = stringFrom(message.text || body.text || body.message || body.content);
const payloadHash = new Bun.CryptoHasher("sha256").update(JSON.stringify(body)).digest("hex");
const content = `${text}\n\n---\nsource=unidesk-wechat-archive\npayloadSha256=${payloadHash}\n`;
return { content, filename, remotePath, maxBytes: 10 * 1024 * 1024 };
}
async function coreJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {
const response = await fetch(new URL(path, config.coreInternalUrl), {
...init,
headers: {
"content-type": "application/json",
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers as Record<string, string> | undefined),
},
});
const text = await response.text();
let parsed: unknown = text;
try {
parsed = text.length > 0 ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!response.ok) return { ok: false, status: response.status, body: parsed };
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? { ok: true, status: response.status, ...(parsed as Record<string, unknown>) }
: { ok: true, status: response.status, body: parsed };
}
async function waitBaiduTransfer(jobId: string, timeoutMs: number): Promise<Record<string, unknown>> {
const deadline = Date.now() + timeoutMs;
let last: Record<string, unknown> = { ok: false, error: "not-polled" };
while (Date.now() < deadline) {
last = await coreJson(`/api/microservices/baidu-netdisk/proxy/api/transfers/${encodeURIComponent(jobId)}`);
const job = recordFrom(last.job);
const status = stringFrom(job.status || last.status);
if (status === "succeeded") return { ok: true, job };
if (status === "failed" || status === "canceled") return { ok: false, job };
await Bun.sleep(1000);
}
return { ok: false, error: "baidu-transfer-timeout", last };
}
function baiduFsIdFromJob(job: Record<string, unknown>): string {
const result = recordFrom(job.result);
const baidu = recordFrom(result.baidu);
return stringFrom(baidu.fs_id || baidu.fsId || job.fsId);
}
async function wechatArchiveWebhook(req: Request): Promise<Response> {
if (req.method !== "POST") return jsonResponse({ ok: false, error: "method not allowed" }, 405);
const expected = config.wechatArchiveToken;
const supplied = wechatArchiveTokenFromRequest(req);
if (expected === null || supplied === null || !timingSafeStringEqual(supplied, expected)) {
return jsonResponse({ ok: false, error: "authentication required", valuesPrinted: false }, 401);
}
const started = performance.now();
try {
const body = await readJsonRecord(req, 2 * 1024 * 1024);
const uploadBody = archiveContentPayload(body);
const created = await coreJson("/api/microservices/baidu-netdisk/proxy/api/transfers/upload-content", {
method: "POST",
body: JSON.stringify(uploadBody),
});
if (created.ok !== true) {
recordOperationPerformance("frontend", "wechat_archive_upload_create", performance.now() - started, false, stringFrom(created.status));
return jsonResponse({ ok: false, error: "baidu upload create failed", upstream: created, valuesPrinted: false }, 502);
}
const job = recordFrom(created.job);
const jobId = stringFrom(job.id);
if (!jobId) throw new Error("baidu upload create did not return job id");
const waited = await waitBaiduTransfer(jobId, 90_000);
const waitedJob = recordFrom(waited.job);
const fsId = baiduFsIdFromJob(waitedJob);
const remotePath = stringFrom(uploadBody.remotePath);
const ok = waited.ok === true && fsId.length > 0;
recordOperationPerformance("frontend", "wechat_archive_upload", performance.now() - started, ok, `${jobId}:${remotePath}`);
logger(ok ? "info" : "warn", "wechat_archive_upload_finished", { ok, jobId, remotePath, fsIdPresent: fsId.length > 0 });
return jsonResponse({
ok,
response: ok ? `已归档到百度网盘:${remotePath}` : "归档失败",
archive: {
remotePath,
fsId,
local: recordFrom(created.local),
uploadJob: waitedJob,
listed: null,
},
valuesPrinted: false,
}, ok ? 200 : 502);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordOperationPerformance("frontend", "wechat_archive_upload", performance.now() - started, false, message);
logger("warn", "wechat_archive_upload_failed", { error: message });
return jsonResponse({ ok: false, error: message, valuesPrinted: false }, 400);
}
}
async function proxyApi(req: Request, url: URL): Promise<Response> {
if (sessionFromRequest(req) === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
@@ -952,6 +1093,7 @@ async function handleRequest(req: Request, server: Server<FrontendWsData>): Prom
if (url.pathname === "/logout" && req.method === "POST") return logout();
if (url.pathname === "/api/session") return sessionResponse(req);
if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req);
if (url.pathname === "/webhooks/wechat-archive") return wechatArchiveWebhook(req);
if (url.pathname === "/ws/ssh") return proxySshWebSocket(req, server);
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);