chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,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 }
}
+105
View File
@@ -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
}
+185
View File
@@ -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}`
}
+210
View File
@@ -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
}
+45
View File
@@ -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}>`
}
+29
View File
@@ -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)
+22
View File
@@ -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