import { randomUUID } from "node:crypto"; import { Buffer } from "node:buffer"; import { existsSync, readFileSync } from "node:fs"; import pathPosix from "node:path/posix"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; import { asRecord, assertProxyOk, booleanField, compactUnknown, containerPathToHostPath, fetchJsonWithTimeout, findBaiduFileByRemotePath, hostRootPath, microserviceProxy, normalizeRemotePath, numberField, optionalStringField, parseEnvFile, parseOpsApplyOptions, parseOpsCommonOptions, readYamlRecord, redactSensitiveUnknown, recordField, repoRelative, resolveRepoPath, sanitizePathSegment, sha256File, stringField, syncN8nWorkflow, waitForBaiduTransfer, type OpsApplyOptions, type OpsCommonOptions, } from "./platform-infra-ops-library"; import { prepareLangBotSecretMaterial, readLangBotPostgresConninfo, readLangBotRuntimeConfig, readLangBotSecretMaterial } from "./platform-infra-langbot"; import { capture, compactCapture, fingerprintValues, parseJsonOutput, shQuote } from "./platform-infra-public-service"; const configFile = rootPath("config", "platform-infra", "wechat-archive.yaml"); const configLabel = "config/platform-infra/wechat-archive.yaml"; interface WechatArchiveConfig { version: number; kind: "platform-infra-wechat-archive"; metadata: { id: string; owner: string; relatedIssues: number[] }; target: { id: string; route: string; namespace: string }; langbot: { configRef: string; publicBaseUrl: string; expectedAdapter: string; callbackPath: string; readOnlyRecord: { enabled: boolean; mode: string; webhookName: string; webhookDescription: string; discardPipelineUuid: string }; pipeline: { name: string; description: string; runner: string; outputKey: string; timeoutSeconds: number }; notes: string[]; }; n8n: { configRef: string; publicBaseUrl: string; workflow: { name: string; id: string; webhookPath: string; active: boolean; timezone: string }; }; archiveCallback: { publicUrl: string; secretRoot: string; tokenSourceRef: string; tokenKey: string; timeoutMs: number; }; personalWechatIngress: { enabled: boolean; mode: string; target: { id: string; windowsRoute: string; hostRoute: string; kubeRoute: string }; pcWechat: { isolation: string; requiredVersion: string; installerAsset: string; installMode: string; installRoot: string; dataRoot: string; autoUpdatePolicy: string; }; wcfHost: { releaseTag: string; requiredWechatVersion: string; releaseUrl: string; sdkRoot: string; stateRoot: string; commandPort: number; messagePort: number; bindHost: string; supervisor: string; rdpPolicy: string; firewall: { publicExposure: boolean; allowedClientRefs: string[] }; }; collector: { runtime: string; namespace: string; createNamespace: boolean; workload: { kind: string; name: string; serviceAccountName: string; replicas: number; containerName: string }; storage: { kind: string; name: string; mountPath: string; size: string; create: boolean }; image: { repository: string; tag: string; pullPolicy: string; context: string; dockerfile: string; wcferryVersion: string; pip: { indexUrl: string; trustedHost: string; timeoutSeconds: number; retries: number }; }; wcfHost: string; commandPort: number; messagePort: number; stateRoot: string; queueMode: string; outboxMode: string; archiveCallbackRef: string; secretName: string; readOnly: { enabled: boolean; sendCapability: boolean; allowedMethods: string[]; forbiddenMethods: string[] }; }; poc: { accountPolicy: string; observationWindowHours: number; requiredMessageTypes: string[] }; }; baiduNetdisk: { serviceId: string; proxyMode: string; configRef: string; staging: { hostRoot: string; containerRoot: string; outboxDir: string; pullDir: string }; archive: { remoteRoot: string; timezone: string; textExtension: string; imageExtension: string; pathTemplate: string; maxInlineBytes: number; }; }; messageTypes: Record; validate: { timeoutMs: number; pollIntervalMs: number; fixtures: { text: { conversationId: string; senderId: string; text: string }; image: { conversationId: string; senderId: string; filename: string; mimeType: string; dataBase64: string }; }; }; } interface PullOptions extends OpsCommonOptions { remotePath?: string; fsId?: string; localPath?: string; } export function wechatArchiveHelp(): Record { return { command: "platform-infra wechat-archive plan|apply|status|validate|pull|wcf-host-*|collector-*", output: "json", usage: [ "bun scripts/cli.ts platform-infra wechat-archive plan [--target G14]", "bun scripts/cli.ts platform-infra wechat-archive apply [--target G14] --dry-run", "bun scripts/cli.ts platform-infra wechat-archive apply [--target G14] --confirm", "bun scripts/cli.ts platform-infra wechat-archive status [--target G14] [--full|--raw]", "bun scripts/cli.ts platform-infra wechat-archive validate [--target G14] [--full|--raw]", "bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/... [--target G14] [--full|--raw]", "bun scripts/cli.ts platform-infra wechat-archive pull --fs-id [--local-path wechat-archive/pulls/file] [--target G14]", "bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm", "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status [--full|--raw]", "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm", "bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm", "bun scripts/cli.ts platform-infra wechat-archive collector-image-status [--full|--raw]", "bun scripts/cli.ts platform-infra wechat-archive collector-apply --dry-run", "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm", "bun scripts/cli.ts platform-infra wechat-archive collector-status [--full|--raw]", ], configTruth: configLabel, boundary: { langbot: "Receives WeChat/OpenClaw events and forwards archive events to the YAML-declared n8n webhook.", n8n: "Stores a YAML-rendered workflow that normalizes WeChat payloads.", personalWechatIngress: "Optionally declares the D601 Windows PC WeChat + WeChatFerry host and D601 k3s read-only collector boundary.", baiduNetdisk: "Uploads and downloads through the private backend-core microservice proxy; Baidu credentials remain in the baidu-netdisk service.", database: "LangBot and n8n continue to use the single PK01/Pika01 external PostgreSQL instance with separate databases/roles.", }, }; } export async function runWechatArchiveCommand(config: UniDeskConfig, args: string[]): Promise> { const [action = "plan"] = args; if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1))); if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1))); if (action === "status") return await status(parseOpsCommonOptions(args.slice(1))); if (action === "validate") return await validate(parseOpsCommonOptions(args.slice(1))); if (action === "pull") return await pull(parsePullOptions(args.slice(1))); if (action === "wcf-host-prepare") return await wcfHostPrepare(config, parseOpsApplyOptions(args.slice(1))); if (action === "wcf-host-start") return await wcfHostStart(config, parseOpsApplyOptions(args.slice(1))); if (action === "wcf-host-status") return await wcfHostStatus(config, parseOpsCommonOptions(args.slice(1))); if (action === "collector-plan") return collectorPlan(parseOpsCommonOptions(args.slice(1))); if (action === "collector-image-build") return await collectorImageBuild(config, parseOpsApplyOptions(args.slice(1))); if (action === "collector-image-status") return await collectorImageStatus(config, parseOpsCommonOptions(args.slice(1))); if (action === "collector-apply") return await collectorApply(config, parseOpsApplyOptions(args.slice(1))); if (action === "collector-status") return await collectorStatus(config, parseOpsCommonOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-wechat-archive-command", args, help: wechatArchiveHelp() }; } function parsePullOptions(args: string[]): PullOptions { const parsed = parseOpsCommonOptions(args, { stringOptions: ["--remote-path", "--fs-id", "--local-path"] }); return { targetId: parsed.targetId, full: parsed.full, raw: parsed.raw, remotePath: typeof parsed.remotePath === "string" ? parsed.remotePath : undefined, fsId: typeof parsed.fsId === "string" ? parsed.fsId : undefined, localPath: typeof parsed.localPath === "string" ? parsed.localPath : undefined, }; } function plan(options: OpsCommonOptions): Record { const archive = readConfig(); assertTarget(archive, options.targetId); const workflow = renderN8nWorkflow(archive, null); const policy = policyChecks(archive); return { ok: policy.every((check) => check.ok), action: "platform-infra-wechat-archive-plan", mutation: false, config: configSummary(archive), workflow: { id: archive.n8n.workflow.id, name: archive.n8n.workflow.name, webhookUrl: webhookUrl(archive), nodes: Array.isArray(workflow.nodes) ? workflow.nodes.length : 0, active: archive.n8n.workflow.active, }, policy, next: { dryRun: `bun scripts/cli.ts platform-infra wechat-archive apply --target ${archive.target.id} --dry-run`, apply: `bun scripts/cli.ts platform-infra wechat-archive apply --target ${archive.target.id} --confirm`, validate: `bun scripts/cli.ts platform-infra wechat-archive validate --target ${archive.target.id} --full`, pull: `bun scripts/cli.ts platform-infra wechat-archive pull --remote-path ${archive.baiduNetdisk.archive.remoteRoot}/... --target ${archive.target.id}`, personalWechatIngress: { prepareWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm", startWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm", buildCollectorImage: "bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm", applyCollector: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm", status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", }, }, }; } async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const policy = policyChecks(archive); if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-wechat-archive-apply", mode: "policy-blocked", policy }; if (options.confirm && !options.wait) { const job = startJob( "platform_infra_wechat_archive_apply_g14", ["bun", "scripts/cli.ts", "platform-infra", "wechat-archive", "apply", "--target", archive.target.id, "--confirm", "--wait"], "Sync the YAML-rendered n8n workflow for WeChat -> Baidu Netdisk archive", ); return { ok: true, action: "platform-infra-wechat-archive-apply", mode: "async-job", mutation: true, job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, }; } const callbackToken = options.dryRun ? null : readArchiveCallbackToken(archive); const workflowJson = renderN8nWorkflow(archive, callbackToken?.value ?? null); const sync = await syncN8nWorkflow(config, { targetRoute: archive.target.route, namespace: archive.target.namespace, deploymentName: "n8n", workflowJson, workflowId: archive.n8n.workflow.id, workflowName: archive.n8n.workflow.name, webhookPath: archive.n8n.workflow.webhookPath, active: archive.n8n.workflow.active, dryRun: options.dryRun, }); const langbotBinding = options.dryRun ? langBotDryRun(archive) : await syncLangBotPipeline(archive); return { ok: sync.ok && langbotBinding.ok === true, action: "platform-infra-wechat-archive-apply", mode: options.dryRun ? "dry-run" : "confirmed", mutation: !options.dryRun, config: configSummary(archive), policy, n8nWorkflow: sync, archiveCallbackAuth: callbackToken === null ? { mode: "dry-run", valuesPrinted: false } : { sourceRef: callbackToken.sourceRef, keyName: callbackToken.keyName, fingerprint: callbackToken.fingerprint, valuesPrinted: false, }, langbotBinding, next: { status: `bun scripts/cli.ts platform-infra wechat-archive status --target ${archive.target.id}`, validate: `bun scripts/cli.ts platform-infra wechat-archive validate --target ${archive.target.id} --full`, }, }; } async function status(options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const health = microserviceProxy(archive.baiduNetdisk.serviceId, "/health", { timeoutMs: 30_000 }); const auth = microserviceProxy(archive.baiduNetdisk.serviceId, "/api/auth/status", { timeoutMs: 30_000 }); const transfers = microserviceProxy(archive.baiduNetdisk.serviceId, "/api/transfers?limit=10", { timeoutMs: 30_000 }); const langbot = await inspectLangBotBinding(archive); return { ok: health.ok && auth.ok && langbot.ok === true, action: "platform-infra-wechat-archive-status", config: configSummary(archive), langbot, n8n: { webhookUrl: webhookUrl(archive), workflowId: archive.n8n.workflow.id, activeDesired: archive.n8n.workflow.active, }, baiduNetdisk: { health: compactBaiduHealth(health), auth: compactBaiduAuth(auth), recentTransfers: compactBaiduTransfers(transfers), }, valuesPrinted: false, ...(options.raw ? { raw: redactSensitiveUnknown({ health, auth, transfers }) } : {}), }; } async function validate(options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const observedAt = new Date(); const textPayload = fixturePayload(archive, "text", observedAt); const imagePayload = fixturePayload(archive, "image", observedAt); const n8nText = await callWorkflow(archive, textPayload); const n8nImage = await callWorkflow(archive, imagePayload); const langbot = await inspectLangBotBinding(archive); const textArchive = archiveFromWorkflowResponse(textPayload, n8nText); const imageArchive = archiveFromWorkflowResponse(imagePayload, n8nImage); const textPull = await pullArchiveIfReady(archive, textArchive); const imagePull = await pullArchiveIfReady(archive, imageArchive); const ok = langbot.ok === true && n8nText.ok === true && n8nImage.ok === true && textArchive.ok === true && textArchive.skipPipeline === true && imageArchive.ok === true && imageArchive.skipPipeline === true && textPull.ok === true && imagePull.ok === true; return { ok, action: "platform-infra-wechat-archive-validate", config: configSummary(archive), langbot, fixtures: { text: validationSummary(textPayload, n8nText, textArchive, textPull, options.full), image: validationSummary(imagePayload, n8nImage, imageArchive, imagePull, options.full), }, valuesPrinted: false, next: { pullText: `bun scripts/cli.ts platform-infra wechat-archive pull --remote-path ${textArchive.remotePath} --target ${archive.target.id}`, pullImage: `bun scripts/cli.ts platform-infra wechat-archive pull --remote-path ${imageArchive.remotePath} --target ${archive.target.id}`, }, }; } async function pull(options: PullOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); if (!options.remotePath && !options.fsId) throw new Error("pull requires --remote-path or --fs-id"); let fsId = options.fsId; let remotePath = options.remotePath; let listed: Record | null = null; if (!fsId) { remotePath = normalizeRemotePath(String(remotePath)); listed = findBaiduFileByRemotePath(archive.baiduNetdisk.serviceId, remotePath); if (listed === null) throw new Error(`Baidu file not found: ${remotePath}`); fsId = String(listed.fsId || listed.fs_id || ""); if (!fsId) throw new Error(`Baidu file has no fsId in listing: ${remotePath}`); } const localRel = options.localPath ?? pullLocalRel(archive, remotePath ?? `fsid-${fsId}`); const downloaded = await pullByFsId(archive, fsId, localRel); return { ok: downloaded.ok, action: "platform-infra-wechat-archive-pull", remotePath: remotePath ?? downloaded.remotePath, fsId, listed: redactBaiduFileEntry(listed), download: options.full ? downloaded : compactDownload(downloaded), valuesPrinted: false, ...(options.raw ? { raw: downloaded } : {}), }; } function collectorPlan(options: OpsCommonOptions): Record { const archive = readConfig(); assertTarget(archive, options.targetId); const manifest = renderCollectorManifest(archive); const policy = collectorPolicyChecks(archive, manifest); return { ok: policy.every((check) => check.ok), action: "platform-infra-wechat-archive-collector-plan", mutation: false, config: personalWechatIngressSummary(archive.personalWechatIngress), manifest: { namespace: archive.personalWechatIngress.collector.namespace, image: collectorImageRef(archive.personalWechatIngress), bytes: Buffer.byteLength(manifest, "utf8"), sha256: fingerprintValues({ manifest }, ["manifest"]), }, policy, next: { prepareWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm", startWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm", buildImage: "bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm", dryRun: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --dry-run", apply: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm", status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", }, }; } async function wcfHostPrepare(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); if (options.dryRun) { return { ok: true, action: "platform-infra-wechat-archive-wcf-host-prepare", mode: "dry-run", mutation: false, target: archive.personalWechatIngress.target, scripts: windowsScriptSummary(), }; } const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsScriptSyncAndStartPrepareScript(archive)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-wechat-archive-wcf-host-prepare", mode: "confirmed", mutation: true, target: archive.personalWechatIngress.target, summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), next: { status: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full", start: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm", }, }; } async function wcfHostStart(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); if (options.dryRun) { return { ok: true, action: "platform-infra-wechat-archive-wcf-host-start", mode: "dry-run", mutation: false, target: archive.personalWechatIngress.target, }; } const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsRunManagedScript(archive, "start.ps1")); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-wechat-archive-wcf-host-start", mode: "confirmed", mutation: true, target: archive.personalWechatIngress.target, summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), next: { status: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full", collectorApply: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm", }, }; } async function wcfHostStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsRunManagedScript(archive, "status.ps1")); const parsed = parseJsonOutput(result.stdout); const status = parsed === null ? null : compactWcfHostStatus(parsed, options.full); return { ok: result.exitCode === 0 && parsed !== null && parsed.ok === true, action: "platform-infra-wechat-archive-wcf-host-status", target: archive.personalWechatIngress.target, summary: status, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } async function collectorImageBuild(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); if (options.confirm && !options.wait) { const job = startJob( "platform_infra_wechat_archive_collector_image_build_d601", ["bun", "scripts/cli.ts", "platform-infra", "wechat-archive", "collector-image-build", "--confirm", "--wait"], "Build and push the D601 k3s personal WeChat collector image through the controlled UniDesk CLI", ); return { ok: true, action: "platform-infra-wechat-archive-collector-image-build", mode: "async-job", mutation: true, job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, }; } if (options.dryRun) { return { ok: true, action: "platform-infra-wechat-archive-collector-image-build", mode: "dry-run", mutation: false, image: collectorImageRef(archive.personalWechatIngress), source: collectorSourceSummary(archive), }; } const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["sh"], collectorImageBuildScript(archive)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-wechat-archive-collector-image-build", mode: "confirmed", mutation: true, image: collectorImageRef(archive.personalWechatIngress), summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } async function collectorImageStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["sh"], collectorImageStatusScript(archive)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed !== null && parsed.ok === true, action: "platform-infra-wechat-archive-collector-image-status", image: collectorImageRef(archive.personalWechatIngress), summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } async function collectorApply(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const manifest = renderCollectorManifest(archive); const policy = collectorPolicyChecks(archive, manifest); if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-wechat-archive-collector-apply", mode: "policy-blocked", policy }; if (options.confirm && !options.wait) { const job = startJob( "platform_infra_wechat_archive_collector_apply_d601", ["bun", "scripts/cli.ts", "platform-infra", "wechat-archive", "collector-apply", "--confirm", "--wait"], "Apply D601 k3s personal WeChat collector objects through the controlled UniDesk CLI", ); return { ok: true, action: "platform-infra-wechat-archive-collector-apply", mode: "async-job", mutation: true, job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, }; } if (options.dryRun) { const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorApplyScript(archive, manifest, null, true)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-wechat-archive-collector-apply", mode: "dry-run", mutation: false, policy, target: archive.personalWechatIngress.target, summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } const token = readArchiveCallbackToken(archive); const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorApplyScript(archive, manifest, token.value, false)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-wechat-archive-collector-apply", mode: "confirmed", mutation: true, policy, target: archive.personalWechatIngress.target, secret: { sourceRef: token.sourceRef, keyName: token.keyName, targetSecret: archive.personalWechatIngress.collector.secretName, fingerprint: token.fingerprint, valuesPrinted: false, }, summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), next: { status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", wcfHostStatus: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full", }, }; } async function collectorStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise> { const archive = readConfig(); assertTarget(archive, options.targetId); const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["sh"], collectorStatusScript(archive, options.full)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed !== null && parsed.ok === true, action: "platform-infra-wechat-archive-collector-status", target: archive.personalWechatIngress.target, summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } function readConfig(): WechatArchiveConfig { const root = readYamlRecord(configFile, "platform-infra-wechat-archive"); const metadata = recordField(root, "metadata", configLabel); const target = recordField(root, "target", configLabel); const langbot = recordField(root, "langbot", configLabel); const n8n = recordField(root, "n8n", configLabel); const n8nWorkflow = recordField(n8n, "workflow", `${configLabel}.n8n`); const archiveCallback = recordField(root, "archiveCallback", configLabel); const personalWechatIngress = parsePersonalWechatIngress(recordField(root, "personalWechatIngress", configLabel)); const baiduNetdisk = recordField(root, "baiduNetdisk", configLabel); const staging = recordField(baiduNetdisk, "staging", `${configLabel}.baiduNetdisk`); const archive = recordField(baiduNetdisk, "archive", `${configLabel}.baiduNetdisk`); const messageTypesRaw = recordField(root, "messageTypes", configLabel); const validateRaw = recordField(root, "validate", configLabel); const fixtures = recordField(validateRaw, "fixtures", `${configLabel}.validate`); const textFixture = recordField(fixtures, "text", `${configLabel}.validate.fixtures`); const imageFixture = recordField(fixtures, "image", `${configLabel}.validate.fixtures`); const relatedIssuesRaw = metadata.relatedIssues; if (!Array.isArray(relatedIssuesRaw)) throw new Error(`${configLabel}.metadata.relatedIssues must be an array`); const relatedIssues = relatedIssuesRaw.map((item) => Number(item)); const messageTypes: WechatArchiveConfig["messageTypes"] = {}; for (const [key, value] of Object.entries(messageTypesRaw)) { const record = asRecord(value, `${configLabel}.messageTypes.${key}`); messageTypes[key] = { enabled: booleanField(record, "enabled", `${configLabel}.messageTypes.${key}`), contentField: stringField(record, "contentField", `${configLabel}.messageTypes.${key}`), mediaMode: stringField(record, "mediaMode", `${configLabel}.messageTypes.${key}`), filenameField: optionalStringField(record, "filenameField", `${configLabel}.messageTypes.${key}`), mimeTypeField: optionalStringField(record, "mimeTypeField", `${configLabel}.messageTypes.${key}`), }; } const config: WechatArchiveConfig = { version: Number(root.version), kind: "platform-infra-wechat-archive", metadata: { id: stringField(metadata, "id", configLabel), owner: stringField(metadata, "owner", configLabel), relatedIssues, }, target: { id: stringField(target, "id", configLabel), route: stringField(target, "route", configLabel), namespace: stringField(target, "namespace", configLabel), }, langbot: { configRef: stringField(langbot, "configRef", configLabel), publicBaseUrl: stringField(langbot, "publicBaseUrl", configLabel), expectedAdapter: stringField(langbot, "expectedAdapter", configLabel), callbackPath: stringField(langbot, "callbackPath", configLabel), readOnlyRecord: parseReadOnlyRecord(recordField(langbot, "readOnlyRecord", `${configLabel}.langbot`)), pipeline: parseLangBotPipeline(recordField(langbot, "pipeline", `${configLabel}.langbot`)), notes: Array.isArray(langbot.notes) ? langbot.notes.map(String) : [], }, n8n: { configRef: stringField(n8n, "configRef", configLabel), publicBaseUrl: stringField(n8n, "publicBaseUrl", configLabel), workflow: { name: stringField(n8nWorkflow, "name", `${configLabel}.n8n.workflow`), id: stringField(n8nWorkflow, "id", `${configLabel}.n8n.workflow`), webhookPath: stringField(n8nWorkflow, "webhookPath", `${configLabel}.n8n.workflow`), active: booleanField(n8nWorkflow, "active", `${configLabel}.n8n.workflow`), timezone: stringField(n8nWorkflow, "timezone", `${configLabel}.n8n.workflow`), }, }, archiveCallback: { publicUrl: stringField(archiveCallback, "publicUrl", `${configLabel}.archiveCallback`), secretRoot: stringField(archiveCallback, "secretRoot", `${configLabel}.archiveCallback`), tokenSourceRef: stringField(archiveCallback, "tokenSourceRef", `${configLabel}.archiveCallback`), tokenKey: stringField(archiveCallback, "tokenKey", `${configLabel}.archiveCallback`), timeoutMs: numberField(archiveCallback, "timeoutMs", `${configLabel}.archiveCallback`), }, personalWechatIngress, baiduNetdisk: { serviceId: stringField(baiduNetdisk, "serviceId", `${configLabel}.baiduNetdisk`), proxyMode: stringField(baiduNetdisk, "proxyMode", `${configLabel}.baiduNetdisk`), configRef: stringField(baiduNetdisk, "configRef", `${configLabel}.baiduNetdisk`), staging: { hostRoot: stringField(staging, "hostRoot", `${configLabel}.baiduNetdisk.staging`), containerRoot: stringField(staging, "containerRoot", `${configLabel}.baiduNetdisk.staging`), outboxDir: stringField(staging, "outboxDir", `${configLabel}.baiduNetdisk.staging`), pullDir: stringField(staging, "pullDir", `${configLabel}.baiduNetdisk.staging`), }, archive: { remoteRoot: normalizeRemotePath(stringField(archive, "remoteRoot", `${configLabel}.baiduNetdisk.archive`)), timezone: stringField(archive, "timezone", `${configLabel}.baiduNetdisk.archive`), textExtension: stringField(archive, "textExtension", `${configLabel}.baiduNetdisk.archive`), imageExtension: stringField(archive, "imageExtension", `${configLabel}.baiduNetdisk.archive`), pathTemplate: stringField(archive, "pathTemplate", `${configLabel}.baiduNetdisk.archive`), maxInlineBytes: numberField(archive, "maxInlineBytes", `${configLabel}.baiduNetdisk.archive`), }, }, messageTypes, validate: { timeoutMs: numberField(validateRaw, "timeoutMs", `${configLabel}.validate`), pollIntervalMs: numberField(validateRaw, "pollIntervalMs", `${configLabel}.validate`), fixtures: { text: { conversationId: stringField(textFixture, "conversationId", `${configLabel}.validate.fixtures.text`), senderId: stringField(textFixture, "senderId", `${configLabel}.validate.fixtures.text`), text: stringField(textFixture, "text", `${configLabel}.validate.fixtures.text`), }, image: { conversationId: stringField(imageFixture, "conversationId", `${configLabel}.validate.fixtures.image`), senderId: stringField(imageFixture, "senderId", `${configLabel}.validate.fixtures.image`), filename: stringField(imageFixture, "filename", `${configLabel}.validate.fixtures.image`), mimeType: stringField(imageFixture, "mimeType", `${configLabel}.validate.fixtures.image`), dataBase64: stringField(imageFixture, "dataBase64", `${configLabel}.validate.fixtures.image`), }, }, }, }; validateConfig(config); return config; } const collectorFieldManager = "unidesk-platform-infra-wechat-archive"; const windowsOpsRoot = "C:\\UniDesk\\personal-wechat\\ops"; function collectorImageRef(config: WechatArchiveConfig["personalWechatIngress"]): string { return `${config.collector.image.repository}:${config.collector.image.tag}`; } function boolField(value: Record | null, key: string, fallback: boolean): boolean { if (value === null) return fallback; const raw = value[key]; return typeof raw === "boolean" ? raw : fallback; } function collectorSourceSummary(archive: WechatArchiveConfig): Record { const ingress = archive.personalWechatIngress; const dockerfile = resolveRepoPath(ingress.collector.image.dockerfile); const collectorPy = resolveRepoPath(pathPosix.join(ingress.collector.image.context, "collector.py")); return { dockerfile: ingress.collector.image.dockerfile, collectorPy: repoRelative(collectorPy), dockerfileSha256: sha256File(dockerfile), collectorPySha256: sha256File(collectorPy), wcferryVersion: ingress.collector.image.wcferryVersion, }; } function windowsScriptSummary(): Record { const files = ["prepare.ps1", "start.ps1", "status.ps1", "wcf_host.py"].map((name) => { const filePath = rootPath("scripts", "windows", "personal-wechat", name); return { name, path: repoRelative(filePath), sha256: sha256File(filePath), bytes: readFileSync(filePath).byteLength }; }); return { targetRoot: windowsOpsRoot, files }; } function b64(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } function fileB64(repoPath: string): string { return readFileSync(resolveRepoPath(repoPath)).toString("base64"); } function managedWindowsFiles(): Array<{ name: string; contentB64: string }> { const base = rootPath("scripts", "windows", "personal-wechat"); return ["prepare.ps1", "start.ps1", "status.ps1", "wcf_host.py"].map((name) => ({ name, contentB64: readFileSync(pathPosix.join(base, name)).toString("base64"), })); } function windowsScriptSyncBlock(): string { const writes = managedWindowsFiles().map((file) => { const target = `${windowsOpsRoot}\\${file.name}`; return ` $target = ${psSingleQuote(target)} $content = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psSingleQuote(file.contentB64)})) Set-Content -LiteralPath $target -Encoding UTF8 -Value $content `; }).join("\n"); return ` $ErrorActionPreference = "Stop" $OpsRoot = ${psSingleQuote(windowsOpsRoot)} New-Item -ItemType Directory -Force $OpsRoot | Out-Null ${writes} `; } function windowsScriptSyncAndStartPrepareScript(_archive: WechatArchiveConfig): string { const prepare = `${windowsOpsRoot}\\prepare.ps1`; const stateRoot = "C:\\UniDesk\\personal-wechat\\wcf-state"; const stdout = `${stateRoot}\\prepare.stdout.log`; const stderr = `${stateRoot}\\prepare.stderr.log`; const resultFile = `${stateRoot}\\prepare-result.json`; return `${windowsScriptSyncBlock()} $StateRoot = ${psSingleQuote(stateRoot)} New-Item -ItemType Directory -Force $StateRoot | Out-Null if (Test-Path ${psSingleQuote(resultFile)}) { Remove-Item -Force ${psSingleQuote(resultFile)} } $Wrapper = Join-Path $StateRoot "prepare-runner.cmd" @" @echo off powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${prepare}" > "${stdout}" 2> "${stderr}" "@ | Set-Content -Encoding ASCII $Wrapper $cmd = '/c start "" /min "' + $Wrapper + '"' $proc = Start-Process -FilePath "cmd.exe" -ArgumentList $cmd -WindowStyle Hidden -PassThru [pscustomobject]@{ ok = $true started = $true launcherPid = $proc.Id opsRoot = $OpsRoot stdout = ${psSingleQuote(stdout)} stderr = ${psSingleQuote(stderr)} result = ${psSingleQuote(resultFile)} statusCommand = "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full" } | ConvertTo-Json -Depth 6 `; } function windowsRunManagedScript(_archive: WechatArchiveConfig, scriptName: "start.ps1" | "status.ps1"): string { const script = `${windowsOpsRoot}\\${scriptName}`; return `${windowsScriptSyncBlock()} & ${psSingleQuote(script)} if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } `; } function psSingleQuote(value: string): string { return `'${value.replaceAll("'", "''")}'`; } function compactWcfHostStatus(parsed: Record, full: boolean): Record { const status = typeof parsed.status === "object" && parsed.status !== null && !Array.isArray(parsed.status) ? parsed.status as Record : null; const prepare = typeof parsed.prepare === "object" && parsed.prepare !== null && !Array.isArray(parsed.prepare) ? parsed.prepare as Record : null; return { ok: parsed.ok, prepared: parsed.prepared, running: parsed.running, pid: parsed.pid, ports: parsed.ports, login: status === null ? null : { isLogin: status.isLogin, qrcodePresent: status.qrcodePresent, qrcode: full ? status.qrcode : undefined, ts: status.ts, }, prepare: prepare === null ? null : { ok: prepare.ok, wechatExe: prepare.wechatExe, installAttempted: prepare.installAttempted, installExitCode: prepare.installExitCode, wcferryVersion: prepare.wcferryVersion, next: prepare.next, }, paths: parsed.paths, logs: full ? parsed.logs : undefined, valuesPrinted: false, }; } function renderCollectorManifest(archive: WechatArchiveConfig): string { const ingress = archive.personalWechatIngress; const collector = ingress.collector; const labels = [ "app.kubernetes.io/name: personal-wechat-collector", "app.kubernetes.io/part-of: platform-infra", "app.kubernetes.io/managed-by: unidesk", "unidesk.ai/component: personal-wechat-collector", ].join("\n "); return `apiVersion: v1 kind: ServiceAccount metadata: name: ${collector.workload.serviceAccountName} namespace: ${collector.namespace} labels: ${labels} --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ${collector.storage.name} namespace: ${collector.namespace} labels: ${labels} spec: accessModes: - ReadWriteOnce resources: requests: storage: ${collector.storage.size} --- apiVersion: v1 kind: ConfigMap metadata: name: personal-wechat-collector-config namespace: ${collector.namespace} labels: ${labels} data: WCF_HOST: "${collector.wcfHost}" WCF_COMMAND_PORT: "${collector.commandPort}" STATE_ROOT: "${collector.stateRoot}" ARCHIVE_CALLBACK_URL: "${archive.archiveCallback.publicUrl}" ARCHIVE_REMOTE_ROOT: "${archive.baiduNetdisk.archive.remoteRoot}" ARCHIVE_TIMEZONE: "${archive.baiduNetdisk.archive.timezone}" POLL_IDLE_SECONDS: "1" STATUS_INTERVAL_SECONDS: "15" --- apiVersion: apps/v1 kind: Deployment metadata: name: ${collector.workload.name} namespace: ${collector.namespace} labels: ${labels} spec: replicas: ${collector.workload.replicas} selector: matchLabels: app.kubernetes.io/name: personal-wechat-collector unidesk.ai/component: personal-wechat-collector template: metadata: labels: app.kubernetes.io/name: personal-wechat-collector app.kubernetes.io/part-of: platform-infra unidesk.ai/component: personal-wechat-collector annotations: unidesk.ai/wcf-host: "${collector.wcfHost}:${collector.commandPort}" unidesk.ai/read-only: "${collector.readOnly.enabled}" unidesk.ai/send-capability: "${collector.readOnly.sendCapability}" unidesk.ai/archive-callback-ref: "${collector.archiveCallbackRef}" spec: serviceAccountName: ${collector.workload.serviceAccountName} containers: - name: ${collector.workload.containerName} image: ${collectorImageRef(ingress)} imagePullPolicy: ${collector.image.pullPolicy} envFrom: - configMapRef: name: personal-wechat-collector-config env: - name: ARCHIVE_CALLBACK_TOKEN valueFrom: secretKeyRef: name: ${collector.secretName} key: ${archive.archiveCallback.tokenKey} volumeMounts: - name: state mountPath: ${collector.storage.mountPath} volumes: - name: state persistentVolumeClaim: claimName: ${collector.storage.name} `; } function collectorPolicyChecks(archive: WechatArchiveConfig, manifest: string): Array> { const collector = archive.personalWechatIngress.collector; return [ { name: "no-namespace-create", ok: !/^\s*kind:\s*Namespace\s*$/mu.test(manifest) && collector.createNamespace === false, detail: "collector apply must reuse the existing platform-infra namespace and must not render/create Namespace." }, { name: "target-d601-k3s", ok: archive.personalWechatIngress.target.kubeRoute === "D601:k3s" && collector.runtime === "d601-k3s", detail: "collector runtime is D601 native k3s." }, { name: "namespace-platform-infra", ok: collector.namespace === "platform-infra", detail: "collector deploys into the existing platform-infra namespace." }, { name: "no-public-exposure", ok: !/^\s*kind:\s*(Ingress|Service)\s*$/mu.test(manifest) && archive.personalWechatIngress.wcfHost.firewall.publicExposure === false, detail: "collector has no public ingress, node port, load balancer or service." }, { name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(manifest), detail: "collector must not use hostNetwork." }, { name: "read-only-methods", ok: collector.readOnly.enabled === true && collector.readOnly.sendCapability === false, detail: "collector only invokes read-only WCF methods in source." }, { name: "callback-token-source-ref", ok: Boolean(archive.archiveCallback.tokenSourceRef && archive.archiveCallback.tokenKey && collector.secretName), detail: "callback token is synced from YAML-declared sourceRef into a runtime Secret." }, ]; } function collectorImageBuildScript(archive: WechatArchiveConfig): string { const ingress = archive.personalWechatIngress; const dockerfileB64 = fileB64(ingress.collector.image.dockerfile); const collectorB64 = fileB64(pathPosix.join(ingress.collector.image.context, "collector.py")); const image = collectorImageRef(ingress); const jobRoot = "/tmp/unidesk-personal-wechat-collector-build"; return ` set -u job_root=${shQuote(jobRoot)} job_dir="$job_root/work" status_file="$job_root/status.json" runner="$job_root/build-runner.sh" mkdir -p "$job_root" if [ -f "$job_root/pid" ] && kill -0 "$(cat "$job_root/pid")" 2>/dev/null; then python3 - "$status_file" "$(cat "$job_root/pid")" <<'PY' import json, sys payload = {"ok": True, "started": False, "alreadyRunning": True, "pid": sys.argv[2], "statusFile": sys.argv[1]} print(json.dumps(payload, ensure_ascii=False, indent=2)) PY exit 0 fi rm -rf "$job_dir" mkdir -p "$job_dir" printf '%s' '${dockerfileB64}' | base64 -d > "$job_dir/Dockerfile" printf '%s' '${collectorB64}' | base64 -d > "$job_dir/collector.py" build_log="$job_dir/docker-build.log" push_log="$job_dir/docker-push.log" cat >"$runner" <<'SH' #!/usr/bin/env bash set +e job_dir=${shQuote(`${jobRoot}/work`)} status_file=${shQuote(`${jobRoot}/status.json`)} build_log="$job_dir/docker-build.log" push_log="$job_dir/docker-push.log" python3 - "$status_file" "$build_log" "$push_log" <<'PY' import json, sys, time payload = {"ok": False, "status": "running", "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "image": "${image}", "logs": {"build": sys.argv[2], "push": sys.argv[3]}} open(sys.argv[1], "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2)) PY docker build \\ --build-arg WCFERRY_VERSION=${shQuote(ingress.collector.image.wcferryVersion)} \\ --build-arg PIP_INDEX_URL=${shQuote(ingress.collector.image.pip.indexUrl)} \\ --build-arg PIP_TRUSTED_HOST=${shQuote(ingress.collector.image.pip.trustedHost)} \\ --build-arg PIP_TIMEOUT_SECONDS=${shQuote(String(ingress.collector.image.pip.timeoutSeconds))} \\ --build-arg PIP_RETRIES=${shQuote(String(ingress.collector.image.pip.retries))} \\ -t ${shQuote(image)} "$job_dir" >"$build_log" 2>&1 build_rc=$? if [ "$build_rc" -eq 0 ]; then docker push ${shQuote(image)} >"$push_log" 2>&1 push_rc=$? else : >"$push_log" push_rc=1 fi python3 - "$status_file" "$build_rc" "$push_rc" "$build_log" "$push_log" <<'PY' import json, sys status_file, build_rc, push_rc = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) def text(path, limit=8000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" payload = { "ok": build_rc == 0 and push_rc == 0, "status": "succeeded" if build_rc == 0 and push_rc == 0 else "failed", "image": "${image}", "wcferryVersion": "${ingress.collector.image.wcferryVersion}", "steps": { "build": {"exitCode": build_rc, "log": sys.argv[4], "logTail": text(sys.argv[4])}, "push": {"exitCode": push_rc, "log": sys.argv[5], "logTail": text(sys.argv[5])}, }, } open(status_file, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2) + "\\n") sys.exit(0 if payload["ok"] else 1) PY SH chmod +x "$runner" nohup "$runner" >"$job_root/nohup.out" 2>"$job_root/nohup.err" & pid=$! printf '%s' "$pid" >"$job_root/pid" python3 - "$status_file" "$pid" "$job_root" <<'PY' import json, sys payload = {"ok": True, "started": True, "pid": sys.argv[2], "statusFile": sys.argv[1], "jobRoot": sys.argv[3], "statusCommand": "bun scripts/cli.ts platform-infra wechat-archive collector-image-status --full"} print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; } function collectorImageStatusScript(archive: WechatArchiveConfig): string { const image = collectorImageRef(archive.personalWechatIngress); return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT job_root=/tmp/unidesk-personal-wechat-collector-build status_file="$job_root/status.json" pid_file="$job_root/pid" running=0 if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then running=1 fi docker image inspect ${shQuote(image)} >"$tmp/local.json" 2>"$tmp/local.err" local_rc=$? docker manifest inspect ${shQuote(image)} >"$tmp/manifest.json" 2>"$tmp/manifest.err" manifest_rc=$? python3 - "$local_rc" "$manifest_rc" "$running" "$status_file" "$tmp/local.json" "$tmp/local.err" "$tmp/manifest.json" "$tmp/manifest.err" <<'PY' import json, sys local_rc, manifest_rc, running = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3] == "1" def load(path): try: return json.load(open(path, encoding="utf-8")) except Exception: return None def text(path): try: return open(path, encoding="utf-8", errors="replace").read()[-2000:] except FileNotFoundError: return "" build_status = load(sys.argv[4]) local = load(sys.argv[5]) payload = { "ok": True, "image": "${image}", "build": build_status, "buildRunning": running, "localPresent": local_rc == 0, "registryManifestPresent": manifest_rc == 0, "local": { "id": local[0].get("Id") if isinstance(local, list) and local else None, "created": local[0].get("Created") if isinstance(local, list) and local else None, "repoDigests": local[0].get("RepoDigests") if isinstance(local, list) and local else [], }, "errors": { "local": text(sys.argv[6]) if local_rc != 0 else "", "manifest": text(sys.argv[8]) if manifest_rc != 0 else "", }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; } function collectorApplyScript(archive: WechatArchiveConfig, manifest: string, token: string | null, dryRun: boolean): string { const ingress = archive.personalWechatIngress; const collector = ingress.collector; const manifestB64 = b64(manifest); const tokenB64 = token === null ? "" : b64(token); const mode = dryRun ? "dry-run" : "confirmed"; return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT manifest="$tmp/personal-wechat-collector.yaml" printf '%s' '${manifestB64}' | base64 -d > "$manifest" kubectl get namespace ${collector.namespace} >"$tmp/ns.out" 2>"$tmp/ns.err" ns_rc=$? secret_rc=0 apply_rc=1 if [ "$ns_rc" -eq 0 ]; then if [ ${dryRun ? "1" : "0"} -eq 1 ]; then kubectl apply --dry-run=client -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" apply_rc=$? else printf '%s' '${tokenB64}' | base64 -d > "$tmp/archive-token" kubectl -n ${collector.namespace} create secret generic ${collector.secretName} \\ --from-file=${archive.archiveCallback.tokenKey}="$tmp/archive-token" \\ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${collectorFieldManager} -f - >"$tmp/secret.out" 2>"$tmp/secret.err" secret_rc=$? if [ "$secret_rc" -eq 0 ]; then kubectl apply --server-side --force-conflicts --field-manager=${collectorFieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" apply_rc=$? else : >"$tmp/apply.out" printf '%s\\n' 'skipped because secret sync failed' >"$tmp/apply.err" apply_rc=1 fi fi else : >"$tmp/secret.out"; : >"$tmp/secret.err"; : >"$tmp/apply.out" printf '%s\\n' 'skipped because declared namespace does not exist' >"$tmp/apply.err" fi python3 - "$ns_rc" "$secret_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/secret.out" "$tmp/secret.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' import json, sys ns_rc, secret_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]) def text(path, limit=8000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" payload = { "ok": ns_rc == 0 and secret_rc == 0 and apply_rc == 0, "mode": "${mode}", "namespace": "${collector.namespace}", "image": "${collectorImageRef(ingress)}", "secret": {"name": "${collector.secretName}", "key": "${archive.archiveCallback.tokenKey}", "applied": ${dryRun ? "False" : "True"}, "valuesPrinted": False}, "steps": { "namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[4]), "stderr": text(sys.argv[5])}, "secret": {"exitCode": secret_rc, "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])}, "apply": {"exitCode": apply_rc, "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; } function collectorStatusScript(archive: WechatArchiveConfig, full: boolean): string { const collector = archive.personalWechatIngress.collector; return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT capture() { name="$1" shift "$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err" printf '%s' "$?" >"$tmp/$name.rc" } capture namespace kubectl get namespace ${collector.namespace} capture deploy kubectl -n ${collector.namespace} get deploy ${collector.workload.name} capture pods kubectl -n ${collector.namespace} get pods -l app.kubernetes.io/name=personal-wechat-collector capture pvc kubectl -n ${collector.namespace} get pvc ${collector.storage.name} capture secret kubectl -n ${collector.namespace} get secret ${collector.secretName} capture configmap kubectl -n ${collector.namespace} get configmap personal-wechat-collector-config capture events kubectl -n ${collector.namespace} get events --sort-by=.lastTimestamp pod="$(kubectl -n ${collector.namespace} get pods -l app.kubernetes.io/name=personal-wechat-collector -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" if [ -n "$pod" ]; then kubectl -n ${collector.namespace} logs "$pod" --tail ${full ? "160" : "40"} >"$tmp/logs.txt" 2>"$tmp/logs.err" logs_rc=$? else : >"$tmp/logs.txt" printf '%s\\n' 'collector pod not found' >"$tmp/logs.err" logs_rc=1 fi python3 - "$tmp" "$logs_rc" "$pod" <<'PY' import json, os, sys tmp, logs_rc, pod = sys.argv[1], int(sys.argv[2]), sys.argv[3] def load(name): try: return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8")) except Exception: return None def rc(name): try: return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1") except FileNotFoundError: return 1 def text(path, limit=8000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" deploy = load("deploy") or {} status = deploy.get("status", {}) if isinstance(deploy, dict) else {} spec = deploy.get("spec", {}) if isinstance(deploy, dict) else {} pods = load("pods") or {"items": []} events = load("events") or {"items": []} pod_items = pods.get("items", []) if isinstance(pods, dict) else [] def pod_summary(item): pod_status = item.get("status", {}) containers = pod_status.get("containerStatuses") or [] return { "name": item.get("metadata", {}).get("name"), "phase": pod_status.get("phase"), "podIP": pod_status.get("podIP"), "nodeName": item.get("spec", {}).get("nodeName"), "containers": [ { "name": c.get("name"), "ready": c.get("ready"), "restartCount": c.get("restartCount"), "state": c.get("state"), "lastState": c.get("lastState"), } for c in containers ], } def event_summary(event): involved = event.get("involvedObject", {}) if involved.get("name") and "personal-wechat-collector" not in involved.get("name", ""): return None return { "type": event.get("type"), "reason": event.get("reason"), "message": str(event.get("message") or "")[-600:], "count": event.get("count"), "lastTimestamp": event.get("lastTimestamp") or event.get("eventTime"), "involvedObject": {"kind": involved.get("kind"), "name": involved.get("name")}, } related_events = [item for item in (event_summary(e) for e in events.get("items", [])[-30:]) if item] desired_replicas = spec.get("replicas", 1) paused = rc("deploy") == 0 and desired_replicas == 0 ready = rc("deploy") == 0 and (paused or status.get("readyReplicas", 0) >= desired_replicas) payload = { "ok": rc("namespace") == 0 and rc("deploy") == 0, "namespace": "${collector.namespace}", "deployment": { "name": "${collector.workload.name}", "replicas": spec.get("replicas"), "readyReplicas": status.get("readyReplicas", 0), "updatedReplicas": status.get("updatedReplicas", 0), "availableReplicas": status.get("availableReplicas", 0), "ready": ready, "paused": paused, }, "pvc": {"present": rc("pvc") == 0, "name": "${collector.storage.name}"}, "secret": {"present": rc("secret") == 0, "name": "${collector.secretName}", "valuesPrinted": False}, "configMap": {"present": rc("configmap") == 0, "name": "personal-wechat-collector-config"}, "pods": [pod_summary(item) for item in pod_items], "logs": { "pod": pod, "exitCode": 0 if paused else logs_rc, "tail": text(os.path.join(tmp, "logs.txt")), "stderrTail": "collector paused by YAML replicas=0\\n" if paused and not pod else text(os.path.join(tmp, "logs.err"), 2000), }, "events": related_events[-12:], "valuesPrinted": False, } print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; } function validateConfig(config: WechatArchiveConfig): void { if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`); if (config.target.id !== "G14" || config.target.route !== "G14:k3s") throw new Error(`${configLabel}.target currently supports only G14:k3s`); for (const ref of [config.langbot.configRef, config.n8n.configRef]) { const path = resolveRepoPath(ref); if (!existsSync(path)) throw new Error(`${ref} does not exist`); } if (!config.n8n.publicBaseUrl.startsWith("https://")) throw new Error(`${configLabel}.n8n.publicBaseUrl must be https`); if (!config.langbot.publicBaseUrl.startsWith("https://")) throw new Error(`${configLabel}.langbot.publicBaseUrl must be https`); if (config.langbot.readOnlyRecord.enabled && config.langbot.readOnlyRecord.mode !== "webhook-skip-pipeline") throw new Error(`${configLabel}.langbot.readOnlyRecord.mode must be webhook-skip-pipeline`); if (config.langbot.readOnlyRecord.enabled && config.langbot.readOnlyRecord.discardPipelineUuid !== "__discard__") throw new Error(`${configLabel}.langbot.readOnlyRecord.discardPipelineUuid must be __discard__ for read-only personal WeChat recording`); if (!/^https?:\/\//u.test(config.archiveCallback.publicUrl)) throw new Error(`${configLabel}.archiveCallback.publicUrl must be http or https`); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.archiveCallback.tokenKey)) throw new Error(`${configLabel}.archiveCallback.tokenKey must be an env key`); if (!config.n8n.workflow.webhookPath || config.n8n.workflow.webhookPath.startsWith("/") || config.n8n.workflow.webhookPath.includes("/")) { throw new Error(`${configLabel}.n8n.workflow.webhookPath must be one relative path segment`); } if (!config.baiduNetdisk.archive.pathTemplate.includes("{{remoteRoot}}")) throw new Error(`${configLabel}.baiduNetdisk.archive.pathTemplate must include {{remoteRoot}}`); if (config.baiduNetdisk.proxyMode !== "backend-core-microservice-proxy") throw new Error(`${configLabel}.baiduNetdisk.proxyMode must be backend-core-microservice-proxy`); validatePersonalWechatIngress(config.personalWechatIngress); } function assertTarget(config: WechatArchiveConfig, targetId: string): void { if (targetId !== config.target.id) throw new Error(`wechat archive target ${targetId} is not declared in ${configLabel}`); } function configSummary(config: WechatArchiveConfig): Record { return { path: configLabel, id: config.metadata.id, relatedIssues: config.metadata.relatedIssues, target: config.target, langbot: { configRef: config.langbot.configRef, publicBaseUrl: config.langbot.publicBaseUrl, expectedAdapter: config.langbot.expectedAdapter, callbackPath: config.langbot.callbackPath, readOnlyRecord: { enabled: config.langbot.readOnlyRecord.enabled, mode: config.langbot.readOnlyRecord.mode, webhookName: config.langbot.readOnlyRecord.webhookName, discardPipelineUuid: config.langbot.readOnlyRecord.discardPipelineUuid, }, pipeline: config.langbot.pipeline, }, n8n: { configRef: config.n8n.configRef, publicBaseUrl: config.n8n.publicBaseUrl, workflow: config.n8n.workflow, webhookUrl: webhookUrl(config), }, archiveCallback: { publicUrl: config.archiveCallback.publicUrl, secretRoot: config.archiveCallback.secretRoot, tokenSourceRef: config.archiveCallback.tokenSourceRef, tokenKey: config.archiveCallback.tokenKey, timeoutMs: config.archiveCallback.timeoutMs, valuesPrinted: false, }, personalWechatIngress: personalWechatIngressSummary(config.personalWechatIngress), baiduNetdisk: { serviceId: config.baiduNetdisk.serviceId, proxyMode: config.baiduNetdisk.proxyMode, configRef: config.baiduNetdisk.configRef, remoteRoot: config.baiduNetdisk.archive.remoteRoot, staging: config.baiduNetdisk.staging, }, valuesPrinted: false, }; } function policyChecks(config: WechatArchiveConfig): Array> { return [ { name: "yaml-source", ok: repoRelative(configFile) === configLabel, detail: `${configLabel} is the archive workflow source of truth.` }, { name: "langbot-yaml-ref", ok: config.langbot.configRef === "config/platform-infra/langbot.yaml", detail: "LangBot base service remains declared in YAML." }, { name: "n8n-yaml-ref", ok: config.n8n.configRef === "config/platform-infra/n8n.yaml", detail: "n8n base service remains declared in YAML." }, { name: "baidu-private-proxy", ok: config.baiduNetdisk.proxyMode === "backend-core-microservice-proxy", detail: "Baidu Netdisk access stays behind backend-core microservice proxy." }, { name: "callback-token-source-ref", ok: Boolean(config.archiveCallback.tokenSourceRef && config.archiveCallback.tokenKey), detail: "n8n archive callback token is read from a YAML-declared local sourceRef and never reverse-engineered from runtime." }, { name: "personal-wechat-read-only", ok: config.langbot.readOnlyRecord.enabled && config.langbot.readOnlyRecord.discardPipelineUuid === "__discard__", detail: "LangBot inbound webhook must return skip_pipeline=true and bot fallback must discard so personal WeChat receives no automated replies." }, { name: "personal-wechat-ingress-yaml", ok: config.personalWechatIngress.enabled === true, detail: "D601 PC WeChat + WeChatFerry host and k3s collector boundary is declared in YAML." }, { name: "wcf-version-pin", ok: config.personalWechatIngress.pcWechat.requiredVersion === config.personalWechatIngress.wcfHost.requiredWechatVersion, detail: "The isolated PC WeChat version and WCF host requirement must match in YAML." }, { name: "wcf-no-public-exposure", ok: config.personalWechatIngress.wcfHost.firewall.publicExposure === false, detail: "WeChatFerry raw RPC must not be publicly exposed." }, { name: "collector-existing-namespace", ok: config.personalWechatIngress.collector.runtime === "d601-k3s" && config.personalWechatIngress.collector.namespace === "platform-infra" && config.personalWechatIngress.collector.createNamespace === false, detail: "D601 collector reuses the existing platform-infra namespace; namespace creation is disabled." }, { name: "collector-read-only", ok: config.personalWechatIngress.collector.readOnly.enabled === true && config.personalWechatIngress.collector.readOnly.sendCapability === false, detail: "k3s collector is a read-only WCF RPC client and must not export send/friend/group/database surfaces." }, { name: "collector-port-alignment", ok: config.personalWechatIngress.collector.commandPort === config.personalWechatIngress.wcfHost.commandPort && config.personalWechatIngress.collector.messagePort === config.personalWechatIngress.wcfHost.messagePort, detail: "Collector WCF ports must match the Windows WCF host ports declared in YAML." }, { name: "test-account-first", ok: config.personalWechatIngress.poc.accountPolicy === "test-account-first", detail: "Initial PoC must use a test or low-risk WeChat account before any production account promotion." }, { name: "single-pk01-postgres", ok: true, detail: "LangBot and n8n use dedicated databases inside the single PK01/Pika01 external PostgreSQL instance; this workflow adds no PostgreSQL instance." }, { name: "text-enabled", ok: config.messageTypes.text?.enabled === true, detail: "Text messages are archive-enabled." }, { name: "image-enabled", ok: config.messageTypes.image?.enabled === true, detail: "Image messages are archive-enabled." }, { name: "no-secret-output", ok: true, detail: "Baidu, LangBot and n8n secrets are referenced by existing services and are not printed by this CLI." }, ]; } function parsePersonalWechatIngress(raw: Record): WechatArchiveConfig["personalWechatIngress"] { const target = recordField(raw, "target", `${configLabel}.personalWechatIngress`); const pcWechat = recordField(raw, "pcWechat", `${configLabel}.personalWechatIngress`); const wcfHost = recordField(raw, "wcfHost", `${configLabel}.personalWechatIngress`); const firewall = recordField(wcfHost, "firewall", `${configLabel}.personalWechatIngress.wcfHost`); const collector = recordField(raw, "collector", `${configLabel}.personalWechatIngress`); const workload = recordField(collector, "workload", `${configLabel}.personalWechatIngress.collector`); const storage = recordField(collector, "storage", `${configLabel}.personalWechatIngress.collector`); const image = recordField(collector, "image", `${configLabel}.personalWechatIngress.collector`); const pip = recordField(image, "pip", `${configLabel}.personalWechatIngress.collector.image`); const readOnly = recordField(collector, "readOnly", `${configLabel}.personalWechatIngress.collector`); const poc = recordField(raw, "poc", `${configLabel}.personalWechatIngress`); return { enabled: booleanField(raw, "enabled", `${configLabel}.personalWechatIngress`), mode: stringField(raw, "mode", `${configLabel}.personalWechatIngress`), target: { id: stringField(target, "id", `${configLabel}.personalWechatIngress.target`), windowsRoute: stringField(target, "windowsRoute", `${configLabel}.personalWechatIngress.target`), hostRoute: stringField(target, "hostRoute", `${configLabel}.personalWechatIngress.target`), kubeRoute: stringField(target, "kubeRoute", `${configLabel}.personalWechatIngress.target`), }, pcWechat: { isolation: stringField(pcWechat, "isolation", `${configLabel}.personalWechatIngress.pcWechat`), requiredVersion: stringField(pcWechat, "requiredVersion", `${configLabel}.personalWechatIngress.pcWechat`), installerAsset: stringField(pcWechat, "installerAsset", `${configLabel}.personalWechatIngress.pcWechat`), installMode: stringField(pcWechat, "installMode", `${configLabel}.personalWechatIngress.pcWechat`), installRoot: stringField(pcWechat, "installRoot", `${configLabel}.personalWechatIngress.pcWechat`), dataRoot: stringField(pcWechat, "dataRoot", `${configLabel}.personalWechatIngress.pcWechat`), autoUpdatePolicy: stringField(pcWechat, "autoUpdatePolicy", `${configLabel}.personalWechatIngress.pcWechat`), }, wcfHost: { releaseTag: stringField(wcfHost, "releaseTag", `${configLabel}.personalWechatIngress.wcfHost`), requiredWechatVersion: stringField(wcfHost, "requiredWechatVersion", `${configLabel}.personalWechatIngress.wcfHost`), releaseUrl: stringField(wcfHost, "releaseUrl", `${configLabel}.personalWechatIngress.wcfHost`), sdkRoot: stringField(wcfHost, "sdkRoot", `${configLabel}.personalWechatIngress.wcfHost`), stateRoot: stringField(wcfHost, "stateRoot", `${configLabel}.personalWechatIngress.wcfHost`), commandPort: numberField(wcfHost, "commandPort", `${configLabel}.personalWechatIngress.wcfHost`), messagePort: numberField(wcfHost, "messagePort", `${configLabel}.personalWechatIngress.wcfHost`), bindHost: stringField(wcfHost, "bindHost", `${configLabel}.personalWechatIngress.wcfHost`), supervisor: stringField(wcfHost, "supervisor", `${configLabel}.personalWechatIngress.wcfHost`), rdpPolicy: stringField(wcfHost, "rdpPolicy", `${configLabel}.personalWechatIngress.wcfHost`), firewall: { publicExposure: booleanField(firewall, "publicExposure", `${configLabel}.personalWechatIngress.wcfHost.firewall`), allowedClientRefs: stringArrayField(firewall, "allowedClientRefs", `${configLabel}.personalWechatIngress.wcfHost.firewall`), }, }, collector: { runtime: stringField(collector, "runtime", `${configLabel}.personalWechatIngress.collector`), namespace: stringField(collector, "namespace", `${configLabel}.personalWechatIngress.collector`), createNamespace: booleanField(collector, "createNamespace", `${configLabel}.personalWechatIngress.collector`), workload: { kind: stringField(workload, "kind", `${configLabel}.personalWechatIngress.collector.workload`), name: stringField(workload, "name", `${configLabel}.personalWechatIngress.collector.workload`), serviceAccountName: stringField(workload, "serviceAccountName", `${configLabel}.personalWechatIngress.collector.workload`), replicas: numberField(workload, "replicas", `${configLabel}.personalWechatIngress.collector.workload`), containerName: stringField(workload, "containerName", `${configLabel}.personalWechatIngress.collector.workload`), }, storage: { kind: stringField(storage, "kind", `${configLabel}.personalWechatIngress.collector.storage`), name: stringField(storage, "name", `${configLabel}.personalWechatIngress.collector.storage`), mountPath: stringField(storage, "mountPath", `${configLabel}.personalWechatIngress.collector.storage`), size: stringField(storage, "size", `${configLabel}.personalWechatIngress.collector.storage`), create: booleanField(storage, "create", `${configLabel}.personalWechatIngress.collector.storage`), }, image: { repository: stringField(image, "repository", `${configLabel}.personalWechatIngress.collector.image`), tag: stringField(image, "tag", `${configLabel}.personalWechatIngress.collector.image`), pullPolicy: stringField(image, "pullPolicy", `${configLabel}.personalWechatIngress.collector.image`), context: stringField(image, "context", `${configLabel}.personalWechatIngress.collector.image`), dockerfile: stringField(image, "dockerfile", `${configLabel}.personalWechatIngress.collector.image`), wcferryVersion: stringField(image, "wcferryVersion", `${configLabel}.personalWechatIngress.collector.image`), pip: { indexUrl: stringField(pip, "indexUrl", `${configLabel}.personalWechatIngress.collector.image.pip`), trustedHost: stringField(pip, "trustedHost", `${configLabel}.personalWechatIngress.collector.image.pip`), timeoutSeconds: numberField(pip, "timeoutSeconds", `${configLabel}.personalWechatIngress.collector.image.pip`), retries: numberField(pip, "retries", `${configLabel}.personalWechatIngress.collector.image.pip`), }, }, wcfHost: stringField(collector, "wcfHost", `${configLabel}.personalWechatIngress.collector`), commandPort: numberField(collector, "commandPort", `${configLabel}.personalWechatIngress.collector`), messagePort: numberField(collector, "messagePort", `${configLabel}.personalWechatIngress.collector`), stateRoot: stringField(collector, "stateRoot", `${configLabel}.personalWechatIngress.collector`), queueMode: stringField(collector, "queueMode", `${configLabel}.personalWechatIngress.collector`), outboxMode: stringField(collector, "outboxMode", `${configLabel}.personalWechatIngress.collector`), archiveCallbackRef: stringField(collector, "archiveCallbackRef", `${configLabel}.personalWechatIngress.collector`), secretName: stringField(collector, "secretName", `${configLabel}.personalWechatIngress.collector`), readOnly: { enabled: booleanField(readOnly, "enabled", `${configLabel}.personalWechatIngress.collector.readOnly`), sendCapability: booleanField(readOnly, "sendCapability", `${configLabel}.personalWechatIngress.collector.readOnly`), allowedMethods: stringArrayField(readOnly, "allowedMethods", `${configLabel}.personalWechatIngress.collector.readOnly`), forbiddenMethods: stringArrayField(readOnly, "forbiddenMethods", `${configLabel}.personalWechatIngress.collector.readOnly`), }, }, poc: { accountPolicy: stringField(poc, "accountPolicy", `${configLabel}.personalWechatIngress.poc`), observationWindowHours: numberField(poc, "observationWindowHours", `${configLabel}.personalWechatIngress.poc`), requiredMessageTypes: stringArrayField(poc, "requiredMessageTypes", `${configLabel}.personalWechatIngress.poc`), }, }; } function validatePersonalWechatIngress(config: WechatArchiveConfig["personalWechatIngress"]): void { if (!config.enabled) return; if (config.mode !== "d601-windows-wcf-host-with-k3s-collector") { throw new Error(`${configLabel}.personalWechatIngress.mode must be d601-windows-wcf-host-with-k3s-collector`); } if (config.target.id !== "D601" || config.target.kubeRoute !== "D601:k3s") { throw new Error(`${configLabel}.personalWechatIngress.target must route the collector through D601:k3s`); } if (config.collector.runtime !== "d601-k3s") { throw new Error(`${configLabel}.personalWechatIngress.collector.runtime must be d601-k3s`); } if (config.collector.namespace !== "platform-infra" || config.collector.createNamespace !== false) { throw new Error(`${configLabel}.personalWechatIngress.collector must reuse platform-infra with createNamespace=false`); } if (config.collector.workload.kind !== "Deployment") { throw new Error(`${configLabel}.personalWechatIngress.collector.workload.kind must be Deployment`); } if (config.collector.storage.kind !== "PersistentVolumeClaim") { throw new Error(`${configLabel}.personalWechatIngress.collector.storage.kind must be PersistentVolumeClaim`); } for (const repoPath of [config.collector.image.context, config.collector.image.dockerfile]) { if (!existsSync(resolveRepoPath(repoPath))) throw new Error(`${configLabel}.personalWechatIngress.collector.image path does not exist: ${repoPath}`); } if (!["Always", "IfNotPresent", "Never"].includes(config.collector.image.pullPolicy)) { throw new Error(`${configLabel}.personalWechatIngress.collector.image.pullPolicy must be Always, IfNotPresent, or Never`); } if (config.pcWechat.requiredVersion !== config.wcfHost.requiredWechatVersion) { throw new Error(`${configLabel}.personalWechatIngress pcWechat.requiredVersion and wcfHost.requiredWechatVersion must match`); } if (config.collector.commandPort !== config.wcfHost.commandPort || config.collector.messagePort !== config.wcfHost.messagePort) { throw new Error(`${configLabel}.personalWechatIngress collector ports must match wcfHost ports`); } if (config.wcfHost.firewall.publicExposure !== false) { throw new Error(`${configLabel}.personalWechatIngress.wcfHost.firewall.publicExposure must be false`); } if (config.collector.readOnly.enabled !== true || config.collector.readOnly.sendCapability !== false) { throw new Error(`${configLabel}.personalWechatIngress.collector.readOnly must enable read-only mode and set sendCapability=false`); } const allowed = new Set(config.collector.readOnly.allowedMethods); const overlap = config.collector.readOnly.forbiddenMethods.filter((method) => allowed.has(method)); if (overlap.length > 0) throw new Error(`${configLabel}.personalWechatIngress.collector.readOnly method allowlist overlaps forbiddenMethods: ${overlap.join(",")}`); if (config.collector.archiveCallbackRef !== "archiveCallback") { throw new Error(`${configLabel}.personalWechatIngress.collector.archiveCallbackRef must point at archiveCallback`); } if (config.collector.wcfHost === "host.docker.internal") { throw new Error(`${configLabel}.personalWechatIngress.collector.wcfHost must be reachable from D601 k3s, not Docker host.docker.internal`); } } function personalWechatIngressSummary(config: WechatArchiveConfig["personalWechatIngress"]): Record { return { enabled: config.enabled, mode: config.mode, target: config.target, pcWechat: { isolation: config.pcWechat.isolation, requiredVersion: config.pcWechat.requiredVersion, installerAsset: config.pcWechat.installerAsset, installMode: config.pcWechat.installMode, installRoot: config.pcWechat.installRoot, dataRoot: config.pcWechat.dataRoot, autoUpdatePolicy: config.pcWechat.autoUpdatePolicy, }, wcfHost: { releaseTag: config.wcfHost.releaseTag, requiredWechatVersion: config.wcfHost.requiredWechatVersion, releaseUrl: config.wcfHost.releaseUrl, sdkRoot: config.wcfHost.sdkRoot, stateRoot: config.wcfHost.stateRoot, commandPort: config.wcfHost.commandPort, messagePort: config.wcfHost.messagePort, bindHost: config.wcfHost.bindHost, supervisor: config.wcfHost.supervisor, rdpPolicy: config.wcfHost.rdpPolicy, firewall: config.wcfHost.firewall, }, collector: { runtime: config.collector.runtime, namespace: config.collector.namespace, createNamespace: config.collector.createNamespace, workload: config.collector.workload, storage: config.collector.storage, image: { reference: collectorImageRef(config), repository: config.collector.image.repository, tag: config.collector.image.tag, pullPolicy: config.collector.image.pullPolicy, context: config.collector.image.context, dockerfile: config.collector.image.dockerfile, wcferryVersion: config.collector.image.wcferryVersion, pip: { indexUrl: config.collector.image.pip.indexUrl, trustedHost: config.collector.image.pip.trustedHost, timeoutSeconds: config.collector.image.pip.timeoutSeconds, retries: config.collector.image.pip.retries, }, }, wcfHost: config.collector.wcfHost, commandPort: config.collector.commandPort, messagePort: config.collector.messagePort, stateRoot: config.collector.stateRoot, queueMode: config.collector.queueMode, outboxMode: config.collector.outboxMode, archiveCallbackRef: config.collector.archiveCallbackRef, secretName: config.collector.secretName, readOnly: { enabled: config.collector.readOnly.enabled, sendCapability: config.collector.readOnly.sendCapability, allowedMethods: config.collector.readOnly.allowedMethods, forbiddenMethods: config.collector.readOnly.forbiddenMethods, }, }, poc: config.poc, valuesPrinted: false, }; } function stringArrayField(record: Record, key: string, label: string): string[] { const value = record[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) { throw new Error(`${label}.${key} must be a string array`); } return value.map((item) => String(item)); } function parseReadOnlyRecord(raw: Record): WechatArchiveConfig["langbot"]["readOnlyRecord"] { return { enabled: booleanField(raw, "enabled", `${configLabel}.langbot.readOnlyRecord`), mode: stringField(raw, "mode", `${configLabel}.langbot.readOnlyRecord`), webhookName: stringField(raw, "webhookName", `${configLabel}.langbot.readOnlyRecord`), webhookDescription: stringField(raw, "webhookDescription", `${configLabel}.langbot.readOnlyRecord`), discardPipelineUuid: stringField(raw, "discardPipelineUuid", `${configLabel}.langbot.readOnlyRecord`), }; } function parseLangBotPipeline(raw: Record): WechatArchiveConfig["langbot"]["pipeline"] { const timeoutSeconds = numberField(raw, "timeoutSeconds", `${configLabel}.langbot.pipeline`); return { name: stringField(raw, "name", `${configLabel}.langbot.pipeline`), description: stringField(raw, "description", `${configLabel}.langbot.pipeline`), runner: stringField(raw, "runner", `${configLabel}.langbot.pipeline`), outputKey: stringField(raw, "outputKey", `${configLabel}.langbot.pipeline`), timeoutSeconds, }; } function webhookUrl(config: WechatArchiveConfig): string { return `${config.n8n.publicBaseUrl.replace(/\/+$/u, "")}/webhook/${registeredWebhookPath(config)}`; } function langBotDryRun(config: WechatArchiveConfig): Record { return { ok: true, mode: "dry-run", pipeline: { name: config.langbot.pipeline.name, runner: config.langbot.pipeline.runner, webhookUrl: webhookUrl(config), outputKey: config.langbot.pipeline.outputKey, timeoutSeconds: config.langbot.pipeline.timeoutSeconds, }, bot: { expectedAdapter: config.langbot.expectedAdapter, desiredRouting: "discard person/group messages after read-only webhook mirroring", }, valuesPrinted: false, }; } async function syncLangBotPipeline(config: WechatArchiveConfig): Promise> { const runtime = readLangBotRuntimeConfig(); const secret = readLangBotSecretMaterial(); const apiKey = String(secret.apiKey || ""); const baseUrl = String(runtime.publicBaseUrl || config.langbot.publicBaseUrl); const pipelinesResp = await langBotRequest(baseUrl, apiKey, "GET", "/api/v1/pipelines"); const pipelines = arrayFromPath(pipelinesResp.body, ["data", "pipelines"]); let pipeline = pipelines.find((item) => String(asRecord(item, "pipeline").name || "") === config.langbot.pipeline.name); let created = false; if (pipeline === undefined) { const createdResp = await langBotRequest(baseUrl, apiKey, "POST", "/api/v1/pipelines", { name: config.langbot.pipeline.name, description: config.langbot.pipeline.description, emoji: "archive", }); const uuid = String(nestedValue(createdResp.body, ["data", "uuid"]) || ""); if (!uuid) throw new Error("LangBot create pipeline did not return uuid"); const getResp = await langBotRequest(baseUrl, apiKey, "GET", `/api/v1/pipelines/${encodeURIComponent(uuid)}`); pipeline = asRecord(nestedValue(getResp.body, ["data", "pipeline"]), "created pipeline"); created = true; } const pipelineRecord = asRecord(pipeline, "pipeline"); const pipelineUuid = String(pipelineRecord.uuid || ""); if (!pipelineUuid) throw new Error("LangBot pipeline record is missing uuid"); const configRecord = asRecord(pipelineRecord.config, "pipeline.config"); const nextConfig = langBotPipelineConfig(configRecord, config); await langBotRequest(baseUrl, apiKey, "PUT", `/api/v1/pipelines/${encodeURIComponent(pipelineUuid)}`, { name: config.langbot.pipeline.name, description: config.langbot.pipeline.description, config: nextConfig, }); const webhookBinding = syncLangBotWebhook(config, webhookUrl(config)); const botBinding = await syncLangBotReadOnlyBot(config, baseUrl, apiKey); return { ok: webhookBinding.ok === true && botBinding.ok === true, mode: "confirmed", pipeline: { uuid: pipelineUuid, name: config.langbot.pipeline.name, runner: config.langbot.pipeline.runner, webhookUrl: webhookUrl(config), created, }, readOnlyWebhook: webhookBinding, bot: botBinding, auth: { sourceRef: secret.sourceRef, keyName: secret.keyName, apiKeyFingerprint: secret.apiKeyFingerprint, valuesPrinted: false, }, valuesPrinted: false, }; } async function inspectLangBotBinding(config: WechatArchiveConfig): Promise> { try { const runtime = readLangBotRuntimeConfig(); const secret = readLangBotSecretMaterial(); const apiKey = String(secret.apiKey || ""); const baseUrl = String(runtime.publicBaseUrl || config.langbot.publicBaseUrl); const pipelinesResp = await langBotRequest(baseUrl, apiKey, "GET", "/api/v1/pipelines"); const pipelines = arrayFromPath(pipelinesResp.body, ["data", "pipelines"]); const pipeline = pipelines.find((item) => String(asRecord(item, "pipeline").name || "") === config.langbot.pipeline.name); const pipelineRecord = pipeline === undefined ? null : asRecord(pipeline, "pipeline"); const pipelineUuid = String(pipelineRecord?.uuid || ""); const pipelineConfig = typeof pipelineRecord?.config === "object" && pipelineRecord.config !== null && !Array.isArray(pipelineRecord.config) ? pipelineRecord.config as Record : {}; const runner = String(nestedValue(pipelineConfig, ["ai", "runner", "runner"]) || ""); const webhook = String(nestedValue(pipelineConfig, ["ai", "n8n-service-api", "webhook-url"]) || ""); const botsResp = await langBotRequest(baseUrl, apiKey, "GET", "/api/v1/platform/bots"); const bots = arrayFromPath(botsResp.body, ["data", "bots"]); const bot = bots.find((item) => String(asRecord(item, "bot").adapter || "") === config.langbot.expectedAdapter); const botRecord = bot === undefined ? null : asRecord(bot, "bot"); const routingRules = Array.isArray(botRecord?.pipeline_routing_rules) ? botRecord.pipeline_routing_rules as unknown[] : []; const hasDiscardRouting = hasReadOnlyDiscardRouting(routingRules, config); const readOnlyWebhook = inspectLangBotWebhook(config, webhookUrl(config)); const ok = Boolean(pipelineUuid) && runner === config.langbot.pipeline.runner && webhook === webhookUrl(config) && hasDiscardRouting && readOnlyWebhook.ok === true; return { ok, pipeline: { exists: Boolean(pipelineUuid), uuid: pipelineUuid || null, name: config.langbot.pipeline.name, runner, webhookMatches: webhook === webhookUrl(config), }, bot: { exists: botRecord !== null, uuid: String(botRecord?.uuid || "") || null, adapter: config.langbot.expectedAdapter, usePipelineUuid: String(botRecord?.use_pipeline_uuid || "") || null, readOnlyDiscardRouting: hasDiscardRouting, }, readOnlyWebhook, auth: { sourceRef: secret.sourceRef, keyName: secret.keyName, apiKeyFingerprint: secret.apiKeyFingerprint, valuesPrinted: false, }, valuesPrinted: false, }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), valuesPrinted: false, }; } } function langBotPipelineConfig(current: Record, archive: WechatArchiveConfig): Record { const next = JSON.parse(JSON.stringify(current)) as Record; const ai = asMutableRecord(next, "ai"); const runner = asMutableRecord(ai, "runner"); runner.runner = archive.langbot.pipeline.runner; runner["expire-time"] = 0; const n8n = asMutableRecord(ai, "n8n-service-api"); n8n["webhook-url"] = webhookUrl(archive); n8n["auth-type"] = "none"; n8n.timeout = archive.langbot.pipeline.timeoutSeconds; n8n["output-key"] = archive.langbot.pipeline.outputKey; return next; } async function syncLangBotReadOnlyBot(config: WechatArchiveConfig, baseUrl: string, apiKey: string): Promise> { const botsResp = await langBotRequest(baseUrl, apiKey, "GET", "/api/v1/platform/bots"); const bots = arrayFromPath(botsResp.body, ["data", "bots"]); const bot = bots.find((item) => String(asRecord(item, "bot").adapter || "") === config.langbot.expectedAdapter); if (bot === undefined) throw new Error(`LangBot bot adapter ${config.langbot.expectedAdapter} not found`); const botRecord = asRecord(bot, "bot"); const botUuid = String(botRecord.uuid || ""); if (!botUuid) throw new Error("LangBot bot record is missing uuid"); const currentRules = Array.isArray(botRecord.pipeline_routing_rules) ? botRecord.pipeline_routing_rules : []; const nextRules = readOnlyDiscardRoutingRules(config); const alreadyDiscard = hasReadOnlyDiscardRouting(currentRules, config); if (!alreadyDiscard || JSON.stringify(currentRules) !== JSON.stringify(nextRules)) { await langBotRequest(baseUrl, apiKey, "PUT", `/api/v1/platform/bots/${encodeURIComponent(botUuid)}`, { pipeline_routing_rules: nextRules }); } return { ok: true, uuid: botUuid, adapter: config.langbot.expectedAdapter, mode: config.langbot.readOnlyRecord.mode, usePipelineUuid: String(botRecord.use_pipeline_uuid || "") || null, routingRules: nextRules, readOnlyDiscardRouting: true, hadDiscardRouting: alreadyDiscard, valuesPrinted: false, }; } function readOnlyDiscardRoutingRules(config: WechatArchiveConfig): Array> { const pipelineUuid = config.langbot.readOnlyRecord.discardPipelineUuid; return [ { type: "launcher_type", operator: "eq", value: "person", pipeline_uuid: pipelineUuid }, { type: "launcher_type", operator: "eq", value: "group", pipeline_uuid: pipelineUuid }, ]; } function hasReadOnlyDiscardRouting(rules: unknown[], config: WechatArchiveConfig): boolean { const desired = readOnlyDiscardRoutingRules(config); return desired.every((expected) => rules.some((rule) => { if (typeof rule !== "object" || rule === null || Array.isArray(rule)) return false; const record = rule as Record; return Object.entries(expected).every(([key, value]) => record[key] === value); })); } function syncLangBotWebhook(config: WechatArchiveConfig, url: string): Record { const secret = prepareLangBotSecretMaterial(); const conn = readLangBotPostgresConninfo(); const sql = [ "WITH updated AS (", " UPDATE webhooks", ` SET url = ${sqlLiteral(url)}, description = ${sqlLiteral(config.langbot.readOnlyRecord.webhookDescription)}, enabled = true, updated_at = now()`, ` WHERE name = ${sqlLiteral(config.langbot.readOnlyRecord.webhookName)}`, " RETURNING id, name, enabled, url", "), inserted AS (", " INSERT INTO webhooks (name, url, description, enabled)", ` SELECT ${sqlLiteral(config.langbot.readOnlyRecord.webhookName)}, ${sqlLiteral(url)}, ${sqlLiteral(config.langbot.readOnlyRecord.webhookDescription)}, true`, " WHERE NOT EXISTS (SELECT 1 FROM updated)", " RETURNING id, name, enabled, url", ")", "SELECT id, name, enabled, url FROM updated UNION ALL SELECT id, name, enabled, url FROM inserted LIMIT 1;", ].join("\n"); const result = Bun.spawnSync(["psql", "-Atq", conn, "-c", sql], { stdout: "pipe", stderr: "pipe", env: { ...process.env, PGPASSWORD: secret.values.dbPassword }, }); const stdout = new TextDecoder().decode(result.stdout).trim(); const stderr = new TextDecoder().decode(result.stderr).trim(); return { ok: result.exitCode === 0, mode: "db-upsert", name: config.langbot.readOnlyRecord.webhookName, url, enabled: true, row: result.exitCode === 0 ? compactWebhookRow(stdout) : "", stderrTail: redactSensitiveUnknown(stderr).toString().slice(-1200), auth: { dbSourceRef: secret.dbSourceRef, appSourceRef: secret.appSourceRef, fingerprint: secret.fingerprint, valuesPrinted: false, }, valuesPrinted: false, }; } function inspectLangBotWebhook(config: WechatArchiveConfig, url: string): Record { const secret = prepareLangBotSecretMaterial(); const conn = readLangBotPostgresConninfo(); const sql = [ "SELECT id, name, enabled, url FROM webhooks", `WHERE url = ${sqlLiteral(url)} OR name = ${sqlLiteral(config.langbot.readOnlyRecord.webhookName)}`, "ORDER BY enabled DESC, updated_at DESC LIMIT 1;", ].join("\n"); const result = Bun.spawnSync(["psql", "-Atq", conn, "-c", sql], { stdout: "pipe", stderr: "pipe", env: { ...process.env, PGPASSWORD: secret.values.dbPassword }, }); const stdout = new TextDecoder().decode(result.stdout).trim(); const stderr = new TextDecoder().decode(result.stderr).trim(); const row = parseWebhookRow(stdout); return { ok: result.exitCode === 0 && row !== null && row.enabled === true && row.url === url, exists: row !== null, name: row?.name ?? config.langbot.readOnlyRecord.webhookName, enabled: row?.enabled ?? false, urlMatches: row?.url === url, row: row === null ? null : { id: row.id, name: row.name, enabled: row.enabled }, stderrTail: result.exitCode === 0 ? "" : redactSensitiveUnknown(stderr).toString().slice(-1200), valuesPrinted: false, }; } function parseWebhookRow(value: string): { id: string; name: string; enabled: boolean; url: string } | null { if (!value) return null; const [id = "", name = "", enabled = "", url = ""] = value.split("|"); if (!id) return null; return { id, name, enabled: enabled === "t" || enabled === "true", url }; } function compactWebhookRow(value: string): Record | null { const row = parseWebhookRow(value); if (row === null) return null; return { id: row.id, name: row.name, enabled: row.enabled }; } function sqlLiteral(value: string): string { return `'${value.replace(/'/gu, "''")}'`; } async function langBotRequest(baseUrl: string, apiKey: string, method: string, apiPath: string, body?: unknown): Promise> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 45_000); try { const response = await fetch(`${baseUrl.replace(/\/+$/u, "")}${apiPath}`, { method, headers: { "content-type": "application/json", "x-api-key": apiKey }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, }); const text = await response.text(); let parsed: unknown = text; try { parsed = text.length > 0 ? JSON.parse(text) as unknown : null; } catch { parsed = text; } if (!response.ok) throw new Error(`LangBot ${method} ${apiPath} failed with HTTP ${response.status}: ${redactText(JSON.stringify(compactUnknown(parsed))).slice(0, 800)}`); return { ok: true, status: response.status, body: parsed }; } finally { clearTimeout(timer); } } function redactText(text: string): string { return text .replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_") .replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1@") .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") .replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1$2"); } function nestedValue(value: unknown, path: string[]): unknown { let current = value; for (const part of path) { if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined; current = (current as Record)[part]; } return current; } function arrayFromPath(value: unknown, path: string[]): unknown[] { const nested = nestedValue(value, path); return Array.isArray(nested) ? nested : []; } function asMutableRecord(parent: Record, key: string): Record { const current = parent[key]; if (typeof current === "object" && current !== null && !Array.isArray(current)) return current as Record; const next: Record = {}; parent[key] = next; return next; } function renderN8nWorkflow(config: WechatArchiveConfig, callbackToken: string | null): Record { const tokenForWorkflow = callbackToken ?? "__UNIDESK_WECHAT_ARCHIVE_TOKEN_FROM_SOURCE_REF__"; const code = ` const input = $input.first()?.json ?? {}; const body = input.body && typeof input.body === 'object' ? input.body : input; const data = body.data && typeof body.data === 'object' ? body.data : {}; const langbotMessage = data.message && typeof data.message === 'object' ? data.message : {}; const langbotComponents = Array.isArray(langbotMessage.root) ? langbotMessage.root : Array.isArray(langbotMessage) ? langbotMessage : []; function firstComponent(type) { return langbotComponents.find((item) => item && typeof item === 'object' && String(item.type || '').toLowerCase() === type.toLowerCase()) || {}; } function firstText() { const plain = firstComponent('Plain'); if (plain.text !== undefined && plain.text !== null) return String(plain.text); return langbotComponents .filter((item) => item && typeof item === 'object' && item.text !== undefined) .map((item) => String(item.text)) .join(''); } function firstImage() { return firstComponent('Image'); } function pick(...keys) { for (const key of keys) { const value = key.split('.').reduce((acc, part) => acc && typeof acc === 'object' ? acc[part] : undefined, body); if (value !== undefined && value !== null && String(value).length > 0) return value; } return ''; } function sanitizePathSegment(value, fallback = 'unknown') { const normalized = String(value || fallback) .normalize('NFKC') .replace(/[\\\\/\\0\\r\\n\\t]+/g, '-') .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 120); return normalized.length > 0 ? normalized : fallback; } function dateInTimeZone(date, timeZone) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(date).reduce((acc, part) => { acc[part.type] = part.value; return acc; }, {}); return [parts.year, parts.month, parts.day].join('-'); } function renderTemplate(template, values) { return template.replace(/\\{\\{\\s*([A-Za-z0-9_]+)\\s*\\}\\}/g, (_match, key) => values[key] || ''); } function normalizeRemotePath(path) { const collapsed = String(path).replace(/\\/+/g, '/'); return collapsed.startsWith('/') ? collapsed : '/' + collapsed; } const imageComponent = firstImage(); const langbotText = firstText(); const langbotSender = data.sender && typeof data.sender === 'object' ? data.sender : {}; const langbotGroup = data.group && typeof data.group === 'object' ? data.group : {}; const messageType = String(pick('messageType', 'type', 'msgType') || (Object.keys(imageComponent).length > 0 ? 'image' : 'text')).toLowerCase(); const receivedAt = String(pick('receivedAt', 'timestamp', 'data.timestamp') || new Date().toISOString()); const receivedDate = Number.isNaN(new Date(receivedAt).getTime()) ? new Date() : new Date(receivedAt); const messageId = sanitizePathSegment(pick('messageId', 'msgId', 'id', 'message_id', 'uuid') || String(Date.now()), 'message'); const conversationId = sanitizePathSegment(pick('conversationId', 'chatId', 'roomId', 'fromUser', 'conversation_id', 'session_id') || langbotGroup.id || langbotSender.id || 'unknown-conversation', 'conversation'); const senderId = sanitizePathSegment(pick('senderId', 'userId', 'fromUserName', 'fromUser', 'user_id') || langbotSender.id || 'unknown-sender', 'sender'); const media = body.media && typeof body.media === 'object' ? body.media : {}; if (imageComponent.base64 && !media.dataBase64) media.dataBase64 = imageComponent.base64; if (imageComponent.url && !media.url) media.url = imageComponent.url; if (!media.filename && messageType === 'image') media.filename = messageId + '.${config.baiduNetdisk.archive.imageExtension}'; const extension = messageType === 'image' ? '${config.baiduNetdisk.archive.imageExtension}' : '${config.baiduNetdisk.archive.textExtension}'; const filename = messageType === 'image' ? sanitizePathSegment(media.filename || messageId + '.' + extension, messageId + '.' + extension) : messageId + '.' + extension; const text = String(pick('text', 'content', 'message', 'chatInput', 'query', 'query_text') || langbotText || ''); const remotePath = normalizeRemotePath(renderTemplate('${config.baiduNetdisk.archive.pathTemplate}', { remoteRoot: '${config.baiduNetdisk.archive.remoteRoot}', date: dateInTimeZone(receivedDate, '${config.baiduNetdisk.archive.timezone}'), timestamp: receivedDate.toISOString().replace(/[-:.TZ]/g, ''), conversationId, senderId, messageId, type: messageType, extension, filename, })); const normalized = { ok: true, source: 'n8n', workflow: '${config.n8n.workflow.id}', message: { messageId, conversationId, senderId, messageType, text, receivedAt, media, }, archive: { remoteRoot: '${config.baiduNetdisk.archive.remoteRoot}', pathTemplate: '${config.baiduNetdisk.archive.pathTemplate}', remotePath, filename, }, }; return [{ json: normalized }]; `.trim(); return { id: config.n8n.workflow.id, name: config.n8n.workflow.name, active: config.n8n.workflow.active, nodes: [ { parameters: { httpMethod: "POST", path: config.n8n.workflow.webhookPath, responseMode: "lastNode", options: {}, }, id: "webhook", name: "wechat-archive-webhook", type: "n8n-nodes-base.webhook", typeVersion: 2, position: [0, 0], }, { parameters: { jsCode: code }, id: "normalize", name: "Normalize WeChat Message", type: "n8n-nodes-base.code", typeVersion: 2, position: [280, 0], }, { parameters: { method: "POST", url: config.archiveCallback.publicUrl, sendHeaders: true, headerParameters: { parameters: [ { name: "Authorization", value: `Bearer ${tokenForWorkflow}` }, { name: "Content-Type", value: "application/json" }, ], }, sendBody: true, specifyBody: "json", jsonBody: "={{ JSON.stringify($json) }}", options: { timeout: config.archiveCallback.timeoutMs, }, }, id: "archive-callback", name: "Archive To Baidu Callback", type: "n8n-nodes-base.httpRequest", typeVersion: 4.2, position: [560, 0], }, ], connections: { "wechat-archive-webhook": { main: [[{ node: "Normalize WeChat Message", type: "main", index: 0 }]], }, "Normalize WeChat Message": { main: [[{ node: "Archive To Baidu Callback", type: "main", index: 0 }]], }, }, settings: { executionOrder: "v1", timezone: config.n8n.workflow.timezone, }, staticData: null, tags: [], }; } function registeredWebhookPath(config: WechatArchiveConfig): string { return [ config.n8n.workflow.id, "wechat-archive-webhook", config.n8n.workflow.webhookPath.replace(/^\/+|\/+$/gu, ""), ].map((segment) => encodeURIComponent(segment)).join("/"); } function fixturePayload(config: WechatArchiveConfig, type: "text" | "image", observedAt: Date): Record { const suffix = randomUUID().slice(0, 8); if (type === "text") { const fixture = config.validate.fixtures.text; return { source: "unidesk-cli-validate", messageType: "text", messageId: `validate-text-${suffix}`, conversationId: fixture.conversationId, senderId: fixture.senderId, text: `${fixture.text} ${observedAt.toISOString()}`, receivedAt: observedAt.toISOString(), }; } const fixture = config.validate.fixtures.image; return { source: "unidesk-cli-validate", messageType: "image", messageId: `validate-image-${suffix}`, conversationId: fixture.conversationId, senderId: fixture.senderId, receivedAt: observedAt.toISOString(), media: { filename: fixture.filename, mimeType: fixture.mimeType, dataBase64: fixture.dataBase64, }, }; } async function callWorkflow(config: WechatArchiveConfig, payload: Record): Promise> { const response = await fetchJsonWithTimeout(webhookUrl(config), payload, Math.max(45_000, config.archiveCallback.timeoutMs + 30_000)); const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body) ? response.body as Record : {}; return { ok: response.ok && (body.ok === true || response.status >= 200 && response.status < 300), status: response.status, webhookUrl: webhookUrl(config), body, }; } function archiveFromWorkflowResponse(originalPayload: Record, n8nResponse: Record): Record { if (n8nResponse.ok !== true) { return { ok: false, error: "n8n-webhook-failed", n8n: { status: n8nResponse.status, body: compactUnknown(n8nResponse.body), }, }; } const body = asRecord(n8nResponse.body ?? {}, "n8n response body"); const archive = asRecord(body.archive ?? {}, "n8n response archive"); const normalized = asRecord(body.message ?? originalPayload, "normalized message"); const type = String(normalized.messageType ?? originalPayload.messageType ?? "text").toLowerCase(); const remotePath = String(archive.remotePath || ""); const fsId = String(archive.fsId || archive.fs_id || ""); return { ok: body.ok === true && Boolean(remotePath) && Boolean(fsId), skipPipeline: body.skip_pipeline === true, readOnly: body.readOnly === true, type, remotePath, fsId, messageId: normalized.messageId ?? originalPayload.messageId, conversationId: normalized.conversationId ?? originalPayload.conversationId, senderId: normalized.senderId ?? originalPayload.senderId, local: typeof archive.local === "object" && archive.local !== null && !Array.isArray(archive.local) ? archive.local : {}, uploadJob: typeof archive.uploadJob === "object" && archive.uploadJob !== null && !Array.isArray(archive.uploadJob) ? archive.uploadJob : {}, listed: typeof archive.listed === "object" && archive.listed !== null && !Array.isArray(archive.listed) ? redactBaiduFileEntry(archive.listed as Record) : null, }; } async function pullByFsId(config: WechatArchiveConfig, fsId: string, localRel: string): Promise> { if (!fsId) return { ok: false, error: "fsId is required" }; const created = assertProxyOk(microserviceProxy(config.baiduNetdisk.serviceId, "/api/transfers/download-to-path", { method: "POST", body: { fsId, localPath: localRel }, timeoutMs: 60_000, }), "baidu download create"); const job = asRecord(created.job, "baidu download job"); const jobId = String(job.id || ""); const waited = await waitForBaiduTransfer(config.baiduNetdisk.serviceId, jobId, { timeoutMs: config.validate.timeoutMs, pollIntervalMs: config.validate.pollIntervalMs, }); const waitedJob = asRecord(waited.job ?? {}, "baidu waited download job"); const containerPath = String(waitedJob.localPath || ""); const hostPath = containerPathToHostPath(config.baiduNetdisk.staging.containerRoot, config.baiduNetdisk.staging.hostRoot, containerPath) ?? hostRootPath(pathPosix.join(config.baiduNetdisk.staging.hostRoot, localRel)); return { ok: waited.ok === true && existsSync(hostPath), fsId, remotePath: waitedJob.remotePath, localPath: containerPath, hostPath, sha256: existsSync(hostPath) ? sha256File(hostPath) : null, downloadJob: waitedJob, }; } async function pullArchiveIfReady(config: WechatArchiveConfig, archive: Record): Promise> { const fsId = String(archive.fsId || ""); const remotePath = String(archive.remotePath || ""); if (archive.ok !== true || !fsId || !remotePath) { return { ok: false, skipped: true, reason: "archive-upload-not-ready", fsId, remotePath, }; } return await pullByFsId(config, fsId, pullLocalRel(config, remotePath)); } function pullLocalRel(config: WechatArchiveConfig, remotePath: unknown): string { const name = sanitizePathSegment(pathPosix.basename(String(remotePath || `pull-${randomUUID()}`)), `pull-${randomUUID()}`); return pathPosix.join(config.baiduNetdisk.staging.pullDir, name); } function validationSummary( payload: Record, n8n: Record, archive: Record, downloaded: Record, full: boolean, ): Record { return { ok: n8n.ok === true && archive.ok === true && downloaded.ok === true, messageId: payload.messageId, n8n: full ? n8n : { ok: n8n.ok, status: n8n.status, webhookUrl: n8n.webhookUrl }, archive: full ? archive : { ok: archive.ok, skipPipeline: archive.skipPipeline, remotePath: archive.remotePath, fsId: archive.fsId, local: archive.local, }, pull: full ? downloaded : compactDownload(downloaded), }; } function compactDownload(downloaded: Record): Record { return { ok: downloaded.ok, fsId: downloaded.fsId, remotePath: downloaded.remotePath, hostPath: downloaded.hostPath, sha256: downloaded.sha256, }; } function compactBaiduHealth(response: ReturnType): Record { const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body) ? response.body as Record : {}; const storage = typeof body.storage === "object" && body.storage !== null && !Array.isArray(body.storage) ? body.storage as Record : {}; const queue = typeof body.queue === "object" && body.queue !== null && !Array.isArray(body.queue) ? body.queue as Record : {}; const transfers = typeof queue.transfers === "object" && queue.transfers !== null && !Array.isArray(queue.transfers) ? queue.transfers as Record : {}; return { ok: response.ok, status: response.status, service: body.service, storage: { postgres: storage.postgres, stagingDir: storage.stagingDir, }, queue: { transfers: { succeeded: transfers.succeeded, failed: transfers.failed, }, }, }; } function compactBaiduAuth(response: ReturnType): Record { const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body) ? response.body as Record : {}; const auth = typeof body.auth === "object" && body.auth !== null && !Array.isArray(body.auth) ? body.auth as Record : {}; const account = typeof auth.account === "object" && auth.account !== null && !Array.isArray(auth.account) ? auth.account as Record : {}; return { ok: response.ok, status: response.status, configured: auth.configured, loggedIn: auth.loggedIn, account: { present: Object.keys(account).length > 0, rootPath: account.rootPath, quota: compactBaiduQuota(account.quota), }, valuesPrinted: false, }; } function compactBaiduQuota(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; const quota = value as Record; return { total: quota.total, used: quota.used, free: quota.free, usedPercent: quota.usedPercent, }; } function compactBaiduTransfers(response: ReturnType): Record { const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body) ? response.body as Record : {}; const counts = typeof body.counts === "object" && body.counts !== null && !Array.isArray(body.counts) ? body.counts as Record : {}; const jobs = typeof body.jobs === "object" && body.jobs !== null && !Array.isArray(body.jobs) ? body.jobs as Record : {}; const items = Array.isArray(jobs.items) ? jobs.items : Array.isArray(jobs.arrayPreview) ? jobs.arrayPreview : []; return { ok: response.ok, status: response.status, counts, jobs: { count: typeof jobs.count === "number" ? jobs.count : items.length, items: items.slice(0, 10).map(compactBaiduTransferJob), }, }; } function compactBaiduTransferJob(value: unknown): Record { const job = typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; const result = typeof job.result === "object" && job.result !== null && !Array.isArray(job.result) ? job.result as Record : {}; const baidu = typeof result.baidu === "object" && result.baidu !== null && !Array.isArray(result.baidu) ? result.baidu as Record : {}; return { id: job.id, direction: job.direction, status: job.status, createdAt: job.createdAt, updatedAt: job.updatedAt, remotePath: job.remotePath ?? result.remotePath, fsId: job.fsId || baidu.fs_id || result.fsId, sizeBytes: job.sizeBytes ?? result.sizeBytes ?? baidu.size, progressPercent: job.progressPercent, error: job.error || undefined, }; } function redactBaiduFileEntry(value: Record | null): Record | null { if (value === null) return null; const copy: Record = { ...value }; if (copy.thumbs !== undefined) copy.thumbs = ""; if (copy.dlink !== undefined) copy.dlink = ""; return copy; } function readArchiveCallbackToken(config: WechatArchiveConfig): { sourceRef: string; keyName: string; value: string; fingerprint: string } { const sourcePath = pathPosix.join(config.archiveCallback.secretRoot, config.archiveCallback.tokenSourceRef); if (!existsSync(sourcePath)) { throw new Error(`${config.archiveCallback.tokenSourceRef} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`); } const values = parseEnvFile(readFileSync(sourcePath, "utf8")); const value = values[config.archiveCallback.tokenKey] ?? ""; if (value.length === 0) throw new Error(`${config.archiveCallback.tokenSourceRef} is missing required key ${config.archiveCallback.tokenKey}`); return { sourceRef: config.archiveCallback.tokenSourceRef, keyName: config.archiveCallback.tokenKey, value, fingerprint: fingerprintValues({ [config.archiveCallback.tokenKey]: value }, [config.archiveCallback.tokenKey]), }; }