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
340 lines
9.4 KiB
TypeScript
340 lines
9.4 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 { firefliesConnectorMeta } from '@/connectors/fireflies/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('FirefliesConnector')
|
|
|
|
const FIREFLIES_GRAPHQL_URL = 'https://api.fireflies.ai/graphql'
|
|
const TRANSCRIPTS_PER_PAGE = 50
|
|
|
|
interface FirefliesTranscript {
|
|
id: string
|
|
title: string
|
|
date: number
|
|
duration: number
|
|
host_email?: string
|
|
organizer_email?: string
|
|
participants?: string[]
|
|
transcript_url?: string
|
|
speakers?: { id: number; name: string }[]
|
|
sentences?: { index: number; speaker_name: string; text: string }[]
|
|
summary?: {
|
|
keywords?: string[]
|
|
action_items?: string
|
|
overview?: string
|
|
short_summary?: string
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a GraphQL query against the Fireflies API.
|
|
*/
|
|
async function firefliesGraphQL(
|
|
accessToken: string,
|
|
query: string,
|
|
variables: Record<string, unknown> = {},
|
|
retryOptions?: Parameters<typeof fetchWithRetry>[2]
|
|
): Promise<Record<string, unknown>> {
|
|
const response = await fetchWithRetry(
|
|
FIREFLIES_GRAPHQL_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
},
|
|
retryOptions
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Fireflies API HTTP error: ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.errors) {
|
|
const message = (data.errors as { message: string }[])[0]?.message || 'Unknown GraphQL error'
|
|
throw new Error(`Fireflies API error: ${message}`)
|
|
}
|
|
|
|
return data.data as Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Formats transcript sentences into plain text content.
|
|
*/
|
|
function formatTranscriptContent(transcript: FirefliesTranscript): string {
|
|
const parts: string[] = []
|
|
|
|
if (transcript.title) {
|
|
parts.push(`Meeting: ${transcript.title}`)
|
|
}
|
|
|
|
if (transcript.date) {
|
|
parts.push(`Date: ${new Date(transcript.date).toISOString()}`)
|
|
}
|
|
|
|
if (transcript.duration) {
|
|
const minutes = Math.round(transcript.duration / 60)
|
|
parts.push(`Duration: ${minutes} minutes`)
|
|
}
|
|
|
|
if (transcript.host_email) {
|
|
parts.push(`Host: ${transcript.host_email}`)
|
|
}
|
|
|
|
if (transcript.participants && transcript.participants.length > 0) {
|
|
parts.push(`Participants: ${transcript.participants.join(', ')}`)
|
|
}
|
|
|
|
if (transcript.summary?.overview) {
|
|
parts.push('')
|
|
parts.push('--- Overview ---')
|
|
parts.push(transcript.summary.overview)
|
|
}
|
|
|
|
if (transcript.summary?.action_items) {
|
|
parts.push('')
|
|
parts.push('--- Action Items ---')
|
|
parts.push(transcript.summary.action_items)
|
|
}
|
|
|
|
if (transcript.summary?.keywords && transcript.summary.keywords.length > 0) {
|
|
parts.push('')
|
|
parts.push(`Keywords: ${transcript.summary.keywords.join(', ')}`)
|
|
}
|
|
|
|
if (transcript.sentences && transcript.sentences.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Transcript ---')
|
|
for (const sentence of transcript.sentences) {
|
|
parts.push(`${sentence.speaker_name}: ${sentence.text}`)
|
|
}
|
|
}
|
|
|
|
return parts.join('\n')
|
|
}
|
|
|
|
export const firefliesConnector: ConnectorConfig = {
|
|
...firefliesConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
const hostEmail = (sourceConfig.hostEmail as string) || ''
|
|
const maxTranscripts = sourceConfig.maxTranscripts ? Number(sourceConfig.maxTranscripts) : 0
|
|
|
|
const skip = cursor ? Number(cursor) : 0
|
|
|
|
const variables: Record<string, unknown> = {
|
|
limit: TRANSCRIPTS_PER_PAGE,
|
|
skip,
|
|
}
|
|
|
|
if (hostEmail.trim()) {
|
|
variables.host_email = hostEmail.trim()
|
|
}
|
|
|
|
logger.info('Listing Fireflies transcripts', { skip, limit: TRANSCRIPTS_PER_PAGE, hostEmail })
|
|
|
|
const data = await firefliesGraphQL(
|
|
accessToken,
|
|
`query Transcripts(
|
|
$limit: Int
|
|
$skip: Int
|
|
$host_email: String
|
|
) {
|
|
transcripts(
|
|
limit: $limit
|
|
skip: $skip
|
|
host_email: $host_email
|
|
) {
|
|
id
|
|
title
|
|
date
|
|
duration
|
|
host_email
|
|
organizer_email
|
|
participants
|
|
transcript_url
|
|
speakers {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}`,
|
|
variables
|
|
)
|
|
|
|
const transcripts = (data.transcripts || []) as FirefliesTranscript[]
|
|
|
|
const documents: ExternalDocument[] = transcripts.map((transcript) => {
|
|
const meetingDate = transcript.date ? new Date(transcript.date).toISOString() : undefined
|
|
const speakerNames = transcript.speakers?.map((s) => s.name).filter(Boolean) ?? []
|
|
|
|
return {
|
|
externalId: transcript.id,
|
|
title: transcript.title || 'Untitled Meeting',
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain' as const,
|
|
sourceUrl: transcript.transcript_url || undefined,
|
|
contentHash: `fireflies:${transcript.id}:${transcript.date ?? ''}:${transcript.duration ?? ''}`,
|
|
metadata: {
|
|
hostEmail: transcript.host_email,
|
|
duration: transcript.duration,
|
|
meetingDate,
|
|
participants: transcript.participants,
|
|
speakers: speakerNames,
|
|
},
|
|
}
|
|
})
|
|
|
|
const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
const hitLimit = maxTranscripts > 0 && totalFetched >= maxTranscripts
|
|
|
|
const hasMore = !hitLimit && transcripts.length === TRANSCRIPTS_PER_PAGE
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hasMore ? String(skip + transcripts.length) : undefined,
|
|
hasMore,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
const data = await firefliesGraphQL(
|
|
accessToken,
|
|
`query Transcript($id: String!) {
|
|
transcript(id: $id) {
|
|
id
|
|
title
|
|
date
|
|
duration
|
|
host_email
|
|
organizer_email
|
|
participants
|
|
transcript_url
|
|
speakers {
|
|
id
|
|
name
|
|
}
|
|
sentences {
|
|
index
|
|
speaker_name
|
|
text
|
|
}
|
|
summary {
|
|
keywords
|
|
action_items
|
|
overview
|
|
short_summary
|
|
}
|
|
}
|
|
}`,
|
|
{ id: externalId }
|
|
)
|
|
|
|
const transcript = data.transcript as FirefliesTranscript | null
|
|
if (!transcript) return null
|
|
|
|
const content = formatTranscriptContent(transcript)
|
|
const contentHash = `fireflies:${transcript.id}:${transcript.date ?? ''}:${transcript.duration ?? ''}`
|
|
|
|
const meetingDate = transcript.date ? new Date(transcript.date).toISOString() : undefined
|
|
const speakerNames = transcript.speakers?.map((s) => s.name).filter(Boolean) ?? []
|
|
|
|
return {
|
|
externalId: transcript.id,
|
|
title: transcript.title || 'Untitled Meeting',
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: transcript.transcript_url || undefined,
|
|
contentHash,
|
|
metadata: {
|
|
hostEmail: transcript.host_email,
|
|
duration: transcript.duration,
|
|
meetingDate,
|
|
participants: transcript.participants,
|
|
speakers: speakerNames,
|
|
keywords: transcript.summary?.keywords,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Fireflies transcript', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxTranscripts = sourceConfig.maxTranscripts as string | undefined
|
|
if (maxTranscripts && (Number.isNaN(Number(maxTranscripts)) || Number(maxTranscripts) < 0)) {
|
|
return { valid: false, error: 'Max transcripts must be a non-negative number' }
|
|
}
|
|
|
|
try {
|
|
await firefliesGraphQL(
|
|
accessToken,
|
|
`query User {
|
|
user {
|
|
user_id
|
|
name
|
|
email
|
|
}
|
|
}`,
|
|
{},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
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.hostEmail === 'string') {
|
|
result.hostEmail = metadata.hostEmail
|
|
}
|
|
|
|
const speakers = Array.isArray(metadata.speakers) ? (metadata.speakers as string[]) : []
|
|
if (speakers.length > 0) {
|
|
result.speakers = speakers.join(', ')
|
|
}
|
|
|
|
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
|
|
},
|
|
}
|