Files
pikasTech-unidesk/scripts/src/platform-infra-wechat-archive.ts
T
2026-06-15 04:01:04 +00:00

2471 lines
114 KiB
TypeScript

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<string, { enabled: boolean; contentField: string; mediaMode: string; filenameField?: string; mimeTypeField?: string }>;
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<string, unknown> {
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 <baiduFsId> [--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<Record<string, unknown>> {
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> | 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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> | 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<string, unknown> {
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<string, unknown> {
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<string, unknown>, full: boolean): Record<string, unknown> {
const status = typeof parsed.status === "object" && parsed.status !== null && !Array.isArray(parsed.status)
? parsed.status as Record<string, unknown>
: null;
const prepare = typeof parsed.prepare === "object" && parsed.prepare !== null && !Array.isArray(parsed.prepare)
? parsed.prepare as Record<string, unknown>
: 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<Record<string, unknown>> {
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<string, unknown> {
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<Record<string, unknown>> {
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<string, unknown>): 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<string, unknown> {
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<string, unknown>, 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<string, unknown>): 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<string, unknown>): 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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown>
: {};
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<string, unknown>, archive: WechatArchiveConfig): Record<string, unknown> {
const next = JSON.parse(JSON.stringify(current)) as Record<string, unknown>;
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<Record<string, unknown>> {
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<Record<string, string>> {
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<string, unknown>;
return Object.entries(expected).every(([key, value]) => record[key] === value);
}));
}
function syncLangBotWebhook(config: WechatArchiveConfig, url: string): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> | 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<Record<string, unknown>> {
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_<redacted>")
.replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1<redacted>@")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
.replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$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<string, unknown>)[part];
}
return current;
}
function arrayFromPath(value: unknown, path: string[]): unknown[] {
const nested = nestedValue(value, path);
return Array.isArray(nested) ? nested : [];
}
function asMutableRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const current = parent[key];
if (typeof current === "object" && current !== null && !Array.isArray(current)) return current as Record<string, unknown>;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
}
function renderN8nWorkflow(config: WechatArchiveConfig, callbackToken: string | null): Record<string, unknown> {
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<string, unknown> {
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<string, unknown>): Promise<Record<string, unknown>> {
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<string, unknown>
: {};
return {
ok: response.ok && (body.ok === true || response.status >= 200 && response.status < 300),
status: response.status,
webhookUrl: webhookUrl(config),
body,
};
}
function archiveFromWorkflowResponse(originalPayload: Record<string, unknown>, n8nResponse: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>) : null,
};
}
async function pullByFsId(config: WechatArchiveConfig, fsId: string, localRel: string): Promise<Record<string, unknown>> {
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<string, unknown>): Promise<Record<string, unknown>> {
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<string, unknown>,
n8n: Record<string, unknown>,
archive: Record<string, unknown>,
downloaded: Record<string, unknown>,
full: boolean,
): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
return {
ok: downloaded.ok,
fsId: downloaded.fsId,
remotePath: downloaded.remotePath,
hostPath: downloaded.hostPath,
sha256: downloaded.sha256,
};
}
function compactBaiduHealth(response: ReturnType<typeof microserviceProxy>): Record<string, unknown> {
const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)
? response.body as Record<string, unknown>
: {};
const storage = typeof body.storage === "object" && body.storage !== null && !Array.isArray(body.storage) ? body.storage as Record<string, unknown> : {};
const queue = typeof body.queue === "object" && body.queue !== null && !Array.isArray(body.queue) ? body.queue as Record<string, unknown> : {};
const transfers = typeof queue.transfers === "object" && queue.transfers !== null && !Array.isArray(queue.transfers) ? queue.transfers as Record<string, unknown> : {};
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<typeof microserviceProxy>): Record<string, unknown> {
const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)
? response.body as Record<string, unknown>
: {};
const auth = typeof body.auth === "object" && body.auth !== null && !Array.isArray(body.auth) ? body.auth as Record<string, unknown> : {};
const account = typeof auth.account === "object" && auth.account !== null && !Array.isArray(auth.account) ? auth.account as Record<string, unknown> : {};
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<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const quota = value as Record<string, unknown>;
return {
total: quota.total,
used: quota.used,
free: quota.free,
usedPercent: quota.usedPercent,
};
}
function compactBaiduTransfers(response: ReturnType<typeof microserviceProxy>): Record<string, unknown> {
const body = typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)
? response.body as Record<string, unknown>
: {};
const counts = typeof body.counts === "object" && body.counts !== null && !Array.isArray(body.counts) ? body.counts as Record<string, unknown> : {};
const jobs = typeof body.jobs === "object" && body.jobs !== null && !Array.isArray(body.jobs) ? body.jobs as Record<string, unknown> : {};
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<string, unknown> {
const job = typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
const result = typeof job.result === "object" && job.result !== null && !Array.isArray(job.result) ? job.result as Record<string, unknown> : {};
const baidu = typeof result.baidu === "object" && result.baidu !== null && !Array.isArray(result.baidu) ? result.baidu as Record<string, unknown> : {};
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<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
const copy: Record<string, unknown> = { ...value };
if (copy.thumbs !== undefined) copy.thumbs = "<redacted-signed-thumbnail-urls>";
if (copy.dlink !== undefined) copy.dlink = "<redacted-download-link>";
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]),
};
}