docs: add personal WeChat WCF ingress config

This commit is contained in:
Codex
2026-06-13 11:45:40 +00:00
parent 3d47a86cbf
commit d7def22a3b
6 changed files with 277 additions and 3 deletions
@@ -64,6 +64,45 @@ interface WechatArchiveConfig {
tokenKey: string;
timeoutMs: number;
};
personalWechatIngress: {
enabled: boolean;
mode: string;
target: { id: string; windowsRoute: string; hostRoute: string; dockerRoute: string };
pcWechat: {
isolation: string;
requiredVersion: string;
installerAsset: 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;
containerName: string;
wcfHost: string;
commandPort: number;
messagePort: number;
stateRoot: string;
queueMode: string;
outboxMode: string;
archiveCallbackRef: string;
readOnly: { enabled: boolean; sendCapability: boolean; allowedMethods: string[]; forbiddenMethods: string[] };
};
poc: { accountPolicy: string; observationWindowHours: number; requiredMessageTypes: string[] };
};
baiduNetdisk: {
serviceId: string;
proxyMode: string;
@@ -112,6 +151,7 @@ export function wechatArchiveHelp(): Record<string, unknown> {
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 Docker 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.",
},
@@ -325,6 +365,7 @@ function readConfig(): WechatArchiveConfig {
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`);
@@ -387,6 +428,7 @@ function readConfig(): WechatArchiveConfig {
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`),
@@ -448,6 +490,7 @@ function validateConfig(config: WechatArchiveConfig): void {
}
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 {
@@ -487,6 +530,7 @@ function configSummary(config: WechatArchiveConfig): Record<string, unknown> {
timeoutMs: config.archiveCallback.timeoutMs,
valuesPrinted: false,
},
personalWechatIngress: personalWechatIngressSummary(config.personalWechatIngress),
baiduNetdisk: {
serviceId: config.baiduNetdisk.serviceId,
proxyMode: config.baiduNetdisk.proxyMode,
@@ -506,6 +550,12 @@ function policyChecks(config: WechatArchiveConfig): Array<Record<string, unknown
{ 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 Docker 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-read-only", ok: config.personalWechatIngress.collector.readOnly.enabled === true && config.personalWechatIngress.collector.readOnly.sendCapability === false, detail: "Docker 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." },
@@ -513,6 +563,150 @@ function policyChecks(config: WechatArchiveConfig): Array<Record<string, unknown
];
}
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 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`),
dockerRoute: stringField(target, "dockerRoute", `${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`),
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`),
containerName: stringField(collector, "containerName", `${configLabel}.personalWechatIngress.collector`),
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`),
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.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`);
}
}
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,
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,
containerName: config.collector.containerName,
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,
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`),