chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { zendeskConnector } from '@/connectors/zendesk/zendesk'
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ZendeskIcon } from '@/components/icons'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
|
||||
export const DEFAULT_MAX_TICKETS = 500
|
||||
|
||||
export const zendeskConnectorMeta: ConnectorMeta = {
|
||||
id: 'zendesk',
|
||||
name: 'Zendesk',
|
||||
description: 'Sync Help Center articles and support tickets from Zendesk',
|
||||
version: '1.0.0',
|
||||
icon: ZendeskIcon,
|
||||
|
||||
auth: {
|
||||
mode: 'apiKey',
|
||||
label: 'API Token',
|
||||
placeholder: 'Enter your Zendesk API token',
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'subdomain',
|
||||
title: 'Subdomain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yourcompany (from yourcompany.zendesk.com)',
|
||||
required: true,
|
||||
description: 'Your Zendesk subdomain',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
type: 'short-input',
|
||||
placeholder: 'agent@yourcompany.com',
|
||||
required: true,
|
||||
description: 'Email address of the Zendesk user for API authentication',
|
||||
},
|
||||
{
|
||||
id: 'contentType',
|
||||
title: 'Content Type',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
description: 'What content to sync from Zendesk',
|
||||
options: [
|
||||
{ label: 'Articles & Tickets', id: 'both' },
|
||||
{ label: 'Help Center Articles Only', id: 'articles' },
|
||||
{ label: 'Support Tickets Only', id: 'tickets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ticketStatus',
|
||||
title: 'Ticket Status Filter',
|
||||
type: 'dropdown',
|
||||
required: false,
|
||||
description: 'Filter tickets by status (applies only when syncing tickets)',
|
||||
options: [
|
||||
{ label: 'All Statuses', id: 'all' },
|
||||
{ label: 'New', id: 'new' },
|
||||
{ label: 'Open', id: 'open' },
|
||||
{ label: 'Pending', id: 'pending' },
|
||||
{ label: 'On Hold', id: 'hold' },
|
||||
{ label: 'Solved', id: 'solved' },
|
||||
{ label: 'Closed', id: 'closed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'locale',
|
||||
title: 'Article Locale',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: 'e.g. en-us (default: all locales)',
|
||||
description: 'Locale for Help Center articles',
|
||||
},
|
||||
{
|
||||
id: 'maxTickets',
|
||||
title: 'Max Tickets',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: `e.g. 200 (default: ${DEFAULT_MAX_TICKETS})`,
|
||||
description: 'Maximum number of tickets to sync',
|
||||
},
|
||||
],
|
||||
|
||||
tagDefinitions: [
|
||||
{ id: 'contentType', displayName: 'Content Type', fieldType: 'text' },
|
||||
{ id: 'status', displayName: 'Status', fieldType: 'text' },
|
||||
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
|
||||
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
|
||||
{ id: 'tags', displayName: 'Tags', fieldType: 'text' },
|
||||
{ id: 'updatedAt', displayName: 'Last Updated', fieldType: 'date' },
|
||||
{ id: 'commentCount', displayName: 'Comment Count', fieldType: 'number' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBaseUrl } from '@/connectors/zendesk/zendesk'
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it.concurrent('builds the base URL for a valid subdomain', () => {
|
||||
expect(buildBaseUrl('acme')).toBe('https://acme.zendesk.com')
|
||||
})
|
||||
|
||||
it.concurrent('allows hyphens and digits within the label', () => {
|
||||
expect(buildBaseUrl('acme-support-1')).toBe('https://acme-support-1.zendesk.com')
|
||||
})
|
||||
|
||||
it.concurrent('allows a single-character subdomain', () => {
|
||||
expect(buildBaseUrl('a')).toBe('https://a.zendesk.com')
|
||||
})
|
||||
|
||||
it.concurrent('allows the maximum 63-character label', () => {
|
||||
const label = `a${'b'.repeat(61)}c`
|
||||
expect(label).toHaveLength(63)
|
||||
expect(buildBaseUrl(label)).toBe(`https://${label}.zendesk.com`)
|
||||
})
|
||||
|
||||
it.concurrent('trims surrounding whitespace', () => {
|
||||
expect(buildBaseUrl(' acme ')).toBe('https://acme.zendesk.com')
|
||||
})
|
||||
|
||||
it.concurrent('normalizes uppercase to lowercase (DNS is case-insensitive)', () => {
|
||||
expect(buildBaseUrl('MyCompany')).toBe('https://mycompany.zendesk.com')
|
||||
})
|
||||
|
||||
describe('rejects SSRF payloads', () => {
|
||||
const ssrfPayloads: Array<[string, string]> = [
|
||||
['fragment truncation', 'webhook.site/abc#'],
|
||||
['fragment with path', 'evil.com/path#'],
|
||||
['embedded path', 'acme/api/v2'],
|
||||
['scheme injection', 'http://evil.com'],
|
||||
['userinfo', 'user@evil.com'],
|
||||
['port', 'acme:8080'],
|
||||
['open-redirect host', 'httpbin.org/redirect-to?url=http://169.254.169.254'],
|
||||
['loopback', '127.0.0.1'],
|
||||
['link-local literal', '169.254.169.254'],
|
||||
['whitespace injection', 'acme evil'],
|
||||
['leading hyphen', '-acme'],
|
||||
['trailing hyphen', 'acme-'],
|
||||
['leading dot', '.acme'],
|
||||
['trailing dot', 'acme.'],
|
||||
['empty string', ''],
|
||||
['whitespace only', ' '],
|
||||
['over-length label', 'a'.repeat(64)],
|
||||
['unicode', 'acmé'],
|
||||
]
|
||||
|
||||
it.concurrent.each(ssrfPayloads)('rejects %s', (_label, payload) => {
|
||||
expect(() => buildBaseUrl(payload)).toThrow('Invalid Zendesk subdomain')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user