|
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
|
|
type OpsApplyOptions,
|
|
|
|
|
type OpsCommonOptions,
|
|
|
|
|
} from "./platform-infra-ops-library";
|
|
|
|
|
import { readLangBotRuntimeConfig, readLangBotSecretMaterial } from "./platform-infra-langbot";
|
|
|
|
|
import { prepareLangBotSecretMaterial, readLangBotPostgresConninfo, readLangBotRuntimeConfig, readLangBotSecretMaterial } from "./platform-infra-langbot";
|
|
|
|
|
import { fingerprintValues } from "./platform-infra-public-service";
|
|
|
|
|
|
|
|
|
|
const configFile = rootPath("config", "platform-infra", "wechat-archive.yaml");
|
|
|
|
@@ -48,6 +48,7 @@ interface WechatArchiveConfig {
|
|
|
|
|
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[];
|
|
|
|
|
};
|
|
|
|
@@ -262,7 +263,15 @@ async function validate(options: OpsCommonOptions): Promise<Record<string, unkno
|
|
|
|
|
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 && imageArchive.ok === true && textPull.ok === true && imagePull.ok === true;
|
|
|
|
|
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",
|
|
|
|
@@ -356,6 +365,7 @@ function readConfig(): WechatArchiveConfig {
|
|
|
|
|
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) : [],
|
|
|
|
|
},
|
|
|
|
@@ -429,6 +439,8 @@ function validateConfig(config: WechatArchiveConfig): void {
|
|
|
|
|
}
|
|
|
|
|
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("/")) {
|
|
|
|
@@ -453,6 +465,12 @@ function configSummary(config: WechatArchiveConfig): Record<string, unknown> {
|
|
|
|
|
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: {
|
|
|
|
@@ -487,6 +505,7 @@ function policyChecks(config: WechatArchiveConfig): Array<Record<string, unknown
|
|
|
|
|
{ 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: "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." },
|
|
|
|
@@ -494,6 +513,16 @@ function policyChecks(config: WechatArchiveConfig): Array<Record<string, unknown
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
@@ -522,7 +551,7 @@ function langBotDryRun(config: WechatArchiveConfig): Record<string, unknown> {
|
|
|
|
|
},
|
|
|
|
|
bot: {
|
|
|
|
|
expectedAdapter: config.langbot.expectedAdapter,
|
|
|
|
|
desiredUsePipelineName: config.langbot.pipeline.name,
|
|
|
|
|
desiredRouting: "discard person/group messages after read-only webhook mirroring",
|
|
|
|
|
},
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
@@ -559,19 +588,10 @@ async function syncLangBotPipeline(config: WechatArchiveConfig): Promise<Record<
|
|
|
|
|
description: config.langbot.pipeline.description,
|
|
|
|
|
config: nextConfig,
|
|
|
|
|
});
|
|
|
|
|
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 alreadyBound = String(botRecord.use_pipeline_uuid || "") === pipelineUuid;
|
|
|
|
|
if (!alreadyBound) {
|
|
|
|
|
await langBotRequest(baseUrl, apiKey, "PUT", `/api/v1/platform/bots/${encodeURIComponent(botUuid)}`, { use_pipeline_uuid: pipelineUuid });
|
|
|
|
|
}
|
|
|
|
|
const webhookBinding = syncLangBotWebhook(config, webhookUrl(config));
|
|
|
|
|
const botBinding = await syncLangBotReadOnlyBot(config, baseUrl, apiKey);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
ok: webhookBinding.ok === true && botBinding.ok === true,
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
pipeline: {
|
|
|
|
|
uuid: pipelineUuid,
|
|
|
|
@@ -580,14 +600,8 @@ async function syncLangBotPipeline(config: WechatArchiveConfig): Promise<Record<
|
|
|
|
|
webhookUrl: webhookUrl(config),
|
|
|
|
|
created,
|
|
|
|
|
},
|
|
|
|
|
bot: {
|
|
|
|
|
uuid: botUuid,
|
|
|
|
|
adapter: config.langbot.expectedAdapter,
|
|
|
|
|
bound: true,
|
|
|
|
|
wasAlreadyBound: alreadyBound,
|
|
|
|
|
usePipelineUuid: pipelineUuid,
|
|
|
|
|
usePipelineName: config.langbot.pipeline.name,
|
|
|
|
|
},
|
|
|
|
|
readOnlyWebhook: webhookBinding,
|
|
|
|
|
bot: botBinding,
|
|
|
|
|
auth: {
|
|
|
|
|
sourceRef: secret.sourceRef,
|
|
|
|
|
keyName: secret.keyName,
|
|
|
|
@@ -618,11 +632,14 @@ async function inspectLangBotBinding(config: WechatArchiveConfig): Promise<Recor
|
|
|
|
|
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 botPipelineUuid = String(botRecord?.use_pipeline_uuid || "");
|
|
|
|
|
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)
|
|
|
|
|
&& botPipelineUuid === pipelineUuid;
|
|
|
|
|
&& hasDiscardRouting
|
|
|
|
|
&& readOnlyWebhook.ok === true;
|
|
|
|
|
return {
|
|
|
|
|
ok,
|
|
|
|
|
pipeline: {
|
|
|
|
@@ -636,9 +653,10 @@ async function inspectLangBotBinding(config: WechatArchiveConfig): Promise<Recor
|
|
|
|
|
exists: botRecord !== null,
|
|
|
|
|
uuid: String(botRecord?.uuid || "") || null,
|
|
|
|
|
adapter: config.langbot.expectedAdapter,
|
|
|
|
|
usePipelineUuid: botPipelineUuid || null,
|
|
|
|
|
bound: Boolean(pipelineUuid) && botPipelineUuid === pipelineUuid,
|
|
|
|
|
usePipelineUuid: String(botRecord?.use_pipeline_uuid || "") || null,
|
|
|
|
|
readOnlyDiscardRouting: hasDiscardRouting,
|
|
|
|
|
},
|
|
|
|
|
readOnlyWebhook,
|
|
|
|
|
auth: {
|
|
|
|
|
sourceRef: secret.sourceRef,
|
|
|
|
|
keyName: secret.keyName,
|
|
|
|
@@ -670,6 +688,137 @@ function langBotPipelineConfig(current: Record<string, unknown>, archive: Wechat
|
|
|
|
|
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);
|
|
|
|
@@ -687,13 +836,21 @@ async function langBotRequest(baseUrl: string, apiKey: string, method: string, a
|
|
|
|
|
} catch {
|
|
|
|
|
parsed = text;
|
|
|
|
|
}
|
|
|
|
|
if (!response.ok) throw new Error(`LangBot ${method} ${apiPath} failed with HTTP ${response.status}: ${JSON.stringify(compactUnknown(parsed)).slice(0, 800)}`);
|
|
|
|
|
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) {
|
|
|
|
@@ -721,6 +878,27 @@ function renderN8nWorkflow(config: WechatArchiveConfig, callbackToken: string |
|
|
|
|
|
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);
|
|
|
|
@@ -756,18 +934,25 @@ function normalizeRemotePath(path) {
|
|
|
|
|
const collapsed = String(path).replace(/\\/+/g, '/');
|
|
|
|
|
return collapsed.startsWith('/') ? collapsed : '/' + collapsed;
|
|
|
|
|
}
|
|
|
|
|
const messageType = String(pick('messageType', 'type', 'msgType') || 'text').toLowerCase();
|
|
|
|
|
const receivedAt = String(pick('receivedAt', 'timestamp') || new Date().toISOString());
|
|
|
|
|
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') || String(Date.now()), 'message');
|
|
|
|
|
const conversationId = sanitizePathSegment(pick('conversationId', 'chatId', 'roomId', 'fromUser', 'conversation_id', 'session_id') || 'unknown-conversation', 'conversation');
|
|
|
|
|
const senderId = sanitizePathSegment(pick('senderId', 'userId', 'fromUserName', 'fromUser', 'user_id') || 'unknown-sender', 'sender');
|
|
|
|
|
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') || '');
|
|
|
|
|
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}'),
|
|
|
|
@@ -939,6 +1124,8 @@ function archiveFromWorkflowResponse(originalPayload: Record<string, unknown>, n
|
|
|
|
|
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,
|
|
|
|
@@ -1012,6 +1199,7 @@ function validationSummary(
|
|
|
|
|
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,
|
|
|
|
|