2471 lines
114 KiB
TypeScript
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]),
|
|
};
|
|
}
|