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
502 lines
15 KiB
TypeScript
502 lines
15 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 type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import {
|
|
CONNECTOR_MAX_FILE_BYTES,
|
|
isSkippedDocument,
|
|
markSkipped,
|
|
parseTagDate,
|
|
readBodyWithLimit,
|
|
sizeLimitSkipReason,
|
|
stubOrSkipBySize,
|
|
takeIndexableWithinCap,
|
|
} from '@/connectors/utils'
|
|
import { zoomConnectorMeta } from '@/connectors/zoom/meta'
|
|
|
|
const logger = createLogger('ZoomConnector')
|
|
|
|
const ZOOM_API_BASE = 'https://api.zoom.us/v2'
|
|
const PAGE_SIZE = 300
|
|
const WINDOW_DAYS = 30
|
|
const DEFAULT_LOOKBACK_DAYS = 180
|
|
const MAX_LOOKBACK_DAYS = 180
|
|
/**
|
|
* Days of overlap added when computing the incremental sync window. Zoom transcript
|
|
* generation is usually fast, but AI Companion / audio transcription can lag hours to
|
|
* days for large accounts. A 30-day overlap catches late-arriving transcripts at the
|
|
* cost of at most one extra 30-day window per sync.
|
|
*/
|
|
const INCREMENTAL_OVERLAP_DAYS = 30
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
|
|
interface ZoomRecordingFile {
|
|
id?: string
|
|
meeting_id?: string
|
|
recording_start?: string
|
|
recording_end?: string
|
|
file_type?: string
|
|
file_extension?: string
|
|
file_size?: number
|
|
download_url?: string
|
|
status?: string
|
|
recording_type?: string
|
|
}
|
|
|
|
interface ZoomRecording {
|
|
uuid: string
|
|
id?: number | string
|
|
topic?: string
|
|
start_time?: string
|
|
duration?: number
|
|
total_size?: number
|
|
recording_count?: number
|
|
share_url?: string
|
|
host_email?: string
|
|
host_id?: string
|
|
account_id?: string
|
|
type?: number
|
|
recording_files?: ZoomRecordingFile[]
|
|
}
|
|
|
|
interface ZoomRecordingsListResponse {
|
|
meetings?: ZoomRecording[]
|
|
next_page_token?: string
|
|
page_size?: number
|
|
total_records?: number
|
|
from?: string
|
|
to?: string
|
|
}
|
|
|
|
interface CursorState {
|
|
windowIndex: number
|
|
pageToken?: string
|
|
}
|
|
|
|
/**
|
|
* URL-encodes a Zoom meeting UUID. Double-encodes when the UUID starts with '/'
|
|
* or contains '//', per Zoom's API requirements.
|
|
*/
|
|
function encodeMeetingUuid(uuid: string): string {
|
|
const encoded = encodeURIComponent(uuid)
|
|
if (uuid.startsWith('/') || uuid.includes('//')) {
|
|
return encodeURIComponent(encoded)
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
function formatDate(date: Date): string {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
return `${y}-${m}-${d}`
|
|
}
|
|
|
|
function encodeCursor(state: CursorState): string {
|
|
return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url')
|
|
}
|
|
|
|
function decodeCursor(cursor?: string): CursorState {
|
|
if (!cursor) return { windowIndex: 0 }
|
|
try {
|
|
const json = Buffer.from(cursor, 'base64url').toString('utf8')
|
|
const parsed = JSON.parse(json) as Partial<CursorState>
|
|
return {
|
|
windowIndex: Number(parsed.windowIndex) || 0,
|
|
pageToken: typeof parsed.pageToken === 'string' ? parsed.pageToken : undefined,
|
|
}
|
|
} catch {
|
|
return { windowIndex: 0 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Picks the best transcript file from a recording's files array.
|
|
* Prefers the AI Companion audio_transcript (file_type TRANSCRIPT) and falls back
|
|
* to closed captions (file_type CC) — both are VTT and contain spoken text.
|
|
*/
|
|
function findTranscriptFile(files?: ZoomRecordingFile[]): ZoomRecordingFile | undefined {
|
|
if (!files) return undefined
|
|
const eligible = (f: ZoomRecordingFile) =>
|
|
Boolean(f.download_url) && (f.status === 'completed' || f.status == null)
|
|
|
|
const transcript = files.find((f) => f.file_type === 'TRANSCRIPT' && eligible(f))
|
|
if (transcript) return transcript
|
|
return files.find((f) => f.file_type === 'CC' && eligible(f))
|
|
}
|
|
|
|
/**
|
|
* Extracts spoken text from a Zoom WebVTT transcript, stripping cue identifiers,
|
|
* timestamps, and inline markup. Handles both Zoom's `Speaker: text` convention
|
|
* and standard WebVTT `<v Speaker>text</v>` voice tags.
|
|
*
|
|
* Exported for unit tests; not part of the connector's public surface.
|
|
*/
|
|
export function parseVtt(vtt: string): string {
|
|
const lines = vtt.split(/\r?\n/)
|
|
const segments: string[] = []
|
|
let i = 0
|
|
|
|
while (i < lines.length && lines[i].trim() !== '') i++
|
|
|
|
while (i < lines.length) {
|
|
while (i < lines.length && lines[i].trim() === '') i++
|
|
if (i >= lines.length) break
|
|
|
|
if (i + 1 < lines.length && !lines[i].includes('-->') && lines[i + 1].includes('-->')) {
|
|
i++
|
|
}
|
|
|
|
if (i < lines.length && lines[i].includes('-->')) {
|
|
i++
|
|
} else {
|
|
while (i < lines.length && lines[i].trim() !== '') i++
|
|
continue
|
|
}
|
|
|
|
const textParts: string[] = []
|
|
while (i < lines.length && lines[i].trim() !== '') {
|
|
textParts.push(lines[i])
|
|
i++
|
|
}
|
|
|
|
if (textParts.length > 0) {
|
|
const raw = textParts.join(' ')
|
|
const withSpeakers = raw.replace(/<v(?:\.[^\s>]+)?\s+([^>]+)>([\s\S]*?)<\/v>/g, '$1: $2')
|
|
let withoutTags = withSpeakers
|
|
let previous: string
|
|
do {
|
|
previous = withoutTags
|
|
withoutTags = withoutTags.replace(/<\/?[^>]+>/g, '')
|
|
} while (withoutTags !== previous)
|
|
const stripped = withoutTags.replace(/\s+/g, ' ').trim()
|
|
if (stripped) segments.push(stripped)
|
|
}
|
|
}
|
|
|
|
return segments.join('\n')
|
|
}
|
|
|
|
function formatTranscriptContent(recording: ZoomRecording, transcript: string): string {
|
|
const parts: string[] = []
|
|
if (recording.topic) parts.push(`Meeting: ${recording.topic}`)
|
|
if (recording.start_time) parts.push(`Date: ${recording.start_time}`)
|
|
if (recording.duration != null) parts.push(`Duration: ${recording.duration} minutes`)
|
|
if (recording.host_email) parts.push(`Host: ${recording.host_email}`)
|
|
|
|
parts.push('')
|
|
parts.push('--- Transcript ---')
|
|
parts.push(transcript)
|
|
|
|
return parts.join('\n')
|
|
}
|
|
|
|
function buildContentHash(recording: ZoomRecording, file: ZoomRecordingFile): string {
|
|
return `zoom:${recording.uuid}:${file.id ?? ''}:${file.file_size ?? ''}:${file.recording_end ?? ''}`
|
|
}
|
|
|
|
function buildSourceUrl(recording: ZoomRecording): string | undefined {
|
|
return recording.share_url || undefined
|
|
}
|
|
|
|
function recordingToStub(
|
|
recording: ZoomRecording,
|
|
transcriptFile: ZoomRecordingFile
|
|
): ExternalDocument {
|
|
return {
|
|
externalId: recording.uuid,
|
|
title: recording.topic?.trim() || 'Untitled Zoom Meeting',
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: buildSourceUrl(recording),
|
|
contentHash: buildContentHash(recording, transcriptFile),
|
|
metadata: {
|
|
meetingId: recording.id != null ? String(recording.id) : undefined,
|
|
hostEmail: recording.host_email,
|
|
duration: recording.duration,
|
|
meetingDate: recording.start_time,
|
|
topic: recording.topic,
|
|
fileSize: transcriptFile.file_size,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
export const zoomConnector: ConnectorConfig = {
|
|
...zoomConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>,
|
|
lastSyncAt?: Date
|
|
): Promise<ExternalDocumentList> => {
|
|
const lookbackDays = computeLookbackDays(sourceConfig, lastSyncAt)
|
|
const maxRecordings = sourceConfig.maxRecordings ? Number(sourceConfig.maxRecordings) : 0
|
|
const numWindows = Math.max(1, Math.ceil(lookbackDays / WINDOW_DAYS))
|
|
const state = decodeCursor(cursor)
|
|
|
|
if (state.windowIndex >= numWindows) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
const now = new Date()
|
|
const earliest = new Date(now.getTime() - lookbackDays * MS_PER_DAY)
|
|
const toDate = new Date(now.getTime() - state.windowIndex * WINDOW_DAYS * MS_PER_DAY)
|
|
const rawFromDate = new Date(toDate.getTime() - WINDOW_DAYS * MS_PER_DAY)
|
|
const fromDate = rawFromDate < earliest ? earliest : rawFromDate
|
|
|
|
if (fromDate >= toDate) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
const queryParams = new URLSearchParams({
|
|
page_size: String(PAGE_SIZE),
|
|
from: formatDate(fromDate),
|
|
to: formatDate(toDate),
|
|
trash: 'false',
|
|
})
|
|
if (state.pageToken) queryParams.set('next_page_token', state.pageToken)
|
|
|
|
const url = `${ZOOM_API_BASE}/users/me/recordings?${queryParams.toString()}`
|
|
|
|
logger.info('Listing Zoom recordings', {
|
|
windowIndex: state.windowIndex,
|
|
windowTotal: numWindows,
|
|
from: formatDate(fromDate),
|
|
to: formatDate(toDate),
|
|
hasToken: Boolean(state.pageToken),
|
|
incremental: Boolean(lastSyncAt),
|
|
})
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
logger.error('Failed to list Zoom recordings', {
|
|
status: response.status,
|
|
error: errorText.slice(0, 500),
|
|
})
|
|
throw new Error(`Failed to list Zoom recordings: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as ZoomRecordingsListResponse
|
|
const meetings = data.meetings ?? []
|
|
const nextPageToken = data.next_page_token?.trim() || undefined
|
|
|
|
const allDocuments: ExternalDocument[] = []
|
|
for (const meeting of meetings) {
|
|
if (!meeting.uuid) continue
|
|
const transcript = findTranscriptFile(meeting.recording_files)
|
|
if (!transcript) continue
|
|
allDocuments.push(
|
|
stubOrSkipBySize(
|
|
recordingToStub(meeting, transcript),
|
|
transcript.file_size,
|
|
CONNECTOR_MAX_FILE_BYTES
|
|
)
|
|
)
|
|
}
|
|
|
|
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
const { documents, indexableCount, capReached } = takeIndexableWithinCap(
|
|
allDocuments,
|
|
isSkippedDocument,
|
|
maxRecordings,
|
|
prevFetched
|
|
)
|
|
|
|
const totalFetched = prevFetched + indexableCount
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
const hitLimit = capReached
|
|
if (hitLimit && syncContext) syncContext.listingCapped = true
|
|
|
|
let nextCursor: string | undefined
|
|
let hasMore = false
|
|
|
|
if (hitLimit) {
|
|
// Stop syncing — limit reached
|
|
} else if (nextPageToken) {
|
|
nextCursor = encodeCursor({ windowIndex: state.windowIndex, pageToken: nextPageToken })
|
|
hasMore = true
|
|
} else if (state.windowIndex + 1 < numWindows) {
|
|
nextCursor = encodeCursor({ windowIndex: state.windowIndex + 1 })
|
|
hasMore = true
|
|
}
|
|
|
|
return { documents, nextCursor, hasMore }
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
if (!externalId) return null
|
|
|
|
const url = `${ZOOM_API_BASE}/meetings/${encodeMeetingUuid(externalId)}/recordings`
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404 || response.status === 410) return null
|
|
throw new Error(`Failed to fetch Zoom recording: ${response.status}`)
|
|
}
|
|
|
|
const recording = (await response.json()) as ZoomRecording
|
|
const transcript = findTranscriptFile(recording.recording_files)
|
|
|
|
if (!transcript?.download_url) {
|
|
logger.info('Transcript no longer available for Zoom recording', { externalId })
|
|
return null
|
|
}
|
|
|
|
const vttResponse = await fetchWithRetry(transcript.download_url, {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
})
|
|
|
|
if (!vttResponse.ok) {
|
|
logger.warn('Failed to download Zoom transcript', {
|
|
externalId,
|
|
status: vttResponse.status,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const vttBuffer = await readBodyWithLimit(vttResponse, CONNECTOR_MAX_FILE_BYTES)
|
|
if (!vttBuffer) {
|
|
return markSkipped(
|
|
recordingToStub(recording, transcript),
|
|
sizeLimitSkipReason(CONNECTOR_MAX_FILE_BYTES)
|
|
)
|
|
}
|
|
|
|
const vttText = vttBuffer.toString('utf8')
|
|
const transcriptText = parseVtt(vttText).trim()
|
|
if (!transcriptText) return null
|
|
|
|
const content = formatTranscriptContent(recording, transcriptText)
|
|
|
|
return {
|
|
externalId: recording.uuid || externalId,
|
|
title: recording.topic?.trim() || 'Untitled Zoom Meeting',
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: buildSourceUrl(recording),
|
|
contentHash: buildContentHash(recording, transcript),
|
|
metadata: {
|
|
meetingId: recording.id != null ? String(recording.id) : undefined,
|
|
hostEmail: recording.host_email,
|
|
duration: recording.duration,
|
|
meetingDate: recording.start_time,
|
|
topic: recording.topic,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Zoom recording', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxRecordings = sourceConfig.maxRecordings as string | undefined
|
|
if (maxRecordings && (Number.isNaN(Number(maxRecordings)) || Number(maxRecordings) < 0)) {
|
|
return { valid: false, error: 'Max recordings must be a non-negative number' }
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithRetry(
|
|
`${ZOOM_API_BASE}/users/me`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `Zoom 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.topic === 'string' && metadata.topic.trim()) {
|
|
result.topic = metadata.topic
|
|
}
|
|
|
|
if (typeof metadata.hostEmail === 'string' && metadata.hostEmail.trim()) {
|
|
result.hostEmail = metadata.hostEmail
|
|
}
|
|
|
|
if (metadata.duration != null) {
|
|
const num = Number(metadata.duration)
|
|
if (!Number.isNaN(num)) result.duration = num
|
|
}
|
|
|
|
const meetingDate = parseTagDate(metadata.meetingDate)
|
|
if (meetingDate) result.meetingDate = meetingDate
|
|
|
|
return result
|
|
},
|
|
}
|