Files
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

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
},
}