diff --git a/extensions/salesforce/package-lock.json b/extensions/salesforce/package-lock.json index 4ca87622..4692caa5 100644 --- a/extensions/salesforce/package-lock.json +++ b/extensions/salesforce/package-lock.json @@ -1,20 +1,22 @@ { "name": "salesforce", - "version": "4.4.0", + "version": "4.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "salesforce", - "version": "4.4.0", + "version": "4.5.1", "license": "MIT", "dependencies": { - "@cognigy/extension-tools": "^0.16.1", + "@cognigy/extension-tools": "^0.17.0", "axios": "^1.13.5" }, "devDependencies": { - "@types/node": "^10.12.5", + "@types/node": "^10.17.60", "@types/qs": "^6.9.17", + "dotenv": "^17.3.1", + "ts-node": "^10.9.2", "tslint": "^6.1.2", "typescript": "^4.9.5" } @@ -45,11 +47,80 @@ } }, "node_modules/@cognigy/extension-tools": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@cognigy/extension-tools/-/extension-tools-0.16.6.tgz", - "integrity": "sha512-hWvUZsdDnsfsncIryMolrij2SVMdiKQC5d5zQdF7snNPBiz3Bb2Pg3PD0mlWRMAQtxwXbvRt03jpH3QMiXS38w==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@cognigy/extension-tools/-/extension-tools-0.17.0.tgz", + "integrity": "sha512-JTSQI+1Xm1IBZVGYwJK1lTpIBlJlrrqFpNQJYWZPczRF7ceX11I+aYUm7XY7bnHnFCQxyHKJSDXNXjvG6Jr9Aw==", "license": "SEE LICENSE IN LICENSE" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "10.17.60", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", @@ -64,6 +135,32 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -77,6 +174,13 @@ "node": ">=4" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -203,6 +307,13 @@ "dev": true, "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -222,6 +333,19 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -533,6 +657,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -703,6 +834,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -769,12 +944,29 @@ "node": ">=4.2.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } } } } diff --git a/extensions/salesforce/package.json b/extensions/salesforce/package.json index a4e2120e..34ff717c 100644 --- a/extensions/salesforce/package.json +++ b/extensions/salesforce/package.json @@ -1,7 +1,8 @@ { "name": "salesforce", - "version": "4.4.0", + "version": "4.5.1", "description": "This Extension integrates with all Salesforce Clouds", + "authors": "Matt Muller ", "main": "build/module.js", "scripts": { "transpile": "tsc -p .", @@ -23,13 +24,15 @@ "author": "Cognigy GmbH", "license": "MIT", "devDependencies": { - "@types/node": "^10.12.5", + "@types/node": "^10.17.60", "@types/qs": "^6.9.17", + "dotenv": "^17.3.1", + "ts-node": "^10.9.2", "tslint": "^6.1.2", "typescript": "^4.9.5" }, "dependencies": { - "@cognigy/extension-tools": "^0.16.1", + "@cognigy/extension-tools": "^0.17.0", "axios": "^1.13.5" } } diff --git a/extensions/salesforce/src/authenticate.ts b/extensions/salesforce/src/authenticate.ts index 3c5c83b1..bb566411 100644 --- a/extensions/salesforce/src/authenticate.ts +++ b/extensions/salesforce/src/authenticate.ts @@ -133,4 +133,4 @@ export const authenticate = async (oauthConnection: IConnection["oauthConnection query, sobject }; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/extensions/salesforce/src/knowledge-connectors/cognigyManagementApi.ts b/extensions/salesforce/src/knowledge-connectors/cognigyManagementApi.ts new file mode 100644 index 00000000..5e1e27b4 --- /dev/null +++ b/extensions/salesforce/src/knowledge-connectors/cognigyManagementApi.ts @@ -0,0 +1,76 @@ +/** + * Thin wrapper around the Cognigy Management REST API. + * + * Provides listing and deletion of knowledge sources — operations not + * exposed by the @cognigy/extension-tools SDK — for incremental sync. + * + * Auth: X-API-Key header (from Profile → API Keys in Cognigy.AI UI). + */ + +import axios from "axios"; + +export interface ManagedSource { + _id: string; + name: string; + description?: string; +} + +const REQUEST_TIMEOUT = 30_000; + +/** + * List all knowledge sources in a store, paginating automatically. + * + * GET {apiUrl}/v2.0/knowledgestores/{storeId}/knowledgesources?limit=100&skip=0 + */ +export async function listKnowledgeSources( + apiUrl: string, + apiKey: string, + storeId: string, +): Promise { + const baseUrl = apiUrl.replace(/\/+$/, ""); + const all: ManagedSource[] = []; + const limit = 100; + let skip = 0; + + while (true) { + const res = await axios.get<{ data: ManagedSource[] }>( + `${baseUrl}/v2.0/knowledgestores/${storeId}/knowledgesources`, + { + params: { limit, skip }, + headers: { "X-API-Key": apiKey }, + timeout: REQUEST_TIMEOUT, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + + const page = res.data?.data ?? []; + all.push(...page); + + if (page.length < limit) break; + skip += limit; + } + + return all; +} + +/** + * Delete a single knowledge source by its ID. + * + * DELETE {apiUrl}/v2.0/knowledgestores/{storeId}/knowledgesources/{sourceId} + */ +export async function deleteKnowledgeSourceById( + apiUrl: string, + apiKey: string, + storeId: string, + sourceId: string, +): Promise { + const baseUrl = apiUrl.replace(/\/+$/, ""); + await axios.delete( + `${baseUrl}/v2.0/knowledgestores/${storeId}/knowledgesources/${sourceId}`, + { + headers: { "X-API-Key": apiKey }, + timeout: REQUEST_TIMEOUT, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); +} diff --git a/extensions/salesforce/src/knowledge-connectors/salesforceKnowledgeConnector.ts b/extensions/salesforce/src/knowledge-connectors/salesforceKnowledgeConnector.ts new file mode 100644 index 00000000..a3c04369 --- /dev/null +++ b/extensions/salesforce/src/knowledge-connectors/salesforceKnowledgeConnector.ts @@ -0,0 +1,527 @@ +import * as crypto from "crypto"; +import { createKnowledgeConnector } from "@cognigy/extension-tools"; +import { authenticate } from "../authenticate"; + +interface IOAuthConnection { + consumerKey: string; + consumerSecret: string; + instanceUrl: string; +} + +/** + * Remove C0/C1 control characters. Preserves tab, LF, CR, and all printable + * Unicode so non-English article content (accented chars, CJK) is kept intact. + */ +function sanitizeText(text: string): string { + if (!text) return ""; + return text + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") + .replace(/[\x7F-\x9F]/g, "") + .replace(/[ \t]{2,}/g, " ") + .trim(); +} + +/** + * Convert HTML from a Salesforce field to Markdown. + * Heading levels are shifted down by fieldDepth so the field-label heading + * (## FieldName) sits above any headings inside the HTML content. + * + * e.g. fieldDepth=2:

→ ###,

→ #### + */ +function htmlFieldToMarkdown(html: string, fieldDepth: number = 2): string { + if (!html) return ""; + + const shiftedH = (level: number) => "#".repeat(Math.min(level + fieldDepth, 6)); + const stripInner = (s: string) => s.replace(/<[^>]+>/g, "").trim(); + + const md = html + .replace(/]*>([\s\S]*?)<\/h1>/gi, (_, t) => `${shiftedH(1)} ${stripInner(t)}\n\n`) + .replace(/]*>([\s\S]*?)<\/h2>/gi, (_, t) => `${shiftedH(2)} ${stripInner(t)}\n\n`) + .replace(/]*>([\s\S]*?)<\/h3>/gi, (_, t) => `${shiftedH(3)} ${stripInner(t)}\n\n`) + .replace(/]*>([\s\S]*?)<\/h4>/gi, (_, t) => `${shiftedH(4)} ${stripInner(t)}\n\n`) + .replace(/]*>([\s\S]*?)<\/h5>/gi, (_, t) => `${shiftedH(5)} ${stripInner(t)}\n\n`) + .replace(/]*>([\s\S]*?)<\/h6>/gi, (_, t) => `${shiftedH(6)} ${stripInner(t)}\n\n`) + .replace(/<(strong|b)[^>]*>([\s\S]*?)<\/(strong|b)>/gi, "**$2**") + .replace(/<(em|i)[^>]*>([\s\S]*?)<\/(em|i)>/gi, "_$2_") + .replace(/]*>/gi, "").replace(/<\/ul>/gi, "\n") + .replace(/]*>/gi, "").replace(/<\/ol>/gi, "\n") + .replace(/]*>([\s\S]*?)<\/li>/gi, (_, t) => `- ${stripInner(t)}\n`) + .replace(/]*>/gi, "\n").replace(/<\/table>/gi, "\n") + .replace(/]*>/gi, "").replace(/<\/t(?:head|body|foot)>/gi, "") + .replace(/]*>/gi, "").replace(/<\/tr>/gi, " |\n") + .replace(/]*>([\s\S]*?)<\/th>/gi, (_, t) => `| **${stripInner(t)}** `) + .replace(/]*>([\s\S]*?)<\/td>/gi, (_, t) => `| ${stripInner(t)} `) + .replace(/<\/p>/gi, "\n\n").replace(/]*>/gi, "") + .replace(/<\/div>/gi, "\n").replace(/]*>/gi, "") + .replace(//gi, "\n") + .replace(/]*>([\s\S]*?)<\/a>/gi, "$1") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/ /g, " ").replace(/"/g, '"').replace(/'/g, "'") + .replace(/&[a-z]+;/gi, " ") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return sanitizeText(md); +} + +/** Sanitize a string for use as a Cognigy knowledge source name. */ +function sanitizeSourceName(name: string): string { + return name + .replace(/\u2013|\u2014/g, "-") + .replace(/&/g, "and") + .replace(/[/?!()#*+<>=^~%@\\]/g, " ") + .replace(/[ \t]{2,}/g, " ") + .replace(/-{2,}/g, "-") + .trim(); +} + +/** Return true if the string is a valid Salesforce API identifier. */ +function isValidSfApiName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); +} + +/** Return true if the string looks like a valid Salesforce language code. */ +function isValidLanguageCode(lang: string): boolean { + return /^[a-z]{2,3}(_[A-Za-z0-9]{2,8})*$/.test(lang); +} + +/** First 12 hex chars of SHA-256 — used as contentHashOrTimestamp. */ +function shortHash(text: string): string { + return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12); +} + +/** + * Build a Markdown document from a Salesforce article's fields. + * + * # Article Title + * ## Field Label + * + * ## Next Field Label + * ... + * + * Note: the standard Salesforce Summary field is intentionally excluded. + * Add "Summary" to agentFields if you want it as a dedicated chunk, since + * including it automatically duplicates content when Summary == Overview__c. + */ +function buildArticleText(title: string, fields: string[], article: any): string { + const sections: string[] = []; + + if (title) sections.push(`# ${sanitizeText(title)}`); + + for (const field of fields) { + const raw = article[field]; + if (!raw) continue; + const content = htmlFieldToMarkdown(String(raw), 2); + if (!content) continue; + const label = field.replace(/__c$/i, "").replace(/_/g, " ").trim(); + sections.push(`## ${label}\n\n${content}`); + } + + return sections.join("\n\n"); +} + +// --------------------------------------------------------------------------- +// Heading-aware chunker +// --------------------------------------------------------------------------- + +const HARD_MAX_CHARS = 1950; +const MIN_BODY_CHARS = 30; + +interface ArticleChunk { + text: string; + section: string; +} + +/** + * Split article Markdown into RAG-optimised chunks. + * Splits at H2 (field-level) boundaries only; every chunk is prefixed with + * the article's H1 title for context. + */ +function chunkArticleMarkdown(markdown: string, maxChars: number = 1800): ArticleChunk[] { + const effective = Math.min(maxChars, HARD_MAX_CHARS); + + const titleMatch = markdown.match(/^#\s+(.+)$/m); + const titlePrefix = titleMatch ? `# ${titleMatch[1].trim()}\n\n` : ""; + + const lines = markdown.split("\n"); + const sections: Array<{ level: number; heading: string; content: string }> = []; + let curLevel = 0; + let curHeading = ""; + let curLines: string[] = []; + let started = false; + + for (const line of lines) { + const m = line.match(/^(#{1,2})\s+(.+)$/); + if (m) { + if (started) { + sections.push({ level: curLevel, heading: curHeading, content: curLines.join("\n").trim() }); + } + curLevel = m[1].length; + curHeading = m[2].trim(); + curLines = []; + started = true; + } else if (started) { + curLines.push(line); + } + } + if (started) { + sections.push({ level: curLevel, heading: curHeading, content: curLines.join("\n").trim() }); + } + + const chunks: ArticleChunk[] = []; + + for (const section of sections) { + if (section.level === 1) { + if (!section.content.trim()) continue; + const fullText = titlePrefix + section.content; + if (fullText.length <= effective) { + chunks.push({ text: fullText, section: "" }); + } else { + for (const sub of splitAtBoundaries(section.content, effective - titlePrefix.length)) { + chunks.push({ text: titlePrefix + sub, section: "" }); + } + } + continue; + } + + const headingLine = `## ${section.heading}\n\n`; + const headingLineCont = `## ${section.heading} (continued)\n\n`; + const fullText = titlePrefix + headingLine + section.content; + + if (fullText.length <= effective) { + chunks.push({ text: fullText, section: section.heading }); + } else { + const subMax = effective - titlePrefix.length - headingLineCont.length; + const subTexts = splitAtBoundaries(section.content, Math.max(subMax, 200)); + for (let i = 0; i < subTexts.length; i++) { + const cont = i > 0 ? " (continued)" : ""; + chunks.push({ + text: titlePrefix + `## ${section.heading}${cont}\n\n` + subTexts[i], + section: section.heading, + }); + } + } + } + + if (chunks.length === 0 && markdown.trim()) { + return splitAtBoundaries(markdown, effective).map(t => ({ text: t, section: "" })); + } + + return chunks.filter(c => { + const body = c.text.replace(/^#{1,6}[^\n]*\n/gm, "").replace(/\s/g, ""); + return body.length >= MIN_BODY_CHARS; + }); +} + +function splitAtBoundaries(text: string, maxChars: number): string[] { + if (text.length <= maxChars) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxChars) { + chunks.push(remaining.trim()); + break; + } + + let splitAt = remaining.lastIndexOf("\n\n", maxChars); + if (splitAt < maxChars * 0.4) splitAt = remaining.lastIndexOf("\n", maxChars); + if (splitAt < maxChars * 0.4) splitAt = remaining.lastIndexOf(". ", maxChars); + if (splitAt < maxChars * 0.4) splitAt = remaining.lastIndexOf(" ", maxChars); + if (splitAt <= 0) splitAt = maxChars; + + const chunk = remaining.substring(0, splitAt).trim(); + if (chunk.length > 0) chunks.push(chunk); + remaining = remaining.substring(splitAt).trim(); + } + + return chunks.filter(c => c.length > 0); +} + +export const salesforceKnowledgeConnector = createKnowledgeConnector({ + type: "salesforceKnowledgeConnector", + label: "Salesforce Knowledge", + summary: "Imports published Salesforce Knowledge articles into Cognigy Knowledge AI with role-based separation for supervisors and managers", + fields: [ + { + key: "oauthConnection", + label: "Salesforce Connected App", + type: "connection", + params: { connectionType: "oauth", required: true } + }, + { + key: "knowledgeApiName", + label: "Knowledge Article Object API Name", + type: "text", + defaultValue: "Knowledge_Article__kav", + description: "The API name of your Salesforce Knowledge Article object, e.g. Knowledge_Article__kav", + params: { required: true } + }, + { + key: "language", + label: "Language", + type: "text", + defaultValue: "en_US", + description: "Language code to filter published articles, e.g. en_US", + params: { required: true } + }, + { + key: "agentFields", + label: "Agent Content Fields", + type: "textArray", + defaultValue: [ + "Overview__c", "Details__c", "Inclusions__c", "Exclusions__c", + "Information__c", "Processing_Steps_Text__c", "Questions__c", + "Answer__c", "Scripting__c", "Actions__c" + ], + description: "API names of article fields to include in agent-accessible knowledge.", + params: { required: true } + }, + { + key: "agentTags", + label: "Agent Knowledge Tags", + type: "chipInput", + defaultValue: ["agent"], + description: "Tags applied to agent knowledge sources. Press ENTER to add a tag." + }, + { + key: "supervisorFields", + label: "Supervisor / Manager Content Fields", + type: "textArray", + defaultValue: [ + "Manager_Actions__c", "Manager_information__c", + "Manager_processing_steps__c", "Manager_scripting__c" + ], + description: "Fields with manager-only content. A separate supervisor-tagged source is created per article.", + params: { required: false } + }, + { + key: "supervisorTags", + label: "Supervisor Knowledge Tags", + type: "chipInput", + defaultValue: ["supervisor"], + description: "Tags applied to supervisor-only knowledge sources. Press ENTER to add a tag." + }, + { + key: "syncMode", + label: "Sync Mode", + type: "select", + defaultValue: "full", + description: "Full: fetch all published articles (recommended — unchanged articles are skipped via content hash). Incremental: only fetch articles modified since Last Sync Date.", + params: { + options: [ + { label: "Full", value: "full" }, + { label: "Incremental (filter by Last Sync Date)", value: "incremental" } + ] + } + }, + { + key: "lastSyncDate", + label: "Last Sync Date", + type: "text", + description: "ISO 8601 date for Incremental mode, e.g. 2026-01-01T00:00:00Z. Stale removal is skipped in Incremental mode.", + params: { required: false } + } + ] as const, + sections: [ + { + key: "supervisorAccess", + label: "Supervisor / Manager Access", + defaultCollapsed: true, + fields: ["supervisorFields", "supervisorTags"] + }, + { + key: "syncSettings", + label: "Sync Settings", + defaultCollapsed: true, + fields: ["syncMode", "lastSyncDate"] + } + ], + form: [ + { type: "field", key: "oauthConnection" }, + { type: "field", key: "knowledgeApiName" }, + { type: "field", key: "language" }, + { type: "field", key: "agentFields" }, + { type: "field", key: "agentTags" }, + { type: "section", key: "supervisorAccess" }, + { type: "section", key: "syncSettings" } + ], + function: async ({ config, api, sources }) => { + const { + oauthConnection, knowledgeApiName, language, + agentFields, agentTags, supervisorFields, supervisorTags, + syncMode, lastSyncDate + } = config; + + // --- Input validation (prevent SOQL injection) ---------------------- + const apiNameRaw = (knowledgeApiName as string)?.trim() ?? ""; + if (!isValidSfApiName(apiNameRaw)) { + throw new Error(`[Salesforce KC] Invalid Knowledge Article Object API Name: "${apiNameRaw}". Must match [A-Za-z_][A-Za-z0-9_]*.`); + } + const langRaw = (language as string)?.trim() ?? ""; + if (!isValidLanguageCode(langRaw)) { + throw new Error(`[Salesforce KC] Invalid Language code: "${langRaw}". Expected format e.g. en_US or de.`); + } + + const salesforceConnection = await authenticate(oauthConnection as IOAuthConnection); + + const agentFieldList = Array.isArray(agentFields) ? (agentFields as string[]) : []; + const supervisorFieldList = Array.isArray(supervisorFields) ? (supervisorFields as string[]) : []; + + const invalidFields = [...agentFieldList, ...supervisorFieldList].filter(f => !isValidSfApiName(f)); + if (invalidFields.length > 0) { + throw new Error(`[Salesforce KC] Invalid field API name(s): ${invalidFields.join(", ")}.`); + } + if (agentFieldList.length === 0) { + throw new Error("[Salesforce KC] At least one Agent Content Field is required."); + } + + const allContentFields = [...new Set([...agentFieldList, ...supervisorFieldList])]; + + // Incremental date filter — validate ISO 8601 before embedding in SOQL + const rawDate = (lastSyncDate as string)?.trim() ?? ""; + const isValidIsoDate = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+\-]+)?$/.test(rawDate); + if ((syncMode as string) === "incremental" && rawDate && !isValidIsoDate) { + throw new Error(`[Salesforce KC] Invalid Last Sync Date: "${rawDate}". Expected ISO 8601, e.g. 2026-01-01T00:00:00Z`); + } + const dateFilter = (syncMode as string) === "incremental" && isValidIsoDate + ? `AND LastModifiedDate > ${rawDate}` + : ""; + + const fixedFields = "Id, KnowledgeArticleId, ArticleNumber, Title, UrlName, Language, LastPublishedDate"; + const selectClause = allContentFields.length > 0 + ? `${fixedFields}, ${allContentFields.join(", ")}` + : fixedFields; + + const soql = [ + `SELECT ${selectClause}`, + `FROM ${apiNameRaw}`, + `WHERE PublishStatus = 'Online'`, + `AND Language = '${langRaw}'`, + `AND IsLatestVersion = true`, + dateFilter, + `ORDER BY Title ASC` + ].filter(Boolean).join(" "); + + if (dateFilter) { + console.log(`[Salesforce KC] Incremental mode: articles modified after ${rawDate}`); + } + + const result = await salesforceConnection.query(soql, { autoFetch: true }); + const articles = result.records; + console.log(`[Salesforce KC] ${articles.length} article(s) to process`); + + // Track externalIdentifiers created this run — used for stale removal + const processedIds = new Set(); + + for (const article of articles) { + const title = article.Title || `Article ${article.ArticleNumber}`; + const articleNumber = String(article.ArticleNumber || ""); + const knowledgeArticleId = String(article.KnowledgeArticleId || ""); + const articleUrl = `${salesforceConnection.instanceUrl}/lightning/articles/${knowledgeArticleId}`; + + // --- Agent Knowledge Source --- + const agentText = buildArticleText(title, agentFieldList, article); + const agentBody = agentText.replace(/^#[^\n]*\n?/m, "").trim(); + if (!agentBody) { + console.log(`[Salesforce KC] Agent has no body content — skipping: ${articleNumber}`); + } else { + const agentExternalId = `${articleNumber}:agent`; + processedIds.add(agentExternalId); + const agentChunks = chunkArticleMarkdown(agentText); + const agentHash = shortHash(agentText); + + const agentSource = await api.upsertKnowledgeSource({ + name: sanitizeSourceName(`[SF:${articleNumber}] ${title}`), + description: `Salesforce article ${articleNumber} - agent`, + tags: agentTags as string[], + chunkCount: agentChunks.length, + contentHashOrTimestamp: agentHash, + externalIdentifier: agentExternalId + }); + + if (agentSource) { + console.log(`[Salesforce KC] Agent: "${title}" (${articleNumber}) — ${agentChunks.length} chunk(s)`); + for (const chunk of agentChunks) { + const data: Record = { + articleNumber, + role: "agent", + url: articleUrl + }; + if (chunk.section) data.section = chunk.section; + await api.createKnowledgeChunk({ + knowledgeSourceId: agentSource.knowledgeSourceId, + text: chunk.text, + data + }); + } + } else { + console.log(`[Salesforce KC] Agent unchanged — skipping: ${articleNumber}`); + } + } + + // --- Supervisor Knowledge Source --- + const hasSupervisorContent = supervisorFieldList.some(field => { + const raw = article[field]; + return raw && htmlFieldToMarkdown(String(raw)).length > 0; + }); + + if (hasSupervisorContent) { + const supervisorText = buildArticleText(title, supervisorFieldList, article); + const supervisorExternalId = `${articleNumber}:supervisor`; + processedIds.add(supervisorExternalId); + const supervisorChunks = chunkArticleMarkdown(supervisorText); + const supervisorHash = shortHash(supervisorText); + + const supervisorSource = await api.upsertKnowledgeSource({ + name: sanitizeSourceName(`[SF:${articleNumber}] ${title} - Manager`), + description: `Salesforce article ${articleNumber} - supervisor`, + tags: supervisorTags as string[], + chunkCount: supervisorChunks.length, + contentHashOrTimestamp: supervisorHash, + externalIdentifier: supervisorExternalId + }); + + if (supervisorSource) { + console.log(`[Salesforce KC] Supervisor: "${title}" (${articleNumber}) — ${supervisorChunks.length} chunk(s)`); + for (const chunk of supervisorChunks) { + const data: Record = { + articleNumber, + role: "supervisor", + url: articleUrl + }; + if (chunk.section) data.section = chunk.section; + await api.createKnowledgeChunk({ + knowledgeSourceId: supervisorSource.knowledgeSourceId, + text: chunk.text, + data + }); + } + } else { + console.log(`[Salesforce KC] Supervisor unchanged — skipping: ${articleNumber}`); + } + } + } + + // --- Stale source removal --- + // Only run in full sync — in incremental mode we don't have a complete + // picture of all current articles so can't safely identify stale sources. + if ((syncMode as string) !== "incremental") { + for (const source of sources) { + const extId = source.externalIdentifier || source.name; + if (!processedIds.has(extId)) { + console.log(`[Salesforce KC] Removing stale source: ${source.name}`); + try { + await api.deleteKnowledgeSource({ knowledgeSourceId: source.knowledgeSourceId }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[Salesforce KC] Could not remove stale source ${source.knowledgeSourceId}: ${msg}`); + } + } + } + } + } +}); diff --git a/extensions/salesforce/src/module.ts b/extensions/salesforce/src/module.ts index 813431fb..5e8ca1ec 100644 --- a/extensions/salesforce/src/module.ts +++ b/extensions/salesforce/src/module.ts @@ -1,10 +1,11 @@ import { createExtension } from "@cognigy/extension-tools"; import { oauth } from "./connections/oauth"; +import { salesforceKnowledgeConnector } from "./knowledge-connectors/salesforceKnowledgeConnector"; import { onEmptyQueryResults, onFoundQueryResults, queryNode } from "./nodes/query"; import { createCaseNode, onErrorCreateCase, onSuccessCreateCase } from "./nodes/createCase"; import { getCaseNode, onErrorGetCase, onSuccessGetCase } from "./nodes/getCase"; import { entityRequestNode, onErrorEntityRequest, onSuccessEntityRequest } from "./nodes/entityRequest"; -import { onFoundContact, onNotFoundContact, searchContactNode } from "./nodes/searchContact"; +import { onErrorContact, onFoundContact, onNotFoundContact, searchContactNode } from "./nodes/searchContact"; export default createExtension({ nodes: [ @@ -19,6 +20,7 @@ export default createExtension({ searchContactNode, onFoundContact, onNotFoundContact, + onErrorContact, queryNode, onFoundQueryResults, @@ -33,6 +35,10 @@ export default createExtension({ oauth ], + knowledge: [ + salesforceKnowledgeConnector + ], + options: { label: "Salesforce" } diff --git a/extensions/salesforce/src/nodes/createCase.ts b/extensions/salesforce/src/nodes/createCase.ts index 55f60d27..f05e5abd 100644 --- a/extensions/salesforce/src/nodes/createCase.ts +++ b/extensions/salesforce/src/nodes/createCase.ts @@ -101,7 +101,7 @@ export const createCaseNode = createNodeDescriptor({ // Step 3: Map statuses to "options array" return statuses.map((status: ISalesforceCaseStatus) => ({ label: status.MasterLabel, - value: status.Id, + value: status.MasterLabel, })); } catch (error) { const errorMessage = error instanceof Error diff --git a/extensions/salesforce/src/nodes/searchContact.ts b/extensions/salesforce/src/nodes/searchContact.ts index 649b1cee..6568cbf5 100644 --- a/extensions/salesforce/src/nodes/searchContact.ts +++ b/extensions/salesforce/src/nodes/searchContact.ts @@ -200,7 +200,8 @@ export const searchContactNode = createNodeDescriptor({ dependencies: { children: [ "onFoundContact", - "onNotFoundContact" + "onNotFoundContact", + "onErrorContact" ] }, function: async ({ cognigy, config, childConfigs }: ISearchContactParams) => { @@ -208,35 +209,53 @@ export const searchContactNode = createNodeDescriptor({ const { contactField, contactFieldValue, oauthConnection, storeLocation, contextKey, inputKey } = config; try { - const salesforceConnection = await authenticate(oauthConnection); - const soql: string = `SELECT FIELDS(All) FROM Contact WHERE ${contactField} LIKE '${contactFieldValue}' LIMIT 200`; - const record = await salesforceConnection.query(soql, { autoFetch: true, maxFetch: 1 }); + // Escape single quotes to prevent SOQL injection. + const escapedValue = contactFieldValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); - if (record.records.length === 0) { - const onEmptyQueryResultsChild = childConfigs.find(child => child.type === "onNotFoundContact"); - api.setNextNode(onEmptyQueryResultsChild.id); - } else { - const onFoundQueryResultsChild = childConfigs.find(child => child.type === "onFoundContact"); - api.setNextNode(onFoundQueryResultsChild.id); - } + // Step 1: Find the contact ID using the specified field + // Note: LIMIT 1 is used here because only one record is ever stored (records[0]). + // sobject.retrieve() is used in Step 2 to return all standard and custom fields + // without requiring the "View All Data" permission that FIELDS(All) demands. + const soql: string = `SELECT Id FROM Contact WHERE ${contactField} = '${escapedValue}' ORDER BY CreatedDate DESC LIMIT 1`; + const result = await salesforceConnection.query(soql); - if (storeLocation === "context") { - api.addToContext(contextKey, record?.records[0], "simple"); + if (result.records.length === 0) { + const onNotFoundChild = childConfigs.find(child => child.type === "onNotFoundContact"); + api.setNextNode(onNotFoundChild.id); } else { - // @ts-ignore - api.addToInput(inputKey, record?.records[0]); + // Step 2: Retrieve the full contact record by ID — returns all standard and custom fields + const contactId = result.records[0].Id; + const fullContact = await salesforceConnection.sobject("Contact").retrieve(contactId); + + const onFoundChild = childConfigs.find(child => child.type === "onFoundContact"); + api.setNextNode(onFoundChild.id); + + if (storeLocation === "context") { + api.addToContext(contextKey, fullContact, "simple"); + } else { + // @ts-ignore + api.addToInput(inputKey, fullContact); + } } } catch (error) { - const errorMessage = error instanceof Error - ? error.message - : JSON.stringify(error); + let errorMessage: string; + if (error instanceof Error) { + const axiosResponseData = (error as any)?.response?.data; + errorMessage = axiosResponseData + ? `${error.message} — ${JSON.stringify(axiosResponseData)}` + : error.message; + } else { + errorMessage = JSON.stringify(error); + } api.log("error", `searchContact execution failed: ${errorMessage}`); - const onErrorChild = childConfigs.find(child => child.type === "onErrorGetCase"); - api.setNextNode(onErrorChild.id); + const onErrorChild = childConfigs.find(child => child.type === "onErrorContact"); + if (onErrorChild) { + api.setNextNode(onErrorChild.id); + } if (storeLocation === "context") { api.addToContext(contextKey, errorMessage, "simple"); @@ -292,4 +311,27 @@ export const onNotFoundContact = createNodeDescriptor({ variant: "mini", showIcon: false } -}); \ No newline at end of file +}); + +export const onErrorContact = createNodeDescriptor({ + type: "onErrorContact", + parentType: "searchContact", + defaultLabel: "On Error", + constraints: { + editable: false, + deletable: false, + creatable: false, + movable: false, + placement: { + predecessor: { + whitelist: [] + } + } + }, + appearance: { + color: "#cf142b", + textColor: "white", + variant: "mini", + showIcon: false + } +});