chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import getOrCreateConversation from './proactive-conversation'
|
||||
import getOrCreateUser from './proactive-user'
|
||||
import startDmConversationFromComment from './start-dm-conversation-from-comment'
|
||||
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
startDmConversationFromComment,
|
||||
startTypingIndicator,
|
||||
stopTypingIndicator,
|
||||
getOrCreateConversation,
|
||||
getOrCreateUser,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const getOrCreateConversation: bp.IntegrationProps['actions']['getOrCreateConversation'] = async (props) => {
|
||||
const { client, ctx, input } = props
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
throw new RuntimeError('Starting a conversation is not supported in sandbox mode')
|
||||
}
|
||||
|
||||
const { userId, commentId } = input.conversation
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: userId, commentId },
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
|
||||
export default getOrCreateConversation
|
||||
@@ -0,0 +1,35 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { createAuthenticatedMessengerClient } from '../misc/messenger-client'
|
||||
import { tryGetUserProfile } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({ client, ctx, input }) => {
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
throw new RuntimeError('Creating a user is not supported in sandbox mode')
|
||||
}
|
||||
|
||||
const userId = input.user.id
|
||||
if (!userId) {
|
||||
throw new RuntimeError('User ID is required')
|
||||
}
|
||||
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
const profile = await tryGetUserProfile(messengerClient, ctx, userId)
|
||||
if (!profile) {
|
||||
throw new RuntimeError(
|
||||
'Could not fetch user profile from Messenger, make sure you are using a configuration with the necessary permissions or enable the "Get user profile" option.'
|
||||
)
|
||||
}
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: { id: profile.id },
|
||||
name: profile.name,
|
||||
pictureUrl: profile.profilePic,
|
||||
})
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
}
|
||||
}
|
||||
|
||||
export default getOrCreateUser
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { createAuthenticatedMessengerClient } from 'src/misc/messenger-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const startDmConversationFromComment: bp.IntegrationProps['actions']['startDmConversationFromComment'] = async (
|
||||
props
|
||||
) => {
|
||||
const { client, ctx, input, logger } = props
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
throw new RuntimeError('Starting a conversation is not supported in sandbox mode')
|
||||
}
|
||||
|
||||
const { commentId, message } = input
|
||||
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
|
||||
const { recipientId } = await messengerClient.sendText({ commentId }, message).catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to send Messenger message from comment ${commentId}: ${error.message}`)
|
||||
})
|
||||
|
||||
const { conversation } = await client
|
||||
.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: recipientId },
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get or create conversation for recipient ${recipientId}: ${error.message}`)
|
||||
})
|
||||
|
||||
await client
|
||||
.createMessage({
|
||||
origin: 'synthetic',
|
||||
conversationId: conversation.id,
|
||||
userId: ctx.botId,
|
||||
type: 'text',
|
||||
payload: { text: message },
|
||||
tags: { id: commentId },
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
logger.forBot().error(`Failed to create synthetic message from comment ${commentId}: ${error.message}`)
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
|
||||
export default startDmConversationFromComment
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MessengerTypes } from 'messaging-api-messenger'
|
||||
import { createAuthenticatedMessengerClient } from '../misc/messenger-client'
|
||||
import { getEndUserMessengerId } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async (props) => {
|
||||
await sendSenderActions({ props, actions: ['typing_on', 'mark_seen'] })
|
||||
return {}
|
||||
}
|
||||
|
||||
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async (props) => {
|
||||
await sendSenderActions({ props, actions: ['typing_off'] })
|
||||
return {}
|
||||
}
|
||||
|
||||
const sendSenderActions = async ({
|
||||
props: { client, ctx, input, logger },
|
||||
actions,
|
||||
}: {
|
||||
props: bp.ActionProps['startTypingIndicator'] | bp.ActionProps['stopTypingIndicator']
|
||||
actions: MessengerTypes.SenderAction[]
|
||||
}) => {
|
||||
const { conversationId } = input
|
||||
const { conversation } = await client.getConversation({ id: conversationId })
|
||||
|
||||
// Skip typing indicators for comment replies channel as they aren't available in Facebook comments
|
||||
if (conversation.channel !== 'channel') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
const userMessengerId = getEndUserMessengerId(conversation)
|
||||
for (const action of actions) {
|
||||
await messengerClient.sendSenderAction(userMessengerId, action).catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().debug(`Error sending sender action "${action}": ${error}`)
|
||||
})
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { MessengerTypes, MessengerClient } from 'messaging-api-messenger'
|
||||
import { createAuthenticatedMessengerClient } from '../misc/messenger-client'
|
||||
import {
|
||||
Card,
|
||||
Carousel,
|
||||
Choice,
|
||||
Dropdown,
|
||||
MessengerOutMessageAttachment,
|
||||
SendMessengerMessageProps,
|
||||
} from '../misc/types'
|
||||
import { getGoogleMapLinkFromLocation, getEndUserMessengerId } from '../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const channel: bp.IntegrationProps['channels']['channel'] = {
|
||||
messages: {
|
||||
text: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendText(recipient, props.payload.text)
|
||||
}),
|
||||
image: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendImage(recipient, props.payload.imageUrl)
|
||||
}),
|
||||
audio: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendAudio(recipient, props.payload.audioUrl)
|
||||
}),
|
||||
video: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendVideo(recipient, props.payload.videoUrl)
|
||||
}),
|
||||
file: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendFile(recipient, props.payload.fileUrl)
|
||||
}),
|
||||
location: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
const googleMapLink = getGoogleMapLinkFromLocation(props.payload)
|
||||
return messenger.sendText(recipient, googleMapLink)
|
||||
}),
|
||||
carousel: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendMessage(recipient, _getCarouselMessage(props.payload))
|
||||
}),
|
||||
card: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendMessage(recipient, _getCarouselMessage({ items: [props.payload] }))
|
||||
}),
|
||||
dropdown: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendMessage(recipient, _getChoiceMessage(props.payload))
|
||||
}),
|
||||
choice: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
return messenger.sendMessage(recipient, _getChoiceMessage(props.payload))
|
||||
}),
|
||||
bloc: async (props) =>
|
||||
_sendMessage(props, async (messenger, recipient) => {
|
||||
let lastMessageId = ''
|
||||
for (const item of props.payload.items) {
|
||||
try {
|
||||
if (item.type === 'text') {
|
||||
const { messageId } = await messenger.sendText(recipient, item.payload.text)
|
||||
lastMessageId = messageId
|
||||
} else if (item.type === 'image') {
|
||||
const { messageId } = await messenger.sendImage(recipient, item.payload.imageUrl)
|
||||
lastMessageId = messageId
|
||||
} else if (item.type === 'audio') {
|
||||
const { messageId } = await messenger.sendAudio(recipient, item.payload.audioUrl)
|
||||
lastMessageId = messageId
|
||||
} else if (item.type === 'video') {
|
||||
const { messageId } = await messenger.sendVideo(recipient, item.payload.videoUrl)
|
||||
lastMessageId = messageId
|
||||
} else if (item.type === 'file') {
|
||||
const { messageId } = await messenger.sendFile(recipient, item.payload.fileUrl)
|
||||
lastMessageId = messageId
|
||||
} else if (item.type === 'location') {
|
||||
const googleMapLink = getGoogleMapLinkFromLocation(item.payload)
|
||||
const { messageId } = await messenger.sendText(recipient, googleMapLink)
|
||||
lastMessageId = messageId
|
||||
} else {
|
||||
props.logger.forBot().warn(`Unsupported bloc item type: ${item.type}`)
|
||||
}
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
props.logger.forBot().error(`Failed to send bloc item of type "${item.type}": ${error.message}`)
|
||||
}
|
||||
}
|
||||
if (!lastMessageId) {
|
||||
throw new Error('No bloc items were successfully sent')
|
||||
}
|
||||
return { messageId: lastMessageId }
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export function formatCardElement(payload: Card) {
|
||||
const buttons: MessengerOutMessageAttachment[] = payload.actions.map((action) => {
|
||||
switch (action.action) {
|
||||
case 'postback':
|
||||
return {
|
||||
type: 'postback',
|
||||
title: action.label,
|
||||
payload: action.value,
|
||||
}
|
||||
case 'say':
|
||||
return {
|
||||
type: 'postback',
|
||||
title: action.label,
|
||||
payload: action.value,
|
||||
}
|
||||
case 'url':
|
||||
return {
|
||||
type: 'web_url',
|
||||
title: action.label,
|
||||
url: action.value,
|
||||
}
|
||||
default:
|
||||
throw new RuntimeError(`Unknown action type: ${action.action}`)
|
||||
}
|
||||
})
|
||||
return {
|
||||
title: payload.title,
|
||||
image_url: payload.imageUrl,
|
||||
subtitle: payload.subtitle,
|
||||
buttons,
|
||||
}
|
||||
}
|
||||
|
||||
async function _sendMessage(
|
||||
{ ack, client, ctx, conversation, logger, type, payload }: SendMessengerMessageProps,
|
||||
send: (client: MessengerClient, recipient: MessengerTypes.PsidOrRecipient) => Promise<{ messageId: string }>
|
||||
) {
|
||||
const commentId = payload.commentId
|
||||
let recipient: MessengerTypes.PsidOrRecipient
|
||||
if (commentId) {
|
||||
recipient = { commentId }
|
||||
} else {
|
||||
recipient = getEndUserMessengerId(conversation)
|
||||
}
|
||||
|
||||
logger
|
||||
.forBot()
|
||||
.debug(
|
||||
`Sending ${type} message ${commentId ? 'as private reply ' : ''}from bot to Messenger: ${_formatPayloadToStr(payload)}`
|
||||
)
|
||||
|
||||
try {
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
const { messageId } = await send(messengerClient, recipient)
|
||||
await ack({ tags: { id: messageId, commentId } })
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
const errorMessage = `Failed to send ${type} message to Messenger: ${error.message}`
|
||||
logger.forBot().error(errorMessage)
|
||||
}
|
||||
|
||||
if (commentId && conversation.tags.lastCommentId !== commentId) {
|
||||
await client.updateConversation({
|
||||
id: conversation.id,
|
||||
tags: {
|
||||
lastCommentId: commentId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function _formatPayloadToStr(payload: any): string {
|
||||
return Object.entries(payload)
|
||||
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function _getCarouselMessage(payload: Carousel): MessengerTypes.AttachmentMessage {
|
||||
return {
|
||||
attachment: {
|
||||
type: 'template',
|
||||
payload: {
|
||||
templateType: 'generic',
|
||||
elements: payload.items.map(formatCardElement),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function _getChoiceMessage(payload: Choice | Dropdown): MessengerTypes.TextMessage {
|
||||
if (!payload.options.length) {
|
||||
return { text: payload.text }
|
||||
}
|
||||
|
||||
if (payload.options.length > 13) {
|
||||
return {
|
||||
text: `${payload.text}\n\n${payload.options.map((o, idx) => `${idx + 1}. ${o.label}`).join('\n')}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: payload.text,
|
||||
quickReplies: payload.options.map((option) => ({
|
||||
contentType: 'text',
|
||||
title: option.label,
|
||||
payload: option.value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export default channel
|
||||
@@ -0,0 +1,56 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { createAuthenticatedFacebookClient } from '../misc/facebook-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const commentReplies: bp.IntegrationProps['channels']['commentReplies'] = {
|
||||
messages: {
|
||||
text: async (props) => {
|
||||
const { logger, conversation, payload, ctx, client, ack } = props
|
||||
const { id } = conversation.tags
|
||||
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
logger.forBot().error('Comment replies are not supported in sandbox mode')
|
||||
return
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
logger.forBot().error('Comment ID is required to reply to comments')
|
||||
return
|
||||
}
|
||||
|
||||
await _replyToComment({ id, message: payload.text, ctx, client, ack })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const _replyToComment = async ({
|
||||
id,
|
||||
message,
|
||||
ctx,
|
||||
client,
|
||||
ack,
|
||||
}: {
|
||||
id: string
|
||||
message: string
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
ack: bp.AnyAckFunction
|
||||
}) => {
|
||||
const facebookClient = await createAuthenticatedFacebookClient(ctx, client)
|
||||
try {
|
||||
const response = await facebookClient.replyToComment({
|
||||
commentId: id,
|
||||
message,
|
||||
})
|
||||
|
||||
// Update message tags with the new comment ID if ack is provided
|
||||
await ack({ tags: { id: response.id } })
|
||||
|
||||
return response
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to reply to comment ${id}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default commentReplies
|
||||
@@ -0,0 +1,8 @@
|
||||
import channel from './channel'
|
||||
import commentReplies from './comment-replies'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
channel,
|
||||
commentReplies,
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
@@ -0,0 +1,25 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import actions from './actions'
|
||||
import channels from './channels'
|
||||
import { register, unregister } from './setup'
|
||||
import { handler } from './webhook'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integrationConfig: bp.IntegrationProps = {
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
}
|
||||
|
||||
export default posthogHelper.wrapIntegration(
|
||||
{
|
||||
integrationName: INTEGRATION_NAME,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
rateLimitByFunction: { handler: 1 / 1000 },
|
||||
},
|
||||
integrationConfig
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import {
|
||||
MessengerClientCredentials,
|
||||
FacebookClientCredentials,
|
||||
MetaClientCredentials,
|
||||
MetaClientConfigType,
|
||||
} from './types'
|
||||
import * as bp from '.botpress'
|
||||
import { Oauth as OAuthState } from '.botpress/implementation/typings/states/oauth'
|
||||
|
||||
export async function getMessengerClientCredentials(
|
||||
client: bp.Client,
|
||||
ctx: bp.Context
|
||||
): Promise<MessengerClientCredentials> {
|
||||
let credentials: MessengerClientCredentials
|
||||
const clientSecret = getClientSecret(ctx)
|
||||
if (ctx.configurationType === 'manual') {
|
||||
credentials = {
|
||||
accessToken: ctx.configuration.accessToken,
|
||||
clientSecret,
|
||||
clientId: ctx.configuration.clientId,
|
||||
}
|
||||
} else if (ctx.configurationType === 'sandbox') {
|
||||
credentials = {
|
||||
accessToken: bp.secrets.SANDBOX_ACCESS_TOKEN,
|
||||
clientSecret,
|
||||
clientId: bp.secrets.SANDBOX_CLIENT_ID,
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
state: {
|
||||
payload: { pageToken },
|
||||
},
|
||||
} = await client.getState({ type: 'integration', name: 'oauth', id: ctx.integrationId })
|
||||
|
||||
if (!pageToken) {
|
||||
throw new RuntimeError('No page token found, please reauthorize')
|
||||
}
|
||||
|
||||
credentials = {
|
||||
accessToken: pageToken,
|
||||
clientSecret,
|
||||
clientId: bp.secrets.CLIENT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
return credentials
|
||||
}
|
||||
|
||||
export async function getFacebookClientCredentials(
|
||||
client: bp.Client,
|
||||
ctx: bp.Context
|
||||
): Promise<FacebookClientCredentials> {
|
||||
let credentials: FacebookClientCredentials
|
||||
if (ctx.configurationType === 'manual') {
|
||||
credentials = {
|
||||
pageId: ctx.configuration.pageId,
|
||||
pageToken: ctx.configuration.accessToken,
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
state: {
|
||||
payload: { pageToken, pageId },
|
||||
},
|
||||
} = await client.getState({ type: 'integration', name: 'oauth', id: ctx.integrationId })
|
||||
if (!pageToken || !pageId) {
|
||||
throw new RuntimeError('No page token or page id found, please reauthorize')
|
||||
}
|
||||
credentials = {
|
||||
pageId,
|
||||
pageToken,
|
||||
}
|
||||
}
|
||||
|
||||
return credentials
|
||||
}
|
||||
|
||||
type WritableOAuthMetaClientCredentials = Partial<Omit<OAuthState['payload'], 'accessToken'>> & { userToken?: string }
|
||||
async function _getWritableOAuthMetaClientCredentials(
|
||||
client: bp.Client,
|
||||
ctx: bp.Context
|
||||
): Promise<WritableOAuthMetaClientCredentials> {
|
||||
return await client.getState({ type: 'integration', name: 'oauth', id: ctx.integrationId }).then((result) => ({
|
||||
...result.state.payload,
|
||||
userToken: result.state.payload.accessToken,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getOAuthMetaClientCredentials(
|
||||
client: bp.Client,
|
||||
ctx: bp.Context
|
||||
): Promise<MetaClientCredentials> {
|
||||
const { userToken, pageToken, pageId } = await _getWritableOAuthMetaClientCredentials(client, ctx)
|
||||
return {
|
||||
userToken,
|
||||
pageToken,
|
||||
pageId,
|
||||
clientId: bp.secrets.CLIENT_ID,
|
||||
clientSecret: bp.secrets.CLIENT_SECRET,
|
||||
appToken: bp.secrets.ACCESS_TOKEN,
|
||||
}
|
||||
}
|
||||
|
||||
// `client.patchState` is not working correctly
|
||||
export async function patchOAuthMetaClientCredentials(
|
||||
client: bp.Client,
|
||||
ctx: bp.Context,
|
||||
credentials: WritableOAuthMetaClientCredentials
|
||||
) {
|
||||
const {
|
||||
state: { payload: currentState },
|
||||
} = await client.getState({ type: 'integration', name: 'oauth', id: ctx.integrationId })
|
||||
const { userToken, ...credentialsWithoutUserToken } = credentials
|
||||
const newState: OAuthState['payload'] = credentialsWithoutUserToken
|
||||
if (userToken) {
|
||||
newState.accessToken = userToken
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
...currentState,
|
||||
...newState,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMetaClientCredentials({
|
||||
configType: explicitConfigType,
|
||||
client,
|
||||
ctx,
|
||||
}: {
|
||||
configType?: MetaClientConfigType
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}): Promise<MetaClientCredentials> {
|
||||
const configType: MetaClientConfigType = explicitConfigType ?? ctx.configurationType
|
||||
|
||||
let credentials: MetaClientCredentials | undefined
|
||||
if (configType === 'sandbox') {
|
||||
throw new RuntimeError('Meta client credentials are not available for sandbox configuration')
|
||||
} else if (configType === 'manual') {
|
||||
if (ctx.configurationType !== configType) {
|
||||
throw new RuntimeError(
|
||||
'Meta client credentials for manual configuration only available when configured with manual configuration'
|
||||
)
|
||||
}
|
||||
credentials = {
|
||||
pageToken: ctx.configuration.accessToken,
|
||||
pageId: ctx.configuration.pageId,
|
||||
clientId: ctx.configuration.clientId,
|
||||
clientSecret: ctx.configuration.clientSecret,
|
||||
}
|
||||
} else if (configType === 'oauth' || configType === null) {
|
||||
credentials = await getOAuthMetaClientCredentials(client, ctx)
|
||||
}
|
||||
|
||||
if (!credentials) {
|
||||
throw new RuntimeError('Could not get Meta client credentials')
|
||||
}
|
||||
|
||||
return credentials
|
||||
}
|
||||
|
||||
export function getVerifyToken(ctx: bp.Context): string {
|
||||
// Should normally be verified in the fallbackHandler script with OAuth and Sandbox
|
||||
let verifyToken: string
|
||||
if (ctx.configurationType === 'manual') {
|
||||
verifyToken = ctx.configuration.verifyToken
|
||||
} else if (ctx.configurationType === 'sandbox') {
|
||||
verifyToken = bp.secrets.SANDBOX_VERIFY_TOKEN
|
||||
} else {
|
||||
verifyToken = bp.secrets.VERIFY_TOKEN
|
||||
}
|
||||
|
||||
return verifyToken
|
||||
}
|
||||
|
||||
export function getClientSecret(ctx: bp.Context): string | undefined {
|
||||
let value: string | undefined
|
||||
if (ctx.configurationType === 'manual') {
|
||||
value = ctx.configuration.clientSecret
|
||||
} else if (ctx.configurationType === 'sandbox') {
|
||||
value = bp.secrets.SANDBOX_CLIENT_SECRET
|
||||
} else {
|
||||
value = bp.secrets.CLIENT_SECRET
|
||||
}
|
||||
|
||||
return value?.length ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import axios from 'axios'
|
||||
import { getFacebookClientCredentials } from './auth'
|
||||
import { FacebookClientCredentials, CommentReply, PostReply } from './types'
|
||||
import { makeMetaErrorHandler } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export class FacebookClient {
|
||||
private _pageAccessToken: string
|
||||
private _pageId: string
|
||||
private _baseUrl = 'https://graph.facebook.com/v23.0'
|
||||
|
||||
public constructor(config: FacebookClientCredentials) {
|
||||
this._pageAccessToken = config.pageToken
|
||||
this._pageId = config.pageId
|
||||
}
|
||||
|
||||
// Helper method for making Facebook API requests
|
||||
private async _makeRequest<T = any>({
|
||||
method,
|
||||
endpoint,
|
||||
data,
|
||||
customHeaders,
|
||||
}: {
|
||||
method: 'GET' | 'POST' | 'DELETE'
|
||||
endpoint: string
|
||||
data?: any
|
||||
customHeaders?: Record<string, string>
|
||||
}): Promise<T> {
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${this._baseUrl}/${endpoint}`
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this._pageAccessToken}`,
|
||||
...(customHeaders ?? {}),
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
}).catch(makeMetaErrorHandler(url))
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Post Methods
|
||||
public async replyToPost(reply: PostReply): Promise<{ id: string }> {
|
||||
return this._makeRequest({
|
||||
method: 'POST',
|
||||
endpoint: `${this._pageId}/comments`,
|
||||
data: {
|
||||
message: reply.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public async getPost(postId: string): Promise<any> {
|
||||
return this._makeRequest({ method: 'GET', endpoint: postId })
|
||||
}
|
||||
|
||||
// Comment Methods
|
||||
public async replyToComment(reply: CommentReply): Promise<{ id: string }> {
|
||||
return this._makeRequest({
|
||||
method: 'POST',
|
||||
endpoint: `${reply.commentId}/comments`,
|
||||
data: {
|
||||
message: reply.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public async getComment(commentId: string): Promise<any> {
|
||||
return this._makeRequest({ method: 'GET', endpoint: commentId })
|
||||
}
|
||||
|
||||
public async deleteComment(commentId: string): Promise<{ success: boolean }> {
|
||||
await this._makeRequest({ method: 'DELETE', endpoint: commentId })
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
public async hideComment(commentId: string): Promise<{ success: boolean }> {
|
||||
await this._makeRequest({
|
||||
method: 'POST',
|
||||
endpoint: commentId,
|
||||
data: {
|
||||
data: {
|
||||
is_hidden: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
public async showComment(commentId: string): Promise<{ success: boolean }> {
|
||||
await this._makeRequest({
|
||||
method: 'POST',
|
||||
endpoint: commentId,
|
||||
data: {
|
||||
data: {
|
||||
is_hidden: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Factory Functions
|
||||
export async function createAuthenticatedFacebookClient(ctx: bp.Context, client: bp.Client): Promise<FacebookClient> {
|
||||
const credentials = await getFacebookClientCredentials(client, ctx)
|
||||
return new FacebookClient(credentials)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MessengerClient } from 'messaging-api-messenger'
|
||||
import { getMessengerClientCredentials } from './auth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export async function createAuthenticatedMessengerClient(client: bp.Client, ctx: bp.Context): Promise<MessengerClient> {
|
||||
const { accessToken, clientId, clientSecret } = await getMessengerClientCredentials(client, ctx)
|
||||
|
||||
return new MessengerClient({
|
||||
accessToken,
|
||||
appSecret: clientSecret,
|
||||
appId: clientId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { z, RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { getMetaClientCredentials } from './auth'
|
||||
import { MetaClientCredentials, MetaClientConfigType } from './types'
|
||||
import { makeMetaErrorHandler } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const ERROR_SUBSCRIBE_TO_WEBHOOKS = 'Failed to subscribe to webhooks'
|
||||
const ERROR_UNSUBSCRIBE_FROM_WEBHOOKS = 'Failed to unsubscribe from webhooks'
|
||||
const FIELDS_TO_SUBSCRIBE = ['messages', 'messaging_postbacks', 'feed']
|
||||
export class MetaClient {
|
||||
private _userToken?: string
|
||||
private _pageToken?: string
|
||||
private _pageId?: string
|
||||
private _clientId: string
|
||||
private _baseUrl = 'https://graph.facebook.com/v23.0'
|
||||
private _clientSecret?: string
|
||||
private _appToken?: string
|
||||
private _logger?: bp.Logger
|
||||
|
||||
public constructor(config: MetaClientCredentials, logger?: bp.Logger) {
|
||||
this._userToken = config.userToken
|
||||
this._pageToken = config.pageToken
|
||||
this._pageId = config.pageId
|
||||
this._clientId = config.clientId
|
||||
this._clientSecret = config.clientSecret
|
||||
this._appToken = config.appToken
|
||||
this._logger = logger
|
||||
}
|
||||
|
||||
// Helper method for making Facebook API requests
|
||||
private async _makeRequest<T = any>({
|
||||
method,
|
||||
endpoint,
|
||||
customHeaders,
|
||||
data,
|
||||
tokenType,
|
||||
}: {
|
||||
method: 'GET' | 'POST' | 'DELETE'
|
||||
endpoint: string
|
||||
customHeaders?: Record<string, string>
|
||||
data?: any
|
||||
tokenType?: 'user' | 'page' | 'none'
|
||||
}): Promise<T> {
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${this._baseUrl}/${endpoint}`
|
||||
let authHeader
|
||||
if (tokenType === 'page') {
|
||||
authHeader = this._getPageTokenAuthorizationHeader()
|
||||
} else if (tokenType === 'none') {
|
||||
authHeader = {}
|
||||
} else {
|
||||
authHeader = this._getUserTokenAuthorizationHeader()
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
...(customHeaders ?? {}),
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
}).catch(makeMetaErrorHandler(url))
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// OAuth Methods
|
||||
public async exchangeAuthorizationCodeForAccessToken(code: string, redirectUri: string) {
|
||||
const query = new URLSearchParams({
|
||||
client_id: this._clientId,
|
||||
client_secret: this._getClientSecret(),
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
})
|
||||
|
||||
const data = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `oauth/access_token?${query.toString()}`,
|
||||
tokenType: 'none',
|
||||
}).catch(() => {
|
||||
// Don't log original error, client secret is in the URL
|
||||
const errorMsg = 'Error exchanging authorization code for access token'
|
||||
this._logger?.forBot().error(errorMsg)
|
||||
throw new RuntimeError(errorMsg)
|
||||
})
|
||||
const parsedData = z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
.parse(data)
|
||||
|
||||
return parsedData.access_token
|
||||
}
|
||||
|
||||
public setPageToken(pageToken: string) {
|
||||
this._pageToken = pageToken
|
||||
}
|
||||
|
||||
public async getPageToken(inputPageId?: string) {
|
||||
const pageId = this._getPageId(inputPageId)
|
||||
const query = new URLSearchParams({
|
||||
fields: 'access_token',
|
||||
access_token: this._getUserToken(),
|
||||
})
|
||||
|
||||
const data = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `${pageId}?${query.toString()}`,
|
||||
tokenType: 'none',
|
||||
})
|
||||
const parsedData = z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
})
|
||||
.parse(data)
|
||||
|
||||
if (!parsedData.access_token) {
|
||||
throw new RuntimeError('Unable to find the page token for the specified page')
|
||||
}
|
||||
|
||||
return parsedData.access_token
|
||||
}
|
||||
|
||||
public async getFacebookPagesFromToken(inputToken: string): Promise<{ id: string; name: string }[]> {
|
||||
const query = new URLSearchParams({
|
||||
input_token: inputToken,
|
||||
access_token: this._getAppToken(),
|
||||
})
|
||||
|
||||
const dataDebugToken = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `debug_token?${query.toString()}`,
|
||||
tokenType: 'none',
|
||||
})
|
||||
|
||||
const scope = dataDebugToken.data.granular_scopes?.find(
|
||||
(item: { scope: string; target_ids?: string[] }) => item.scope === 'pages_messaging'
|
||||
)
|
||||
|
||||
if (!scope?.target_ids?.length) {
|
||||
return this._getUserManagedPagesFromToken(this._getUserToken())
|
||||
}
|
||||
|
||||
const ids = scope.target_ids
|
||||
|
||||
try {
|
||||
const dataBusinesses = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `?ids=${ids.join()}&fields=id,name`,
|
||||
tokenType: 'none',
|
||||
customHeaders: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
return Object.keys(dataBusinesses).map((key) => dataBusinesses[key])
|
||||
} catch (error) {
|
||||
this._logger?.forBot().warn('Batch page fetch failed for page selection, trying individually, error: ', error)
|
||||
return this._fetchPagesIndividually(ids, inputToken)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchPagesIndividually(pageIds: string[], userToken: string): Promise<{ id: string; name: string }[]> {
|
||||
const results: { id: string; name: string }[] = []
|
||||
|
||||
await Promise.all(
|
||||
pageIds.map(async (pageId) => {
|
||||
try {
|
||||
const pageData = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `${pageId}?fields=id,name`,
|
||||
tokenType: 'none',
|
||||
customHeaders: {
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
},
|
||||
})
|
||||
if (pageData.id && pageData.name) {
|
||||
results.push({ id: pageData.id, name: pageData.name })
|
||||
}
|
||||
} catch {
|
||||
this._logger?.forBot().debug(`Skipping page "${pageId}", failed to get details`)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async _getUserManagedPagesFromToken(userToken: string) {
|
||||
let allPages: { id: string; name: string }[] = []
|
||||
|
||||
const query = new URLSearchParams({
|
||||
access_token: userToken,
|
||||
fields: 'id,name',
|
||||
})
|
||||
let url = `${this._baseUrl}/me/accounts?${query.toString()}`
|
||||
|
||||
while (url) {
|
||||
const { data, paging } = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: url,
|
||||
tokenType: 'none',
|
||||
})
|
||||
|
||||
// Add the pages to the allPages array
|
||||
allPages = allPages.concat(data)
|
||||
|
||||
// Check if there's a next page
|
||||
url = paging && paging.next ? paging.next : null
|
||||
}
|
||||
|
||||
return allPages
|
||||
}
|
||||
|
||||
// Webhook Methods
|
||||
public async subscribeToWebhooks(inputPageId?: string) {
|
||||
const pageId = this._getPageId(inputPageId)
|
||||
try {
|
||||
const responseData = await this._makeRequest({
|
||||
method: 'POST',
|
||||
endpoint: `${pageId}/subscribed_apps`,
|
||||
tokenType: 'page',
|
||||
data: {
|
||||
subscribed_fields: FIELDS_TO_SUBSCRIBE,
|
||||
},
|
||||
})
|
||||
|
||||
if (!responseData.success) {
|
||||
throw new RuntimeError(ERROR_SUBSCRIBE_TO_WEBHOOKS)
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger?.error(`Error subscribing to webhooks for Page ${pageId}: ${error}`)
|
||||
throw new RuntimeError(ERROR_SUBSCRIBE_TO_WEBHOOKS)
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribeFromWebhooks(inputPageId?: string) {
|
||||
const pageId = this._getPageId(inputPageId)
|
||||
try {
|
||||
const responseData = await this._makeRequest({
|
||||
method: 'DELETE',
|
||||
endpoint: `${pageId}/subscribed_apps`,
|
||||
tokenType: 'page',
|
||||
})
|
||||
|
||||
if (!responseData || !responseData.success) {
|
||||
throw new RuntimeError(ERROR_UNSUBSCRIBE_FROM_WEBHOOKS)
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger?.error(`Error unsubscribing from webhooks for Page ${pageId}: ${error}`)
|
||||
throw new RuntimeError(ERROR_UNSUBSCRIBE_FROM_WEBHOOKS)
|
||||
}
|
||||
}
|
||||
|
||||
public async getSubscribedWebhooks(inputPageId?: string): Promise<string[] | undefined> {
|
||||
const pageId = this._getPageId(inputPageId)
|
||||
const responseData = await this._makeRequest({
|
||||
method: 'GET',
|
||||
endpoint: `${pageId}/subscribed_apps`,
|
||||
tokenType: 'page',
|
||||
})
|
||||
|
||||
const { data: applications } = z
|
||||
.array(z.object({ id: z.string(), subscribed_fields: z.array(z.string()) }))
|
||||
.safeParse(responseData.data)
|
||||
|
||||
const application = applications?.find((app) => app.id === this._clientId)
|
||||
if (!application) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return application.subscribed_fields
|
||||
}
|
||||
|
||||
public async isSubscribedToWebhooks(inputPageId?: string) {
|
||||
const subscribedFields = await this.getSubscribedWebhooks(inputPageId)
|
||||
|
||||
if (!subscribedFields) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!FIELDS_TO_SUBSCRIBE.every((f) => subscribedFields.includes(f))) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
private _getPageId(inputPageId?: string) {
|
||||
const pageId = inputPageId ?? this._pageId
|
||||
if (!pageId) {
|
||||
throw new RuntimeError('Page ID is not set and no page ID was provided')
|
||||
}
|
||||
return pageId
|
||||
}
|
||||
|
||||
private _getClientSecret() {
|
||||
if (!this._clientSecret) {
|
||||
throw new RuntimeError('Client secret is not set')
|
||||
}
|
||||
return this._clientSecret
|
||||
}
|
||||
|
||||
private _getUserToken() {
|
||||
if (!this._userToken) {
|
||||
throw new RuntimeError('User token is not set')
|
||||
}
|
||||
return this._userToken
|
||||
}
|
||||
|
||||
private _getPageToken() {
|
||||
if (!this._pageToken) {
|
||||
throw new RuntimeError('Page token is not set')
|
||||
}
|
||||
return this._pageToken
|
||||
}
|
||||
|
||||
private _getPageTokenAuthorizationHeader() {
|
||||
return {
|
||||
Authorization: `Bearer ${this._getPageToken()}`,
|
||||
}
|
||||
}
|
||||
|
||||
private _getUserTokenAuthorizationHeader() {
|
||||
return {
|
||||
Authorization: `Bearer ${this._getUserToken()}`,
|
||||
}
|
||||
}
|
||||
|
||||
private _getAppToken() {
|
||||
if (!this._appToken) {
|
||||
throw new RuntimeError('App token is not set')
|
||||
}
|
||||
return this._appToken
|
||||
}
|
||||
}
|
||||
|
||||
// Factory Function
|
||||
export async function createAuthenticatedMetaClient({
|
||||
configType,
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
}: {
|
||||
configType?: MetaClientConfigType
|
||||
ctx: bp.Context
|
||||
client: bp.Client
|
||||
logger?: bp.Logger
|
||||
}): Promise<MetaClient> {
|
||||
const credentials = await getMetaClientCredentials({ configType, client, ctx })
|
||||
return new MetaClient(credentials, logger)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
// Client credentials types
|
||||
export type MetaClientConfigType = bp.Context['configurationType'] | 'oauth'
|
||||
export type MetaClientCredentials = {
|
||||
userToken?: string
|
||||
pageToken?: string
|
||||
pageId?: string
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
appToken?: string
|
||||
}
|
||||
|
||||
export type MessengerClientCredentials = {
|
||||
accessToken: string
|
||||
clientSecret?: string
|
||||
clientId: string
|
||||
}
|
||||
|
||||
export type FacebookClientCredentials = {
|
||||
pageId: string
|
||||
pageToken: string
|
||||
}
|
||||
|
||||
// Messenger channel types
|
||||
export type Carousel = bp.channels.channel.carousel.Carousel
|
||||
export type Card = bp.channels.channel.card.Card
|
||||
export type Choice = bp.channels.channel.choice.Choice
|
||||
export type Dropdown = bp.channels.channel.dropdown.Dropdown
|
||||
export type Location = bp.channels.channel.location.Location
|
||||
|
||||
type Channels = bp.Integration['channels']
|
||||
type MessengerMessages = Channels['channel']['messages']
|
||||
type MessengerMessageHandler = MessengerMessages[keyof MessengerMessages]
|
||||
type MessengerMessageHandlerProps = Parameters<MessengerMessageHandler>[0]
|
||||
|
||||
export type SendMessengerMessageProps = Pick<
|
||||
MessengerMessageHandlerProps,
|
||||
'client' | 'ctx' | 'conversation' | 'ack' | 'logger' | 'type' | 'payload'
|
||||
>
|
||||
|
||||
type MessengerOutMessagePostbackAttachment = {
|
||||
type: 'postback'
|
||||
title: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
type MessengerOutMessageSayAttachment = {
|
||||
type: 'postback'
|
||||
title: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
type MessengerOutMessageUrlAttachment = {
|
||||
type: 'web_url'
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MessengerOutMessageAttachment =
|
||||
| MessengerOutMessagePostbackAttachment
|
||||
| MessengerOutMessageSayAttachment
|
||||
| MessengerOutMessageUrlAttachment
|
||||
|
||||
// Messenger event schemas
|
||||
const baseMessengerMessagingItemSchema = z.object({
|
||||
sender: z.object({ id: z.string() }),
|
||||
recipient: z.object({ id: z.string() }),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
const messengerMessagingItemMessageSchema = baseMessengerMessagingItemSchema.extend({
|
||||
message: z.object({
|
||||
mid: z.string(),
|
||||
text: z.string().optional(),
|
||||
quick_reply: z.object({ payload: z.string() }).optional(),
|
||||
attachments: z.array(z.object({ type: z.string(), payload: z.object({ url: z.string() }) })).optional(),
|
||||
}),
|
||||
})
|
||||
export type MessengerMessagingItemMessage = z.infer<typeof messengerMessagingItemMessageSchema>
|
||||
|
||||
const messengerMessagingItemPostbackSchema = baseMessengerMessagingItemSchema.extend({
|
||||
postback: z.object({
|
||||
mid: z.string(),
|
||||
payload: z.string(),
|
||||
title: z.string(),
|
||||
}),
|
||||
})
|
||||
export type MessengerMessagingItemPostback = z.infer<typeof messengerMessagingItemPostbackSchema>
|
||||
|
||||
const messengerMessagingItemSchema = z.union([
|
||||
messengerMessagingItemMessageSchema,
|
||||
messengerMessagingItemPostbackSchema,
|
||||
])
|
||||
export type MessengerMessagingItem = z.infer<typeof messengerMessagingItemSchema>
|
||||
|
||||
const messengerMessagingSchema = z.tuple([messengerMessagingItemSchema])
|
||||
export type MessengerMessaging = z.infer<typeof messengerMessagingSchema>
|
||||
|
||||
// Facebook channel types
|
||||
export type CommentReply = {
|
||||
message: string
|
||||
commentId: string
|
||||
}
|
||||
|
||||
export type PostReply = {
|
||||
message: string
|
||||
postId: string
|
||||
}
|
||||
|
||||
// Feed event schemas
|
||||
const commentItemTypeSchema = z.literal('comment')
|
||||
const otherItemTypesSchema = z.enum([
|
||||
'album',
|
||||
'address',
|
||||
'connection',
|
||||
'coupon',
|
||||
'event',
|
||||
'experience',
|
||||
'group',
|
||||
'group_message',
|
||||
'interest',
|
||||
'link',
|
||||
'mention',
|
||||
'milestone',
|
||||
'note',
|
||||
'page',
|
||||
'picture',
|
||||
'platform-story',
|
||||
'photo',
|
||||
'photo-album',
|
||||
'post',
|
||||
'profile',
|
||||
'question',
|
||||
'rating',
|
||||
'reaction',
|
||||
'relationship-status',
|
||||
'share',
|
||||
'status',
|
||||
'story',
|
||||
'timeline cover',
|
||||
'tag',
|
||||
'video',
|
||||
])
|
||||
|
||||
const baseFeedChangeValueSchema = z.object({
|
||||
verb: z.string(),
|
||||
created_time: z.number(),
|
||||
post_id: z.string(),
|
||||
message: z.string().optional(),
|
||||
from: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const commentChangeValueSchema = baseFeedChangeValueSchema.extend({
|
||||
item: commentItemTypeSchema,
|
||||
comment_id: z.string(),
|
||||
parent_id: z.string(),
|
||||
})
|
||||
export type CommentChangeValue = z.infer<typeof commentChangeValueSchema>
|
||||
|
||||
const feedChangeValueSchema = baseFeedChangeValueSchema.extend({
|
||||
item: otherItemTypesSchema,
|
||||
comment_id: z.string().optional(),
|
||||
parent_id: z.string().optional(),
|
||||
parent: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const feedChangeSchema = z.object({
|
||||
field: z.string(),
|
||||
value: z.discriminatedUnion('item', [commentChangeValueSchema, feedChangeValueSchema]),
|
||||
})
|
||||
export type FeedChange = z.infer<typeof feedChangeSchema>
|
||||
|
||||
const feedChangesSchema = z.array(feedChangeSchema)
|
||||
export type FeedChanges = z.infer<typeof feedChangesSchema>
|
||||
|
||||
// Common event schemas
|
||||
const baseEventEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
time: z.number(),
|
||||
})
|
||||
|
||||
const messengerEntrySchema = baseEventEntrySchema.extend({
|
||||
messaging: messengerMessagingSchema,
|
||||
})
|
||||
|
||||
const feedEntrySchema = baseEventEntrySchema.extend({
|
||||
changes: feedChangesSchema,
|
||||
})
|
||||
|
||||
const eventEntrySchema = z.union([messengerEntrySchema, feedEntrySchema])
|
||||
|
||||
export const eventPayloadSchema = z.object({
|
||||
object: z.string(),
|
||||
entry: z.array(eventEntrySchema),
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { MessengerClient, MessengerTypes } from 'messaging-api-messenger'
|
||||
import { Location, SendMessengerMessageProps } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export function getGoogleMapLinkFromLocation(payload: Location) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}`
|
||||
}
|
||||
|
||||
export function getEndUserMessengerId(messengerConversation: SendMessengerMessageProps['conversation']): string {
|
||||
const id = messengerConversation.tags.id
|
||||
|
||||
if (!id) {
|
||||
throw new RuntimeError(`No recipient id found for conversation ${messengerConversation.id}`)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export function safeJsonParse(x: any) {
|
||||
try {
|
||||
return { data: JSON.parse(x), success: true }
|
||||
} catch {
|
||||
return { data: x, success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export type FileMetadata = { mimeType: string; fileSize?: number; fileName?: string }
|
||||
|
||||
export async function getMediaMetadata(url: string): Promise<FileMetadata> {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RuntimeError(`Failed to fetch metadata for URL: ${url}`)
|
||||
}
|
||||
|
||||
const mimeType = response.headers.get('content-type') ?? 'application/octet-stream'
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const contentDisposition = response.headers.get('content-disposition')
|
||||
|
||||
const fileSize = contentLength ? Number(contentLength) : undefined
|
||||
if (fileSize !== undefined && isNaN(fileSize)) {
|
||||
throw new RuntimeError(`Failed to parse file size from response: ${contentLength}`)
|
||||
}
|
||||
|
||||
// Try to extract filename from content-disposition
|
||||
let fileName: string | undefined
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^"]+)"?/i)
|
||||
const rawFileName = match?.[1]
|
||||
if (rawFileName) {
|
||||
fileName = decodeURIComponent(rawFileName)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
fileSize,
|
||||
fileName,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateIdFromUrl(url: string): Promise<string> {
|
||||
const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(url))
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 24)
|
||||
}
|
||||
|
||||
export function getErrorFromUnknown(thrown: unknown): Error {
|
||||
if (thrown instanceof Error) {
|
||||
return thrown
|
||||
}
|
||||
return new Error(String(thrown))
|
||||
}
|
||||
|
||||
export const shouldGetUserProfile = (ctx: bp.Context) => {
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
return bp.secrets.SANDBOX_SHOULD_GET_USER_PROFILE === 'true'
|
||||
}
|
||||
if (ctx.configurationType === 'manual') {
|
||||
return ctx.configuration.shouldGetUserProfile ?? true
|
||||
}
|
||||
|
||||
return bp.secrets.SHOULD_GET_USER_PROFILE === 'true'
|
||||
}
|
||||
|
||||
export const tryGetUserProfile = async (
|
||||
messengerClient: MessengerClient,
|
||||
ctx: bp.Context,
|
||||
userId: string,
|
||||
fields?: MessengerTypes.UserProfileField[]
|
||||
) => {
|
||||
if (!shouldGetUserProfile(ctx)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return await messengerClient.getUserProfile(userId, { fields })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const isMetaError = (
|
||||
error: unknown
|
||||
): error is { response: { data: { error: { message: string; error_user_msg: string } } } } => {
|
||||
return (
|
||||
axios.isAxiosError(error) &&
|
||||
'error' in error.response?.data &&
|
||||
'error_user_msg' in error.response?.data.error &&
|
||||
'message' in error.response?.data.error &&
|
||||
error.response?.data.error.message &&
|
||||
error.response?.data.error.error_user_msg
|
||||
)
|
||||
}
|
||||
|
||||
export const makeMetaErrorHandler = (url: string) => {
|
||||
return (error: unknown) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.debug(`Axios error when calling Meta API: ${error.toJSON()}`)
|
||||
}
|
||||
const urlWithoutQuery = url.split('?')[0]
|
||||
const baseMessage = `Error calling Meta API with url ${urlWithoutQuery}`
|
||||
let errorMessage: string
|
||||
if (isMetaError(error)) {
|
||||
const metaError = error.response.data.error
|
||||
errorMessage = `${baseMessage}: ${metaError.message}, ${metaError.error_user_msg}`
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `${baseMessage}: ${error.message}`
|
||||
} else {
|
||||
errorMessage = `${baseMessage}: Unkown error`
|
||||
}
|
||||
console.debug(`Meta error: ${errorMessage}`)
|
||||
throw new RuntimeError(errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { getOAuthMetaClientCredentials } from './misc/auth'
|
||||
import { createAuthenticatedMetaClient } from './misc/meta-client'
|
||||
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type RegisterProps = Parameters<bp.IntegrationProps['register']>[0]
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async (props) => {
|
||||
const { ctx } = props
|
||||
if (ctx.configurationType === 'manual') {
|
||||
await _registerManual(props)
|
||||
} else if (ctx.configurationType === 'sandbox') {
|
||||
await _registerSandbox(props)
|
||||
} else {
|
||||
await _registerOAuth(props)
|
||||
}
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
|
||||
|
||||
const _registerManual = async (props: RegisterProps) => {
|
||||
await _clearAllIdentifiers(props)
|
||||
await _unsubscribeFromOAuthWebhooks(props)
|
||||
}
|
||||
|
||||
const _registerSandbox = async (props: RegisterProps) => {
|
||||
await _clearAllIdentifiers(props)
|
||||
await _unsubscribeFromOAuthWebhooks(props)
|
||||
}
|
||||
|
||||
const _registerOAuth = async (props: RegisterProps) => {
|
||||
const { client } = props
|
||||
|
||||
// Only remove sandbox identifiers
|
||||
await client.configureIntegration({
|
||||
sandboxIdentifiers: null,
|
||||
})
|
||||
|
||||
// Verify OAuth credentials and check if reauthorization is needed
|
||||
await _verifyOAuthCredentials(props)
|
||||
}
|
||||
|
||||
const _verifyOAuthCredentials = async (props: RegisterProps) => {
|
||||
const { client, ctx, logger } = props
|
||||
const reauthorizeMessage = 'Authentication failed. Please reauthorize.'
|
||||
const credentials = await getOAuthMetaClientCredentials(client, ctx).catch(() => undefined)
|
||||
|
||||
const handleAuthFailure = async (logMessage: string, logLevel: 'warn' | 'error' = 'warn') => {
|
||||
logger.forBot()[logLevel](logMessage)
|
||||
await _clearAllIdentifiers(props)
|
||||
throw new RuntimeError(reauthorizeMessage)
|
||||
}
|
||||
|
||||
if (!credentials) {
|
||||
await handleAuthFailure('OAuth credentials not found. Please reauthorize.', 'error')
|
||||
}
|
||||
|
||||
// Verify the oauth state
|
||||
if (!credentials?.pageToken || !credentials?.pageId) {
|
||||
await handleAuthFailure('OAuth flow not completed yet. Please reauthorize.', 'error')
|
||||
}
|
||||
|
||||
// Verify that we can make an authenticated request to the Meta API
|
||||
try {
|
||||
const metaClient = await createAuthenticatedMetaClient({
|
||||
configType: 'oauth',
|
||||
ctx,
|
||||
client,
|
||||
logger,
|
||||
})
|
||||
|
||||
const pageId = credentials?.pageId
|
||||
const isSubscribedToWebhooks = await metaClient.isSubscribedToWebhooks(pageId)
|
||||
|
||||
if (!isSubscribedToWebhooks) {
|
||||
await handleAuthFailure('OAuth credentials verified. No webhooks subscribed.', 'warn')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
await handleAuthFailure(`Error verifying OAuth credentials: ${errorMessage}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const _unsubscribeFromOAuthWebhooks = async ({ ctx, logger, client }: RegisterProps) => {
|
||||
const credentials = await getOAuthMetaClientCredentials(client, ctx).catch(() => undefined)
|
||||
if (!credentials) {
|
||||
// No credentials means the OAuth flow hasn't been completed yet
|
||||
return
|
||||
}
|
||||
|
||||
const { pageId } = credentials
|
||||
if (!pageId) {
|
||||
// No page ID means the OAuth flow was probably never fully completed
|
||||
return
|
||||
}
|
||||
|
||||
const metaClient = await createAuthenticatedMetaClient({ configType: 'oauth', ctx, client, logger })
|
||||
const isSubscribedToWebhooks = await metaClient.isSubscribedToWebhooks(pageId)
|
||||
if (!isSubscribedToWebhooks) {
|
||||
logger.forBot().info(`No webhooks subscribed to for page ${pageId}. Skipping unsubscription.`)
|
||||
return
|
||||
}
|
||||
await metaClient.unsubscribeFromWebhooks(pageId)
|
||||
}
|
||||
|
||||
const _clearAllIdentifiers = async ({ client }: RegisterProps) => {
|
||||
await client.configureIntegration({
|
||||
identifier: null,
|
||||
sandboxIdentifiers: null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isSandboxCommand, meta } from '@botpress/common'
|
||||
import { getClientSecret, getVerifyToken } from '../misc/auth'
|
||||
import { eventPayloadSchema } from '../misc/types'
|
||||
import { safeJsonParse } from '../misc/utils'
|
||||
import { oauthHandler, messagingHandler, sandboxHandler, feedHandler } from './handlers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, client, ctx, logger } = props
|
||||
|
||||
if (req.path.startsWith('/oauth')) {
|
||||
return oauthHandler({ req, client, ctx, logger })
|
||||
}
|
||||
|
||||
if (isSandboxCommand(props)) {
|
||||
return await sandboxHandler(props)
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(req.query)
|
||||
if (queryParams.has('hub.mode')) {
|
||||
return await meta.subscribeHandler({ ...props, verifyToken: getVerifyToken(ctx) })
|
||||
}
|
||||
|
||||
const validationResult = await meta.validateRequestSignature({ req, clientSecret: getClientSecret(ctx) })
|
||||
if (validationResult.error) {
|
||||
return { status: 401, body: validationResult.message }
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
logger.forBot().warn('Handler received an empty body, so the message was ignored')
|
||||
return
|
||||
}
|
||||
|
||||
props.logger.debug(`Handler received body: ${req.body}`)
|
||||
|
||||
const jsonParseResult = safeJsonParse(req.body)
|
||||
if (!jsonParseResult.success) {
|
||||
logger.forBot().warn('Error while parsing body as JSON:', jsonParseResult.data)
|
||||
return
|
||||
}
|
||||
|
||||
const parseResult = eventPayloadSchema.safeParse(jsonParseResult.data)
|
||||
if (!parseResult.success) {
|
||||
logger.forBot().warn('Unsupported Event Payload: ' + parseResult.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const data = parseResult.data
|
||||
for (const entry of data.entry) {
|
||||
if ('messaging' in entry) {
|
||||
await messagingHandler(entry.messaging, props)
|
||||
} else if ('changes' in entry) {
|
||||
await feedHandler(entry.changes, props)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const _handlerWrapper: typeof _handler = async (props: bp.HandlerProps) => {
|
||||
try {
|
||||
const response = await _handler(props)
|
||||
|
||||
if (response?.status && response.status >= 400) {
|
||||
const errorMessage = `Messenger handler failed with status ${response.status}: ${response.body}`
|
||||
props.logger.error(errorMessage)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (thrown: unknown) {
|
||||
const error = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
const errorMessage = `Messenger handler failed with error: ${error}`
|
||||
props.logger.error(errorMessage)
|
||||
return { status: 500, body: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export default _handlerWrapper satisfies bp.IntegrationProps['handler']
|
||||
@@ -0,0 +1,129 @@
|
||||
import { getMetaClientCredentials } from '../../misc/auth'
|
||||
import { FeedChanges, FeedChange, CommentChangeValue } from '../../misc/types'
|
||||
import { getErrorFromUnknown } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler = async (changes: FeedChanges, props: bp.HandlerProps) => {
|
||||
if (props.ctx.configurationType === 'sandbox') {
|
||||
props.logger.error(
|
||||
'Feed changes are not supported in sandbox mode, turn off webhook subscriptions in the Sandbox Meta App'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
await _handleFeedChange(change, props)
|
||||
}
|
||||
}
|
||||
|
||||
const _handleFeedChange = async (change: FeedChange, props: bp.HandlerProps) => {
|
||||
const { logger } = props
|
||||
const { value } = change
|
||||
|
||||
try {
|
||||
switch (value.item) {
|
||||
case 'comment':
|
||||
await _handleCommentEvent(value, props)
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn(`Unhandled event item: ${value.item}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorFromUnknown(error)
|
||||
logger.forBot().error(`Error processing feed change: ${errorMsg.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _handleCommentEvent = async (value: CommentChangeValue, props: bp.HandlerProps) => {
|
||||
const { logger, ctx, client } = props
|
||||
const { from, verb } = value
|
||||
const { pageId } = await getMetaClientCredentials({ client, ctx })
|
||||
if (!pageId) {
|
||||
logger.forBot().error('Page ID is not set, cannot process comment event. Please configure or reauthorize')
|
||||
return
|
||||
}
|
||||
|
||||
if (from?.id === pageId) {
|
||||
logger.forBot().debug('Comment is from our page, ignoring')
|
||||
return
|
||||
}
|
||||
|
||||
logger.forBot().debug(`Processing comment event: verb=${verb} `)
|
||||
|
||||
switch (verb) {
|
||||
case 'add':
|
||||
await _handleCommentCreated(value, props)
|
||||
break
|
||||
case 'remove': // For removed comments
|
||||
break
|
||||
case 'edited': // For edited comments
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const _handleCommentCreated = async (value: CommentChangeValue, props: bp.HandlerProps) => {
|
||||
const { client, logger, ctx } = props
|
||||
|
||||
if (ctx.configurationType === 'sandbox') {
|
||||
logger.forBot().error('Comment replies are not supported in sandbox mode')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ctx.configuration.replyToComments) {
|
||||
logger.forBot().info('Comment replies are disabled in the configuration')
|
||||
return
|
||||
}
|
||||
|
||||
const { comment_id: commentId, post_id: postId, message, from, parent_id: parentId } = value
|
||||
|
||||
if (!message) {
|
||||
logger
|
||||
.forBot()
|
||||
.warn(
|
||||
'Incoming comment has no message, will not reply. Make sure that your app has been granted the necessary permissions to access user data.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (postId !== parentId) {
|
||||
logger.forBot().debug('Incoming comment is not a root comment, will not reply')
|
||||
return
|
||||
}
|
||||
|
||||
if (!from) {
|
||||
logger.forBot().error("Incoming comment doesn't contain 'from' information, will not reply")
|
||||
return
|
||||
}
|
||||
|
||||
// Use the thread resolver to create conversation based on root thread ID
|
||||
const userId = from.id
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'commentReplies',
|
||||
tags: { id: commentId, postId, userId },
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: { id: userId },
|
||||
})
|
||||
|
||||
if (!user.name) {
|
||||
await client.updateUser({
|
||||
id: user.id,
|
||||
name: from.name,
|
||||
})
|
||||
}
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags: {
|
||||
id: commentId,
|
||||
postId,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
type: 'text',
|
||||
payload: { text: message, commentId },
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { handler as oauthHandler } from './oauth'
|
||||
export { handler as messagingHandler } from './message'
|
||||
export { handler as feedHandler } from './feed'
|
||||
export { handler as sandboxHandler } from './sandbox'
|
||||
@@ -0,0 +1,241 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { createAuthenticatedMessengerClient } from '../../misc/messenger-client'
|
||||
import {
|
||||
MessengerMessaging,
|
||||
MessengerMessagingItem,
|
||||
MessengerMessagingItemMessage,
|
||||
MessengerMessagingItemPostback,
|
||||
} from '../../misc/types'
|
||||
import {
|
||||
FileMetadata,
|
||||
generateIdFromUrl,
|
||||
getErrorFromUnknown,
|
||||
getMediaMetadata,
|
||||
shouldGetUserProfile,
|
||||
} from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type IncomingMessageTypes = keyof Pick<bp.channels.channel.Messages, 'audio' | 'image' | 'text' | 'video' | 'bloc'>
|
||||
type IncomingMessages = {
|
||||
[TMessage in IncomingMessageTypes]: {
|
||||
type: TMessage
|
||||
payload: bp.channels.channel.Messages[TMessage]
|
||||
}
|
||||
}
|
||||
type IncomingMessage = IncomingMessages[IncomingMessageTypes]
|
||||
type User = Awaited<ReturnType<bp.Client['getOrCreateUser']>>['user']
|
||||
|
||||
export const handler = async (messaging: MessengerMessaging, props: bp.HandlerProps) => {
|
||||
const messagingItem = messaging[0]
|
||||
if ('message' in messagingItem) {
|
||||
await _messageHandler(messagingItem, props)
|
||||
}
|
||||
if ('postback' in messagingItem) {
|
||||
await _postbackHandler(messagingItem, props)
|
||||
}
|
||||
}
|
||||
|
||||
const _messageHandler = async (messagingItem: MessengerMessagingItemMessage, handlerProps: bp.HandlerProps) => {
|
||||
const { message } = messagingItem
|
||||
const { client, ctx, logger } = handlerProps
|
||||
logger
|
||||
.forBot()
|
||||
.debug(
|
||||
`Received message from Messenger: text=${message.text ?? '[None]'}, attachments=[${message.attachments?.map((a) => `${a.type}:${a.payload.url}`).join(', ') ?? 'None'}]`
|
||||
)
|
||||
|
||||
const incomingMessages: IncomingMessage[] = []
|
||||
const { text, attachments } = message
|
||||
if (text) {
|
||||
incomingMessages.push({ type: 'text', payload: { text } })
|
||||
}
|
||||
if (attachments) {
|
||||
for (const attachment of attachments) {
|
||||
const { url } = await _getOrDownloadMedia(attachment.payload.url, client, ctx)
|
||||
if (attachment.type === 'image') {
|
||||
incomingMessages.push({ type: 'image', payload: { imageUrl: url } })
|
||||
} else if (attachment.type === 'video') {
|
||||
incomingMessages.push({ type: 'video', payload: { videoUrl: url } })
|
||||
} else if (attachment.type === 'audio') {
|
||||
incomingMessages.push({ type: 'audio', payload: { audioUrl: url } })
|
||||
} else {
|
||||
logger.forBot().warn(`Unsupported attachment type in incoming message: ${attachment.type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let incomingMessage: IncomingMessage | undefined
|
||||
if (incomingMessages.length > 1) {
|
||||
const items = incomingMessages.filter((m) => m.type !== 'bloc')
|
||||
incomingMessage = {
|
||||
type: 'bloc',
|
||||
payload: {
|
||||
items,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
incomingMessage = incomingMessages[0]
|
||||
}
|
||||
|
||||
if (!incomingMessage) {
|
||||
logger.forBot().debug('No incoming message to process')
|
||||
return
|
||||
}
|
||||
await _commonMessagingHandler({
|
||||
incomingMessage,
|
||||
mid: message.mid,
|
||||
messagingItem,
|
||||
handlerProps,
|
||||
})
|
||||
}
|
||||
|
||||
const _postbackHandler = async (messagingItem: MessengerMessagingItemPostback, handlerProps: bp.HandlerProps) => {
|
||||
const { postback } = messagingItem
|
||||
handlerProps.logger
|
||||
.forBot()
|
||||
.debug(`Received postback from Messenger: label=${postback.title}, value=${postback.payload}`)
|
||||
await _commonMessagingHandler({
|
||||
incomingMessage: { type: 'text', payload: { text: postback.payload } },
|
||||
mid: postback.mid,
|
||||
messagingItem,
|
||||
handlerProps,
|
||||
})
|
||||
}
|
||||
|
||||
const _commonMessagingHandler = async ({
|
||||
incomingMessage: { type, payload },
|
||||
mid,
|
||||
messagingItem,
|
||||
handlerProps,
|
||||
}: {
|
||||
incomingMessage: IncomingMessage
|
||||
mid: string
|
||||
messagingItem: MessengerMessagingItem
|
||||
handlerProps: bp.HandlerProps
|
||||
}) => {
|
||||
const { client } = handlerProps
|
||||
|
||||
const { sender, recipient } = messagingItem
|
||||
const { conversation } = await client
|
||||
.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: sender.id },
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
handlerProps.logger.error(`Failed to get or create conversation for Messenger user ${sender.id}: ${errorMessage}`)
|
||||
throw thrown
|
||||
})
|
||||
|
||||
const { user } = await client
|
||||
.getOrCreateUser({
|
||||
tags: {
|
||||
id: sender.id,
|
||||
},
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const errorMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
handlerProps.logger.error(`Failed to get or create user for Messenger user ${sender.id}: ${errorMessage}`)
|
||||
throw thrown
|
||||
})
|
||||
|
||||
await _updateUserProfile(user, sender.id, handlerProps)
|
||||
|
||||
await client.getOrCreateMessage({
|
||||
tags: {
|
||||
id: mid,
|
||||
senderId: sender.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
type,
|
||||
payload,
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
})
|
||||
}
|
||||
|
||||
function _getMediaExpiry(ctx: bp.Context) {
|
||||
const expiryDelayHours = ctx.configuration.downloadedMediaExpiry || 0
|
||||
if (expiryDelayHours === 0) {
|
||||
return undefined
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000)
|
||||
return expiresAt.toISOString()
|
||||
}
|
||||
|
||||
async function _getOrDownloadMedia(
|
||||
url: string,
|
||||
client: bp.Client,
|
||||
ctx: bp.Context
|
||||
): Promise<FileMetadata & { url: string }> {
|
||||
const metadata = await getMediaMetadata(url)
|
||||
if (ctx.configuration.downloadMedia) {
|
||||
return await _downloadMedia({ url, ...metadata }, client, ctx)
|
||||
}
|
||||
|
||||
return { url, ...metadata }
|
||||
}
|
||||
|
||||
async function _downloadMedia(params: { url: string } & FileMetadata, client: bp.Client, ctx: bp.Context) {
|
||||
const { url, mimeType, fileSize, fileName } = params
|
||||
const { file } = await client.upsertFile({
|
||||
key: 'messenger-media_' + (await generateIdFromUrl(url)),
|
||||
expiresAt: _getMediaExpiry(ctx),
|
||||
contentType: mimeType,
|
||||
accessPolicies: ['public_content'],
|
||||
publicContentImmediatelyAccessible: true,
|
||||
size: fileSize ?? 0,
|
||||
tags: {
|
||||
source: 'integration',
|
||||
integration: 'messenger',
|
||||
channel: 'channel',
|
||||
originUrl: url,
|
||||
...(fileName?.length && { name: fileName }),
|
||||
},
|
||||
})
|
||||
|
||||
const downloadResponse = await axios
|
||||
.get(url, {
|
||||
responseType: 'stream',
|
||||
})
|
||||
.catch((err) => {
|
||||
throw new RuntimeError(`Failed to download media: ${err.message}`)
|
||||
})
|
||||
|
||||
await axios
|
||||
.put(file.uploadUrl, downloadResponse.data, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': fileSize,
|
||||
'x-amz-tagging': 'public=true',
|
||||
},
|
||||
maxBodyLength: fileSize,
|
||||
})
|
||||
.catch((err) => {
|
||||
throw new RuntimeError(`Failed to upload media: ${err.message}`)
|
||||
})
|
||||
|
||||
return { url: file.url, mimeType, fileSize, fileName }
|
||||
}
|
||||
|
||||
const _updateUserProfile = async (user: User, messengerUserId: string, props: bp.HandlerProps) => {
|
||||
const { client, ctx, logger } = props
|
||||
|
||||
if (shouldGetUserProfile(ctx) && (!user.name || !user.pictureUrl)) {
|
||||
try {
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
const profile = await messengerClient.getUserProfile(messengerUserId, { fields: ['id', 'name', 'profile_pic'] })
|
||||
logger.forBot().debug('Fetched latest Messenger user profile: ', profile)
|
||||
|
||||
await client.updateUser({ id: user.id, name: profile.name, pictureUrl: profile.profilePic })
|
||||
} catch (error) {
|
||||
logger
|
||||
.forBot()
|
||||
.error(
|
||||
'Error while fetching user profile from Messenger, make sure your app was granted the necessary permissions. Error:',
|
||||
getErrorFromUnknown(error).message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
|
||||
import { getErrorFromUnknown } from '../../../misc/utils'
|
||||
import * as wizard from './wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ req, client, ctx, logger }) => {
|
||||
if (!isOAuthWizardUrl(req.path)) {
|
||||
return {
|
||||
status: 404,
|
||||
body: 'Invalid OAuth endpoint',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await wizard.handler({ req, client, ctx, logger })
|
||||
} catch (error) {
|
||||
const errorMessage = 'OAuth registration error: ' + getErrorFromUnknown(error).message
|
||||
logger.forBot().error(errorMessage)
|
||||
return generateRedirection(getInterstitialUrl(false, errorMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { getMetaClientCredentials, patchOAuthMetaClientCredentials } from '../../../misc/auth'
|
||||
import { createAuthenticatedMetaClient } from '../../../misc/meta-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const ERROR_ACCESS_TOKEN_UNAVAILABLE = 'Access token is not available, please try again'
|
||||
|
||||
export const handler = async (props: bp.HandlerProps) => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startHandler })
|
||||
.addStep({ id: 'reset', handler: _resetHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'select-page', handler: _selectPageHandler })
|
||||
.addStep({ id: 'setup', handler: _setupHandler })
|
||||
.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 _resetHandler(props)
|
||||
}
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Reset Configuration',
|
||||
htmlOrMarkdownPageContents: `
|
||||
This wizard will reset your configuration, so the bot will stop working on Facebook and Messenger until a new configuration is put in place, continue?
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
action: 'navigate',
|
||||
label: 'Yes',
|
||||
navigateToStep: 'reset',
|
||||
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: 'oauth', id: ctx.integrationId })
|
||||
return Boolean(result?.state?.payload?.pageToken)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const _resetHandler: WizardHandler = async ({ responses, client, ctx }) => {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: {},
|
||||
})
|
||||
return responses.redirectToExternalUrl(_getOAuthAuthorizationPromptUri(ctx))
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ responses, query, client, ctx, logger }) => {
|
||||
const authorizationCode = query.get('code')
|
||||
if (!authorizationCode) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Error extracting authorization code in OAuth callback',
|
||||
})
|
||||
}
|
||||
|
||||
const metaClient = await createAuthenticatedMetaClient({ configType: 'oauth', ctx, client, logger })
|
||||
const userToken = await metaClient.exchangeAuthorizationCodeForAccessToken(
|
||||
authorizationCode,
|
||||
_getOAuthRedirectUri(ctx)
|
||||
)
|
||||
|
||||
await patchOAuthMetaClientCredentials(client, ctx, { userToken })
|
||||
|
||||
return responses.redirectToStep('select-page')
|
||||
}
|
||||
|
||||
const _selectPageHandler: WizardHandler = async ({ responses, client, ctx, logger }) => {
|
||||
const { userToken } = await getMetaClientCredentials({ configType: 'oauth', client, ctx }).catch(() => ({
|
||||
userToken: undefined,
|
||||
}))
|
||||
if (!userToken) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: ERROR_ACCESS_TOKEN_UNAVAILABLE,
|
||||
})
|
||||
}
|
||||
|
||||
const metaClient = await createAuthenticatedMetaClient({ configType: 'oauth', ctx, client, logger })
|
||||
logger.forBot().info('Fetching Facebook pages for page selection', ctx)
|
||||
const pages = await metaClient.getFacebookPagesFromToken(userToken)
|
||||
|
||||
if (!pages || pages.length === 0) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'No Pages Found',
|
||||
htmlOrMarkdownPageContents: `No Facebook pages found for the authenticated user. Please ensure that your Facebook account has at least one page and that you have granted the necessary permissions.
|
||||
|
||||
- **1. Opt in to all current and future Pages:**
|
||||
Gives access to Pages you own only.
|
||||
*Does not give access to Pages owned by other users, even if you are an admin.*
|
||||
|
||||
- **2. Opt in to current Pages only:**
|
||||
Gives access to pages you own or have a role on. This includes Pages you have access to through Meta Business Suite.`,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Edit permissions',
|
||||
buttonType: 'primary',
|
||||
action: 'navigate',
|
||||
navigateToStep: 'reset',
|
||||
},
|
||||
{
|
||||
label: 'Close',
|
||||
buttonType: 'secondary',
|
||||
action: 'close',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return responses.displayChoices({
|
||||
choices: pages.map((page) => ({
|
||||
label: page.name,
|
||||
value: page.id,
|
||||
})),
|
||||
pageTitle: 'Select Page',
|
||||
htmlOrMarkdownPageContents: 'Choose a page to use for this bot:',
|
||||
nextStepId: 'setup',
|
||||
})
|
||||
}
|
||||
|
||||
const _setupHandler: WizardHandler = async ({
|
||||
responses,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
selectedChoice,
|
||||
setIntegrationIdentifier,
|
||||
}) => {
|
||||
const { userToken } = await getMetaClientCredentials({ configType: 'oauth', client, ctx }).catch(() => ({
|
||||
userToken: undefined,
|
||||
}))
|
||||
if (!userToken) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: ERROR_ACCESS_TOKEN_UNAVAILABLE,
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedChoice) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'No page selected',
|
||||
})
|
||||
}
|
||||
|
||||
const pageId = selectedChoice
|
||||
await patchOAuthMetaClientCredentials(client, ctx, { pageId })
|
||||
|
||||
const metaClient = await createAuthenticatedMetaClient({ configType: 'oauth', ctx, client, logger })
|
||||
const pageToken = await metaClient.getPageToken(pageId)
|
||||
if (!pageToken) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Page token is not available, please try again',
|
||||
})
|
||||
}
|
||||
|
||||
await patchOAuthMetaClientCredentials(client, ctx, { pageToken })
|
||||
metaClient.setPageToken(pageToken)
|
||||
|
||||
if (!(await metaClient.isSubscribedToWebhooks(pageId))) {
|
||||
logger.forBot().info(`Subscribing to webhooks for OAuth page ${pageId}`)
|
||||
await metaClient.subscribeToWebhooks(pageId)
|
||||
}
|
||||
|
||||
logger.forBot().info(`Successfully subscribed to webhooks for OAuth page ${pageId}`)
|
||||
|
||||
setIntegrationIdentifier(pageId)
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Configuration Complete',
|
||||
htmlOrMarkdownPageContents: `
|
||||
Your configuration is now complete, and this bot will begin responding for the selected Facebook page. You can open it on Facebook and Messenger to test it.
|
||||
|
||||
**Here are some things to verify if you are unable to communicate with your bot on Facebook and Messenger**
|
||||
|
||||
- Confirm that you are interacting with the page selected for this bot.
|
||||
- Double-check that you have published this bot.
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Okay',
|
||||
buttonType: 'primary',
|
||||
action: 'navigate',
|
||||
navigateToStep: 'end',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => {
|
||||
return responses.endWizard({
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
|
||||
const _getOAuthAuthorizationPromptUri = (ctx?: bp.Context) =>
|
||||
'https://www.facebook.com/v23.0/dialog/oauth?' +
|
||||
'client_id=' +
|
||||
bp.secrets.CLIENT_ID +
|
||||
'&redirect_uri=' +
|
||||
_getOAuthRedirectUri(ctx) +
|
||||
'&config_id=' +
|
||||
bp.secrets.OAUTH_CONFIG_ID +
|
||||
'&override_default_response_type=true' +
|
||||
'&response_type=code'
|
||||
|
||||
const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
CONVERSATION_CONNECTED_MESSAGE,
|
||||
CONVERSATION_DISCONNECTED_MESSAGE,
|
||||
extractSandboxCommand,
|
||||
} from '@botpress/common'
|
||||
import { createAuthenticatedMessengerClient } from '../../misc/messenger-client'
|
||||
import { MessengerMessagingItem, eventPayloadSchema } from '../../misc/types'
|
||||
import { getErrorFromUnknown } from '../../misc/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const NO_MESSAGE_ERROR = { status: 400, body: 'No message found in request' } as const
|
||||
|
||||
export const handler = async (props: bp.HandlerProps) => {
|
||||
const { req } = props
|
||||
const command = extractSandboxCommand(req)
|
||||
if (!command) {
|
||||
return { status: 400, body: 'No sandbox command to handle' }
|
||||
}
|
||||
|
||||
if (command === 'join') {
|
||||
return await _handleJoinCommand(props)
|
||||
} else if (command === 'leave') {
|
||||
return await _handleLeaveCommand(props)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const _handleJoinCommand = async (props: bp.HandlerProps) => {
|
||||
return await _sendConfirmationMessage(props, CONVERSATION_CONNECTED_MESSAGE)
|
||||
}
|
||||
|
||||
const _handleLeaveCommand = async (props: bp.HandlerProps) => {
|
||||
return await _sendConfirmationMessage(props, CONVERSATION_DISCONNECTED_MESSAGE)
|
||||
}
|
||||
|
||||
const _sendConfirmationMessage = async (props: bp.HandlerProps, message: string) => {
|
||||
const { client, ctx } = props
|
||||
const messagingItem = _extractMessagingItemFromRequest(props)
|
||||
if (!messagingItem) {
|
||||
return NO_MESSAGE_ERROR
|
||||
}
|
||||
const messengerClient = await createAuthenticatedMessengerClient(client, ctx)
|
||||
for (const action of ['typing_on', 'mark_seen'] as const) {
|
||||
await messengerClient.sendSenderAction(messagingItem.sender.id, action)
|
||||
}
|
||||
await messengerClient.sendText(messagingItem.sender.id, message)
|
||||
return
|
||||
}
|
||||
|
||||
const _extractMessagingItemFromRequest = (props: bp.HandlerProps): MessengerMessagingItem | undefined => {
|
||||
const { req, logger } = props
|
||||
if (!req.body) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(req.body)
|
||||
const payload = eventPayloadSchema.parse(data)
|
||||
const entry = payload.entry[0]
|
||||
if (!entry) {
|
||||
logger.error('No entry found in payload')
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!('messaging' in entry)) {
|
||||
logger.error('No messaging found in entry')
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.messaging[0]
|
||||
} catch (error) {
|
||||
logger.error('Error while extracting message from request:', getErrorFromUnknown(error).message)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import handler from './handler'
|
||||
export { handler }
|
||||
Reference in New Issue
Block a user