Files
simstudioai--sim/apps/sim/connectors/intercom/intercom.ts
T
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

492 lines
15 KiB
TypeScript

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<typeof IntercomArticleSchema>
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<typeof IntercomConversationSchema>
/**
* Makes a GET request to the Intercom API with Bearer token auth.
*/
async function intercomApiGet(
path: string,
accessToken: string,
params?: Record<string, string>,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<Record<string, unknown>> {
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<string, unknown>
}
/**
* Fetches all articles from Intercom, respecting page-based pagination and max items cap.
*/
async function fetchArticles(
accessToken: string,
maxItems: number,
stateFilter: string
): Promise<IntercomArticle[]> {
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<IntercomConversation[]> {
const allConversations: IntercomConversation[] = []
let startingAfter: string | undefined
while (allConversations.length < maxItems) {
const params: Record<string, string> = {
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<IntercomConversation> {
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<string, unknown>,
_cursor?: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
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<string, unknown>
): 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<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
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
},
}