import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { z } from 'zod' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { DEFAULT_MAX_ITEMS, intercomConnectorMeta } from '@/connectors/intercom/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { htmlToPlainText, parseTagDate } from '@/connectors/utils' const logger = createLogger('IntercomConnector') const INTERCOM_API_BASE = 'https://api.intercom.io' const ARTICLES_PER_PAGE = 50 const CONVERSATIONS_PER_PAGE = 50 const IntercomAuthorSchema = z .object({ type: z.string(), id: z.string(), name: z.string().optional(), }) .passthrough() const IntercomArticleSchema = z .object({ type: z.string().optional(), id: z.string(), title: z.string().optional(), description: z.string().nullable().optional(), body: z.string().nullable().optional(), author_id: z.union([z.number(), z.string()]).optional(), state: z.string(), created_at: z.number(), updated_at: z.number(), url: z.string().optional(), parent_id: z.number().nullable().optional(), parent_type: z.string().nullable().optional(), }) .passthrough() type IntercomArticle = z.infer const IntercomConversationPartSchema = z .object({ type: z.string().optional(), id: z.string(), part_type: z.string().optional(), body: z.string().nullable().optional(), created_at: z.number(), author: IntercomAuthorSchema.optional(), }) .passthrough() const IntercomConversationSchema = z .object({ type: z.string().optional(), id: z.string(), created_at: z.number(), updated_at: z.number(), title: z.string().nullable().optional(), state: z.string(), open: z.boolean().optional(), source: z .object({ type: z.string(), id: z.string(), subject: z.string().optional(), body: z.string().nullable().optional(), author: IntercomAuthorSchema, delivered_as: z.string().optional(), }) .passthrough() .optional(), tags: z .object({ type: z.string().optional(), tags: z .array( z .object({ id: z.string(), name: z.string(), }) .passthrough() ) .default([]), }) .passthrough() .optional(), conversation_parts: z .object({ type: z.string(), conversation_parts: z.array(IntercomConversationPartSchema), total_count: z.number(), }) .passthrough() .optional(), }) .passthrough() type IntercomConversation = z.infer /** * Makes a GET request to the Intercom API with Bearer token auth. */ async function intercomApiGet( path: string, accessToken: string, params?: Record, retryOptions?: Parameters[2] ): Promise> { const url = new URL(`${INTERCOM_API_BASE}${path}`) if (params) { for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value) } } const response = await fetchWithRetry( url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Intercom-Version': '2.11', }, }, retryOptions ) if (!response.ok) { const errorBody = await response.text().catch(() => '') throw new Error(`Intercom API HTTP error ${response.status}: ${errorBody}`) } return (await response.json()) as Record } /** * Fetches all articles from Intercom, respecting page-based pagination and max items cap. */ async function fetchArticles( accessToken: string, maxItems: number, stateFilter: string ): Promise { const allArticles: IntercomArticle[] = [] let page = 1 while (allArticles.length < maxItems) { const data = await intercomApiGet('/articles', accessToken, { page: String(page), per_page: String(ARTICLES_PER_PAGE), }) const articles = z.array(IntercomArticleSchema).parse(data.data ?? []) if (articles.length === 0) break for (const article of articles) { if (stateFilter !== 'all' && article.state !== stateFilter) continue allArticles.push(article) if (allArticles.length >= maxItems) break } const pages = data.pages as { total_pages?: number } | null if (!pages?.total_pages || page >= pages.total_pages) break page++ } return allArticles } /** * Fetches conversations from Intercom using cursor-based pagination. */ async function fetchConversations( accessToken: string, maxItems: number, stateFilter: string ): Promise { const allConversations: IntercomConversation[] = [] let startingAfter: string | undefined while (allConversations.length < maxItems) { const params: Record = { per_page: String(Math.min(CONVERSATIONS_PER_PAGE, 150)), } if (startingAfter) { params.starting_after = startingAfter } const data = await intercomApiGet('/conversations', accessToken, params) const conversations = z.array(IntercomConversationSchema).parse(data.conversations ?? []) if (conversations.length === 0) break for (const conversation of conversations) { if (stateFilter !== 'all' && conversation.state !== stateFilter) continue allConversations.push(conversation) if (allConversations.length >= maxItems) break } const pages = data.pages as { next?: { starting_after?: string } } | null const nextCursor = pages?.next?.starting_after if (!nextCursor) break startingAfter = nextCursor } return allConversations } /** * Fetches the full conversation with conversation_parts included. */ async function fetchConversationDetail( accessToken: string, conversationId: string ): Promise { const data = await intercomApiGet(`/conversations/${conversationId}`, accessToken) return IntercomConversationSchema.parse(data) } /** * Converts a conversation (with parts) into a plain text document. */ function formatConversation(conversation: IntercomConversation): string { const lines: string[] = [] if (conversation.title) { lines.push(`Subject: ${conversation.title}`) } const source = conversation.source const sourceBody = source?.body if (sourceBody) { const authorName = source.author?.name || source.author?.type || 'unknown' const timestamp = new Date(conversation.created_at * 1000).toISOString() lines.push(`[${timestamp}] ${authorName}: ${htmlToPlainText(sourceBody)}`) } const parts = conversation.conversation_parts?.conversation_parts || [] for (const part of parts) { if (!part.body) continue const authorName = part.author?.name || part.author?.type || 'unknown' const timestamp = new Date(part.created_at * 1000).toISOString() lines.push(`[${timestamp}] ${authorName}: ${htmlToPlainText(part.body)}`) } return lines.join('\n') } /** * Converts an article to a plain text document. */ function formatArticle(article: IntercomArticle): string { const parts: string[] = [] if (article.title) { parts.push(article.title) } if (article.description) { parts.push(article.description) } if (article.body) { parts.push(htmlToPlainText(article.body)) } return parts.join('\n\n') } export const intercomConnector: ConnectorConfig = { ...intercomConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, _cursor?: string, _syncContext?: Record ): Promise => { const contentType = (sourceConfig.contentType as string) || 'articles' const articleState = (sourceConfig.articleState as string) || 'published' const conversationState = (sourceConfig.conversationState as string) || 'all' const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : DEFAULT_MAX_ITEMS const documents: ExternalDocument[] = [] if (contentType === 'articles' || contentType === 'both') { logger.info('Fetching Intercom articles', { articleState, maxItems }) const articles = await fetchArticles(accessToken, maxItems, articleState) for (const article of articles) { const content = formatArticle(article) if (!content.trim()) continue const updatedAt = new Date(article.updated_at * 1000).toISOString() documents.push({ externalId: `article-${article.id}`, title: article.title || `Article ${article.id}`, content, mimeType: 'text/plain', sourceUrl: `https://app.intercom.com/a/apps/_/articles/articles/${article.id}/show`, contentHash: `intercom:article-${article.id}:${article.updated_at}`, metadata: { type: 'article', state: article.state, authorId: String(article.author_id), updatedAt, createdAt: new Date(article.created_at * 1000).toISOString(), }, }) } logger.info('Fetched Intercom articles', { count: articles.length }) } if (contentType === 'conversations' || contentType === 'both') { logger.info('Fetching Intercom conversations', { conversationState, maxItems }) const conversations = await fetchConversations(accessToken, maxItems, conversationState) for (const conversation of conversations) { const updatedAt = new Date(conversation.updated_at * 1000).toISOString() const tags = conversation.tags?.tags?.map((t) => t.name) || [] documents.push({ externalId: `conversation-${conversation.id}`, title: conversation.title || `Conversation #${conversation.id}`, content: '', contentDeferred: true, mimeType: 'text/plain', sourceUrl: `https://app.intercom.com/a/apps/_/inbox/inbox/all/conversations/${conversation.id}`, contentHash: `intercom:conversation-${conversation.id}:${conversation.updated_at}`, metadata: { type: 'conversation', state: conversation.state, tags: tags.join(', '), updatedAt, createdAt: new Date(conversation.created_at * 1000).toISOString(), }, }) } logger.info('Fetched Intercom conversations', { count: conversations.length }) } return { documents, hasMore: false } }, getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string ): Promise => { try { if (externalId.startsWith('article-')) { const articleId = externalId.replace('article-', '') const data = await intercomApiGet(`/articles/${articleId}`, accessToken) const article = IntercomArticleSchema.parse(data) const content = formatArticle(article) if (!content.trim()) return null const updatedAt = new Date(article.updated_at * 1000).toISOString() return { externalId, title: article.title || `Article ${article.id}`, content, mimeType: 'text/plain', sourceUrl: `https://app.intercom.com/a/apps/_/articles/articles/${article.id}/show`, contentHash: `intercom:article-${article.id}:${article.updated_at}`, metadata: { type: 'article', state: article.state, authorId: String(article.author_id), updatedAt, createdAt: new Date(article.created_at * 1000).toISOString(), }, } } if (externalId.startsWith('conversation-')) { const conversationId = externalId.replace('conversation-', '') const detail = await fetchConversationDetail(accessToken, conversationId) const content = formatConversation(detail) if (!content.trim()) return null const updatedAt = new Date(detail.updated_at * 1000).toISOString() const tags = detail.tags?.tags?.map((t) => t.name) || [] return { externalId, title: detail.title || `Conversation #${detail.id}`, content, contentDeferred: false, mimeType: 'text/plain', sourceUrl: `https://app.intercom.com/a/apps/_/inbox/inbox/all/conversations/${detail.id}`, contentHash: `intercom:conversation-${detail.id}:${detail.updated_at}`, metadata: { type: 'conversation', state: detail.state, tags: tags.join(', '), updatedAt, createdAt: new Date(detail.created_at * 1000).toISOString(), messageCount: (detail.conversation_parts?.total_count ?? 0) + 1, }, } } logger.warn('Unknown external ID format', { externalId }) return null } catch (error) { logger.warn('Failed to get Intercom document', { externalId, error: toError(error).message, }) return null } }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const contentType = sourceConfig.contentType as string | undefined const maxItems = sourceConfig.maxItems as string | undefined if (!contentType) { return { valid: false, error: 'Content type is required' } } if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) { return { valid: false, error: 'Max items must be a positive number' } } try { // Verify API access by fetching the first page of articles or conversations if (contentType === 'articles' || contentType === 'both') { await intercomApiGet( '/articles', accessToken, { page: '1', per_page: '1' }, VALIDATE_RETRY_OPTIONS ) } if (contentType === 'conversations' || contentType === 'both') { await intercomApiGet( '/conversations', accessToken, { per_page: '1' }, VALIDATE_RETRY_OPTIONS ) } return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') return { valid: false, error: message } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.type === 'string') { result.type = metadata.type } if (typeof metadata.state === 'string') { result.state = metadata.state } if (typeof metadata.tags === 'string' && metadata.tags) { result.tags = metadata.tags } if (typeof metadata.authorId === 'string') { result.authorId = metadata.authorId } if (typeof metadata.messageCount === 'number') { result.messageCount = metadata.messageCount } const updatedAt = parseTagDate(metadata.updatedAt) if (updatedAt) { result.updatedAt = updatedAt } return result }, }