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,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']
+25
View File
@@ -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
)
+192
View File
@@ -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)
}
+207
View File
@@ -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),
})
+138
View File
@@ -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)
}
}
+112
View File
@@ -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 }