chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,30 @@
import * as sdk from '@botpress/sdk'
import { AxiosRequestConfig } from 'axios'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const callApi: bp.IntegrationProps['actions']['callApi'] = async (
props
): Promise<bp.actions.callApi.output.Output> => {
const { client, ctx, input, logger } = props
const { method, path, headers, params, requestBody } = input
const zendeskClient = await getZendeskClient(client, ctx, logger)
try {
const requestConfig: AxiosRequestConfig = {
method,
url: `/api/v2/${path}`,
headers: headers ? JSON.parse(headers) : {},
params: params ? JSON.parse(params) : {},
validateStatus: () => true,
}
if (method !== 'GET') {
requestConfig.data = requestBody ? JSON.parse(requestBody) : {}
}
return await zendeskClient.makeRequest(requestConfig)
} catch (error) {
throw new sdk.RuntimeError(`Error: ${error instanceof Error ? error.message : 'An unknown error occurred'}`)
}
}
@@ -0,0 +1,21 @@
import { transformTicket } from 'src/definitions/schemas'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const closeTicket: bp.IntegrationProps['actions']['closeTicket'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const originalTicket = await zendeskClient.getTicket(input.ticketId)
const { ticket } = await zendeskClient.updateTicket(input.ticketId, {
comment: {
body: input.comment,
author_id: originalTicket.requester_id,
},
status: 'closed',
})
return {
ticket: transformTicket(ticket),
}
}
@@ -0,0 +1,21 @@
import { transformTicket } from 'src/definitions/schemas'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const createTicket: bp.IntegrationProps['actions']['createTicket'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const ticket = await zendeskClient.createTicket(
input.subject,
input.comment,
{
name: input.requesterName,
email: input.requesterEmail,
},
input.ticketFormId ? { ticket_form_id: parseInt(input.ticketFormId, 10) } : {}
)
return {
ticket: transformTicket(ticket),
}
}
@@ -0,0 +1,56 @@
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const createUser: bp.IntegrationProps['actions']['createUser'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const { name, email, pictureUrl } = input
const { user } = await bpClient.getOrCreateUser({
name,
pictureUrl,
tags: {
email,
role: 'end-user',
},
discriminateByTags: ['email'],
})
let remote_photo_url = pictureUrl
const urlLength = _getEncodedLength(pictureUrl)
const maxUrlLength = 255
if (urlLength && urlLength > maxUrlLength) {
// Zendesk has a limit of 255 characters for the remote_photo_url
logger
.forBot()
.warn(
`Picture URL is too long (${urlLength} characters, but max is ${maxUrlLength}). It will not be set in Zendesk.`
)
remote_photo_url = ''
}
const zendeskUser = await zendeskClient.createOrUpdateUser({
role: 'end-user',
external_id: user.id,
name,
remote_photo_url,
email,
})
await bpClient.updateUser({
id: user.id,
name,
pictureUrl,
tags: {
id: `${zendeskUser.id}`,
},
})
return {
userId: user.id,
}
}
const _getEncodedLength = (url: string | undefined): number | undefined => (url ? encodeURI(url).length : undefined)
@@ -0,0 +1,10 @@
import { transformUser } from 'src/definitions/schemas'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const findCustomer: bp.IntegrationProps['actions']['findCustomer'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const customers = await zendeskClient.findCustomers(input.query)
return { customers: customers.map(transformUser) }
}
@@ -0,0 +1,10 @@
import { transformTicket } from 'src/definitions/schemas'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const getTicket: bp.IntegrationProps['actions']['getTicket'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const ticket = await zendeskClient.getTicket(input.ticketId)
return { ticket: transformTicket(ticket) }
}
+120
View File
@@ -0,0 +1,120 @@
import { buildConversationTranscript } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { getZendeskClient, type ZendeskClient } from '../client'
import * as bp from '.botpress'
export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (props) => {
const { ctx, input, client: bpClient, logger } = props
const downstreamBotpressUser = await bpClient.getUser({ id: ctx.botUserId })
const chatbotName = input.hitlSession?.chatbotName ?? downstreamBotpressUser.user.name ?? 'Botpress'
const chatbotPhotoUrl =
input.hitlSession?.chatbotPhotoUrl ??
downstreamBotpressUser.user.pictureUrl ??
'https://app.botpress.dev/favicon/bp.svg'
const { user } = await bpClient.getUser({
id: input.userId,
})
const zendeskAuthorId = user.tags.id
if (!zendeskAuthorId) {
throw new sdk.RuntimeError(`User ${user.id} not linked in Zendesk`)
}
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
await _updateZendeskBotpressUser(props, {
zendeskClient,
chatbotName,
chatbotPhotoUrl,
})
const requester = input.hitlSession?.requesterName
? {
name: input.hitlSession?.requesterName,
...(input.hitlSession?.requesterEmail ? { email: input.hitlSession?.requesterEmail } : {}),
}
: { id: zendeskAuthorId }
const ticket = await zendeskClient.createTicket(
input.title ?? 'Untitled Ticket',
await _buildTicketBody(props, { chatbotName }),
requester,
{
priority: input.hitlSession?.priority,
...(input.hitlSession?.ticketFormId ? { ticket_form_id: parseInt(input.hitlSession.ticketFormId, 10) } : {}),
}
)
const zendeskTicketId = `${ticket.id}`
const { conversation } = await bpClient.getOrCreateConversation({
channel: 'hitl',
tags: {
id: zendeskTicketId,
},
})
await zendeskClient.updateTicket(zendeskTicketId, {
external_id: conversation.id,
})
return {
conversationId: conversation.id,
}
}
const _updateZendeskBotpressUser = async (
{ client, ctx }: bp.ActionProps['startHitl'],
{
zendeskClient,
chatbotName,
chatbotPhotoUrl,
}: { zendeskClient: ZendeskClient; chatbotName: string; chatbotPhotoUrl: string }
) => {
await client.updateUser({
id: ctx.botUserId,
pictureUrl: chatbotPhotoUrl,
name: chatbotName,
})
await zendeskClient.createOrUpdateUser({
external_id: ctx.botUserId,
name: chatbotName,
remote_photo_url: chatbotPhotoUrl,
})
}
const _buildTicketBody = async (
{ input, client, ctx }: bp.ActionProps['startHitl'],
{ chatbotName }: { chatbotName: string }
) => {
const description = input.description?.trim() || `Someone opened a ticket using your ${chatbotName} chatbot.`
const messageHistory = await buildConversationTranscript({ client, ctx, messages: input.messageHistory })
return description + (messageHistory.length ? `\n\n---\n\n${messageHistory}` : '')
}
export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const { conversation } = await bpClient.getConversation({
id: input.conversationId,
})
const ticketId: string | undefined = conversation.tags.id
if (!ticketId) {
return {}
}
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
try {
await zendeskClient.updateTicket(ticketId, {
status: 'closed',
})
return {}
} catch (err) {
console.error('Could not close ticket', err)
throw new sdk.RuntimeError(`Failed to close ticket: ${err}`)
}
}
+23
View File
@@ -0,0 +1,23 @@
import { callApi } from './call-api'
import { closeTicket } from './close-ticket'
import { createTicket } from './create-ticket'
import { createUser } from './create-user'
import { findCustomer } from './find-customer'
import { getTicket } from './get-ticket'
import { startHitl, stopHitl } from './hitl'
import { listAgents } from './list-agents'
import { syncKb } from './sync-kb'
import * as bp from '.botpress'
export default {
getTicket,
findCustomer,
createTicket,
createUser,
closeTicket,
listAgents,
callApi,
startHitl,
stopHitl,
syncKb,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,13 @@
import { transformUser } from 'src/definitions/schemas'
import { getZendeskClient } from '../client'
import * as bp from '.botpress'
export const listAgents: bp.IntegrationProps['actions']['listAgents'] = async (props) => {
const { client: bpClient, ctx, input, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const agents = await zendeskClient.getAgents(input.isOnline)
return {
agents: agents.map(transformUser),
}
}
@@ -0,0 +1,21 @@
import * as sdk from '@botpress/sdk'
import { uploadArticlesToKb } from 'src/misc/upload-articles-to-kb'
import { deleteKbArticles } from 'src/misc/utils'
import * as bp from '.botpress'
export const syncKb: bp.IntegrationProps['actions']['syncKb'] = async (props) => {
try {
const { client, ctx, input, logger } = props
const kbId = input.knowledgeBaseId
await deleteKbArticles(kbId, client)
await uploadArticlesToKb({ ctx, client, logger, kbId })
return {
success: true,
}
} catch (error) {
throw new sdk.RuntimeError(`Error: ${error instanceof Error ? error.message : 'An unknown error occurred'}`)
}
}
+152
View File
@@ -0,0 +1,152 @@
import * as bpCommon from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { getZendeskClient } from './client'
import * as bp from '.botpress'
type IntegrationLogger = bp.Logger
class Tags<T extends Record<string, string>> {
private constructor(
private _t: { tags: T },
private _logger: IntegrationLogger
) {}
public static of<T extends Record<string, string>>(t: { tags: T }, logger: IntegrationLogger) {
return new Tags(t, logger)
}
public find(key: keyof T): string | undefined {
return this._t.tags[key]
}
public get(key: keyof T): string {
const value = this.find(key)
if (!value) {
const msg = `Could not find tag ${key as string}`
this._logger.forBot().error(msg)
throw new sdk.RuntimeError(`Could not find tag ${key as string}`)
}
return value
}
}
const wrapChannel = bpCommon.createChannelWrapper<bp.IntegrationProps>()({
toolFactories: {
ticketId: ({ conversation, logger }) => Tags.of(conversation, logger).get('id'),
zendeskAuthorId: async ({ client, logger, payload, user }) =>
Tags.of((await client.getUser({ id: payload.userId ?? user.id })).user, logger).get('id'),
zendeskClient: async ({ client, ctx, logger }) => await getZendeskClient(client, ctx, logger),
},
})
export default {
hitl: {
messages: {
text: wrapChannel(
{ channelName: 'hitl', messageType: 'text' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
const { zendeskCommentId } = await zendeskClient.createPlaintextComment(
ticketId,
zendeskAuthorId,
payload.text
)
await ack({ tags: { zendeskCommentId: String(zendeskCommentId) } })
}
),
image: wrapChannel(
{ channelName: 'hitl', messageType: 'image' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
const { zendeskCommentId } = await zendeskClient.createPlaintextComment(
ticketId,
zendeskAuthorId,
payload.imageUrl
)
await ack({ tags: { zendeskCommentId: String(zendeskCommentId) } })
}
),
audio: wrapChannel(
{ channelName: 'hitl', messageType: 'audio' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
const { zendeskCommentId } = await zendeskClient.createPlaintextComment(
ticketId,
zendeskAuthorId,
payload.audioUrl
)
await ack({ tags: { zendeskCommentId: String(zendeskCommentId) } })
}
),
video: wrapChannel(
{ channelName: 'hitl', messageType: 'video' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
const { zendeskCommentId } = await zendeskClient.createPlaintextComment(
ticketId,
zendeskAuthorId,
payload.videoUrl
)
await ack({ tags: { zendeskCommentId: String(zendeskCommentId) } })
}
),
file: wrapChannel(
{ channelName: 'hitl', messageType: 'file' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
const { zendeskCommentId } = await zendeskClient.createPlaintextComment(
ticketId,
zendeskAuthorId,
payload.fileUrl
)
await ack({ tags: { zendeskCommentId: String(zendeskCommentId) } })
}
),
bloc: wrapChannel(
{ channelName: 'hitl', messageType: 'bloc' },
async ({ ack, payload, ticketId, zendeskAuthorId, zendeskClient }) => {
for (const item of payload.items) {
switch (item.type) {
case 'text':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.text)
break
case 'markdown':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.markdown)
break
case 'image':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.imageUrl)
break
case 'video':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.videoUrl)
break
case 'audio':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.audioUrl)
break
case 'file':
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, item.payload.fileUrl)
break
case 'location':
const { title, address, latitude, longitude } = item.payload
const messageParts = []
if (title) {
messageParts.push(title, '')
}
if (address) {
messageParts.push(address, '')
}
messageParts.push(`Latitude: ${latitude}`, `Longitude: ${longitude}`)
await zendeskClient.createPlaintextComment(ticketId, zendeskAuthorId, messageParts.join('\n'))
break
default:
item satisfies never
}
}
await ack({ tags: {} })
}
),
},
},
} satisfies bp.IntegrationProps['channels']
+264
View File
@@ -0,0 +1,264 @@
import * as sdk from '@botpress/sdk'
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import axiosRetry from 'axios-retry'
import type { ZendeskUser, ZendeskTicket, ZendeskWebhook } from './definitions/schemas'
import { summarizeAxiosError } from './misc/axios-utils'
import { ConditionsData, getTriggerTemplate, type TriggerNames } from './triggers'
import type { ZendeskEventType } from './webhookEvents'
import * as bp from '.botpress'
export type TicketRequester =
| {
name?: string
email?: string
}
| {
id: string
}
export type Trigger = {
url: string
id: string
}
const _makeBaseUrl = (organizationDomain: string) => {
return organizationDomain.startsWith('https') ? organizationDomain : `https://${organizationDomain}.zendesk.com`
}
type AxiosRetryClient = Parameters<typeof axiosRetry>[0]
type ZendeskConfig = {
type: 'OAuth'
accessToken: string
subdomain: string
}
class ZendeskApi {
private _client: AxiosInstance
private _logger: bp.Logger
public constructor(config: ZendeskConfig, logger: bp.Logger) {
this._logger = logger
this._client = axios.create({
baseURL: _makeBaseUrl(config.subdomain),
headers: {
Authorization: `Bearer ${config.accessToken}`,
},
})
axiosRetry(this._client as AxiosRetryClient, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) => {
const rateLimitReached = error.response?.status === 429
return axiosRetry.isNetworkOrIdempotentRequestError(error) || rateLimitReached
},
})
}
public async findCustomers(query: string): Promise<ZendeskUser[]> {
const { data } = await this._client
.get<{ users: ZendeskUser[] }>(`/api/v2/users/search.json?query=${query}`)
.catch(this._summarizeAxiosError)
return data.users
}
public async getTicket(ticketId: string) {
const { data } = await this._client
.get<{ ticket: ZendeskTicket }>(`/api/v2/tickets/${ticketId}.json`)
.catch(this._summarizeAxiosError)
return data.ticket
}
public async createTicket(
subject: string,
comment: string,
requester: TicketRequester,
extraFields: Partial<ZendeskTicket> = {}
): Promise<ZendeskTicket> {
const requesterPayload = 'id' in requester ? { requester_id: requester.id } : { requester }
const { data } = await this._client
.post<{ ticket: ZendeskTicket }>('/api/v2/tickets.json', {
ticket: {
subject,
comment: { body: comment },
...requesterPayload,
...extraFields,
},
})
.catch(this._summarizeAxiosError)
return data.ticket
}
public async subscribeWebhook(webhookUrl: string): Promise<string> {
const { data } = await this._client
.post<{ webhook: { id: string } }>('/api/v2/webhooks', {
webhook: {
name: 'bpc_integration_webhook',
status: 'active',
endpoint: webhookUrl,
http_method: 'POST',
request_format: 'json',
subscriptions: ['conditional_ticket_events'],
},
})
.catch(this._summarizeAxiosError)
return data.webhook?.id
}
public async createTrigger(name: TriggerNames, subscriptionId: string, conditions: ConditionsData): Promise<string> {
const { data } = await this._client
.post<{ trigger: Trigger }>('/api/v2/triggers.json', {
trigger: {
actions: [
{
field: 'notification_webhook',
value: [subscriptionId, JSON.stringify(getTriggerTemplate(name), null, 2)],
},
],
conditions,
title: `bpc_${name}`,
},
})
.catch(this._summarizeAxiosError)
return `${data.trigger.id}`
}
public async deleteTrigger(triggerId: string): Promise<void> {
await this._client.delete(`/api/v2/triggers/${triggerId}.json`).catch(this._summarizeAxiosError)
}
public async unsubscribeWebhook(subscriptionId: string): Promise<void> {
await this._client.delete(`/api/v2/webhooks/${subscriptionId}`).catch(this._summarizeAxiosError)
}
public async createPlaintextComment(ticketId: string, authorId: string, content: string) {
const response = await this.updateTicket(ticketId, {
comment: {
body: content,
author_id: authorId,
},
}).catch(this._summarizeAxiosError)
const zendeskCommentId = this._extractCommentId(response)
return { zendeskCommentId }
}
private _extractCommentId(response: Awaited<ReturnType<typeof this.updateTicket>>) {
const commentId = response.audit.events.find((event) => event.type === 'Comment' && 'id' in event)?.id
if (!commentId) {
throw new sdk.RuntimeError('Failed to retrieve comment ID')
}
return commentId
}
public async updateTicket(ticketId: string | number, updateFields: Partial<ZendeskTicket>) {
const response = await this._client
.put<{
ticket: ZendeskTicket
audit: {
events: (
| {
id: number
type: 'Comment'
}
| { type: string }
)[]
}
}>(`/api/v2/tickets/${ticketId}.json`, {
ticket: updateFields,
})
.catch(this._summarizeAxiosError)
return response.data
}
public async getAgents(online?: boolean): Promise<ZendeskUser[]> {
const { data } = await this._client
.get<{ users: ZendeskUser[] }>('/api/v2/users.json?role[]=agent&role[]=admin')
.catch(this._summarizeAxiosError)
return online ? data.users.filter((user) => user.user_fields?.availability === 'online') : data.users
}
public async createOrUpdateUser(fields: Partial<ZendeskUser>): Promise<ZendeskUser> {
const { data } = await this._client
.post<{ user: ZendeskUser }>('/api/v2/users/create_or_update', {
user: fields,
})
.catch(this._summarizeAxiosError)
return data.user
}
public async updateUser(userId: number | string, fields: Partial<ZendeskUser>): Promise<ZendeskUser> {
const { data } = await this._client
.put<{ user: ZendeskUser }>(`/api/v2/users/${userId}.json`, {
user: fields,
})
.catch(this._summarizeAxiosError)
return data.user
}
public async getUser(userId: number | string): Promise<ZendeskUser> {
const { data } = await this._client
.get<{ user: ZendeskUser }>(`/api/v2/users/${userId}.json`)
.catch(this._summarizeAxiosError)
return data.user
}
public async createArticleWebhook(webhookUrl: string, webhookId: string): Promise<void> {
const subscriptions: ZendeskEventType[] = ['zen:event-type:article.published', 'zen:event-type:article.unpublished']
await this._client
.post('/api/v2/webhooks', {
webhook: {
endpoint: `${webhookUrl}/article-event`,
http_method: 'POST',
name: `bpc_article_event_${webhookId}`,
request_format: 'json',
status: 'active',
subscriptions,
},
})
.catch(this._summarizeAxiosError)
}
public async deleteWebhook(webhookId: string): Promise<void> {
await this._client.delete(`/api/v2/webhooks/${webhookId}`).catch(this._summarizeAxiosError)
}
public async findWebhooks(params?: Record<string, string>): Promise<ZendeskWebhook[]> {
const { data } = await this._client.get('/api/v2/webhooks', { params }).catch(this._summarizeAxiosError)
return data.webhooks
}
public async makeRequest(requestConfig: AxiosRequestConfig) {
const { data, headers, status } = await this._client.request(requestConfig).catch(this._summarizeAxiosError)
return {
data,
headers: headers as Record<string, string>,
status,
}
}
private _summarizeAxiosError = (thrown: unknown) => summarizeAxiosError(thrown, this._logger)
}
export type ZendeskClient = InstanceType<typeof ZendeskApi>
export const getZendeskClient = async (client: bp.Client, ctx: bp.Context, logger: bp.Logger): Promise<ZendeskApi> => {
const { accessToken, subdomain } = await client
.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
.then((result) => result.state.payload)
if (accessToken === undefined) {
throw new sdk.RuntimeError('Failed to get the OAuth accessToken')
}
if (subdomain === undefined) {
throw new sdk.RuntimeError('Failed to get the subdomain')
}
return new ZendeskApi({ type: 'OAuth', accessToken, subdomain }, logger)
}
@@ -0,0 +1,141 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
import { ticketSchema, userSchema } from './schemas'
const createTicket = {
title: 'Create Ticket',
description: 'Creates a new ticket in Zendesk',
input: {
schema: z.object({
subject: z.string().title('Ticket Subject').describe('Subject for the ticket'),
comment: z.string().title('Ticket Comment').describe('Comment for the ticket'),
requesterName: z.string().title('Requester Name').describe('Requester name'),
requesterEmail: z.string().title('Requester Email').describe('Requester email'),
ticketFormId: z
.string()
.regex(/^\d+$/, 'Must be a numeric ID')
.title('Ticket Form ID')
.describe('Ticket Form ID')
.optional(),
}),
},
output: {
schema: z.object({
ticket: ticketSchema.title('Ticket').describe('The created ticket object'),
}),
},
}
const getTicket = {
title: 'Get ticket',
description: 'Get Ticket by id.',
input: {
schema: z.object({
ticketId: z.string().title('Ticket ID').describe('The ID of the ticket'),
}),
},
output: {
schema: z.object({ ticket: ticketSchema.title('Ticket').describe('The retrieved ticket object') }),
},
}
const closeTicket = {
title: 'Close ticket',
description: 'Close a ticket by its id.',
input: {
schema: z.object({
ticketId: z.string().title('Ticket ID').describe('ID of the ticket to close'),
comment: z.string().optional().title('Closing Comment').describe('Closing comment'),
}),
},
output: {
schema: z.object({ ticket: ticketSchema.title('Ticket').describe('The closed ticket object') }),
},
}
const findCustomer = {
title: 'Find Customer',
description: 'Find a Customer in Zendesk',
input: {
schema: z.object({
query: z
.string()
.min(2)
.title('Search Query')
.describe('partial or full value of any user property, including name, email address, notes, or phone.'),
}),
},
output: {
schema: z.object({
customers: z.array(userSchema).title('Customers').describe('Array of customer objects matching the search query'),
}),
},
}
const listAgents = {
title: 'List Agents',
description: 'List agents',
input: {
schema: z.object({
isOnline: z
.boolean()
.optional()
.default(true)
.title('Is Online')
.describe('Only return agents that are currently online'),
}),
},
output: {
schema: z.object({
agents: z.array(userSchema).title('Agents').describe('Array of agent user objects'),
}),
},
}
const callApi = {
title: 'Call API',
description: 'Call Zendesk API',
input: {
schema: z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).title('HTTP Method').describe('HTTP Method'),
path: z.string().title('URL Path').describe('URL Path (https://<subdomain>.zendesk.com/api/v2/PATH)'),
headers: z.string().optional().title('Headers').describe('Headers (JSON)'),
params: z.string().optional().title('Query Params').describe('Query Params (JSON)'),
requestBody: z.string().optional().title('Request Body').describe('Request Body (JSON)'),
}),
},
output: {
schema: z.object({
status: z.number().title('Status Code').describe('HTTP response status code'),
headers: z.record(z.string()).title('Response Headers').describe('HTTP response headers as key-value pairs'),
data: z.record(z.string(), z.any()).title('Response Data').describe('Response body data'),
}),
},
}
const syncKb = {
title: 'Sync Knowledge Base',
description: 'Sync Zendesk knowledge base to bot knowledge base',
input: {
schema: z.object({
knowledgeBaseId: z
.string()
.title('Knowledge Base ID')
.describe('ID of the bot knowledge base you want to sync with'),
}),
},
output: {
schema: z.object({
success: z.boolean().title('Success').describe('Indicates whether the sync operation was successful'),
}),
},
}
export const actions = {
getTicket,
findCustomer,
createTicket,
closeTicket,
listAgents,
callApi,
syncKb,
} satisfies IntegrationDefinitionProps['actions']
@@ -0,0 +1,3 @@
import { IntegrationDefinitionProps } from '@botpress/sdk'
export const channels = undefined satisfies IntegrationDefinitionProps['channels']
@@ -0,0 +1,87 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
export { actions } from './actions'
export { channels } from './channels'
export const events = {
articlePublished: {
title: 'Article Published',
description: 'Triggered when an article is published',
schema: z.object({
articleId: z.string().title('Article ID').describe('The unique identifier of the published article'),
articleTitle: z.string().title('Article Title').describe('The title of the published article'),
}),
ui: {},
},
articleUnpublished: {
title: 'Article Unpublished',
description: 'Triggered when an article is unpublished',
schema: z.object({
articleId: z.string().title('Article ID').describe('The unique identifier of the unpublished article'),
}),
ui: {},
},
} satisfies IntegrationDefinitionProps['events']
export const configuration = {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({
syncKnowledgeBaseWithBot: z
.boolean()
.optional()
.title('Sync Knowledge Base With Bot')
.describe('Would you like to sync Zendesk Knowledge Base into Bot Knowledge Base?'),
knowledgeBaseId: z
.string()
.optional()
.title('Knowledge Base ID')
.describe('ID of the Knowledge Base you wish to synchronize with your Zendesk KB'),
ignoreNonHitlTickets: z
.boolean()
.optional()
.title('Ignore non-HITL tickets')
.describe('Ignore tickets that were not created by the startHitl action'),
}),
} satisfies IntegrationDefinitionProps['configuration']
export const states = {
subscriptionInfo: {
type: 'integration',
schema: z.object({
subscriptionId: z
.string()
.title('Subscription ID')
.describe('The unique identifier for the Zendesk webhook subscription'),
triggerIds: z
.array(z.string())
.title('Trigger IDs')
.describe('Array of trigger IDs associated with the subscription'),
}),
},
credentials: {
type: 'integration',
schema: z.object({
accessToken: z.string().optional().title('Access token').describe('The access token obtained by OAuth'),
subdomain: z.string().optional().title('Subdomain').describe('The bot subdomain'),
}),
},
} satisfies IntegrationDefinitionProps['states']
export const user = {
tags: {
id: {
title: 'User ID',
description: 'The unique identifier of the Zendesk user',
},
email: {
title: 'Email',
description: 'The email address of the Zendesk user',
},
role: {
title: 'Role',
description: 'The role of the Zendesk user (end-user, agent, or admin)',
},
},
} satisfies IntegrationDefinitionProps['user']
@@ -0,0 +1,145 @@
import { z } from '@botpress/sdk'
import { omit, pickBy } from 'lodash'
const requesterSchema = z.object({
name: z.string().optional().title('Name').describe('Requester name'),
email: z.string().optional().title('Email').describe('Requester email'),
})
export const ticketSchema = z.object({
id: z.number().title('ID').describe('Ticket ID'),
subject: z.string().title('Subject').describe('Ticket subject'),
description: z.string().title('Description').describe('Ticket description'),
status: z.enum(['new', 'open', 'pending', 'hold', 'solved', 'closed']).title('Status').describe('Ticket status'),
priority: z.enum(['low', 'normal', 'high', 'urgent']).nullable().title('Priority').describe('Ticket priority'),
requesterId: z.number().title('Requester ID').describe('ID of the requester'),
requester: requesterSchema.optional().title('Requester').describe('Requester information'),
ticketFormId: z
.string()
.nullable()
.optional()
.title('Ticket Form ID')
.describe('ID of the ticket form used when creating the ticket'),
assigneeId: z.number().nullable().title('Assignee ID').describe('ID of the assignee'),
createdAt: z.string().title('Created At').describe('Ticket creation date'),
updatedAt: z.string().title('Updated At').describe('Ticket last update date'),
tags: z.array(z.string()).title('Tags').describe('Ticket tags'),
externalId: z.string().nullable().title('External ID').describe('External ticket ID'),
comment: z.record(z.any()).optional().title('Comment').describe('Ticket comment'),
via: z
.object({ channel: z.string().optional().title('Channel').describe('Channel name') })
.optional()
.title('Via')
.describe('How the ticket was created'),
})
const _zdTicketSchema = ticketSchema.transform((data) => ({
...omit(data, ['requesterId', 'assigneeId', 'createdAt', 'updatedAt', 'externalId', 'ticketFormId']),
created_at: data.createdAt,
updated_at: data.updatedAt,
requester_id: data.requesterId,
assignee_id: data.assigneeId,
external_id: data.externalId,
ticket_form_id: data.ticketFormId != null ? parseInt(data.ticketFormId, 10) : data.ticketFormId,
}))
export type ZendeskTicket = z.output<typeof _zdTicketSchema>
export type Ticket = z.input<typeof ticketSchema>
export const transformTicket = (ticket: ZendeskTicket): Ticket => {
return {
...omit(ticket, ['requester_id', 'assignee_id', 'created_at', 'updated_at', 'external_id', 'ticket_form_id']),
requesterId: ticket.requester_id,
assigneeId: ticket.assignee_id,
createdAt: ticket.created_at,
updatedAt: ticket.updated_at,
externalId: ticket.external_id,
ticketFormId: ticket.ticket_form_id != null ? String(ticket.ticket_form_id) : null,
}
}
export const userSchema = z.object({
id: z.number().title('ID').describe('User ID'),
name: z.string().title('Name').describe('User name'),
email: z.string().title('Email').describe('User email'),
phone: z.string().nullable().optional().title('Phone').describe('User phone number'),
photo: z.string().nullable().optional().title('Photo').describe('User photo URL'),
remotePhotoUrl: z.string().nullable().optional().title('Remote Photo URL').describe('Remote photo URL'),
role: z.enum(['end-user', 'agent', 'admin']).title('Role').describe('User role'),
tags: z.array(z.string()).title('Tags').describe('User tags'),
createdAt: z.string().title('Created At').describe('User creation date'),
updatedAt: z.string().title('Updated At').describe('User last update date'),
externalId: z.string().nullable().title('External ID').describe('External user ID'),
userFields: z.record(z.string()).optional().title('User Fields').describe('Custom user fields'),
})
const _zdUserSchema = userSchema
.omit({ userFields: true })
.extend({
userFields: z.record(z.string().nullable()).optional(),
})
.transform((data) => ({
...omit(data, ['createdAt', 'updatedAt', 'externalId', 'userFields', 'remotePhotoUrl']),
created_at: data.createdAt,
updated_at: data.updatedAt,
external_id: data.externalId,
user_fields: data.userFields,
remote_photo_url: data.remotePhotoUrl,
}))
export type ZendeskUser = z.output<typeof _zdUserSchema>
export type User = z.input<typeof userSchema>
export const transformUser = (ticket: ZendeskUser): User => {
const userFields = ticket.user_fields
? (pickBy(ticket.user_fields, (value): value is string => value !== null) as Record<string, string>)
: undefined
return {
...omit(ticket, ['external_id', 'user_fields', 'created_at', 'updated_at', 'remote_photo_url']),
externalId: ticket.external_id,
userFields,
createdAt: ticket.created_at,
updatedAt: ticket.updated_at,
remotePhotoUrl: ticket.remote_photo_url,
}
}
export type ZendeskArticle = {
id: number
url: string
html_url: string
author_id: number
comments_disabled: boolean
draft: boolean
promoted: boolean
position: number
vote_sum: number
vote_count: number
section_id: number
created_at: string
updated_at: string
name: string
title: string
source_locale: string
locale: string
outdated: boolean
outdated_locales: string[]
edited_at: string
user_segment_id: number | null
permission_group_id: number
content_tag_ids: number[]
label_names: string[]
body: string
}
export type ZendeskWebhook = {
id: string
name: string
status: string
subscriptions: string[]
created_at: string
created_by: string
endpoint: string
http_method: string
request_format: string
}
@@ -0,0 +1,49 @@
import { getZendeskClient } from 'src/client'
import { ZendeskArticle } from 'src/definitions/schemas'
import { getUploadArticlePayload } from 'src/misc/utils'
import { ZendeskEvent } from 'src/webhookEvents'
import { Logger, Client, Context } from '.botpress'
export const articlePublished = async (props: {
event: ZendeskEvent
client: Client
ctx: Context
logger: Logger
}) => {
const { event, client: bpClient, ctx, logger } = props
if (ctx.configuration.syncKnowledgeBaseWithBot) {
const kbId = ctx.configuration.knowledgeBaseId
if (!kbId) {
logger.forBot().error('Knowledge base id was not provided')
return
}
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const response: { data: { article: ZendeskArticle } } = await zendeskClient.makeRequest({
method: 'get',
url: `/api/v2/help_center/articles/${event.detail.id}`,
})
const publishedArticle = response.data.article
if (!publishedArticle.body) {
logger.forBot().info(`Article "${publishedArticle.title}" is empty. Skipping...`)
return
}
const payload = getUploadArticlePayload({ kbId, article: publishedArticle })
await bpClient.uploadFile(payload)
logger.forBot().info(`Successfully uploaded published article "${publishedArticle.title}"`)
}
await bpClient.createEvent({
type: 'articlePublished',
payload: {
articleId: event.detail.id,
articleTitle: event.event.title,
},
})
}
@@ -0,0 +1,39 @@
import { ZendeskEvent } from 'src/webhookEvents'
import { Client, Logger, Context } from '.botpress'
export const articleUnpublished = async ({
event,
client,
logger,
ctx,
}: {
event: ZendeskEvent
client: Client
logger: Logger
ctx: Context
}) => {
if (ctx.configuration.syncKnowledgeBaseWithBot) {
const existingFiles = await client.listFiles({
tags: {
zendeskId: `${event.detail.id}`,
},
})
const existingFile = existingFiles.files[0]
if (!existingFile) {
logger.forBot().error('Article not found in the BP KB')
return
}
await client.deleteFile({ id: existingFile.id })
logger.forBot().info(`Successfully deleted unpublished article "${existingFile.tags?.title}"`)
}
await client.createEvent({
type: 'articleUnpublished',
payload: {
articleId: event.detail.id,
},
})
}
@@ -0,0 +1,54 @@
import * as sdk from '@botpress/sdk'
import type { TriggerPayload } from '../triggers'
import type * as bp from '.botpress'
export const retrieveHitlConversation = async ({
zendeskTrigger,
client,
ctx,
logger,
}: {
zendeskTrigger: TriggerPayload
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}) => {
if (!zendeskTrigger.externalId?.length) {
logger.forBot().debug('No external ID associated with the Zendesk ticket. Ignoring the ticket...', {
zendeskTicketId: zendeskTrigger.ticketId,
})
return
}
if (!ctx.configuration.ignoreNonHitlTickets) {
const { conversation } = await client.getOrCreateConversation({
channel: 'hitl',
tags: { id: zendeskTrigger.ticketId },
})
return conversation
}
try {
const { conversation } = await client.getConversation({ id: zendeskTrigger.externalId })
if (conversation.channel !== 'hitl') {
logger.forBot().debug('Ignoring the ticket since it was not created by the startHitl action', {
conversation,
zendeskTicketId: zendeskTrigger.ticketId,
})
return
}
return conversation
} catch (thrown: unknown) {
if (sdk.isApiError(thrown) && thrown.code === 404) {
logger.forBot().debug('Ignoring the ticket since it does not refer to a Botpress conversation', {
zendeskTicketId: zendeskTrigger.ticketId,
})
return
}
throw thrown
}
}
@@ -0,0 +1,103 @@
import { createOrUpdateUser } from '@botpress/common'
import _ from 'lodash'
import type { ZendeskClient } from '../client'
import type { TriggerPayload } from '../triggers'
import { retrieveHitlConversation } from './hitl-ticket-filter'
import * as bp from '.botpress'
const ON_BEHALF_REGEXP: RegExp = /!\*{3}|\*{3}!/
export const executeMessageReceived = async ({
zendeskClient,
zendeskTrigger,
client,
ctx,
logger,
}: {
zendeskClient: ZendeskClient
zendeskTrigger: TriggerPayload
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}) => {
const isSystemNotification = zendeskTrigger.currentUser.id === '-1'
if (isSystemNotification) {
logger.forBot().debug('Ignoring system notification message from Zendesk', {
zendeskTrigger,
})
return
}
const conversation = await retrieveHitlConversation({
zendeskTrigger,
client,
ctx,
logger,
})
if (!conversation) {
return
}
const isAlreadyDelivered = await _isMessageAlreadyDelivered({
conversationId: conversation.id,
zendeskCommentId: zendeskTrigger.commentId,
client,
})
if (isAlreadyDelivered) {
return
}
const { user } = await createOrUpdateUser({
client,
name: zendeskTrigger.currentUser.name,
pictureUrl: zendeskTrigger.currentUser.remote_photo_url,
tags: {
id: zendeskTrigger.currentUser.id,
email: zendeskTrigger.currentUser.email,
role: zendeskTrigger.currentUser.role,
},
discriminateByTags: ['id'],
})
if (!zendeskTrigger.currentUser.externalId?.length) {
await zendeskClient.updateUser(zendeskTrigger.currentUser.id, {
external_id: user.id,
})
}
let messageWithoutAuthor = _.trimStart(zendeskTrigger.comment, '-').trim()
const firstLine = messageWithoutAuthor.split('\n').at(0)
if (firstLine && ON_BEHALF_REGEXP.test(firstLine)) {
messageWithoutAuthor = messageWithoutAuthor.split('\n').slice(1).join('\n').trim()
}
await client.createMessage({
tags: { zendeskCommentId: zendeskTrigger.commentId },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text: messageWithoutAuthor },
})
}
const _isMessageAlreadyDelivered = async ({
conversationId,
zendeskCommentId,
client,
}: {
conversationId: string
zendeskCommentId: string
client: bp.Client
}): Promise<boolean> => {
if (!zendeskCommentId) {
return false
}
const { messages } = await client.listMessages({ conversationId, tags: { zendeskCommentId } })
return messages.length > 0
}
@@ -0,0 +1,49 @@
import { createOrUpdateUser } from '@botpress/common'
import type { TriggerPayload } from 'src/triggers'
import { retrieveHitlConversation } from './hitl-ticket-filter'
import * as bp from '.botpress'
export const executeTicketAssigned = async ({
zendeskTrigger,
client,
ctx,
logger,
}: {
zendeskTrigger: TriggerPayload
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}) => {
const conversation = await retrieveHitlConversation({
zendeskTrigger,
client,
ctx,
logger,
})
if (!conversation) {
return
}
const { agent } = zendeskTrigger
const { user } = await createOrUpdateUser({
client,
name: agent.name,
pictureUrl: agent.remote_photo_url,
tags: {
id: agent.id,
email: agent.email,
role: agent.role,
},
discriminateByTags: ['id'],
})
await client.createEvent({
type: 'hitlAssigned',
payload: {
conversationId: conversation.id,
userId: user.id,
},
})
}
@@ -0,0 +1,33 @@
import type { TriggerPayload } from 'src/triggers'
import { retrieveHitlConversation } from './hitl-ticket-filter'
import * as bp from '.botpress'
export const executeTicketSolved = async ({
zendeskTrigger,
client,
ctx,
logger,
}: {
zendeskTrigger: TriggerPayload
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}) => {
const conversation = await retrieveHitlConversation({
zendeskTrigger,
client,
ctx,
logger,
})
if (!conversation) {
return
}
await client.createEvent({
type: 'hitlStopped',
payload: {
conversationId: conversation.id,
},
})
}
+59
View File
@@ -0,0 +1,59 @@
import { getZendeskClient } from './client'
import { articlePublished } from './events/article-published'
import { articleUnpublished } from './events/article-unpublished'
import { executeMessageReceived } from './events/message-received'
import { executeTicketAssigned } from './events/ticket-assigned'
import { executeTicketSolved } from './events/ticket-solved'
import { oauthCallbackHandler } from './oauth'
import type { TriggerPayload } from './triggers'
import { ZendeskEvent } from './webhookEvents'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client: bpClient, logger }) => {
if (req.path.startsWith('/oauth')) {
return await oauthCallbackHandler({ req, ctx, client: bpClient, logger })
}
if (!req.body) {
logger.forBot().warn('Handler received an empty body')
return
}
if (req.path === '/article-event' && req.method === 'POST') {
const event: ZendeskEvent = JSON.parse(req.body)
logger.forBot().info('Received event of type: ' + event.type)
switch (event.type) {
case 'zen:event-type:article.published':
await articlePublished({ event, client: bpClient, ctx, logger })
break
case 'zen:event-type:article.unpublished':
await articleUnpublished({ event, client: bpClient, ctx, logger })
break
default:
logger.forBot().warn('Unsupported event type: ' + event.type)
break
}
return
}
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const trigger = JSON.parse(req.body)
const zendeskTrigger = trigger as TriggerPayload
switch (zendeskTrigger.type) {
case 'newMessage':
return await executeMessageReceived({ zendeskClient, zendeskTrigger, client: bpClient, ctx, logger })
case 'ticketAssigned':
return await executeTicketAssigned({ zendeskTrigger, client: bpClient, ctx, logger })
case 'ticketSolved':
await executeMessageReceived({ zendeskClient, zendeskTrigger, client: bpClient, ctx, logger })
return await executeTicketSolved({ zendeskTrigger, client: bpClient, ctx, logger })
default:
logger.forBot().warn('Unsupported trigger type: ' + zendeskTrigger.type)
break
}
}
+16
View File
@@ -0,0 +1,16 @@
import { reporting } from '@botpress/sdk-addons'
import actions from './actions'
import channels from './channels'
import { handler } from './handler'
import { register, unregister } from './setup'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
actions,
channels,
handler,
})
export default reporting.wrapIntegration(integration)
@@ -0,0 +1,76 @@
import axios from 'axios'
import http from 'http'
import * as bp from '.botpress'
type AxiosSummaryErrorProps = {
request: {
method: string
url: string
} | null
response: {
status: number
statusText: string
data: unknown
} | null
}
class AxiosSummaryError extends Error {
public constructor(props: AxiosSummaryErrorProps) {
const { request, response } = props
const messageLines = ['Zendesk API error']
if (request) {
messageLines.push(`Request: ${request.method} ${request.url}`)
}
if (response) {
messageLines.push(`Response: ${response.status} (${response.statusText}) ${JSON.stringify(response.data)}`)
}
const message = messageLines.map((l) => `\n ${l}`).join('')
super(message)
}
}
/**
* Axios Requests are too verbose and can pollute logs.
* This function summarizes the error for better readability.
*/
export const summarizeAxiosError = (thrown: unknown, logger: bp.Logger): never => {
if (!axios.isAxiosError(thrown)) {
throw thrown
}
const { request, response } = thrown
let parsedRequest: AxiosSummaryErrorProps['request'] = null
try {
if (request) {
// for some reason request is not properly typed in axios error
const req = request as http.ClientRequest
const method = req.method
const url = `${req.protocol}//${req.host}${req.path}`
parsedRequest = {
method,
url,
}
}
} catch {}
let parsedResponse: AxiosSummaryErrorProps['response'] = null
if (response) {
const status = response.status
const statusText = response.statusText
const data = response.data
parsedResponse = {
status,
statusText,
data,
}
}
logger.forBotOnly().error('Zendesk API request failed', { request: parsedRequest, response: parsedResponse })
throw new AxiosSummaryError({
request: parsedRequest,
response: parsedResponse,
})
}
@@ -0,0 +1,61 @@
import { getZendeskClient } from 'src/client'
import { ZendeskArticle } from 'src/definitions/schemas'
import { getUploadArticlePayload } from 'src/misc/utils'
import { Client, Context, Logger } from '.botpress'
export const uploadArticlesToKb = async (props: { ctx: Context; client: Client; logger: Logger; kbId: string }) => {
const { ctx, client: bpClient, logger, kbId } = props
logger.forBot().info('Attempting to sync Zendesk KB to BP KB')
const fetchedArticles: ZendeskArticle[] = []
try {
const fetchArticles = async (url: string) => {
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const response: { data: { articles: ZendeskArticle[]; next_page?: string } } = await zendeskClient.makeRequest({
method: 'get',
url,
})
const { articles, next_page } = response.data
fetchedArticles.push(...articles)
if (next_page) {
await fetchArticles(next_page)
}
}
await fetchArticles('/api/v2/help_center/articles')
} catch (error) {
logger
.forBot()
.error(`Error fetching Zendesk articles: ${error instanceof Error ? error.message : 'An unknown error occurred'}`)
return
}
try {
for (const article of fetchedArticles) {
if (article.draft || !article.body) {
logger.forBot().info(`Article "${article.title}" is either unpublished or empty. Skipping...`)
continue
}
const payload = getUploadArticlePayload({ kbId, article })
await bpClient.uploadFile(payload)
logger.forBot().info(`Successfully uploaded article ${article.title} to BP KB`)
}
} catch (error) {
logger
.forBot()
.error(
`Error uploading article to BP KB: ${error instanceof Error ? error.message : 'An unknown error occurred'}`
)
logger.forBot().error(JSON.stringify(error))
return
}
logger.forBot().info('Successfully synced Zendesk KB to BP KB')
}
+40
View File
@@ -0,0 +1,40 @@
import { ZendeskArticle } from 'src/definitions/schemas'
import * as bp from '.botpress'
export const stringifyProperties = (obj: Record<string, any>) => {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = JSON.stringify(value)
}
return result
}
export const getUploadArticlePayload = ({ kbId, article }: { kbId: string; article: ZendeskArticle }) => {
const { body, id, title, locale, label_names } = article
return {
key: `${kbId}/${id}.html`,
accessPolicies: [],
content: body,
index: true,
tags: {
source: 'knowledge-base',
kbId,
title,
locale,
labels: label_names.join(' '),
zendeskId: `${id}`,
},
}
}
export const deleteKbArticles = async (kbId: string, client: bp.Client): Promise<void> => {
const { files } = await client.listFiles({
tags: {
kbId,
},
})
for (const file of files) {
await client.deleteFile({ id: file.id })
}
}
+22
View File
@@ -0,0 +1,22 @@
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'
export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth endpoint',
}
}
try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth registration Error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,204 @@
import { test, expect, describe } from 'vitest'
import { stripSubdomain } from './utils'
describe('stripSubdomain', () => {
test('full url https with -', () => {
expect(stripSubdomain('https://botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('full url http with -', () => {
expect(stripSubdomain('http://botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('subdomain with - with .zendesk.com', () => {
expect(stripSubdomain('botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('subdomain with -', () => {
expect(stripSubdomain('botpress-test-test')).toBe('botpress-test-test')
})
test('full url https with - (leading spaces)', () => {
expect(stripSubdomain(' https://botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('full url https with - (trailing spaces)', () => {
expect(stripSubdomain('https://botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('full url https with - (leading and trailing spaces)', () => {
expect(stripSubdomain(' https://botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('full url http with - (leading spaces)', () => {
expect(stripSubdomain(' http://botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('full url http with - (trailing spaces)', () => {
expect(stripSubdomain('http://botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('full url http with - (leading and trailing spaces)', () => {
expect(stripSubdomain(' http://botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('subdomain with - with .zendesk.com (leading spaces)', () => {
expect(stripSubdomain(' botpress-test-test.zendesk.com')).toBe('botpress-test-test')
})
test('subdomain with - with .zendesk.com (trailing spaces)', () => {
expect(stripSubdomain('botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('subdomain with - with .zendesk.com (leading and trailing spaces)', () => {
expect(stripSubdomain(' botpress-test-test.zendesk.com ')).toBe('botpress-test-test')
})
test('subdomain with - (leading spaces)', () => {
expect(stripSubdomain(' botpress-test-test')).toBe('botpress-test-test')
})
test('subdomain with - (trailing spaces)', () => {
expect(stripSubdomain('botpress-test-test ')).toBe('botpress-test-test')
})
test('subdomain with - (leading and trailing spaces)', () => {
expect(stripSubdomain(' botpress-test-test ')).toBe('botpress-test-test')
})
test('full url https with .', () => {
expect(stripSubdomain('https://botpress........zendesk.com')).toBe('botpress.......')
})
test('full url http with .', () => {
expect(stripSubdomain('http://botpress........zendesk.com')).toBe('botpress.......')
})
test('subdomain with . with .zendesk.com', () => {
expect(stripSubdomain('botpress........zendesk.com')).toBe('botpress.......')
})
test('subdomain with .', () => {
expect(stripSubdomain('botpress.......')).toBe('botpress.......')
})
test('full url https with . (leading spaces)', () => {
expect(stripSubdomain(' https://botpress........zendesk.com')).toBe('botpress.......')
})
test('full url https with . (trailing spaces)', () => {
expect(stripSubdomain('https://botpress........zendesk.com ')).toBe('botpress.......')
})
test('full url https with . (leading and trailing spaces)', () => {
expect(stripSubdomain(' https://botpress........zendesk.com ')).toBe('botpress.......')
})
test('full url http with . (leading spaces)', () => {
expect(stripSubdomain(' http://botpress........zendesk.com')).toBe('botpress.......')
})
test('full url http with . (trailing spaces)', () => {
expect(stripSubdomain('http://botpress........zendesk.com ')).toBe('botpress.......')
})
test('full url http with . (leading and trailing spaces)', () => {
expect(stripSubdomain(' http://botpress........zendesk.com ')).toBe('botpress.......')
})
test('subdomain with . with .zendesk.com (leading spaces)', () => {
expect(stripSubdomain(' botpress........zendesk.com')).toBe('botpress.......')
})
test('subdomain with . with .zendesk.com (trailing spaces)', () => {
expect(stripSubdomain('botpress........zendesk.com ')).toBe('botpress.......')
})
test('subdomain with . with .zendesk.com (leading and trailing spaces)', () => {
expect(stripSubdomain(' botpress........zendesk.com ')).toBe('botpress.......')
})
test('subdomain with . (leading spaces)', () => {
expect(stripSubdomain(' botpress.......')).toBe('botpress.......')
})
test('subdomain with . (trailing spaces)', () => {
expect(stripSubdomain('botpress....... ')).toBe('botpress.......')
})
test('subdomain with . (leading and trailing spaces)', () => {
expect(stripSubdomain(' botpress....... ')).toBe('botpress.......')
})
test('full url https with symbols', () => {
expect(stripSubdomain('https://!@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('full url http with symbols', () => {
expect(stripSubdomain('http://!@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols with .zendesk.com', () => {
expect(stripSubdomain('!@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols', () => {
expect(stripSubdomain('!@#$%^&*()_+~')).toBe('!@#$%^&*()_+~')
})
test('full url https with symbols (leading spaces)', () => {
expect(stripSubdomain(' https://!@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('full url https with symbols (trailing spaces)', () => {
expect(stripSubdomain('https://!@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('full url https with symbols (leading and trailing spaces)', () => {
expect(stripSubdomain(' https://!@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('full url http with symbols (leading spaces)', () => {
expect(stripSubdomain(' http://!@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('full url http with symbols (trailing spaces)', () => {
expect(stripSubdomain('http://!@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('full url http with symbols (leading and trailing spaces)', () => {
expect(stripSubdomain(' http://!@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols with .zendesk.com (leading spaces)', () => {
expect(stripSubdomain(' !@#$%^&*()_+~.zendesk.com')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols with .zendesk.com (trailing spaces)', () => {
expect(stripSubdomain('!@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols with .zendesk.com (leading and trailing spaces)', () => {
expect(stripSubdomain(' !@#$%^&*()_+~.zendesk.com ')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols (leading spaces)', () => {
expect(stripSubdomain(' !@#$%^&*()_+~')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols (trailing spaces)', () => {
expect(stripSubdomain('!@#$%^&*()_+~ ')).toBe('!@#$%^&*()_+~')
})
test('subdomain with symbols (leading and trailing spaces)', () => {
expect(stripSubdomain(' !@#$%^&*()_+~ ')).toBe('!@#$%^&*()_+~')
})
test('full url https with all', () => {
expect(stripSubdomain('https://abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('full url http with all', () => {
expect(stripSubdomain('http://abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('subdomain with all with .zendesk.com', () => {
expect(stripSubdomain('abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('subdomain with all', () => {
expect(stripSubdomain('abc.123-!@#')).toBe('abc.123-!@#')
})
test('full url https with all (leading spaces)', () => {
expect(stripSubdomain(' https://abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('full url https with all (trailing spaces)', () => {
expect(stripSubdomain('https://abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('full url https with all (leading and trailing spaces)', () => {
expect(stripSubdomain(' https://abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('full url http with all (leading spaces)', () => {
expect(stripSubdomain(' http://abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('full url http with all (trailing spaces)', () => {
expect(stripSubdomain('http://abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('full url http with all (leading and trailing spaces)', () => {
expect(stripSubdomain(' http://abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('subdomain with all with .zendesk.com (leading spaces)', () => {
expect(stripSubdomain(' abc.123-!@#.zendesk.com')).toBe('abc.123-!@#')
})
test('subdomain with all with .zendesk.com (trailing spaces)', () => {
expect(stripSubdomain('abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('subdomain with all with .zendesk.com (leading and trailing spaces)', () => {
expect(stripSubdomain(' abc.123-!@#.zendesk.com ')).toBe('abc.123-!@#')
})
test('subdomain with all (leading spaces)', () => {
expect(stripSubdomain(' abc.123-!@#')).toBe('abc.123-!@#')
})
test('subdomain with all (trailing spaces)', () => {
expect(stripSubdomain('abc.123-!@# ')).toBe('abc.123-!@#')
})
test('subdomain with all (leading and trailing spaces)', () => {
expect(stripSubdomain(' abc.123-!@# ')).toBe('abc.123-!@#')
})
})
+14
View File
@@ -0,0 +1,14 @@
export const stripSubdomain = (input: string): string => {
const trimedInput = input.trim()
if (trimedInput.startsWith('https://') && trimedInput.endsWith('.zendesk.com')) {
return trimedInput.substring('https://'.length, trimedInput.length - '.zendesk.com'.length)
}
if (trimedInput.startsWith('http://') && trimedInput.endsWith('.zendesk.com')) {
return trimedInput.substring('http://'.length, trimedInput.length - '.zendesk.com'.length)
}
if (trimedInput.endsWith('.zendesk.com')) {
return trimedInput.substring(0, trimedInput.length - '.zendesk.com'.length)
}
return trimedInput
}
+246
View File
@@ -0,0 +1,246 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import * as sdk from '@botpress/sdk'
import axios from 'axios'
import { webcrypto } from 'crypto'
import { stripSubdomain } from './utils'
import * as bp from '.botpress'
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
export const handler = async (props: bp.HandlerProps) => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({
id: 'start',
handler: _startHandler,
})
.addStep({
id: 'get-subdomain',
handler: _getSubdomain,
})
.addStep({
id: 'validate-subdomain',
handler: _validateSubdomain,
})
.addStep({
id: 'reset',
handler: _resetHandler,
})
.addStep({
id: 'oauth-callback',
handler: _oauthCallbackHandler,
})
.addStep({
id: 'end',
handler: _endHandler,
})
.build()
const response = await wizard.handleRequest()
return response
}
const _startHandler: WizardHandler = async (props) => {
const { responses } = props
// When nothing is connected yet there's nothing to reset, so skip the
// confirmation and go straight to the next step.
if (!(await _isAlreadyConnected(props))) {
return _getSubdomain(props)
}
return responses.displayButtons({
pageTitle: 'Reset Configuration',
htmlOrMarkdownPageContents:
'This wizard will reset your configuration, so the bot will stop working on Zendesk until a new configuration is put in place, continue?',
buttons: [
{
action: 'navigate',
label: 'Yes',
navigateToStep: 'get-subdomain',
buttonType: 'primary',
},
{
action: 'close',
label: 'No',
buttonType: 'secondary',
},
],
})
}
const _isAlreadyConnected = async ({ client, ctx }: bp.HandlerProps): Promise<boolean> => {
try {
const result = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
return Boolean(result?.state?.payload?.accessToken)
} catch {
return false
}
}
const _getSubdomain: WizardHandler = async (props) => {
const { responses } = props
return responses.displayInput({
pageTitle: 'Get Zendesk Subdomain',
htmlOrMarkdownPageContents: "To continue, you need to enter your Zendesk's subdomain",
input: { label: 'e.g. https://{subdomain}.zendesk.com', type: 'text' },
nextStepId: 'validate-subdomain',
})
}
const _validateSubdomain: WizardHandler = async (props) => {
const { client, ctx, responses, inputValue } = props
if (inputValue === undefined) {
throw new sdk.RuntimeError('The subdomain given was empty')
}
const subdomain = stripSubdomain(inputValue)
await _patchCredentialsState(client, ctx, { accessToken: undefined, subdomain })
return responses.displayButtons({
pageTitle: 'Validate Zendesk Subdomain',
htmlOrMarkdownPageContents: `Is <strong>${subdomain}</strong> your Zendesk's subdomain?`,
buttons: [
{
action: 'navigate',
label: 'Yes',
navigateToStep: 'reset',
buttonType: 'primary',
},
{
action: 'navigate',
label: 'No',
navigateToStep: 'get-subdomain',
buttonType: 'secondary',
},
],
})
}
const _resetHandler: WizardHandler = async (props) => {
const { responses, client, ctx } = props
const {
state: {
payload: { subdomain },
},
} = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
if (!subdomain) {
throw new sdk.RuntimeError('The subdomain given was empty')
}
return responses.redirectToExternalUrl(
'https://' +
subdomain +
'.zendesk.com/oauth/authorizations/new?' +
'response_type=code' +
'&redirect_uri=' +
_getOAuthRedirectUri() +
'&state=' +
ctx.webhookId +
'&client_id=' +
bp.secrets.CLIENT_ID +
'&scope=read+write' +
'&code_challenge=' +
(await sha256(bp.secrets.CODE_CHALLENGE)) +
'&code_challenge_method=S256'
)
}
const _oauthCallbackHandler: WizardHandler = async (props) => {
const { responses, query, client, ctx, setIntegrationIdentifier } = props
const authorizationCode = query.get('code')
if (!authorizationCode) {
return responses.endWizard({
success: false,
errorMessage: 'Error extracting authorization code in OAuth callback',
})
}
const credentials = await _getCredentialsState(client, ctx)
const subdomain = credentials.subdomain
if (subdomain === undefined) {
throw new sdk.RuntimeError('The subdomain given was empty')
}
const accessToken = await _exchangeAuthorizationCodeForAccessToken(authorizationCode, subdomain)
const newCredentials = { ...credentials, accessToken }
await _patchCredentialsState(client, ctx, newCredentials)
setIntegrationIdentifier(ctx.webhookId)
return responses.redirectToStep('end')
}
const _endHandler: WizardHandler = ({ responses }) => {
return responses.endWizard({
success: true,
})
}
const sha256 = async (str: string) => {
const data = new TextEncoder().encode(str)
const hash = await webcrypto.subtle.digest('SHA-256', data)
return Buffer.from(hash).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
const _exchangeAuthorizationCodeForAccessToken = async (authorizationCode: string, subdomain: string) => {
const url = 'https://' + subdomain + '.zendesk.com/oauth/tokens'
const res = await axios.post(
url,
{
grant_type: 'authorization_code',
code: authorizationCode,
client_id: bp.secrets.CLIENT_ID,
redirect_uri: _getOAuthRedirectUri(),
scope: 'read write',
code_verifier: bp.secrets.CODE_CHALLENGE,
},
{
headers: {
'Content-Type': 'application/json',
},
}
)
const data = sdk.z
.object({
access_token: sdk.z.string(),
refresh_token: sdk.z.string(),
})
.parse(res.data)
return data.access_token
}
// client.patchState is not working correctly
const _patchCredentialsState = async (
client: bp.Client,
ctx: bp.Context,
newState: Partial<typeof bp.states.credentials>
) => {
const currentState = await _getCredentialsState(client, ctx)
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: {
...currentState,
...newState,
},
})
}
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context) => {
try {
return (
(
await client.getState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
})
)?.state?.payload || {}
)
} catch {
return {}
}
}
+167
View File
@@ -0,0 +1,167 @@
import { isApiError } from '@botpress/client'
import * as sdk from '@botpress/sdk'
import { getZendeskClient } from './client'
import { uploadArticlesToKb } from './misc/upload-articles-to-kb'
import { deleteKbArticles } from './misc/utils'
import { Triggers } from './triggers'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async (props) => {
const { client: bpClient, ctx, webhookUrl, logger } = props
try {
await _unsubscribeWebhooks(props)
} catch {
// silent catch since if it's the first time, there's nothing to unregister
}
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const subscriptionId = await zendeskClient
.subscribeWebhook(webhookUrl)
.catch(_throwRuntimeError('Failed to create webhook subscription'))
if (!subscriptionId) {
throw new sdk.RuntimeError('Failed to create webhook subscription')
}
await zendeskClient
.createArticleWebhook(webhookUrl, ctx.webhookId)
.catch(_throwRuntimeError('Failed to create article webhook'))
const user = await zendeskClient
.createOrUpdateUser({
role: 'end-user',
external_id: ctx.botUserId,
name: 'Botpress',
// FIXME: use a PNG image hosted on the Botpress CDN
remote_photo_url: 'https://app.botpress.dev/favicon/bp.svg',
})
.catch(_throwRuntimeError('Failed to create or update user'))
await bpClient
.updateUser({
id: ctx.botUserId,
pictureUrl: 'https://app.botpress.dev/favicon/bp.svg',
name: 'Botpress',
tags: {
id: `${user.id}`,
role: 'bot-user',
},
})
.catch(_throwRuntimeError('Failed updating user'))
const triggersCreated: string[] = []
try {
for (const trigger of Triggers) {
const triggerId = await zendeskClient.createTrigger(trigger.name, subscriptionId, trigger.conditions)
triggersCreated.push(triggerId)
}
} finally {
await bpClient
.setState({
type: 'integration',
id: ctx.integrationId,
name: 'subscriptionInfo',
payload: {
subscriptionId,
triggerIds: triggersCreated,
},
})
.catch(_throwRuntimeError('Failed setting state'))
}
if (ctx.configuration.syncKnowledgeBaseWithBot) {
if (!ctx.configuration.knowledgeBaseId) {
throw new sdk.RuntimeError('Knowledge base id was not provided')
}
await uploadArticlesToKb({ ctx, client: bpClient, logger, kbId: ctx.configuration.knowledgeBaseId }).catch(
_throwRuntimeError('Failed uploading articles to knowledge base')
)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async (props) => {
const { client: bpClient } = props
await bpClient.configureIntegration({ identifier: null })
await _unsubscribeWebhooks(props)
}
type RegisterOrUnregisterProps =
| Parameters<bp.IntegrationProps['register']>[number]
| Parameters<bp.IntegrationProps['unregister']>[number]
const _unsubscribeWebhooks = async (props: RegisterOrUnregisterProps) => {
const { ctx, client: bpClient, logger } = props
const zendeskClient = await getZendeskClient(bpClient, ctx, logger)
const { state } = await bpClient
.getState({
id: ctx.integrationId,
name: 'subscriptionInfo',
type: 'integration',
})
.catch((thrown) => {
if (isApiError(thrown) && thrown.type === 'ResourceNotFound') {
return { state: null }
}
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError(`Failed to get state : ${err.message}`)
})
if (state === null) {
logger.forBot().warn('Nothing to unregister')
return
}
if (state.payload.subscriptionId?.length) {
await zendeskClient
.unsubscribeWebhook(state.payload.subscriptionId)
.catch(_logError(logger, 'Failed to unsubscribe webhook'))
}
if (state.payload.triggerIds?.length) {
await Promise.all(
state.payload.triggerIds.map((trigger) =>
zendeskClient.deleteTrigger(trigger).catch(_logError(logger, `Failed to delete trigger : ${trigger}`))
)
)
}
const articleWebhooks = await zendeskClient
.findWebhooks({
'filter[name_contains]': `bpc_article_event_${ctx.webhookId}`,
})
.catch(_logError(logger, 'Failed to find webhooks'))
if (articleWebhooks) {
await Promise.all(
articleWebhooks.map((articleWebhook) =>
zendeskClient
.deleteWebhook(articleWebhook.id)
.catch(_logError(logger, `Failed to delete webhook : ${articleWebhook.name}`))
)
)
}
if (ctx.configuration.syncKnowledgeBaseWithBot) {
if (!ctx.configuration.knowledgeBaseId) {
logger.forBot().error('Knowledge base id was not provided')
return
}
await deleteKbArticles(ctx.configuration.knowledgeBaseId, bpClient).catch(
_logError(logger, 'Failed to delete knowledge base articles')
)
}
}
const _buildMessage = (outer: string, thrown: unknown) => {
const inner = (thrown instanceof Error ? thrown : new Error(String(thrown))).message
return inner ? `${outer} : ${inner}` : outer
}
const _throwRuntimeError = (outer: string) => (thrown: unknown) => {
throw new sdk.RuntimeError(_buildMessage(outer, thrown))
}
const _logError = (logger: sdk.IntegrationLogger, outer: string) => (thrown: unknown) => {
logger.forBot().error(_buildMessage(outer, thrown))
}
+85
View File
@@ -0,0 +1,85 @@
export type TriggerPayload = ReturnType<typeof getTriggerTemplate>
export const getTriggerTemplate = (name: TriggerNames) => ({
type: name,
agent: {
id: '{{ticket.assignee.id}}',
role: '{{ticket.assignee.role}}',
name: '{{ticket.assignee.name}}',
email: '{{ticket.assignee.email}}',
remote_photo_url: '{{ticket.assignee.remote_photo_url}}',
},
comment: '{{ticket.latest_public_comment_html}}',
commentId: '{{ticket.public_comments[0].id}}',
ticketId: '{{ticket.id}}',
externalId: '{{ticket.external_id}}',
status: '{{ticket.status}}',
currentUser: {
id: '{{current_user.id}}',
name: '{{current_user.name}}',
email: '{{current_user.email}}',
externalId: '{{current_user.external_id}}',
role: '{{current_user.role}}',
remote_photo_url: '{{current_user.remote_photo_url}}',
},
})
export type Condition = {
field: string
operator: string
value: string
}
export type ConditionsData = {
all: ReadonlyArray<Condition>
any: ReadonlyArray<Condition>
}
export const Triggers = [
{
name: 'ticketAssigned',
conditions: {
all: [],
any: [
{
field: 'assignee_id',
operator: 'changed',
value: '',
},
],
},
},
{
name: 'ticketSolved',
conditions: {
all: [
{
field: 'status',
operator: 'value',
value: 'SOLVED',
},
],
any: [],
},
},
{
name: 'newMessage',
conditions: {
all: [
{
field: 'comment_is_public',
operator: 'is',
value: 'requester_can_see_comment',
},
{
field: 'current_via_id',
operator: 'is_not',
value: '5',
},
],
any: [],
},
},
] as const satisfies ReadonlyArray<{ name: string; conditions: ConditionsData }>
export type TriggerNames = (typeof Triggers)[number]['name']
+15
View File
@@ -0,0 +1,15 @@
export type ZendeskEventType = 'zen:event-type:article.published' | 'zen:event-type:article.unpublished'
export type ZendeskEvent = {
account_id: number
detail: {
brand_id: string
id: string
}
event: Record<string, any>
id: string
subject: string
time: string
type: ZendeskEventType
zendesk_event_version: string
}