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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { zendeskConnector } from '@/connectors/zendesk/zendesk'
+91
View File
@@ -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')
})
})
})
+506
View File
@@ -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
},
}