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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { zoomConnector } from '@/connectors/zoom/zoom'
+52
View File
@@ -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' },
],
}
+87
View File
@@ -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(/<\/?[^>]+>/)
})
})
+501
View File
@@ -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
},
}