diff --git a/integrations/zendesk/hub.md b/integrations/zendesk/hub.md index f28dc20d794..2e0b8276e2a 100644 --- a/integrations/zendesk/hub.md +++ b/integrations/zendesk/hub.md @@ -1,28 +1,43 @@ -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. +# 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. +> 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 -## Installation and Configuration +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. -1. Navigate to the Zendesk Admin Center. -2. Activate the Zendesk API feature. -3. Proceed to Settings and choose the option to Enable Token Access. -4. Incorporate your API token. For more details on this, refer to the [API Token Documentation](https://developer.zendesk.com/api-reference/introduction/security-and-auth/#api-token) +The 3.x.x version introduces full OAuth support and is the required upgrade path. -### Usage +What changes in 3.x.x? -For this integration, you'll require both a username and a password. Ensure you append /token to the end of the specified username. +- 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. -For instance: +## Requirements -Username: `jdoe@example.com/token` -Password: `API_TOKEN` +- 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. +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. +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. diff --git a/integrations/zendesk/integration.definition.ts b/integrations/zendesk/integration.definition.ts index fcde0a1620e..383ea2686c6 100644 --- a/integrations/zendesk/integration.definition.ts +++ b/integrations/zendesk/integration.definition.ts @@ -6,7 +6,7 @@ import { actions, events, configuration, channels, states, user } from './src/de export default new sdk.IntegrationDefinition({ name: 'zendesk', title: 'Zendesk', - version: '2.8.6', + version: '3.0.0', icon: 'icon.svg', description: 'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.', @@ -17,7 +17,18 @@ export default new sdk.IntegrationDefinition({ user, actions, events, - secrets: sentryHelpers.COMMON_SECRET_NAMES, + secrets: { + ...sentryHelpers.COMMON_SECRET_NAMES, + 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({ diff --git a/integrations/zendesk/linkTemplate.vrl b/integrations/zendesk/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/zendesk/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/zendesk/package.json b/integrations/zendesk/package.json index b2a744ae433..35afeeb03e2 100644 --- a/integrations/zendesk/package.json +++ b/integrations/zendesk/package.json @@ -1,5 +1,6 @@ { "name": "@botpresshub/zendesk", + "description": "Zendesk integration for Botpress", "scripts": { "check:type": "tsc --noEmit", "check:bplint": "bp lint", @@ -13,7 +14,9 @@ "@botpress/sdk-addons": "workspace:*", "axios": "^1.4.0", "axios-retry": "^4.5.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "preact": "^10.26.6", + "preact-render-to-string": "^6.5.13" }, "devDependencies": { "@botpress/cli": "workspace:*", diff --git a/integrations/zendesk/src/actions/call-api.ts b/integrations/zendesk/src/actions/call-api.ts index b53ff6c30a1..24e5b176806 100644 --- a/integrations/zendesk/src/actions/call-api.ts +++ b/integrations/zendesk/src/actions/call-api.ts @@ -5,10 +5,11 @@ import * as bp from '.botpress' export const callApi: bp.IntegrationProps['actions']['callApi'] = async ({ ctx, + client, input, }): Promise => { const { method, path, headers, params, requestBody } = input - const client = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(client, ctx) try { const requestConfig: AxiosRequestConfig = { @@ -23,7 +24,7 @@ export const callApi: bp.IntegrationProps['actions']['callApi'] = async ({ requestConfig.data = requestBody ? JSON.parse(requestBody) : {} } - return await client.makeRequest(requestConfig) + return await zendeskClient.makeRequest(requestConfig) } catch (error) { throw new sdk.RuntimeError(`Error: ${error instanceof Error ? error.message : 'An unknown error occurred'}`) } diff --git a/integrations/zendesk/src/actions/close-ticket.ts b/integrations/zendesk/src/actions/close-ticket.ts index 8598f265b37..7a53b7dbbaa 100644 --- a/integrations/zendesk/src/actions/close-ticket.ts +++ b/integrations/zendesk/src/actions/close-ticket.ts @@ -2,10 +2,11 @@ import { transformTicket } from 'src/definitions/schemas' import { getZendeskClient } from '../client' import * as bp from '.botpress' -export const closeTicket: bp.IntegrationProps['actions']['closeTicket'] = async ({ ctx, input }) => { - const originalTicket = await getZendeskClient(ctx.configuration).getTicket(input.ticketId) +export const closeTicket: bp.IntegrationProps['actions']['closeTicket'] = async ({ client: bpClient, ctx, input }) => { + const zendeskClient = await getZendeskClient(bpClient, ctx) + const originalTicket = await zendeskClient.getTicket(input.ticketId) - const { ticket } = await getZendeskClient(ctx.configuration).updateTicket(input.ticketId, { + const { ticket } = await zendeskClient.updateTicket(input.ticketId, { comment: { body: input.comment, author_id: originalTicket.requester_id, diff --git a/integrations/zendesk/src/actions/create-ticket.ts b/integrations/zendesk/src/actions/create-ticket.ts index 9bc2ef79efc..70adc984dd4 100644 --- a/integrations/zendesk/src/actions/create-ticket.ts +++ b/integrations/zendesk/src/actions/create-ticket.ts @@ -2,8 +2,12 @@ import { transformTicket } from 'src/definitions/schemas' import { getZendeskClient } from '../client' import * as bp from '.botpress' -export const createTicket: bp.IntegrationProps['actions']['createTicket'] = async ({ ctx, input }) => { - const zendeskClient = getZendeskClient(ctx.configuration) +export const createTicket: bp.IntegrationProps['actions']['createTicket'] = async ({ + client: bpClient, + ctx, + input, +}) => { + const zendeskClient = await getZendeskClient(bpClient, ctx) const ticket = await zendeskClient.createTicket(input.subject, input.comment, { name: input.requesterName, email: input.requesterEmail, diff --git a/integrations/zendesk/src/actions/create-user.ts b/integrations/zendesk/src/actions/create-user.ts index 2135bc9804a..a9b7974a62a 100644 --- a/integrations/zendesk/src/actions/create-user.ts +++ b/integrations/zendesk/src/actions/create-user.ts @@ -7,7 +7,7 @@ export const createUser: bp.IntegrationProps['actions']['createUser'] = async ({ input, logger, }) => { - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) const { name, email, pictureUrl } = input diff --git a/integrations/zendesk/src/actions/find-customer.ts b/integrations/zendesk/src/actions/find-customer.ts index 98836fbf479..5239a8f35be 100644 --- a/integrations/zendesk/src/actions/find-customer.ts +++ b/integrations/zendesk/src/actions/find-customer.ts @@ -2,7 +2,12 @@ import { transformUser } from 'src/definitions/schemas' import { getZendeskClient } from '../client' import * as bp from '.botpress' -export const findCustomer: bp.IntegrationProps['actions']['findCustomer'] = async ({ ctx, input }) => { - const customers = await getZendeskClient(ctx.configuration).findCustomers(input.query) +export const findCustomer: bp.IntegrationProps['actions']['findCustomer'] = async ({ + client: bpClient, + ctx, + input, +}) => { + const zendeskClient = await getZendeskClient(bpClient, ctx) + const customers = await zendeskClient.findCustomers(input.query) return { customers: customers.map(transformUser) } } diff --git a/integrations/zendesk/src/actions/get-ticket.ts b/integrations/zendesk/src/actions/get-ticket.ts index 4c37b6204a0..5fcd451cc47 100644 --- a/integrations/zendesk/src/actions/get-ticket.ts +++ b/integrations/zendesk/src/actions/get-ticket.ts @@ -2,7 +2,8 @@ import { transformTicket } from 'src/definitions/schemas' import { getZendeskClient } from '../client' import * as bp from '.botpress' -export const getTicket: bp.IntegrationProps['actions']['getTicket'] = async ({ ctx, input }) => { - const ticket = await getZendeskClient(ctx.configuration).getTicket(input.ticketId) +export const getTicket: bp.IntegrationProps['actions']['getTicket'] = async ({ client: bpClient, ctx, input }) => { + const zendeskClient = await getZendeskClient(bpClient, ctx) + const ticket = await zendeskClient.getTicket(input.ticketId) return { ticket: transformTicket(ticket) } } diff --git a/integrations/zendesk/src/actions/hitl.ts b/integrations/zendesk/src/actions/hitl.ts index c6b34f30120..ae6a67cf265 100644 --- a/integrations/zendesk/src/actions/hitl.ts +++ b/integrations/zendesk/src/actions/hitl.ts @@ -4,16 +4,16 @@ import { getZendeskClient, type ZendeskClient } from '../client' import * as bp from '.botpress' export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (props) => { - const { ctx, input, client } = props + const { ctx, input, client: bpClient } = props - const downstreamBotpressUser = await client.getUser({ id: ctx.botUserId }) + 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 client.getUser({ + const { user } = await bpClient.getUser({ id: input.userId, }) @@ -23,7 +23,7 @@ export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (pro throw new sdk.RuntimeError(`User ${user.id} not linked in Zendesk`) } - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) await _updateZendeskBotpressUser(props, { zendeskClient, chatbotName, @@ -42,7 +42,7 @@ export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (pro ) const zendeskTicketId = `${ticket.id}` - const { conversation } = await client.getOrCreateConversation({ + const { conversation } = await bpClient.getOrCreateConversation({ channel: 'hitl', tags: { id: zendeskTicketId, @@ -89,8 +89,8 @@ const _buildTicketBody = async ( return description + (messageHistory.length ? `\n\n---\n\n${messageHistory}` : '') } -export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async ({ ctx, input, client }) => { - const { conversation } = await client.getConversation({ +export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async ({ ctx, input, client: bpClient }) => { + const { conversation } = await bpClient.getConversation({ id: input.conversationId, }) @@ -99,7 +99,7 @@ export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async ({ ctx return {} } - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) try { await zendeskClient.updateTicket(ticketId, { diff --git a/integrations/zendesk/src/actions/list-agents.ts b/integrations/zendesk/src/actions/list-agents.ts index 09b95e7527d..dfec61189be 100644 --- a/integrations/zendesk/src/actions/list-agents.ts +++ b/integrations/zendesk/src/actions/list-agents.ts @@ -2,8 +2,9 @@ import { transformUser } from 'src/definitions/schemas' import { getZendeskClient } from '../client' import * as bp from '.botpress' -export const listAgents: bp.IntegrationProps['actions']['listAgents'] = async ({ ctx, input }) => { - const agents = await getZendeskClient(ctx.configuration).getAgents(input.isOnline) +export const listAgents: bp.IntegrationProps['actions']['listAgents'] = async ({ client: bpClient, ctx, input }) => { + const zendeskClient = await getZendeskClient(bpClient, ctx) + const agents = await zendeskClient.getAgents(input.isOnline) return { agents: agents.map(transformUser), diff --git a/integrations/zendesk/src/channels.ts b/integrations/zendesk/src/channels.ts index a616b19f0cb..3699fb2e878 100644 --- a/integrations/zendesk/src/channels.ts +++ b/integrations/zendesk/src/channels.ts @@ -35,7 +35,7 @@ const wrapChannel = bpCommon.createChannelWrapper()({ 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: ({ ctx }) => getZendeskClient(ctx.configuration), + zendeskClient: async ({ client, ctx }) => await getZendeskClient(client, ctx), }, }) diff --git a/integrations/zendesk/src/client.ts b/integrations/zendesk/src/client.ts index 761e2eb8151..d23abb73502 100644 --- a/integrations/zendesk/src/client.ts +++ b/integrations/zendesk/src/client.ts @@ -21,25 +21,24 @@ export type Trigger = { id: string } -const makeBaseUrl = (organizationDomain: string) => { +const _makeBaseUrl = (organizationDomain: string) => { return organizationDomain.startsWith('https') ? organizationDomain : `https://${organizationDomain}.zendesk.com` } -const makeUsername = (email: string) => { - return email.endsWith('/token') ? email : `${email}/token` -} - type AxiosRetryClient = Parameters[0] +type ZendeskConfig = { + type: 'OAuth' + accessToken: string + subdomain: string +} class ZendeskApi { private _client: AxiosInstance - public constructor(organizationDomain: string, email: string, password: string) { + public constructor(config: ZendeskConfig) { this._client = axios.create({ - baseURL: makeBaseUrl(organizationDomain), - withCredentials: true, - auth: { - username: makeUsername(email), - password, + baseURL: _makeBaseUrl(config.subdomain), + headers: { + Authorization: `Bearer ${config.accessToken}`, }, }) @@ -247,5 +246,16 @@ class ZendeskApi { export type ZendeskClient = InstanceType -export const getZendeskClient = (config: bp.configuration.Configuration): ZendeskApi => - new ZendeskApi(config.organizationSubdomain, config.email, config.apiToken) +export const getZendeskClient = async (client: bp.Client, ctx: bp.Context): Promise => { + 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 }) +} diff --git a/integrations/zendesk/src/definitions/index.ts b/integrations/zendesk/src/definitions/index.ts index 154d27f858c..f33993f81b3 100644 --- a/integrations/zendesk/src/definitions/index.ts +++ b/integrations/zendesk/src/definitions/index.ts @@ -24,18 +24,14 @@ export const events = { } satisfies IntegrationDefinitionProps['events'] export const configuration = { + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, schema: z.object({ - organizationSubdomain: z - .string() - .min(1) - .title('Organization Subdomain') - .describe('Your zendesk organization subdomain. e.g. botpress7281'), - email: z.string().email().title('Email').describe('Your zendesk account email. e.g. john.doe@botpress.com'), - apiToken: z.string().min(1).title('API Token').describe('Zendesk API Token'), syncKnowledgeBaseWithBot: z .boolean() .optional() - .title('Sync Knowledge Base') + .title('Sync Knowledge Base With Bot') .describe('Would you like to sync Zendesk Knowledge Base into Bot Knowledge Base?'), knowledgeBaseId: z .string() @@ -64,6 +60,13 @@ export const states = { .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 = { diff --git a/integrations/zendesk/src/events/article-published.ts b/integrations/zendesk/src/events/article-published.ts index 24951a53a0b..b63e60b2327 100644 --- a/integrations/zendesk/src/events/article-published.ts +++ b/integrations/zendesk/src/events/article-published.ts @@ -10,7 +10,7 @@ export const articlePublished = async (props: { ctx: Context logger: Logger }) => { - const { event, client, ctx, logger } = props + const { event, client: bpClient, ctx, logger } = props if (ctx.configuration.syncKnowledgeBaseWithBot) { const kbId = ctx.configuration.knowledgeBaseId @@ -20,7 +20,7 @@ export const articlePublished = async (props: { return } - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) const response: { data: { article: ZendeskArticle } } = await zendeskClient.makeRequest({ method: 'get', @@ -35,11 +35,11 @@ export const articlePublished = async (props: { } const payload = getUploadArticlePayload({ kbId, article: publishedArticle }) - await client.uploadFile(payload) + await bpClient.uploadFile(payload) logger.forBot().info(`Successfully uploaded published article "${publishedArticle.title}"`) } - await client.createEvent({ + await bpClient.createEvent({ type: 'articlePublished', payload: { articleId: event.detail.id, diff --git a/integrations/zendesk/src/handler.ts b/integrations/zendesk/src/handler.ts index d1011d51313..f3e5e131350 100644 --- a/integrations/zendesk/src/handler.ts +++ b/integrations/zendesk/src/handler.ts @@ -4,11 +4,16 @@ 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, logger }) => { +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 @@ -21,10 +26,10 @@ export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client switch (event.type) { case 'zen:event-type:article.published': - await articlePublished({ event, client, ctx, logger }) + await articlePublished({ event, client: bpClient, ctx, logger }) break case 'zen:event-type:article.unpublished': - await articleUnpublished({ event, client, ctx, logger }) + await articleUnpublished({ event, client: bpClient, ctx, logger }) break default: logger.forBot().warn('Unsupported event type: ' + event.type) @@ -34,18 +39,18 @@ export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client return } - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) const trigger = JSON.parse(req.body) const zendeskTrigger = trigger as TriggerPayload switch (zendeskTrigger.type) { case 'newMessage': - return await executeMessageReceived({ zendeskClient, zendeskTrigger, client, ctx, logger }) + return await executeMessageReceived({ zendeskClient, zendeskTrigger, client: bpClient, ctx, logger }) case 'ticketAssigned': - return await executeTicketAssigned({ zendeskTrigger, client, ctx, logger }) + return await executeTicketAssigned({ zendeskTrigger, client: bpClient, ctx, logger }) case 'ticketSolved': - await executeMessageReceived({ zendeskClient, zendeskTrigger, client, ctx, logger }) - return await executeTicketSolved({ zendeskTrigger, client, ctx, logger }) + 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) diff --git a/integrations/zendesk/src/misc/upload-articles-to-kb.ts b/integrations/zendesk/src/misc/upload-articles-to-kb.ts index c69ee9a7e54..3aca45bfc02 100644 --- a/integrations/zendesk/src/misc/upload-articles-to-kb.ts +++ b/integrations/zendesk/src/misc/upload-articles-to-kb.ts @@ -4,7 +4,7 @@ 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, logger, kbId } = props + const { ctx, client: bpClient, logger, kbId } = props logger.forBot().info('Attempting to sync Zendesk KB to BP KB') @@ -12,7 +12,7 @@ export const uploadArticlesToKb = async (props: { ctx: Context; client: Client; try { const fetchArticles = async (url: string) => { - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) const response: { data: { articles: ZendeskArticle[]; next_page?: string } } = await zendeskClient.makeRequest({ method: 'get', url, @@ -43,7 +43,7 @@ export const uploadArticlesToKb = async (props: { ctx: Context; client: Client; const payload = getUploadArticlePayload({ kbId, article }) - await client.uploadFile(payload) + await bpClient.uploadFile(payload) logger.forBot().info(`Successfully uploaded article ${article.title} to BP KB`) } diff --git a/integrations/zendesk/src/oauth/index.ts b/integrations/zendesk/src/oauth/index.ts new file mode 100644 index 00000000000..58909ec171e --- /dev/null +++ b/integrations/zendesk/src/oauth/index.ts @@ -0,0 +1,23 @@ +import { generateRedirection } from '@botpress/common/src/html-dialogs' +import { isOAuthWizardUrl, getInterstitialUrl } 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)) + } +} diff --git a/integrations/zendesk/src/oauth/wizard.ts b/integrations/zendesk/src/oauth/wizard.ts new file mode 100644 index 00000000000..de4509c5f42 --- /dev/null +++ b/integrations/zendesk/src/oauth/wizard.ts @@ -0,0 +1,194 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import * as sdk from '@botpress/sdk' +import axios from 'axios' +import { webcrypto } from 'crypto' +import * as bp from '.botpress' + +type WizardHandler = oauthWizard.WizardStepHandler + +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: '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 = (props) => { + const { responses } = 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 _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: 'reset', + }) +} + +const _resetHandler: WizardHandler = async (props) => { + const { responses, client, ctx, inputValue } = props + if (inputValue === undefined) { + throw new sdk.RuntimeError('The subdomain given was empty') + } + await _patchCredentialsState(client, ctx, { accessToken: undefined, subdomain: inputValue }) + return responses.redirectToExternalUrl( + 'https://' + + inputValue + + '.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 } = 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) + + await client.configureIntegration({ identifier: subdomain }) + + 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 +) => { + 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 {} + } +} diff --git a/integrations/zendesk/src/setup.ts b/integrations/zendesk/src/setup.ts index 08441e915a2..01c845d3220 100644 --- a/integrations/zendesk/src/setup.ts +++ b/integrations/zendesk/src/setup.ts @@ -6,14 +6,15 @@ import { deleteKbArticles } from './misc/utils' import { Triggers } from './triggers' import * as bp from '.botpress' -export const register: bp.IntegrationProps['register'] = async ({ client, ctx, webhookUrl, logger }) => { +export const register: bp.IntegrationProps['register'] = async (props) => { + const { client: bpClient, ctx, webhookUrl, logger } = props try { - await unregister({ ctx, client, webhookUrl, logger }) + await _unsubscribeWebhooks(props) } catch { // silent catch since if it's the first time, there's nothing to unregister } - const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskClient = await getZendeskClient(bpClient, ctx) const subscriptionId = await zendeskClient .subscribeWebhook(webhookUrl) .catch(_throwRuntimeError('Failed to create webhook subscription')) @@ -36,7 +37,7 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, w }) .catch(_throwRuntimeError('Failed to create or update user')) - await client + await bpClient .updateUser({ id: ctx.botUserId, pictureUrl: 'https://app.botpress.dev/favicon/bp.svg', @@ -56,7 +57,7 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, w triggersCreated.push(triggerId) } } finally { - await client + await bpClient .setState({ type: 'integration', id: ctx.integrationId, @@ -73,16 +74,26 @@ export const register: bp.IntegrationProps['register'] = async ({ client, ctx, w if (!ctx.configuration.knowledgeBaseId) { throw new sdk.RuntimeError('Knowledge base id was not provided') } - await uploadArticlesToKb({ ctx, client, logger, kbId: ctx.configuration.knowledgeBaseId }).catch( + 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 ({ ctx, client, logger }) => { - const zendeskClient = getZendeskClient(ctx.configuration) +export const unregister: bp.IntegrationProps['unregister'] = async (props) => { + const { client: bpClient } = props + await bpClient.configureIntegration({ identifier: null }) + await _unsubscribeWebhooks(props) +} + +type RegisterOrUnregisterProps = + | Parameters[number] + | Parameters[number] +const _unsubscribeWebhooks = async (props: RegisterOrUnregisterProps) => { + const { ctx, client: bpClient, logger } = props + const zendeskClient = await getZendeskClient(bpClient, ctx) - const { state } = await client + const { state } = await bpClient .getState({ id: ctx.integrationId, name: 'subscriptionInfo', @@ -136,7 +147,7 @@ export const unregister: bp.IntegrationProps['unregister'] = async ({ ctx, clien logger.forBot().error('Knowledge base id was not provided') return } - await deleteKbArticles(ctx.configuration.knowledgeBaseId, client).catch( + await deleteKbArticles(ctx.configuration.knowledgeBaseId, bpClient).catch( _logError(logger, 'Failed to delete knowledge base articles') ) } diff --git a/integrations/zendesk/tsconfig.json b/integrations/zendesk/tsconfig.json index 0c1062fd8ce..7f022b0c12e 100644 --- a/integrations/zendesk/tsconfig.json +++ b/integrations/zendesk/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", "baseUrl": ".", - "outDir": "dist" + "outDir": "dist", + "types": ["preact"] }, "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] } diff --git a/packages/cognitive/package.json b/packages/cognitive/package.json index 2939ff2e233..6c56be52393 100644 --- a/packages/cognitive/package.json +++ b/packages/cognitive/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cognitive", - "version": "0.3.2", + "version": "0.3.3", "description": "Wrapper around the Botpress Client to call LLMs", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/llmz/package.json b/packages/llmz/package.json index 15073cf4e0d..842e8358eff 100644 --- a/packages/llmz/package.json +++ b/packages/llmz/package.json @@ -72,7 +72,7 @@ }, "peerDependencies": { "@botpress/client": "1.28.0", - "@botpress/cognitive": "0.3.2", + "@botpress/cognitive": "0.3.3", "@bpinternal/thicktoken": "^1.0.5", "@bpinternal/zui": "1.3.1" }, diff --git a/packages/llmz/src/llmz.ts b/packages/llmz/src/llmz.ts index 894ee618356..c6f7b9084ab 100644 --- a/packages/llmz/src/llmz.ts +++ b/packages/llmz/src/llmz.ts @@ -444,10 +444,7 @@ const executeIteration = async ({ stopSequences: ctx.version.getStopTokens(), }) - const out = - output.output.choices?.[0]?.type === 'text' && typeof output.output.choices?.[0].content === 'string' - ? output.output.choices[0].content - : null + const out = typeof output.output.choices?.[0]?.content === 'string' ? output.output.choices[0].content : null if (!out) { throw new CognitiveError('LLM did not return any text output') diff --git a/packages/zai/package.json b/packages/zai/package.json index c63f2fa54ba..34a531c01d9 100644 --- a/packages/zai/package.json +++ b/packages/zai/package.json @@ -1,7 +1,7 @@ { "name": "@botpress/zai", "description": "Zui AI (zai) – An LLM utility library written on top of Zui and the Botpress API", - "version": "2.5.4", + "version": "2.5.5", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -32,7 +32,7 @@ "author": "", "license": "ISC", "dependencies": { - "@botpress/cognitive": "0.3.2", + "@botpress/cognitive": "0.3.3", "json5": "^2.2.3", "jsonrepair": "^3.10.0", "lodash-es": "^4.17.21", diff --git a/plugins/conversation-insights/package.json b/plugins/conversation-insights/package.json index 057ce09d28b..ab8767ce500 100644 --- a/plugins/conversation-insights/package.json +++ b/plugins/conversation-insights/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/cognitive": "0.3.2", + "@botpress/cognitive": "0.3.3", "@botpress/sdk": "workspace:*", "browser-or-node": "^2.1.1", "jsonrepair": "^3.10.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d550df8a2f..54ebf52722f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2119,6 +2119,12 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + preact: + specifier: ^10.26.6 + version: 10.26.6 + preact-render-to-string: + specifier: ^6.5.13 + version: 6.5.13(preact@10.26.6) devDependencies: '@botpress/cli': specifier: workspace:* @@ -2768,7 +2774,7 @@ importers: specifier: 1.28.0 version: link:../client '@botpress/cognitive': - specifier: 0.3.2 + specifier: 0.3.3 version: link:../cognitive '@bpinternal/thicktoken': specifier: ^1.0.5 @@ -2957,7 +2963,7 @@ importers: packages/zai: dependencies: '@botpress/cognitive': - specifier: 0.3.2 + specifier: 0.3.3 version: link:../cognitive '@bpinternal/thicktoken': specifier: ^1.0.0 @@ -3043,7 +3049,7 @@ importers: plugins/conversation-insights: dependencies: '@botpress/cognitive': - specifier: 0.3.2 + specifier: 0.3.3 version: link:../../packages/cognitive '@botpress/sdk': specifier: workspace:*