Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

543 lines
18 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 { fathomConnectorMeta } from '@/connectors/fathom/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseTagDate } from '@/connectors/utils'
const logger = createLogger('FathomConnector')
const FATHOM_API_BASE = 'https://api.fathom.ai/external/v1'
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* Days subtracted from `lastSyncAt` when computing the incremental `created_after`
* window. Fathom's list endpoint only filters by creation time (no update-based
* filter), so a meeting whose transcript was not yet ready on the sync that first
* saw it would otherwise never be re-listed. The overlap keeps recently-created
* meetings in the window long enough for late transcripts to be retried — the sync
* engine re-attempts meetings whose `getDocument` previously returned null, since
* those are never persisted. Matches the Gong connector's overlap approach.
*/
const INCREMENTAL_OVERLAP_DAYS = 14
/**
* Fathom authenticates external API requests with the `X-Api-Key` header.
* (The API also accepts `Authorization: Bearer` for OAuth-connected apps, but
* the api-key flow this connector uses requires `X-Api-Key`.)
*/
function buildHeaders(apiKey: string): Record<string, string> {
return {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
}
}
/**
* A meeting object as returned by `GET /meetings`. Only the fields this
* connector reads are typed; the API returns additional fields.
*/
interface FathomMeeting {
recording_id?: number
title?: string
meeting_title?: string | null
url?: string
share_url?: string
created_at?: string
scheduled_start_time?: string | null
scheduled_end_time?: string | null
recording_start_time?: string | null
recording_end_time?: string | null
transcript_language?: string
calendar_invitees_domains_type?: 'only_internal' | 'one_or_more_external' | null
recorded_by?: {
name?: string
email?: string
email_domain?: string
team?: string | null
} | null
}
interface FathomMeetingsListResponse {
items?: FathomMeeting[]
next_cursor?: string | null
}
/**
* A single transcript entry as returned by `GET /recordings/{id}/transcript`.
*/
interface FathomTranscriptEntry {
speaker?: {
display_name?: string
matched_calendar_invitee_email?: string | null
}
text?: string
timestamp?: string
}
interface FathomTranscriptResponse {
transcript?: FathomTranscriptEntry[]
}
interface FathomSummary {
template_name?: string | null
markdown_formatted?: string | null
}
interface FathomSummaryResponse {
summary?: FathomSummary | null
}
/**
* Header fields cached per recording during `listDocuments` so `getDocument`
* can render an identical document header. Fathom exposes no single-meeting
* GET and no `recording_ids` filter, so this metadata cannot be refetched once
* listing has moved past the page that contained it — it is carried forward in
* the shared `syncContext` instead.
*/
interface FathomMeetingHeader {
title: string
meetingDate?: string
durationSeconds?: number
recordedByEmail?: string
recordedByName?: string
team?: string
sourceUrl?: string
contentHash: string
metadata: FathomMeetingMetadata
}
/**
* Metadata describing a Fathom meeting, attached to the listing stub. The sync
* engine merges this onto the hydrated document, so `getDocument` never needs
* to reproduce it.
*/
interface FathomMeetingMetadata {
recordingId?: string
recordedByEmail?: string
recordedByName?: string
team?: string
meetingType?: string
meetingDate?: string
durationSeconds?: number
transcriptLanguage?: string
title?: string
}
/**
* Maps Fathom's `calendar_invitees_domains_type` to a human-readable meeting
* type. This is the only meeting-type signal the API exposes: `only_internal`
* means every invitee shares the recorder's domain; `one_or_more_external`
* means at least one external attendee (customer-facing).
*/
function resolveMeetingType(meeting: FathomMeeting): string | undefined {
switch (meeting.calendar_invitees_domains_type) {
case 'only_internal':
return 'internal'
case 'one_or_more_external':
return 'external'
default:
return undefined
}
}
/**
* Computes the meeting duration in whole seconds from the recording window,
* or undefined when either bound is missing or unparseable.
*/
function computeDurationSeconds(meeting: FathomMeeting): number | undefined {
const start = meeting.recording_start_time ?? meeting.scheduled_start_time ?? undefined
const end = meeting.recording_end_time ?? meeting.scheduled_end_time ?? undefined
if (!start || !end) return undefined
const startMs = new Date(start).getTime()
const endMs = new Date(end).getTime()
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return undefined
return Math.round((endMs - startMs) / 1000)
}
/**
* Returns the best title for a meeting, falling back through the title fields.
*/
function resolveTitle(meeting: FathomMeeting): string {
const title = meeting.title?.trim() || meeting.meeting_title?.trim()
return title || 'Untitled Fathom Meeting'
}
/**
* Extracts the connector metadata bag attached to a listing stub.
*/
function buildMetadata(meeting: FathomMeeting): FathomMeetingMetadata {
return {
recordingId: meeting.recording_id != null ? String(meeting.recording_id) : undefined,
recordedByEmail: meeting.recorded_by?.email,
recordedByName: meeting.recorded_by?.name,
team: meeting.recorded_by?.team ?? undefined,
meetingType: resolveMeetingType(meeting),
meetingDate: meeting.recording_start_time ?? meeting.created_at ?? undefined,
durationSeconds: computeDurationSeconds(meeting),
transcriptLanguage: meeting.transcript_language,
title: resolveTitle(meeting),
}
}
/**
* Extracts the lightweight header fields cached for `getDocument`.
*/
function buildHeader(meeting: FathomMeeting): FathomMeetingHeader {
return {
title: resolveTitle(meeting),
meetingDate: meeting.recording_start_time ?? meeting.created_at ?? undefined,
durationSeconds: computeDurationSeconds(meeting),
recordedByEmail: meeting.recorded_by?.email,
recordedByName: meeting.recorded_by?.name,
team: meeting.recorded_by?.team ?? undefined,
sourceUrl: buildSourceUrl(meeting),
contentHash: buildContentHash(meeting),
metadata: buildMetadata(meeting),
}
}
/**
* Builds a metadata-based content hash. Fathom recordings are immutable once
* processed, so the recording id plus its end/creation timestamps fully identify
* a version. The same value is cached in the header and returned by `getDocument`,
* so the stub and hydrated document hash identically.
*/
function buildContentHash(meeting: FathomMeeting): string {
return `fathom:${meeting.recording_id ?? ''}:${meeting.recording_end_time ?? ''}:${meeting.created_at ?? ''}`
}
function buildSourceUrl(meeting: FathomMeeting): string | undefined {
return meeting.share_url || meeting.url || undefined
}
/**
* Reads the cached header for a recording out of the shared sync context.
*/
function readCachedHeader(
syncContext: Record<string, unknown> | undefined,
recordingId: string
): FathomMeetingHeader | undefined {
const cache = syncContext?.meetingHeaders as Record<string, FathomMeetingHeader> | undefined
return cache?.[recordingId]
}
/**
* Stores the header for a recording in the shared sync context.
*/
function cacheHeader(
syncContext: Record<string, unknown> | undefined,
recordingId: string,
header: FathomMeetingHeader
): void {
if (!syncContext) return
const cache =
(syncContext.meetingHeaders as Record<string, FathomMeetingHeader> | undefined) ?? {}
cache[recordingId] = header
syncContext.meetingHeaders = cache
}
/**
* Formats the meeting header, optional summary, and transcript into a single
* plain-text document with one `Speaker: text` line per transcript entry.
*/
function formatMeetingContent(
header: FathomMeetingHeader | undefined,
transcript: FathomTranscriptEntry[],
summary: FathomSummary | null
): string {
const parts: string[] = []
parts.push(`Meeting: ${header?.title ?? 'Untitled Fathom Meeting'}`)
if (header?.meetingDate) parts.push(`Date: ${header.meetingDate}`)
if (header?.durationSeconds != null) {
parts.push(`Duration: ${Math.round(header.durationSeconds / 60)} minutes`)
}
if (header?.recordedByEmail) {
parts.push(`Recorded by: ${header.recordedByName ?? header.recordedByEmail}`)
}
if (header?.team) parts.push(`Team: ${header.team}`)
if (summary?.markdown_formatted?.trim()) {
parts.push('')
parts.push('--- Summary ---')
parts.push(summary.markdown_formatted.trim())
}
if (transcript.length > 0) {
parts.push('')
parts.push('--- Transcript ---')
for (const entry of transcript) {
const speaker = entry.speaker?.display_name?.trim() || 'Unknown'
const text = entry.text?.trim()
if (text) parts.push(`${speaker}: ${text}`)
}
}
return parts.join('\n')
}
/**
* Converts a listing meeting into a deferred stub. Content is fetched lazily
* via `getDocument` only for new or changed meetings.
*/
function meetingToStub(meeting: FathomMeeting): ExternalDocument {
const metadata = buildMetadata(meeting)
return {
externalId: String(meeting.recording_id),
title: resolveTitle(meeting),
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: buildSourceUrl(meeting),
contentHash: buildContentHash(meeting),
metadata: { ...metadata },
}
}
export const fathomConnector: ConnectorConfig = {
...fathomConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>,
lastSyncAt?: Date
): Promise<ExternalDocumentList> => {
const recordedBy = (sourceConfig.recordedBy as string | undefined)?.trim()
const teams = (sourceConfig.teams as string | undefined)?.trim()
const meetingType = (sourceConfig.meetingType as string | undefined)?.trim()
const inviteeDomain = (sourceConfig.inviteeDomains as string | undefined)?.trim()
const maxMeetings = sourceConfig.maxMeetings ? Number(sourceConfig.maxMeetings) : 0
const url = new URL(`${FATHOM_API_BASE}/meetings`)
if (recordedBy) url.searchParams.append('recorded_by[]', recordedBy)
if (teams) url.searchParams.append('teams[]', teams)
if (meetingType && meetingType !== 'all') {
url.searchParams.append('calendar_invitees_domains_type', meetingType)
}
if (inviteeDomain) url.searchParams.append('calendar_invitees_domains[]', inviteeDomain)
if (cursor) url.searchParams.append('cursor', cursor)
if (lastSyncAt) {
const createdAfter = new Date(lastSyncAt.getTime() - INCREMENTAL_OVERLAP_DAYS * MS_PER_DAY)
url.searchParams.append('created_after', createdAfter.toISOString())
}
logger.info('Listing Fathom meetings', {
hasCursor: Boolean(cursor),
recordedBy,
teams,
meetingType,
inviteeDomain,
incremental: Boolean(lastSyncAt),
})
const response = await fetchWithRetry(url.toString(), {
method: 'GET',
headers: buildHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
logger.error('Failed to list Fathom meetings', {
status: response.status,
error: errorText.slice(0, 500),
})
throw new Error(`Failed to list Fathom meetings: ${response.status}`)
}
const data = (await response.json()) as FathomMeetingsListResponse
const meetings = data.items ?? []
const nextCursor = data.next_cursor?.trim() || undefined
const allDocuments: ExternalDocument[] = []
for (const meeting of meetings) {
if (meeting.recording_id == null) continue
const externalId = String(meeting.recording_id)
cacheHeader(syncContext, externalId, buildHeader(meeting))
allDocuments.push(meetingToStub(meeting))
}
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
let documents = allDocuments
if (maxMeetings > 0) {
const remaining = Math.max(0, maxMeetings - prevFetched)
if (allDocuments.length > remaining) {
documents = allDocuments.slice(0, remaining)
}
}
const totalFetched = prevFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxMeetings > 0 && totalFetched >= maxMeetings
if (hitLimit && syncContext) syncContext.listingCapped = true
const hasMore = !hitLimit && Boolean(nextCursor)
return {
documents,
nextCursor: hasMore ? nextCursor : undefined,
hasMore,
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
try {
if (!externalId) return null
const transcriptUrl = `${FATHOM_API_BASE}/recordings/${encodeURIComponent(externalId)}/transcript`
const transcriptResponse = await fetchWithRetry(transcriptUrl, {
method: 'GET',
headers: buildHeaders(accessToken),
})
if (!transcriptResponse.ok) {
if (transcriptResponse.status === 404) return null
throw new Error(`Failed to fetch Fathom transcript: ${transcriptResponse.status}`)
}
const transcriptData = (await transcriptResponse.json()) as FathomTranscriptResponse
const transcript = transcriptData.transcript ?? []
let summary: FathomSummary | null = null
try {
const summaryUrl = `${FATHOM_API_BASE}/recordings/${encodeURIComponent(externalId)}/summary`
const summaryResponse = await fetchWithRetry(summaryUrl, {
method: 'GET',
headers: buildHeaders(accessToken),
})
if (summaryResponse.ok) {
const summaryData = (await summaryResponse.json()) as FathomSummaryResponse
summary = summaryData.summary ?? null
}
} catch (summaryError) {
logger.warn('Failed to fetch Fathom summary', {
externalId,
error: toError(summaryError).message,
})
}
const hasTranscript = transcript.some((entry) => entry.text?.trim())
const hasSummary = Boolean(summary?.markdown_formatted?.trim())
if (!hasTranscript && !hasSummary) {
logger.info('No transcript or summary yet for Fathom meeting', { externalId })
return null
}
const header = readCachedHeader(syncContext, externalId)
if (!header) {
logger.warn(
'No cached header for Fathom meeting; skipping to avoid an un-refreshable record',
{
externalId,
}
)
return null
}
const content = formatMeetingContent(header, transcript, summary).trim()
if (!content) return null
return {
externalId,
title: header.title,
content,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: header.sourceUrl,
contentHash: header.contentHash,
metadata: { ...header.metadata },
}
} catch (error) {
logger.warn('Failed to get Fathom meeting', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const maxMeetings = sourceConfig.maxMeetings as string | undefined
if (maxMeetings && (Number.isNaN(Number(maxMeetings)) || Number(maxMeetings) < 0)) {
return { valid: false, error: 'Max meetings must be a non-negative number' }
}
try {
const response = await fetchWithRetry(
`${FATHOM_API_BASE}/meetings`,
{
method: 'GET',
headers: buildHeaders(accessToken),
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text().catch(() => '')
return {
valid: false,
error: `Fathom 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.title === 'string' && metadata.title.trim()) {
result.title = metadata.title
}
if (typeof metadata.recordedByEmail === 'string' && metadata.recordedByEmail.trim()) {
result.recordedByEmail = metadata.recordedByEmail
}
if (typeof metadata.recordedByName === 'string' && metadata.recordedByName.trim()) {
result.recordedByName = metadata.recordedByName
}
if (typeof metadata.team === 'string' && metadata.team.trim()) {
result.team = metadata.team
}
if (typeof metadata.meetingType === 'string' && metadata.meetingType.trim()) {
result.meetingType = metadata.meetingType
}
if (typeof metadata.transcriptLanguage === 'string' && metadata.transcriptLanguage.trim()) {
result.transcriptLanguage = metadata.transcriptLanguage
}
if (metadata.durationSeconds != null) {
const num = Number(metadata.durationSeconds)
if (!Number.isNaN(num)) result.durationSeconds = num
}
const meetingDate = parseTagDate(metadata.meetingDate)
if (meetingDate) result.meetingDate = meetingDate
return result
},
}