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
507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
|
|
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils'
|
|
import { DEFAULT_MAX_TICKETS, zendeskConnectorMeta } from '@/connectors/zendesk/meta'
|
|
|
|
const logger = createLogger('ZendeskConnector')
|
|
|
|
const ARTICLES_PER_PAGE = 30
|
|
const TICKETS_PER_PAGE = 100
|
|
const SEARCH_API_RESULT_CAP = 1000
|
|
|
|
const VALID_TICKET_STATUSES = new Set(['new', 'open', 'pending', 'hold', 'solved', 'closed'])
|
|
|
|
interface ZendeskArticle {
|
|
id: number
|
|
title: string
|
|
body: string
|
|
html_url: string
|
|
section_id: number | null
|
|
label_names: string[]
|
|
author_id: number
|
|
locale: string
|
|
created_at: string
|
|
updated_at: string
|
|
edited_at: string
|
|
draft: boolean
|
|
}
|
|
|
|
interface ZendeskTicket {
|
|
id: number
|
|
subject: string
|
|
description: string
|
|
status: string
|
|
priority: string | null
|
|
tags: string[]
|
|
requester_id: number
|
|
assignee_id: number | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface ZendeskComment {
|
|
id: number
|
|
body: string
|
|
html_body: string
|
|
author_id: number
|
|
created_at: string
|
|
public: boolean
|
|
}
|
|
|
|
/**
|
|
* Strict Zendesk subdomain label: a single DNS label of lowercase letters,
|
|
* digits, and hyphens (not leading/trailing). Rejects anything that could
|
|
* smuggle a scheme, host, path, port, or fragment into the base URL.
|
|
*/
|
|
const SUBDOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
|
|
/**
|
|
* Validates and normalizes a Zendesk subdomain.
|
|
*
|
|
* @throws {Error} when the subdomain is missing or not a valid DNS label.
|
|
*/
|
|
function normalizeSubdomain(subdomain: string): string {
|
|
const normalized = subdomain.trim().toLowerCase()
|
|
if (!SUBDOMAIN_PATTERN.test(normalized)) {
|
|
throw new Error('Invalid Zendesk subdomain')
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
/**
|
|
* Builds the base URL for a Zendesk subdomain. The subdomain is validated to a
|
|
* strict DNS label so it cannot be used to forge requests to arbitrary hosts.
|
|
*/
|
|
export function buildBaseUrl(subdomain: string): string {
|
|
return `https://${normalizeSubdomain(subdomain)}.zendesk.com`
|
|
}
|
|
|
|
/**
|
|
* Makes an authenticated GET request to the Zendesk API.
|
|
* Uses email/token authentication.
|
|
*/
|
|
async function zendeskApiGet(
|
|
url: string,
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
retryOptions?: Parameters<typeof secureFetchWithRetry>[2]
|
|
): Promise<Record<string, unknown>> {
|
|
const email = sourceConfig.email as string
|
|
|
|
const response = await secureFetchWithRetry(
|
|
url,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Basic ${btoa(`${email}/token:${accessToken}`)}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
retryOptions
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Zendesk API HTTP error: ${response.status}`)
|
|
}
|
|
|
|
return (await response.json()) as Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Fetches all Help Center articles with pagination.
|
|
*/
|
|
async function fetchArticles(
|
|
subdomain: string,
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
locale?: string
|
|
): Promise<ZendeskArticle[]> {
|
|
const allArticles: ZendeskArticle[] = []
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
const localePath = locale ? `/${encodeURIComponent(locale)}` : ''
|
|
let page = 1
|
|
|
|
while (true) {
|
|
const url = `${baseUrl}/api/v2/help_center${localePath}/articles.json?page=${page}&per_page=${ARTICLES_PER_PAGE}`
|
|
const data = await zendeskApiGet(url, accessToken, sourceConfig)
|
|
const articles = (data.articles as ZendeskArticle[]) || []
|
|
|
|
if (articles.length === 0) break
|
|
|
|
allArticles.push(...articles)
|
|
|
|
if (!data.next_page) break
|
|
page++
|
|
}
|
|
|
|
return allArticles
|
|
}
|
|
|
|
/**
|
|
* Fetches tickets with optional status filtering and pagination.
|
|
*/
|
|
async function fetchTickets(
|
|
subdomain: string,
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
statusFilter?: string,
|
|
maxTickets?: number
|
|
): Promise<ZendeskTicket[]> {
|
|
const allTickets: ZendeskTicket[] = []
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
const limit = maxTickets || DEFAULT_MAX_TICKETS
|
|
let url: string | null = `${baseUrl}/api/v2/tickets.json?per_page=${TICKETS_PER_PAGE}`
|
|
|
|
if (statusFilter && statusFilter !== 'all') {
|
|
if (VALID_TICKET_STATUSES.has(statusFilter)) {
|
|
if (limit > SEARCH_API_RESULT_CAP) {
|
|
logger.warn(
|
|
`Zendesk Search API caps at ${SEARCH_API_RESULT_CAP} results; requested limit ${limit} will be truncated. Remove status filter to use the unbounded tickets endpoint.`
|
|
)
|
|
}
|
|
const params = new URLSearchParams({
|
|
query: `type:ticket status:${statusFilter}`,
|
|
per_page: String(TICKETS_PER_PAGE),
|
|
})
|
|
url = `${baseUrl}/api/v2/search.json?${params.toString()}`
|
|
} else {
|
|
logger.warn(
|
|
`Invalid Zendesk statusFilter "${statusFilter}"; falling back to all tickets. Valid values: ${[...VALID_TICKET_STATUSES].join(', ')}.`
|
|
)
|
|
}
|
|
}
|
|
|
|
while (url && allTickets.length < limit) {
|
|
const data = await zendeskApiGet(url, accessToken, sourceConfig)
|
|
const tickets = ((data.tickets || data.results) as ZendeskTicket[]) || []
|
|
|
|
if (tickets.length === 0) break
|
|
|
|
allTickets.push(...tickets)
|
|
|
|
url = (data.next_page as string) || null
|
|
}
|
|
|
|
return allTickets.slice(0, limit)
|
|
}
|
|
|
|
/**
|
|
* Fetches all comments for a ticket.
|
|
*/
|
|
async function fetchTicketComments(
|
|
subdomain: string,
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
ticketId: number
|
|
): Promise<ZendeskComment[]> {
|
|
const allComments: ZendeskComment[] = []
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
let url: string | null = `${baseUrl}/api/v2/tickets/${ticketId}/comments.json?per_page=100`
|
|
|
|
while (url) {
|
|
const data = await zendeskApiGet(url, accessToken, sourceConfig)
|
|
const comments = (data.comments as ZendeskComment[]) || []
|
|
|
|
allComments.push(...comments)
|
|
|
|
url = (data.next_page as string) || null
|
|
}
|
|
|
|
return allComments
|
|
}
|
|
|
|
/**
|
|
* Formats ticket with its comments into a single document content string.
|
|
*/
|
|
function formatTicketContent(ticket: ZendeskTicket, comments: ZendeskComment[]): string {
|
|
const parts: string[] = []
|
|
|
|
parts.push(`Subject: ${ticket.subject}`)
|
|
parts.push(`Status: ${ticket.status}`)
|
|
if (ticket.priority) {
|
|
parts.push(`Priority: ${ticket.priority}`)
|
|
}
|
|
parts.push(`Created: ${ticket.created_at}`)
|
|
parts.push(`Updated: ${ticket.updated_at}`)
|
|
if (ticket.tags.length > 0) {
|
|
parts.push(`Tags: ${ticket.tags.join(', ')}`)
|
|
}
|
|
parts.push('')
|
|
parts.push('--- Description ---')
|
|
parts.push(htmlToPlainText(ticket.description))
|
|
|
|
if (comments.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Comments ---')
|
|
for (const comment of comments) {
|
|
const visibility = comment.public ? 'Public' : 'Internal'
|
|
parts.push(`\n[${comment.created_at}] (${visibility}) Author ${comment.author_id}:`)
|
|
parts.push(htmlToPlainText(comment.html_body || comment.body))
|
|
}
|
|
}
|
|
|
|
return parts.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Converts an article to an ExternalDocument with inline content.
|
|
* Articles return body inline from the list API so no deferral is needed.
|
|
*/
|
|
function articleToDocument(article: ZendeskArticle, subdomain: string): ExternalDocument {
|
|
const content = htmlToPlainText(article.body || '')
|
|
|
|
return {
|
|
externalId: `article-${article.id}`,
|
|
title: article.title,
|
|
content,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: article.html_url || `https://${subdomain}.zendesk.com/hc/articles/${article.id}`,
|
|
contentHash: `zendesk:article:${article.id}:${article.updated_at}`,
|
|
metadata: {
|
|
type: 'article',
|
|
articleId: article.id,
|
|
sectionId: article.section_id,
|
|
labels: article.label_names,
|
|
author: String(article.author_id),
|
|
locale: article.locale,
|
|
draft: article.draft,
|
|
createdAt: article.created_at,
|
|
updatedAt: article.updated_at,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a deferred stub for a ticket. Content is not fetched here because
|
|
* each ticket requires a separate comments API call. Full content is fetched
|
|
* lazily via getDocument only for new/changed documents.
|
|
*/
|
|
function ticketToStub(ticket: ZendeskTicket, subdomain: string): ExternalDocument {
|
|
return {
|
|
externalId: `ticket-${ticket.id}`,
|
|
title: `Ticket #${ticket.id}: ${ticket.subject}`,
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: `https://${subdomain}.zendesk.com/agent/tickets/${ticket.id}`,
|
|
contentHash: `zendesk:ticket:${ticket.id}:${ticket.updated_at}`,
|
|
metadata: {
|
|
type: 'ticket',
|
|
ticketId: ticket.id,
|
|
status: ticket.status,
|
|
priority: ticket.priority,
|
|
tags: ticket.tags,
|
|
createdAt: ticket.created_at,
|
|
updatedAt: ticket.updated_at,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a ticket (with comments) to a full ExternalDocument.
|
|
* Used by getDocument to resolve deferred ticket stubs.
|
|
*/
|
|
function ticketToDocument(
|
|
ticket: ZendeskTicket,
|
|
comments: ZendeskComment[],
|
|
subdomain: string
|
|
): ExternalDocument {
|
|
const content = formatTicketContent(ticket, comments)
|
|
|
|
return {
|
|
externalId: `ticket-${ticket.id}`,
|
|
title: `Ticket #${ticket.id}: ${ticket.subject}`,
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: `https://${subdomain}.zendesk.com/agent/tickets/${ticket.id}`,
|
|
contentHash: `zendesk:ticket:${ticket.id}:${ticket.updated_at}`,
|
|
metadata: {
|
|
type: 'ticket',
|
|
ticketId: ticket.id,
|
|
status: ticket.status,
|
|
priority: ticket.priority,
|
|
tags: ticket.tags,
|
|
commentCount: comments.length,
|
|
createdAt: ticket.created_at,
|
|
updatedAt: ticket.updated_at,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const zendeskConnector: ConnectorConfig = {
|
|
...zendeskConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
_cursor?: string,
|
|
_syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
const subdomain = (sourceConfig.subdomain as string)?.trim()
|
|
if (!subdomain) {
|
|
throw new Error('Subdomain is required')
|
|
}
|
|
|
|
const email = sourceConfig.email as string
|
|
if (!email?.trim()) {
|
|
throw new Error('Email is required')
|
|
}
|
|
|
|
const contentType = (sourceConfig.contentType as string) || 'both'
|
|
const ticketStatus = sourceConfig.ticketStatus as string | undefined
|
|
const locale = (sourceConfig.locale as string)?.trim() || undefined
|
|
const maxTickets = sourceConfig.maxTickets
|
|
? Number(sourceConfig.maxTickets)
|
|
: DEFAULT_MAX_TICKETS
|
|
|
|
const documents: ExternalDocument[] = []
|
|
|
|
if (contentType === 'articles' || contentType === 'both') {
|
|
logger.info('Fetching Zendesk Help Center articles', { subdomain, locale })
|
|
const articles = await fetchArticles(subdomain, accessToken, sourceConfig, locale)
|
|
logger.info(`Fetched ${articles.length} articles from Zendesk`)
|
|
|
|
for (const article of articles) {
|
|
if (!article.body?.trim()) continue
|
|
documents.push(articleToDocument(article, subdomain))
|
|
}
|
|
}
|
|
|
|
if (contentType === 'tickets' || contentType === 'both') {
|
|
logger.info('Fetching Zendesk support tickets', { subdomain, ticketStatus, maxTickets })
|
|
const tickets = await fetchTickets(
|
|
subdomain,
|
|
accessToken,
|
|
sourceConfig,
|
|
ticketStatus,
|
|
maxTickets
|
|
)
|
|
logger.info(`Fetched ${tickets.length} tickets from Zendesk`)
|
|
|
|
for (const ticket of tickets) {
|
|
documents.push(ticketToStub(ticket, subdomain))
|
|
}
|
|
}
|
|
|
|
return {
|
|
documents,
|
|
hasMore: false,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
const subdomain = (sourceConfig.subdomain as string)?.trim()
|
|
if (!subdomain) return null
|
|
|
|
try {
|
|
if (externalId.startsWith('article-')) {
|
|
const articleId = externalId.replace('article-', '')
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
const url = `${baseUrl}/api/v2/help_center/articles/${articleId}.json`
|
|
const data = await zendeskApiGet(url, accessToken, sourceConfig)
|
|
const article = data.article as ZendeskArticle
|
|
if (!article) return null
|
|
return articleToDocument(article, subdomain)
|
|
}
|
|
|
|
if (externalId.startsWith('ticket-')) {
|
|
const ticketId = Number(externalId.replace('ticket-', ''))
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
const url = `${baseUrl}/api/v2/tickets/${ticketId}.json`
|
|
const data = await zendeskApiGet(url, accessToken, sourceConfig)
|
|
const ticket = data.ticket as ZendeskTicket
|
|
if (!ticket) return null
|
|
const comments = await fetchTicketComments(subdomain, accessToken, sourceConfig, ticketId)
|
|
return ticketToDocument(ticket, comments, subdomain)
|
|
}
|
|
|
|
return null
|
|
} catch (error) {
|
|
logger.warn('Failed to get Zendesk document', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const subdomain = (sourceConfig.subdomain as string)?.trim()
|
|
if (!subdomain) {
|
|
return { valid: false, error: 'Subdomain is required' }
|
|
}
|
|
|
|
const email = (sourceConfig.email as string)?.trim()
|
|
if (!email) {
|
|
return { valid: false, error: 'Email is required' }
|
|
}
|
|
|
|
const contentType = sourceConfig.contentType as string | undefined
|
|
if (!contentType) {
|
|
return { valid: false, error: 'Content type is required' }
|
|
}
|
|
|
|
const maxTickets = sourceConfig.maxTickets as string | undefined
|
|
if (maxTickets && (Number.isNaN(Number(maxTickets)) || Number(maxTickets) <= 0)) {
|
|
return { valid: false, error: 'Max tickets must be a positive number' }
|
|
}
|
|
|
|
try {
|
|
const baseUrl = buildBaseUrl(subdomain)
|
|
const url = `${baseUrl}/api/v2/users/me.json`
|
|
await zendeskApiGet(url, accessToken, sourceConfig, VALIDATE_RETRY_OPTIONS)
|
|
return { valid: true }
|
|
} catch (error) {
|
|
return { valid: false, error: toError(error).message || 'Failed to validate configuration' }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.type === 'string') {
|
|
result.contentType = metadata.type
|
|
}
|
|
|
|
if (typeof metadata.status === 'string') {
|
|
result.status = metadata.status
|
|
}
|
|
|
|
if (typeof metadata.priority === 'string') {
|
|
result.priority = metadata.priority
|
|
}
|
|
|
|
const labels = joinTagArray(metadata.labels)
|
|
if (labels) {
|
|
result.labels = labels
|
|
}
|
|
|
|
const tags = joinTagArray(metadata.tags)
|
|
if (tags) {
|
|
result.tags = tags
|
|
}
|
|
|
|
const updatedAt = parseTagDate(metadata.updatedAt)
|
|
if (updatedAt) {
|
|
result.updatedAt = updatedAt
|
|
}
|
|
|
|
if (typeof metadata.commentCount === 'number') {
|
|
result.commentCount = metadata.commentCount
|
|
}
|
|
|
|
return result
|
|
},
|
|
}
|