chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
to_string!(parse_json!(.body).data.item.admin_assignee_id)
|
||||
@@ -0,0 +1,10 @@
|
||||
The Intercom integration empowers your conversational AI by seamlessly integrating Botpress with Intercom's powerful customer messaging platform. Connect your AI-powered chatbot to Intercom and engage with your customers in real-time through personalized conversations. With this integration, you can automate customer support, handle inquiries, collect feedback, and nurture leads directly within Intercom. Leverage Intercom's features such as live chat, user segmentation, event tracking, and in-app messaging to deliver exceptional customer experiences. Streamline your customer communication and take your conversational AI to new heights with the Intercom Integration for Botpress.
|
||||
|
||||
## Migration from 1.x to 2.x
|
||||
|
||||
The Intercom integration now supports the `getOrCreateConversation` and `getOrCreateUser` actions, allowing you to proactively start conversations with Intercom users.
|
||||
|
||||
### Key notes
|
||||
|
||||
- New action cards: `getOrCreateConversation` and `getOrCreateUser`.
|
||||
- Made `adminId` optional within the manual configuration.
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M221.867 140.748a8.534 8.534 0 0 1-17.067 0V64a8.534 8.534 0 0 1 17.067 0v76.748zm-2.978 53.413c-1.319 1.129-32.93 27.655-90.889 27.655-57.958 0-89.568-26.527-90.887-27.656a8.535 8.535 0 0 1-.925-12.033 8.53 8.53 0 0 1 12.013-.942c.501.42 28.729 23.563 79.8 23.563 51.712 0 79.503-23.31 79.778-23.545 3.571-3.067 8.968-2.655 12.033.925a8.534 8.534 0 0 1-.923 12.033zM34.133 64A8.534 8.534 0 0 1 51.2 64v76.748a8.534 8.534 0 0 1-17.067 0V64zm42.668-17.067a8.534 8.534 0 0 1 17.066 0v114.001a8.534 8.534 0 0 1-17.066 0v-114zm42.666-4.318A8.532 8.532 0 0 1 128 34.082a8.532 8.532 0 0 1 8.534 8.533v123.733a8.534 8.534 0 0 1-17.067 0V42.615zm42.667 4.318a8.534 8.534 0 0 1 17.066 0v114.001a8.534 8.534 0 0 1-17.066 0v-114zM224 0H32C14.327 0 0 14.327 0 32v192c0 17.672 14.327 32 32 32h192c17.673 0 32-14.328 32-32V32c0-17.673-14.327-32-32-32z" fill="#1F8DED"/></svg>
|
||||
|
After Width: | Height: | Size: 990 B |
@@ -0,0 +1,130 @@
|
||||
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
|
||||
import proactiveConversation from 'bp_modules/proactive-conversation'
|
||||
import proactiveUser from 'bp_modules/proactive-user'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'intercom',
|
||||
version: '2.0.6',
|
||||
title: 'Intercom',
|
||||
description: 'Engage with customers in realtime with personalized messaging.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
configuration: {
|
||||
schema: z.object({
|
||||
accessToken: z.string().min(1).title('Access Token').describe('The access token of the Intercom app'),
|
||||
adminId: z.string().optional().title('Admin ID').describe('The admin ID of the Bot'),
|
||||
clientSecret: z
|
||||
.string()
|
||||
.secret()
|
||||
.optional()
|
||||
.title('Client Secret')
|
||||
.describe('The client secret of the Intercom app, used for event signature validation'),
|
||||
}),
|
||||
// TODO: Uncomment this once the Intercom app is approved
|
||||
// identifier: {
|
||||
// linkTemplateScript: 'linkTemplate.vrl',
|
||||
// required: true,
|
||||
// },
|
||||
// schema: z.object({
|
||||
// adminId: z.string().min(1).describe('The admin ID of the Bot'),
|
||||
// }),
|
||||
// },
|
||||
// configurations: {
|
||||
// manual: {
|
||||
// title: 'Manual Configuration',
|
||||
// description: 'Manual configuration, use your own Intercom app (for advanced use cases only)',
|
||||
// schema: z.object({
|
||||
// adminId: z.string().min(1).describe('The admin ID of the Bot'),
|
||||
// accessToken: z.string().min(1).describe('The access token of the Intercom app'),
|
||||
// clientSecret: z
|
||||
// .string()
|
||||
// .min(1)
|
||||
// .secret()
|
||||
// .describe(
|
||||
// 'The client secret of the Intercom app. Required for event signature validation, even if not authenticated by OAuth'
|
||||
// ),
|
||||
// }),
|
||||
// },
|
||||
// },
|
||||
},
|
||||
channels: {
|
||||
channel: {
|
||||
title: 'Intercom conversation',
|
||||
description: 'Channel for a Intercom conversation',
|
||||
messages: { ...messages.defaults, bloc: messages.markdownBloc },
|
||||
message: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Message ID',
|
||||
description: 'The Intercom message ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Conversation ID',
|
||||
description: 'The Intercom conversation ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
events: {},
|
||||
identifier: {
|
||||
extractScript: 'extract.vrl',
|
||||
},
|
||||
secrets: {
|
||||
CLIENT_ID: { description: "The Client ID in your app's basic informations" },
|
||||
CLIENT_SECRET: { description: "The Client secret in your app's basic informations" },
|
||||
},
|
||||
user: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'User ID',
|
||||
description: 'The Intercom user ID',
|
||||
},
|
||||
email: {
|
||||
title: 'User Email',
|
||||
description: 'The Intercom user email',
|
||||
},
|
||||
},
|
||||
},
|
||||
states: {
|
||||
credentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
adminId: z.string().min(1).title('Admin ID').describe('The admin ID of the Bot'),
|
||||
accessToken: z.string().min(1).title('Access Token').describe('The access token obtained from OAuth'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
user: {
|
||||
schema: z.object({
|
||||
id: z.string().min(1).title('User ID').describe('The ID of the Intercom user'),
|
||||
}),
|
||||
},
|
||||
conversation: {
|
||||
schema: z.object({
|
||||
id: z.string().min(1).title('Conversation ID').describe('The ID of the Intercom conversation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Customer Support',
|
||||
guideSlug: 'intercom',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
.extend(proactiveUser, ({ entities }) => ({
|
||||
entities: {
|
||||
user: entities.user,
|
||||
},
|
||||
}))
|
||||
.extend(proactiveConversation, ({ entities }) => ({
|
||||
entities: {
|
||||
conversation: entities.conversation,
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,10 @@
|
||||
env = to_string!(.env)
|
||||
webhookId = to_string!(.webhookId)
|
||||
|
||||
clientId = "5eba547d-60b2-4ca6-91bf-053a37bd7710"
|
||||
|
||||
if env == "production" {
|
||||
clientId = "f153fcea-a0db-48a0-8575-bbd2a7cfc05e"
|
||||
}
|
||||
|
||||
"https://app.intercom.com/oauth?client_id={{ clientId }}&state={{ webhookId }}"
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@botpresshub/intercom",
|
||||
"description": "Intercom integration for Botpress",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"axios": "^1.7.8",
|
||||
"intercom-client": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"proactive-user": "../../interfaces/proactive-user",
|
||||
"proactive-conversation": "../../interfaces/proactive-conversation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getOrCreateConversation } from './proactive-conversation'
|
||||
import { getOrCreateUser } from './proactive-user'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
getOrCreateConversation,
|
||||
getOrCreateUser,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { getAuthenticatedIntercomClient } from '../auth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getOrCreateConversation: bp.IntegrationProps['actions']['getOrCreateConversation'] = async (props) => {
|
||||
const { client, ctx, input } = props
|
||||
const { id: conversationId } = input.conversation
|
||||
if (!conversationId) {
|
||||
throw new RuntimeError('Conversation ID is required')
|
||||
}
|
||||
|
||||
const intercomClient = await getAuthenticatedIntercomClient(client, ctx)
|
||||
const chat = await intercomClient.conversations.find({ id: conversationId })
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: chat.id },
|
||||
})
|
||||
|
||||
return { conversationId: conversation.id }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { getAuthenticatedIntercomClient } from '../auth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async (props) => {
|
||||
const { client, ctx, input } = props
|
||||
const { id: userId } = input.user
|
||||
if (!userId) {
|
||||
throw new RuntimeError('User ID is required')
|
||||
}
|
||||
|
||||
const intercomClient = await getAuthenticatedIntercomClient(client, ctx)
|
||||
const { id, email } = await intercomClient.contacts.find({ id: userId })
|
||||
if (!id || !email) {
|
||||
throw new RuntimeError('User not found')
|
||||
}
|
||||
|
||||
const { user } = await client.getOrCreateUser({ tags: { id, email }, discriminateByTags: ['id'] })
|
||||
return { userId: user.id }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { RuntimeError, z } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { Client as IntercomClient } from 'intercom-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getAuthenticatedIntercomClient = async (client: bp.Client, ctx: bp.Context): Promise<IntercomClient> => {
|
||||
// TODO: Change null for 'manual' once the Intercom app is approved
|
||||
if (ctx.configurationType === null) {
|
||||
return new IntercomClient({ tokenAuth: { token: ctx.configuration.accessToken } })
|
||||
}
|
||||
|
||||
const { state } = await client.getState({
|
||||
id: ctx.integrationId,
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
})
|
||||
const { accessToken } = state.payload
|
||||
return new IntercomClient({ tokenAuth: { token: accessToken } })
|
||||
}
|
||||
|
||||
const saveAuthCredentials = async (
|
||||
client: bp.Client,
|
||||
ctx: bp.Context,
|
||||
{ accessToken, adminId }: { accessToken: string; adminId: string }
|
||||
): Promise<void> => {
|
||||
await client.setState({
|
||||
id: ctx.integrationId,
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
payload: { accessToken, adminId },
|
||||
})
|
||||
}
|
||||
|
||||
const exchangeCodeForAccessToken = async (code: string): Promise<string> => {
|
||||
const response = await axios.post('https://api.intercom.io/auth/eagle/token', {
|
||||
code,
|
||||
client_id: bp.secrets.CLIENT_ID,
|
||||
client_secret: bp.secrets.CLIENT_SECRET,
|
||||
})
|
||||
const responseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
const accessToken = responseSchema.safeParse(response.data).data?.access_token
|
||||
if (!accessToken) {
|
||||
throw new RuntimeError('Failed to exchange code for access token')
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export const getSignatureSecret = (ctx: bp.Context): string | undefined => {
|
||||
// TODO: Change null for 'manual' once the Intercom app is approved
|
||||
if (ctx.configurationType === null) {
|
||||
return ctx.configuration.clientSecret
|
||||
}
|
||||
return bp.secrets.CLIENT_SECRET
|
||||
}
|
||||
|
||||
export const handleOAuth = async ({ client, ctx, req, logger }: bp.HandlerProps): Promise<sdk.Response> => {
|
||||
logger.forBot().info('Handling OAuth callback')
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams(req.query)
|
||||
const error = searchParams.get('error')
|
||||
if (error) {
|
||||
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
|
||||
}
|
||||
|
||||
const code = searchParams.get('code')
|
||||
if (!code) {
|
||||
throw new Error('Authorization code not present in OAuth callback')
|
||||
}
|
||||
|
||||
const accessToken = await exchangeCodeForAccessToken(code)
|
||||
const adminId = await getAdminId(ctx)
|
||||
await saveAuthCredentials(client, ctx, { accessToken, adminId })
|
||||
await client.configureIntegration({ identifier: adminId })
|
||||
return generateRedirection(getInterstitialUrl(true))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const errorMessage = 'OAuth error: ' + msg
|
||||
logger.forBot().error(errorMessage)
|
||||
return generateRedirection(getInterstitialUrl(false, errorMessage))
|
||||
}
|
||||
}
|
||||
|
||||
const responseSchema = z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export const getAdminId = async (ctx: bp.Context): Promise<string> => {
|
||||
const response = await axios.get('https://api.intercom.io/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.configuration.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const parsedResponse = responseSchema.safeParse(response.data)
|
||||
if (!parsedResponse.success) {
|
||||
throw new RuntimeError('Failed to parse admin ID from response')
|
||||
}
|
||||
return parsedResponse.data.id
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { ReplyToConversationMessageType } from 'intercom-client'
|
||||
import { getAuthenticatedIntercomClient } from './auth'
|
||||
import * as html from './html.utils'
|
||||
import * as types from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type Card = bp.channels.channel.card.Card
|
||||
type Location = bp.channels.channel.location.Location
|
||||
|
||||
export const channels: bp.IntegrationProps['channels'] = {
|
||||
channel: {
|
||||
messages: {
|
||||
text: async ({ payload, conversation, ack, client, ctx }) => {
|
||||
await sendMessage({
|
||||
body: payload.text,
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
image: async ({ payload, client, ctx, conversation, ack }) => {
|
||||
await sendMessage({
|
||||
body: '',
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
attachmentUrls: [payload.imageUrl],
|
||||
})
|
||||
},
|
||||
audio: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
await sendMessage({
|
||||
body: '',
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
attachmentUrls: [payload.audioUrl],
|
||||
})
|
||||
},
|
||||
video: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
await sendMessage({
|
||||
body: '',
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
attachmentUrls: [payload.videoUrl],
|
||||
})
|
||||
},
|
||||
file: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
await sendMessage({
|
||||
body: '',
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
attachmentUrls: [payload.fileUrl],
|
||||
})
|
||||
},
|
||||
location: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
await sendMessage({
|
||||
body: formatGoogleMapLink(payload),
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
carousel: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
const carousel = payload.items.map((card) => createCard(card)).join('')
|
||||
|
||||
await sendMessage({
|
||||
body: carousel,
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
card: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
await sendMessage({
|
||||
body: createCard(payload),
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
dropdown: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
const choices = payload.options.map((choice) => html.li(choice.value))
|
||||
|
||||
const message = composeMessage(
|
||||
html.p(payload.text),
|
||||
html.p('Type one of the following options:'),
|
||||
choices.length > 0 ? html.ol(choices.join('')) : ''
|
||||
)
|
||||
|
||||
await sendMessage({
|
||||
body: message,
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
choice: async ({ client, ctx, conversation, ack, payload }) => {
|
||||
const choices = payload.options.map((choice) => html.li(choice.value))
|
||||
|
||||
const message = composeMessage(
|
||||
html.p(payload.text),
|
||||
html.p('Type one of the following options:'),
|
||||
choices.length > 0 ? html.ol(choices.join('')) : ''
|
||||
)
|
||||
|
||||
await sendMessage({
|
||||
body: message,
|
||||
conversation,
|
||||
client,
|
||||
ctx,
|
||||
ack,
|
||||
})
|
||||
},
|
||||
bloc: () => {
|
||||
throw new RuntimeError('Not implemented')
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
props: Pick<types.MessageHandlerProps, 'conversation' | 'client' | 'ctx' | 'ack'> & {
|
||||
body: string
|
||||
attachmentUrls?: string[]
|
||||
}
|
||||
) {
|
||||
const { body, attachmentUrls, client, ctx, conversation, ack } = props
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
const { adminId } = state.payload
|
||||
|
||||
const intercomClient = await getAuthenticatedIntercomClient(client, ctx)
|
||||
|
||||
const {
|
||||
conversation_parts: { conversation_parts: conversationParts },
|
||||
} = await intercomClient.conversations.replyByIdAsAdmin({
|
||||
id: conversation.tags.id ?? '',
|
||||
adminId,
|
||||
messageType: ReplyToConversationMessageType.COMMENT,
|
||||
body,
|
||||
attachmentUrls,
|
||||
})
|
||||
|
||||
const lastMessageId = conversationParts.at(-1)?.id
|
||||
await ack({ tags: { id: lastMessageId } })
|
||||
}
|
||||
|
||||
function composeMessage(...parts: string[]) {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function createCard({ title, subtitle, imageUrl, actions }: Card) {
|
||||
const image = imageUrl ? html.img(imageUrl) : ''
|
||||
const text = html.b(title) + html.p(subtitle ? subtitle : '')
|
||||
|
||||
const links = actions.filter((item) => item.action === 'url').map((item) => html.li(html.a(item.value, item.label)))
|
||||
|
||||
const choices = actions
|
||||
.filter((item) => item.action !== 'url')
|
||||
.map((item) => html.li(item.value))
|
||||
.join('')
|
||||
|
||||
return composeMessage(
|
||||
image,
|
||||
text,
|
||||
links.length > 0 ? html.ul(links.join('')) : '',
|
||||
html.p('Type one of the following options:'),
|
||||
html.ol(choices)
|
||||
)
|
||||
}
|
||||
|
||||
function formatGoogleMapLink(payload: Location) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}`
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { Request, z } from '@botpress/sdk'
|
||||
import * as crypto from 'crypto'
|
||||
import { getSignatureSecret, handleOAuth } from './auth'
|
||||
import * as html from './html.utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type IntercomMessage = z.infer<typeof conversationSourceSchema>
|
||||
|
||||
type VerifyResult =
|
||||
| { result: 'success'; isError: false; parsedNotification: z.infer<typeof webhookNotificationSchema> }
|
||||
| { result: 'error'; isError: true; message: string }
|
||||
| { result: 'ignore'; isError: false; message?: string }
|
||||
|
||||
const conversationSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
author: z.object({
|
||||
id: z.string(),
|
||||
email: z.string().nullable(),
|
||||
type: z.string(),
|
||||
}),
|
||||
body: z.string().nullable(),
|
||||
})
|
||||
|
||||
const conversationPartSchema = conversationSourceSchema.extend({
|
||||
type: z.literal('conversation_part'),
|
||||
})
|
||||
|
||||
const conversationSchema = z.object({
|
||||
type: z.literal('conversation'),
|
||||
admin_assignee_id: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((val) => (val ? val.toString() : null)),
|
||||
id: z.string(),
|
||||
source: conversationSourceSchema,
|
||||
conversation_parts: z.object({
|
||||
conversation_parts: z.array(conversationPartSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
const pingSchema = z.object({
|
||||
type: z.literal('ping'),
|
||||
})
|
||||
|
||||
const webhookNotificationSchema = z.object({
|
||||
type: z.literal('notification_event'),
|
||||
topic: z.string(),
|
||||
data: z.object({
|
||||
item: z.union([conversationSchema, pingSchema]),
|
||||
}),
|
||||
})
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
console.info('Handler received request')
|
||||
|
||||
const { req, client, ctx } = props
|
||||
if (req.path.startsWith('/oauth')) {
|
||||
return await handleOAuth(props)
|
||||
}
|
||||
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
const { adminId } = state.payload
|
||||
|
||||
const verifyResult = verifyRequest(req, ctx, adminId)
|
||||
if (verifyResult.isError) {
|
||||
throw new RuntimeError(`Invalid request received: ${verifyResult.message}`)
|
||||
} else if (verifyResult.result === 'ignore') {
|
||||
console.info(`Handler ignored request: ${verifyResult.message ?? 'Unknown reason'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const notification = verifyResult.parsedNotification
|
||||
if (notification.data.item.type === 'ping') {
|
||||
console.info('Handler received a ping event')
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
topic,
|
||||
data: {
|
||||
item: {
|
||||
id: conversationId,
|
||||
conversation_parts: { conversation_parts },
|
||||
source: firstConversationPart,
|
||||
},
|
||||
},
|
||||
} = notification
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: {
|
||||
id: conversationId,
|
||||
},
|
||||
})
|
||||
|
||||
// this uses the message payload from intercom to create the message in the bot
|
||||
const createMessage = async (intercomMessage: IntercomMessage) => {
|
||||
const {
|
||||
author: { id: authorId, email, type: authorType },
|
||||
body,
|
||||
id: messageId,
|
||||
} = intercomMessage
|
||||
|
||||
if (!messageId) {
|
||||
throw new Error('Handler received an empty message id')
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return // ignore no body messages
|
||||
}
|
||||
|
||||
if (authorType === 'bot') {
|
||||
console.info(`Handler received a bot message with id ${messageId}`)
|
||||
return // ignore bot messages
|
||||
}
|
||||
|
||||
const user = await getOrCreateUserAndUpdate(client, {
|
||||
id: authorId,
|
||||
email,
|
||||
})
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags: { id: messageId },
|
||||
type: 'text',
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
payload: { text: html.stripTags(body) },
|
||||
})
|
||||
}
|
||||
|
||||
if (topic === 'conversation.user.created') {
|
||||
await createMessage(firstConversationPart) // important, intercom keeps the first message in a separate object
|
||||
}
|
||||
|
||||
for (const part of conversation_parts) {
|
||||
await createMessage(part)
|
||||
}
|
||||
|
||||
console.info('Handler finished processing request')
|
||||
return
|
||||
}
|
||||
|
||||
function extractSignature(req: Request) {
|
||||
const signatureKv = req.headers['x-hub-signature']
|
||||
if (!signatureKv) {
|
||||
return undefined
|
||||
}
|
||||
const signature = signatureKv.split('=')[1]
|
||||
return signature
|
||||
}
|
||||
|
||||
function isSignatureValid(signature: string, body: string, secret: string) {
|
||||
const hash = crypto.createHmac('sha1', secret).update(body).digest('hex')
|
||||
return hash === signature
|
||||
}
|
||||
|
||||
function verifyRequest(req: Request, ctx: bp.Context, adminId: string): VerifyResult {
|
||||
if (!req.body) {
|
||||
return { result: 'error', isError: true, message: 'Handler received an empty body' }
|
||||
}
|
||||
const signature = extractSignature(req)
|
||||
const secret = getSignatureSecret(ctx)
|
||||
if (secret && (!signature || !isSignatureValid(signature, req.body, secret))) {
|
||||
return { result: 'error', isError: true, message: 'Handler received request with invalid signature' }
|
||||
}
|
||||
|
||||
let parsedJSON
|
||||
try {
|
||||
parsedJSON = JSON.parse(req.body)
|
||||
} catch {
|
||||
return { result: 'error', isError: true, message: 'Handler received an invalid JSON body' }
|
||||
}
|
||||
const parsedBody = webhookNotificationSchema.safeParse(parsedJSON)
|
||||
if (!parsedBody.success) {
|
||||
return { result: 'error', isError: true, message: `Handler received an invalid body: ${parsedBody.error}` }
|
||||
}
|
||||
|
||||
const parsedNotification = parsedBody.data
|
||||
if (parsedNotification.data.item.type === 'ping') {
|
||||
// No further validation for ping events
|
||||
return { result: 'success', isError: false, parsedNotification }
|
||||
}
|
||||
|
||||
const SUBSCRIBED_TOPICS = ['conversation.user.created', 'conversation.user.replied']
|
||||
if (!SUBSCRIBED_TOPICS.includes(parsedNotification.topic)) {
|
||||
return { result: 'ignore', isError: false, message: `Ignoring topic: ${parsedNotification.topic}` }
|
||||
}
|
||||
|
||||
if (adminId !== parsedNotification.data.item.admin_assignee_id) {
|
||||
// Ignore conversations not assigned to the bot
|
||||
return { result: 'ignore', isError: false, message: 'Ignoring conversations not assigned to the bot' }
|
||||
}
|
||||
|
||||
return { result: 'success', isError: false, parsedNotification }
|
||||
}
|
||||
|
||||
const getOrCreateUserAndUpdate = async (client: bp.Client, { id, email }: { id: string; email?: string | null }) => {
|
||||
let { user } = await client.getOrCreateUser({
|
||||
tags: { id },
|
||||
})
|
||||
if (email && email !== user.tags.email) {
|
||||
const updateResponse = await client.updateUser({
|
||||
id: user.id,
|
||||
tags: { email },
|
||||
})
|
||||
user = updateResponse.user
|
||||
}
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type HtmlTag = 'p' | 'b' | 'h1' | 'h2' | 'ul' | 'ol' | 'li' | 'pre' | 'code' | 'i' | 'em' | 'strong'
|
||||
|
||||
export function stripTags(str: string) {
|
||||
return str.replace(/<[^>]*>?/gm, '')
|
||||
}
|
||||
|
||||
export function ol(str: string) {
|
||||
return addHtmlTag(str, 'ol')
|
||||
}
|
||||
|
||||
export function li(str: string) {
|
||||
return addHtmlTag(str, 'li')
|
||||
}
|
||||
|
||||
export function ul(str: string) {
|
||||
return addHtmlTag(str, 'ul')
|
||||
}
|
||||
|
||||
export function p(str: string) {
|
||||
return addHtmlTag(str, 'p')
|
||||
}
|
||||
|
||||
export function b(str: string) {
|
||||
return addHtmlTag(str, 'b')
|
||||
}
|
||||
|
||||
export function h1(str: string) {
|
||||
return addHtmlTag(str, 'h1')
|
||||
}
|
||||
|
||||
export function h2(str: string) {
|
||||
return addHtmlTag(str, 'h2')
|
||||
}
|
||||
|
||||
export function img(src: string) {
|
||||
return `<img src="${src}" />`
|
||||
}
|
||||
|
||||
export function a(href: string, text: string) {
|
||||
return `<a href="${href}">${text}</a>`
|
||||
}
|
||||
|
||||
function addHtmlTag(str: string, tag: HtmlTag) {
|
||||
return `<${tag}>${str}</${tag}>`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { reporting } from '@botpress/sdk-addons'
|
||||
import { actions } from './actions'
|
||||
import { getAdminId } from './auth'
|
||||
import { channels } from './channels'
|
||||
import { handler } from './handler'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integration = new bp.Integration({
|
||||
register: async ({ client, ctx }) => {
|
||||
const adminId = ctx.configuration.adminId || (await getAdminId(ctx))
|
||||
|
||||
await client.setState({
|
||||
id: ctx.integrationId,
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
payload: { adminId, accessToken: ctx.configuration.accessToken },
|
||||
})
|
||||
await client.updateUser({
|
||||
id: ctx.botUserId,
|
||||
tags: { id: adminId },
|
||||
})
|
||||
},
|
||||
unregister: async () => {},
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
|
||||
export default reporting.wrapIntegration(integration)
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type Handler = bp.IntegrationProps['handler']
|
||||
export type HandlerProps = bp.HandlerProps
|
||||
export type IntegrationCtx = bp.Context
|
||||
export type Logger = bp.Logger
|
||||
|
||||
export type Client = bp.Client
|
||||
export type Conversation = bp.ClientResponses['getConversation']['conversation']
|
||||
export type Message = bp.ClientResponses['getMessage']['message']
|
||||
export type User = bp.ClientResponses['getUser']['user']
|
||||
export type Event = bp.ClientResponses['getEvent']['event']
|
||||
|
||||
export type EventDefinition = sdk.EventDefinition
|
||||
export type ActionDefinition = sdk.ActionDefinition
|
||||
export type ChannelDefinition = sdk.ChannelDefinition
|
||||
export type MessageDefinition = sdk.MessageDefinition
|
||||
|
||||
export type ActionProps = bp.AnyActionProps
|
||||
export type MessageHandlerProps = bp.AnyMessageProps
|
||||
export type AckFunction = bp.AnyAckFunction
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user