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
+1
View File
@@ -0,0 +1 @@
.botpress
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+43
View File
@@ -0,0 +1,43 @@
# Zendesk integration
Optimize your customer support workflow with the Zendesk integration for your
chatbot. Seamlessly manage tickets, engage customers, and access critical
information—all within your bot. Elevate your customer service game and improve
internal processes by triggering automations from real-time ticket updates.
> 🤝 **Usage with HITL (Human in the Loop)**
> If you intend to use the Zendesk integration with HITL,
> ensure that you have the HITL plugin installed.
## ⚠️ migrating from 2.x.x to 3.x.x
Zendesk is tightening its security requirements on January 12th, 2026.
From that date forward,
all third-party integrations must authenticate exclusively through OAuth.
Because the 2.x.x version of the Botpress Zendesk integration relies on API
tokens rather than OAuth, it will be deprecated.
The 3.x.x version introduces full OAuth support and is the required upgrade path.
What changes in 3.x.x?
- Authentication now uses Zendesk OAuth instead of API tokens.
- Existing API-token-based connections will stop working once Zendesk
enforces the new policy.
- The integration settings UI has been updated to support OAuth app credentials.
## Requirements
- A Zendesk account
### Knowledge Base Sync
1. Toggle the "Sync Knowledge Base With Bot" option to start syncing.
2. Enter the ID of the desired knowledge base where your Zendesk articles will
be stored.
3. Enable the integration to complete the setup.
Once these steps are completed,
your Zendesk articles will automatically sync to the specified
knowledge base in Botpress.
You can manually sync by using the "Sync KB" action.
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1393 1055.77" xmlns="http://www.w3.org/2000/svg" width="2500" height="1895"><path d="M643.51 278.74v777H0zm0-278.74c0 177.57-143.84 321.41-321.41 321.41S0 177.57 0 0zm106 1055.77c0-177.57 143.84-321.41 321.41-321.41s321.41 143.84 321.41 321.41zm0-278.74V0H1393z" fill="#03363d"/></svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1,104 @@
import * as sdk from '@botpress/sdk'
import hitl from './bp_modules/hitl'
import { actions, events, configuration, channels, states, user } from './src/definitions'
export default new sdk.IntegrationDefinition({
name: 'zendesk',
title: 'Zendesk',
version: '3.1.7',
icon: 'icon.svg',
description:
'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.',
readme: 'hub.md',
configuration,
states,
channels,
user,
actions,
events,
secrets: {
CLIENT_ID: {
description: 'The client ID of your app',
},
CLIENT_SECRET: {
description: 'The client secret of your app',
},
CODE_CHALLENGE: {
description: 'The code challenge for PKCE',
},
},
entities: {
hitlTicket: {
schema: sdk.z.object({
priority: sdk.z
.enum(['low', 'normal', 'high', 'urgent'])
.title('Ticket Priority')
.describe('Priority of the ticket. Leave empty for default priority.')
.optional(),
chatbotName: sdk.z
.string()
.title('Chatbot Name')
.describe('Name of the chatbot that will be used in the Zendesk ticket. Defaults to "Botpress".')
.optional(),
chatbotPhotoUrl: sdk.z
.string()
.title('Chatbot Photo URL')
.describe(
'Photo URL of the chatbot that will be used in the Zendesk ticket. Must be a publicly-accessible PNG image. Defaults to Botpress logo.'
)
.optional(),
requesterName: sdk.z
.string()
.title('Requester Name')
.describe('The name of the requester the bot was talking to. This will be set in zendesk.')
.optional(),
requesterEmail: sdk.z
.string()
.title('Requester Email')
.describe(
'⚠️This needs a requester name to work. The email of the requester the bot was talking to. This will be set in zendesk.'
)
.optional(),
ticketFormId: sdk.z
.string()
.regex(/^\d+$/, 'Must be a numeric ID')
.title('Ticket Form ID')
.describe(
'The ID of the ticket form to use when creating the ticket. This needs to be set up in Zendesk beforehand.'
)
.optional(),
}),
},
},
attributes: {
category: 'Customer Support',
guideSlug: 'zendesk',
repo: 'botpress',
},
}).extend(hitl, (self) => ({
entities: {
hitlSession: self.entities.hitlTicket,
},
channels: {
hitl: {
title: 'Zendesk Ticket',
description: 'Human in the loop channel for managing Zendesk tickets',
conversation: {
tags: {
id: {
title: 'Zendesk Ticket ID',
description: 'The unique identifier of the Zendesk ticket associated with this conversation',
},
},
},
message: {
tags: {
zendeskCommentId: {
title: 'Zendesk Comment ID',
description: 'The ID of the comment in Zendesk',
},
},
},
},
},
}))
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@botpresshub/zendesk",
"description": "Zendesk integration for Botpress",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"axios": "^1.4.0",
"axios-retry": "^4.5.0",
"lodash": "^4.17.21",
"preact": "^10.26.6",
"preact-render-to-string": "^6.5.13"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/hitl": "workspace:*",
"@types/lodash": "^4.14.191"
},
"bpDependencies": {
"hitl": "../../interfaces/hitl"
}
}
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": { "*": ["./*"] },
"outDir": "dist",
"types": ["preact"]
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config