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, retryOptions?: Parameters[2] ): Promise> { 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 } /** * Fetches all Help Center articles with pagination. */ async function fetchArticles( subdomain: string, accessToken: string, sourceConfig: Record, locale?: string ): Promise { 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, statusFilter?: string, maxTickets?: number ): Promise { 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, ticketId: number ): Promise { 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, _cursor?: string, _syncContext?: Record ): Promise => { 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, externalId: string ): Promise => { 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 ): 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): Record => { const result: Record = {} 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 }, }