chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { zoomConnector } from '@/connectors/zoom/zoom'
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
|
||||
export const zoomConnectorMeta: ConnectorMeta = {
|
||||
id: 'zoom',
|
||||
name: 'Zoom',
|
||||
description: 'Sync meeting transcripts from Zoom cloud recordings',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'zoom',
|
||||
requiredScopes: [
|
||||
'user:read:user',
|
||||
'cloud_recording:read:list_user_recordings',
|
||||
'cloud_recording:read:list_recording_files',
|
||||
],
|
||||
},
|
||||
|
||||
supportsIncrementalSync: true,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'lookback',
|
||||
title: 'Date Range',
|
||||
type: 'dropdown',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Last 30 days', id: '30' },
|
||||
{ label: 'Last 90 days', id: '90' },
|
||||
{ label: 'Last 6 months (recommended)', id: '180' },
|
||||
],
|
||||
description:
|
||||
'On initial sync only. Zoom only allows access to cloud recordings within the last 6 months.',
|
||||
},
|
||||
{
|
||||
id: 'maxRecordings',
|
||||
title: 'Max Recordings',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: 'e.g. 200 (default: unlimited)',
|
||||
},
|
||||
],
|
||||
|
||||
tagDefinitions: [
|
||||
{ id: 'topic', displayName: 'Topic', fieldType: 'text' },
|
||||
{ id: 'hostEmail', displayName: 'Host Email', fieldType: 'text' },
|
||||
{ id: 'duration', displayName: 'Duration (minutes)', fieldType: 'number' },
|
||||
{ id: 'meetingDate', displayName: 'Meeting Date', fieldType: 'date' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseVtt } from '@/connectors/zoom/zoom'
|
||||
|
||||
const HEADER = 'WEBVTT\n\n'
|
||||
|
||||
describe('parseVtt', () => {
|
||||
it.concurrent('returns empty string for input with no cues', () => {
|
||||
expect(parseVtt(HEADER)).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('extracts plain spoken text from a single cue', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\nHello world\n`
|
||||
expect(parseVtt(vtt)).toBe('Hello world')
|
||||
})
|
||||
|
||||
it.concurrent('preserves WebVTT voice tags as "Speaker: text"', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n<v Alice>hello there</v>\n`
|
||||
expect(parseVtt(vtt)).toBe('Alice: hello there')
|
||||
})
|
||||
|
||||
it.concurrent('preserves voice tags with class suffix', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n<v.host Bob>welcome</v>\n`
|
||||
expect(parseVtt(vtt)).toBe('Bob: welcome')
|
||||
})
|
||||
|
||||
it.concurrent('strips inline formatting tags but keeps text', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n<b>bold</b> and <i>italic</i>\n`
|
||||
expect(parseVtt(vtt)).toBe('bold and italic')
|
||||
})
|
||||
|
||||
it.concurrent('strips karaoke timestamp tags', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\nhello <00:00:01.000>world\n`
|
||||
expect(parseVtt(vtt)).toBe('hello world')
|
||||
})
|
||||
|
||||
it.concurrent('strips class spans', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n<c.loud>SHOUT</c>\n`
|
||||
expect(parseVtt(vtt)).toBe('SHOUT')
|
||||
})
|
||||
|
||||
it.concurrent('skips cue identifier lines before timing', () => {
|
||||
const vtt = `${HEADER}cue-1\n00:00:00.000 --> 00:00:02.000\nhello\n`
|
||||
expect(parseVtt(vtt)).toBe('hello')
|
||||
})
|
||||
|
||||
it.concurrent('joins multiple cues with newlines', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\nfirst\n\n00:00:02.000 --> 00:00:04.000\nsecond\n`
|
||||
expect(parseVtt(vtt)).toBe('first\nsecond')
|
||||
})
|
||||
|
||||
it.concurrent('collapses repeated whitespace within a cue', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\nhello world\n`
|
||||
expect(parseVtt(vtt)).toBe('hello world')
|
||||
})
|
||||
|
||||
it.concurrent('iteratively strips overlapping tags that reconstruct after one pass', () => {
|
||||
const crafted = '<<b>b>injected</<b>b>'
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n${crafted}\n`
|
||||
const result = parseVtt(vtt)
|
||||
expect(result).not.toMatch(/<\/?[^>]+>/)
|
||||
expect(result).toContain('injected')
|
||||
})
|
||||
|
||||
it.concurrent('iteratively strips nested script-like tag fragments', () => {
|
||||
const crafted = '<scr<script>ipt>alert(1)</scr</script>ipt>'
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n${crafted}\n`
|
||||
const result = parseVtt(vtt)
|
||||
expect(result).not.toMatch(/<\/?[^>]+>/)
|
||||
expect(result.toLowerCase()).not.toContain('script')
|
||||
})
|
||||
|
||||
it.concurrent('sanitizes crafted speaker names that embed tag fragments', () => {
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n<v <b>Evil</b>>payload</v>\n`
|
||||
const result = parseVtt(vtt)
|
||||
expect(result).not.toMatch(/<\/?[^>]+>/)
|
||||
})
|
||||
|
||||
it.concurrent('terminates on adversarial deeply-nested input', () => {
|
||||
const crafted = `${'<'.repeat(50)}b${'>'.repeat(50)}text${'<'.repeat(50)}/b${'>'.repeat(50)}`
|
||||
const vtt = `${HEADER}00:00:00.000 --> 00:00:02.000\n${crafted}\n`
|
||||
const result = parseVtt(vtt)
|
||||
expect(result).not.toMatch(/<\/?[^>]+>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,501 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user