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

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
},
}