chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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'}`)
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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-!@#')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user