d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, toError } from '@sim/utils/errors'
|
|
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import { gongConnectorMeta } from '@/connectors/gong/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('GongConnector')
|
|
|
|
const GONG_API_BASE = 'https://api.gong.io/v2'
|
|
const DEFAULT_LOOKBACK_DAYS = 90
|
|
const MAX_LOOKBACK_DAYS = 180
|
|
/**
|
|
* Days of overlap added when computing the incremental sync window. Gong call data
|
|
* (parties, transcript) can finish processing minutes to hours — occasionally a
|
|
* day or two — after a call ends. The sync engine re-attempts calls whose
|
|
* transcript was not yet ready (a null getDocument result is never persisted, so
|
|
* the call is re-listed and re-fetched on the next sync), but only while the call
|
|
* stays inside the incremental window. A two-week overlap keeps recently-ended
|
|
* calls in that window long enough for late transcripts to be picked up, at the
|
|
* cost of re-listing already-synced calls (skipped downstream by content hash).
|
|
*/
|
|
const INCREMENTAL_OVERLAP_DAYS = 14
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* Metadata for a single call participant. `speakerId` cross-references the
|
|
* `speakerId` field on transcript monologues, letting the connector attribute
|
|
* each spoken line to a named participant.
|
|
*/
|
|
interface GongParty {
|
|
id?: string
|
|
name?: string
|
|
emailAddress?: string
|
|
speakerId?: string
|
|
affiliation?: string
|
|
}
|
|
|
|
/**
|
|
* Core call metadata returned by the extensive calls endpoint. Mirrors Gong's
|
|
* `CallBasicData` (the `metaData` object) — every field here is present on the
|
|
* `/v2/calls/extensive` stub response and never requires the transcript fetch.
|
|
*/
|
|
interface GongCallMetaData {
|
|
id?: string
|
|
title?: string
|
|
scheduled?: string
|
|
started?: string
|
|
duration?: number
|
|
url?: string
|
|
workspaceId?: string
|
|
primaryUserId?: string
|
|
direction?: string
|
|
scope?: string
|
|
system?: string
|
|
language?: string
|
|
purpose?: string
|
|
isPrivate?: boolean
|
|
}
|
|
|
|
/**
|
|
* A single call object from POST /v2/calls/extensive.
|
|
*/
|
|
interface GongExtensiveCall {
|
|
metaData?: GongCallMetaData
|
|
parties?: GongParty[]
|
|
}
|
|
|
|
interface GongRecords {
|
|
cursor?: string
|
|
totalRecords?: number
|
|
currentPageSize?: number
|
|
}
|
|
|
|
interface GongExtensiveCallsResponse {
|
|
calls?: GongExtensiveCall[]
|
|
records?: GongRecords
|
|
}
|
|
|
|
/**
|
|
* A single sentence within a transcript monologue. Gong returns timing in
|
|
* `startMs`/`endMs`; only `text` is used for the formatted transcript.
|
|
*/
|
|
interface GongTranscriptSentence {
|
|
text?: string
|
|
}
|
|
|
|
/**
|
|
* A monologue (one speaker turn) within a call transcript.
|
|
*/
|
|
interface GongMonologue {
|
|
speakerId?: string
|
|
topic?: string
|
|
sentences?: GongTranscriptSentence[]
|
|
}
|
|
|
|
interface GongCallTranscript {
|
|
callId?: string
|
|
transcript?: GongMonologue[]
|
|
}
|
|
|
|
interface GongTranscriptResponse {
|
|
callTranscripts?: GongCallTranscript[]
|
|
records?: GongRecords
|
|
}
|
|
|
|
/**
|
|
* Builds the Authorization header value for Gong's Basic auth scheme.
|
|
*
|
|
* Gong authenticates with `Basic base64(accessKey:accessKeySecret)`. The sync
|
|
* engine passes the user's stored key as `accessToken`. To support both raw
|
|
* `accessKey:accessKeySecret` pairs and pre-encoded credentials, the raw form
|
|
* (containing a colon) is base64-encoded here; an already-encoded value is sent
|
|
* as-is.
|
|
*/
|
|
function buildAuthHeader(accessToken: string): string {
|
|
const token = accessToken.includes(':')
|
|
? Buffer.from(accessToken, 'utf8').toString('base64')
|
|
: accessToken
|
|
return `Basic ${token}`
|
|
}
|
|
|
|
function buildHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
Authorization: buildAuthHeader(accessToken),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a comma- or newline-separated list of Gong IDs into a trimmed,
|
|
* de-duplicated, non-empty array. Returns `undefined` when nothing usable
|
|
* remains so the caller can omit the filter key entirely.
|
|
*/
|
|
function parseIdList(raw: unknown): string[] | undefined {
|
|
if (typeof raw !== 'string') return undefined
|
|
const ids = Array.from(
|
|
new Set(
|
|
raw
|
|
.split(/[\n,]/)
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0)
|
|
)
|
|
)
|
|
return ids.length > 0 ? ids : undefined
|
|
}
|
|
|
|
/**
|
|
* Metadata-based content hash shared by `listDocuments` stubs and `getDocument`
|
|
* results. Derived purely from call identity and its start time so the value is
|
|
* identical across both paths — guaranteeing the sync engine only re-fetches a
|
|
* transcript when the call's metadata actually changes.
|
|
*/
|
|
function buildContentHash(callId: string, started: string | undefined): string {
|
|
return `gong:${callId}:${started ?? ''}`
|
|
}
|
|
|
|
function buildCallTitle(metaData: GongCallMetaData | undefined): string {
|
|
return metaData?.title?.trim() || 'Untitled Gong Call'
|
|
}
|
|
|
|
/**
|
|
* Extracts named participant labels from a call's parties for tag mapping and
|
|
* the transcript header.
|
|
*/
|
|
function buildParticipantNames(parties: GongParty[] | undefined): string[] {
|
|
if (!parties) return []
|
|
const names: string[] = []
|
|
for (const party of parties) {
|
|
const label = party.name?.trim() || party.emailAddress?.trim()
|
|
if (label) names.push(label)
|
|
}
|
|
return names
|
|
}
|
|
|
|
/**
|
|
* Builds a `speakerId` → display-name map from a call's parties so transcript
|
|
* monologues (keyed by `speakerId`) can be attributed to a named speaker.
|
|
*/
|
|
function buildSpeakerMap(parties: GongParty[] | undefined): Record<string, string> {
|
|
const map: Record<string, string> = {}
|
|
if (!parties) return map
|
|
for (const party of parties) {
|
|
if (!party.speakerId) continue
|
|
const label = party.name?.trim() || party.emailAddress?.trim()
|
|
if (label) map[party.speakerId] = label
|
|
}
|
|
return map
|
|
}
|
|
|
|
function buildMetadata(
|
|
metaData: GongCallMetaData | undefined,
|
|
participants: string[]
|
|
): Record<string, unknown> {
|
|
return {
|
|
callId: metaData?.id,
|
|
callTitle: metaData?.title,
|
|
callDate: metaData?.started,
|
|
scheduledDate: metaData?.scheduled,
|
|
duration: metaData?.duration,
|
|
workspaceId: metaData?.workspaceId,
|
|
primaryUserId: metaData?.primaryUserId,
|
|
direction: metaData?.direction,
|
|
scope: metaData?.scope,
|
|
system: metaData?.system,
|
|
language: metaData?.language,
|
|
purpose: metaData?.purpose,
|
|
isPrivate: metaData?.isPrivate,
|
|
participants,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats a call's transcript into speaker-attributed plain text with a header
|
|
* describing the call (title, date, duration, participants).
|
|
*/
|
|
function formatTranscriptContent(
|
|
metaData: GongCallMetaData | undefined,
|
|
participants: string[],
|
|
speakerMap: Record<string, string>,
|
|
monologues: GongMonologue[]
|
|
): string {
|
|
const parts: string[] = []
|
|
|
|
parts.push(`Call: ${buildCallTitle(metaData)}`)
|
|
if (metaData?.started) parts.push(`Date: ${metaData.started}`)
|
|
if (metaData?.duration != null) {
|
|
const minutes = Math.round(metaData.duration / 60)
|
|
parts.push(`Duration: ${minutes} minutes`)
|
|
}
|
|
if (participants.length > 0) parts.push(`Participants: ${participants.join(', ')}`)
|
|
|
|
parts.push('')
|
|
parts.push('--- Transcript ---')
|
|
|
|
for (const monologue of monologues) {
|
|
const speaker = (monologue.speakerId && speakerMap[monologue.speakerId]) || 'Unknown Speaker'
|
|
const text = (monologue.sentences ?? [])
|
|
.map((sentence) => sentence.text?.trim())
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(' ')
|
|
if (text) parts.push(`${speaker}: ${text}`)
|
|
}
|
|
|
|
return parts.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Computes the effective lookback window in days, narrowing to the time since
|
|
* the last successful sync (plus an overlap to catch transcripts that finished
|
|
* processing late) when incremental sync is active.
|
|
*/
|
|
function computeLookbackDays(
|
|
sourceConfig: Record<string, unknown>,
|
|
lastSyncAt: Date | undefined
|
|
): number {
|
|
const raw = sourceConfig.lookback as string | undefined
|
|
const configured = Number(raw)
|
|
const baseline =
|
|
Number.isFinite(configured) && configured > 0
|
|
? Math.min(Math.floor(configured), MAX_LOOKBACK_DAYS)
|
|
: DEFAULT_LOOKBACK_DAYS
|
|
|
|
if (!lastSyncAt) return baseline
|
|
|
|
const sinceLastSync = Math.ceil((Date.now() - lastSyncAt.getTime()) / MS_PER_DAY)
|
|
const incremental = Math.max(sinceLastSync + INCREMENTAL_OVERLAP_DAYS, INCREMENTAL_OVERLAP_DAYS)
|
|
return Math.min(incremental, baseline)
|
|
}
|
|
|
|
/**
|
|
* Fetches a single page of calls from POST /v2/calls/extensive with parties
|
|
* exposed (needed to resolve transcript speaker IDs to names).
|
|
*/
|
|
async function fetchExtensiveCalls(
|
|
accessToken: string,
|
|
filter: Record<string, unknown>,
|
|
cursor: string | undefined,
|
|
retryOptions?: Parameters<typeof fetchWithRetry>[2]
|
|
): Promise<GongExtensiveCallsResponse> {
|
|
const body: Record<string, unknown> = {
|
|
filter,
|
|
contentSelector: { exposedFields: { parties: true } },
|
|
}
|
|
if (cursor) body.cursor = cursor
|
|
|
|
const response = await fetchWithRetry(
|
|
`${GONG_API_BASE}/calls/extensive`,
|
|
{
|
|
method: 'POST',
|
|
headers: buildHeaders(accessToken),
|
|
body: JSON.stringify(body),
|
|
},
|
|
retryOptions
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
throw new Error(
|
|
`Failed to list Gong calls: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`
|
|
)
|
|
}
|
|
|
|
return (await response.json()) as GongExtensiveCallsResponse
|
|
}
|
|
|
|
export const gongConnector: ConnectorConfig = {
|
|
...gongConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>,
|
|
lastSyncAt?: Date
|
|
): Promise<ExternalDocumentList> => {
|
|
const lookbackDays = computeLookbackDays(sourceConfig, lastSyncAt)
|
|
const maxCalls = sourceConfig.maxCalls ? Number(sourceConfig.maxCalls) : 0
|
|
const workspaceId = (sourceConfig.workspaceId as string | undefined)?.trim()
|
|
const primaryUserIds = parseIdList(sourceConfig.primaryUserIds)
|
|
|
|
const cachedWindow = syncContext?.gongDateWindow as
|
|
| { fromDateTime: string; toDateTime: string }
|
|
| undefined
|
|
const now = new Date()
|
|
const window = cachedWindow ?? {
|
|
fromDateTime: new Date(now.getTime() - lookbackDays * MS_PER_DAY).toISOString(),
|
|
toDateTime: now.toISOString(),
|
|
}
|
|
if (syncContext && !cachedWindow) syncContext.gongDateWindow = window
|
|
const { fromDateTime, toDateTime } = window
|
|
|
|
const filter: Record<string, unknown> = { fromDateTime, toDateTime }
|
|
if (workspaceId) filter.workspaceId = workspaceId
|
|
if (primaryUserIds) filter.primaryUserIds = primaryUserIds
|
|
|
|
logger.info('Listing Gong calls', {
|
|
fromDateTime,
|
|
toDateTime,
|
|
hasCursor: Boolean(cursor),
|
|
incremental: Boolean(lastSyncAt),
|
|
})
|
|
|
|
const data = await fetchExtensiveCalls(accessToken, filter, cursor)
|
|
const calls = data.calls ?? []
|
|
const nextPageCursor = data.records?.cursor?.trim() || undefined
|
|
|
|
const allDocuments: ExternalDocument[] = []
|
|
for (const call of calls) {
|
|
const callId = call.metaData?.id
|
|
if (!callId) continue
|
|
const participants = buildParticipantNames(call.parties)
|
|
allDocuments.push({
|
|
externalId: callId,
|
|
title: buildCallTitle(call.metaData),
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: call.metaData?.url || undefined,
|
|
contentHash: buildContentHash(callId, call.metaData?.started),
|
|
metadata: buildMetadata(call.metaData, participants),
|
|
})
|
|
}
|
|
|
|
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
let documents = allDocuments
|
|
let capDroppedDocs = false
|
|
if (maxCalls > 0) {
|
|
const remaining = Math.max(0, maxCalls - prevFetched)
|
|
if (allDocuments.length > remaining) {
|
|
documents = allDocuments.slice(0, remaining)
|
|
capDroppedDocs = true
|
|
}
|
|
}
|
|
|
|
const totalFetched = prevFetched + documents.length
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
const hitLimit = maxCalls > 0 && totalFetched >= maxCalls
|
|
const hasMore = !hitLimit && Boolean(nextPageCursor)
|
|
|
|
/**
|
|
* Only flag the listing as capped when the `maxCalls` limit actually
|
|
* truncated calls that still exist in the source — either by dropping calls
|
|
* from the current page or by stopping while another page remains. Reaching
|
|
* the limit exactly at source exhaustion (no dropped calls, no further
|
|
* cursor) yields a complete listing, so deletion reconciliation must still
|
|
* run for calls removed in Gong.
|
|
*/
|
|
if (syncContext && (capDroppedDocs || (hitLimit && Boolean(nextPageCursor)))) {
|
|
syncContext.listingCapped = true
|
|
}
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hasMore ? nextPageCursor : undefined,
|
|
hasMore,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
if (!externalId) return null
|
|
|
|
const workspaceId = (sourceConfig.workspaceId as string | undefined)?.trim()
|
|
const filter: Record<string, unknown> = { callIds: [externalId] }
|
|
if (workspaceId) filter.workspaceId = workspaceId
|
|
|
|
const callData = await fetchExtensiveCalls(accessToken, filter, undefined)
|
|
const call = callData.calls?.[0]
|
|
if (!call?.metaData?.id) {
|
|
logger.warn('Gong call not found', { externalId })
|
|
return null
|
|
}
|
|
|
|
const metaData = call.metaData
|
|
const participants = buildParticipantNames(call.parties)
|
|
const speakerMap = buildSpeakerMap(call.parties)
|
|
|
|
const transcriptResponse = await fetchWithRetry(`${GONG_API_BASE}/calls/transcript`, {
|
|
method: 'POST',
|
|
headers: buildHeaders(accessToken),
|
|
body: JSON.stringify({ filter: { callIds: [externalId] } }),
|
|
})
|
|
|
|
if (!transcriptResponse.ok) {
|
|
if (transcriptResponse.status === 404) return null
|
|
throw new Error(`Failed to fetch Gong transcript: ${transcriptResponse.status}`)
|
|
}
|
|
|
|
const transcriptData = (await transcriptResponse.json()) as GongTranscriptResponse
|
|
const callTranscript = transcriptData.callTranscripts?.find(
|
|
(entry) => entry.callId === externalId
|
|
)
|
|
const monologues = callTranscript?.transcript ?? []
|
|
if (monologues.length === 0) {
|
|
logger.info('Transcript not available for Gong call', { externalId })
|
|
return null
|
|
}
|
|
|
|
const hasSpokenText = monologues.some((monologue) =>
|
|
(monologue.sentences ?? []).some((sentence) => Boolean(sentence.text?.trim()))
|
|
)
|
|
if (!hasSpokenText) return null
|
|
|
|
const content = formatTranscriptContent(metaData, participants, speakerMap, monologues)
|
|
|
|
return {
|
|
externalId: metaData.id ?? externalId,
|
|
title: buildCallTitle(metaData),
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: metaData.url || undefined,
|
|
contentHash: buildContentHash(metaData.id ?? externalId, metaData.started),
|
|
metadata: buildMetadata(metaData, participants),
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Gong call transcript', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxCalls = sourceConfig.maxCalls as string | undefined
|
|
if (maxCalls && (Number.isNaN(Number(maxCalls)) || Number(maxCalls) < 0)) {
|
|
return { valid: false, error: 'Max calls must be a non-negative number' }
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithRetry(
|
|
`${GONG_API_BASE}/users`,
|
|
{
|
|
method: 'GET',
|
|
headers: buildHeaders(accessToken),
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `Gong access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.callTitle === 'string' && metadata.callTitle.trim()) {
|
|
result.callTitle = metadata.callTitle
|
|
}
|
|
|
|
const participants = Array.isArray(metadata.participants)
|
|
? (metadata.participants as string[])
|
|
: []
|
|
if (participants.length > 0) {
|
|
result.participants = participants.join(', ')
|
|
}
|
|
|
|
if (metadata.duration != null) {
|
|
const num = Number(metadata.duration)
|
|
if (!Number.isNaN(num)) result.duration = num
|
|
}
|
|
|
|
const callDate = parseTagDate(metadata.callDate)
|
|
if (callDate) result.callDate = callDate
|
|
|
|
const scheduledDate = parseTagDate(metadata.scheduledDate)
|
|
if (scheduledDate) result.scheduledDate = scheduledDate
|
|
|
|
const textTags = ['direction', 'scope', 'system', 'language', 'purpose'] as const
|
|
for (const key of textTags) {
|
|
const value = metadata[key]
|
|
if (typeof value === 'string' && value.trim()) result[key] = value.trim()
|
|
}
|
|
|
|
if (typeof metadata.isPrivate === 'boolean') result.isPrivate = metadata.isPrivate
|
|
|
|
return result
|
|
},
|
|
}
|