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
+1
View File
@@ -0,0 +1 @@
.botpress
@@ -0,0 +1,17 @@
import * as sdk from '@botpress/sdk'
export const actions = {
startDmConversationFromComment: {
title: 'Start DM Conversation from Comment',
description: 'Start a DM conversation from a comment',
input: {
schema: sdk.z.object({
commentId: sdk.z.string().title('Comment ID').describe('The ID of the comment to start the conversation from'),
message: sdk.z.string().title('Message').describe('The message to send to the user'),
}),
},
output: {
schema: sdk.z.object({}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,30 @@
import { z } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'
const commentIdSchema = z.object({
commentId: z
.string()
.optional()
.title('Comment ID')
.describe('The Messenger ID of the comment from which to initiate the next private-reply discussion'),
})
export const messages = {
text: { schema: sdk.messages.defaults.text.schema.merge(commentIdSchema) },
image: { schema: sdk.messages.defaults.image.schema.merge(commentIdSchema) },
audio: { schema: sdk.messages.defaults.audio.schema.merge(commentIdSchema) },
video: { schema: sdk.messages.defaults.video.schema.merge(commentIdSchema) },
file: { schema: sdk.messages.defaults.file.schema.merge(commentIdSchema) },
location: { schema: sdk.messages.defaults.location.schema.merge(commentIdSchema) },
carousel: { schema: sdk.messages.defaults.carousel.schema.merge(commentIdSchema) },
card: { schema: sdk.messages.defaults.card.schema.merge(commentIdSchema) },
dropdown: { schema: sdk.messages.defaults.dropdown.schema.merge(commentIdSchema) },
choice: { schema: sdk.messages.defaults.choice.schema.merge(commentIdSchema) },
bloc: { schema: sdk.messages.markdownBloc.schema.merge(commentIdSchema) },
} as const satisfies Record<string, { schema: z.ZodTypeAny }>
type ValueOf<T> = T[keyof T]
type ChannelMessageDefinition = ValueOf<typeof messages>
type ChannelMessage = sdk.z.infer<ChannelMessageDefinition['schema']>
type AssertMessageTypeHasCommentId<_T extends z.infer<typeof commentIdSchema>> = true
type _AssertMessageTypesHaveCommentId = AssertMessageTypeHasCommentId<ChannelMessage>
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+6
View File
@@ -0,0 +1,6 @@
body = parse_json!(.body)
if body.object == "page" {
body.entry[0].id
} else {
null
}
@@ -0,0 +1,40 @@
secrets = .secrets
q = parse_query_string!(.query)
mode = q."hub.mode"
challenge = q."hub.challenge"
verifyTokenReceived = q."hub.verify_token"
isSandbox = starts_with!(.path, "/sandbox")
verifyToken = if isSandbox {
secrets.SANDBOX_VERIFY_TOKEN
} else {
secrets.VERIFY_TOKEN
}
if mode == "subscribe" {
if verifyTokenReceived == verifyToken {
{
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": challenge
}
} else {
{
"status": 403,
"body": "Invalid verify token"
}
}
} else {
if isSandbox {
{
"status": 200, # Make sure unboud message requests are not retried
"body": "Conversation not linked to a bot"
}
} else {
{
"status": 403,
"body": "Invalid webhook request"
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<iframe src="https://www.youtube.com/embed/pOIrLMpZZqc"></iframe>
The Facebook and Messenger integration empowers your chatbot to seamlessly interact across Facebook's messaging ecosystem, combining both Messenger and Facebook Page capabilities into one powerful solution. Connect your AI-powered chatbot to engage with your audience through multiple touchpoints: respond to direct messages, reply to comments on Facebook Page posts, and even initiate private conversations directly from post comments.
## Configuration (Updates)
### Manual configuration
#### Webhook Subscriptions
Using the Messenger App in your Meta app, subscribe to the following fields:
- **messages**: Required to receive incoming direct messages
- **messaging_postbacks**: Required to handle button clicks and quick replies
- **(NEW) feed**: Required to receive and respond to comments on Facebook page posts. This field is essential for the comment interaction features and proactive conversation from comments.
## Proactive Conversations
The integration now supports proactive conversation creation, letting you seamlessly move from public interactions to private DMs. When someone comments on a post from your Facebook Page, you can use that comments ID to automatically initiate a private conversation with the user.
## Migrating from 4.x to 5.x
### Reauthorization for comment replies
If your bot was previously connected to your Facebook page using OAuth, you will need to complete the authorization flow again in order to receive messages on the `commentReplies` channel.
### Changes to the 'Get Or Create Conversation' card
The following changes have been implemented:
- Renaming of the `id` parameter to `userId`. The ID still corresponds to the Messenger ID of the user that is or will be taking part in the conversation.
- It is now the responsibility of the Bot developer to ensure the validity of the provided user ID. No additional checks are made in the action.
### Removal of `recipientId` and `senderId` conversation tags
The `recipientId` and `senderId` conversation tags were removed because of their redundancy. The recipient ID, which corresponded to the Bot's Messenger ID, can still be found in the `recipientId` message tag of incoming messages or in the `senderId` message tag of outgoing messages. The sender ID, which corresponded to the user's Messenger ID, can still be found in the `id` conversation tag.
### Markdown message type
The Markdown message type has been deprecated, so its support has been removed in the `channel` channel. You can use the text message type to send Markdown instead.
## Migrating from 3.x to 4.x
### _postback_ and _say_ messages prefix
In version 4.0 of Messenger, _postback_ and _say_ messages no longer use the prefixes `postback:` or `say:`. If your bot relied on these prefixes for logic or transitions, you can update it to depend solely on the value set for the postback.
+34
View File
@@ -0,0 +1,34 @@
<svg width="2500" height="2500" viewBox="0 0 2500 2500" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_353_7)">
<path d="M1542 904.429C1542 549.443 1254.22 261.665 899.232 261.665C544.247 261.665 256.469 549.443 256.469 904.429C256.469 1205.86 464.004 1458.8 743.967 1528.27V1100.86H611.429V904.429H743.967V819.79C743.967 601.019 842.978 499.616 1057.76 499.616C1098.49 499.616 1168.76 507.612 1197.5 515.582V693.628C1182.33 692.034 1155.98 691.237 1123.25 691.237C1017.86 691.237 977.135 731.165 977.135 834.959V904.429H1187.09L1151.02 1100.86H977.135V1542.49C1295.41 1504.05 1542.02 1233.06 1542.02 904.429H1542Z" fill="url(#paint0_linear_353_7)"/>
<path d="M1150.99 1100.86L1187.06 904.429H977.11V834.959C977.11 731.165 1017.84 691.237 1123.22 691.237C1155.95 691.237 1182.31 692.034 1197.47 693.628V515.582C1168.73 507.586 1098.46 499.616 1057.74 499.616C842.952 499.616 743.941 601.019 743.941 819.79V904.429H611.403V1100.86H743.941V1528.27C793.665 1540.61 845.677 1547.19 899.207 1547.19C925.56 1547.19 951.553 1545.57 977.084 1542.49V1100.86H1150.96H1150.99Z" fill="white"/>
</g>
<g opacity="0.6" filter="url(#filter0_f_353_7)">
<path d="M2194.82 1556.37C2194.82 1914.62 1914.13 2179.85 1552.05 2179.85C1487.03 2179.85 1424.63 2171.26 1365.99 2155.14C1354.6 2151.98 1342.49 2152.88 1331.66 2157.63L1204.09 2213.97C1170.71 2228.7 1133.05 2204.97 1131.94 2168.51L1128.45 2054.15C1128.01 2040.06 1121.68 2026.9 1111.17 2017.51C986.086 1905.67 909.289 1743.77 909.289 1556.37C909.289 1198.12 1189.97 932.887 1552.05 932.887C1914.13 932.887 2194.82 1198.12 2194.82 1556.37Z" fill="url(#paint1_linear_353_7)"/>
</g>
<path d="M2243.53 1576.25C2243.53 1934.5 1962.85 2199.73 1600.77 2199.73C1535.75 2199.73 1473.35 2191.15 1414.7 2175.03C1403.31 2171.86 1391.2 2172.76 1380.38 2177.52L1252.8 2233.85C1219.43 2248.58 1181.76 2224.85 1180.66 2188.4L1177.16 2074.03C1176.72 2059.95 1170.4 2046.78 1159.88 2037.4C1034.8 1925.56 958.004 1763.66 958.004 1576.25C958.004 1218 1238.69 952.772 1600.77 952.772C1962.85 952.772 2243.53 1218 2243.53 1576.25Z" fill="url(#paint2_linear_353_7)"/>
<path d="M1775.28 1755.73L1999.07 1409.88C2021.56 1375.12 1979.89 1334.65 1945.79 1358.18L1712.19 1519.3C1704.32 1524.73 1693.94 1524.88 1685.91 1519.69L1478.56 1385.53C1461 1374.17 1437.58 1379.21 1426.24 1396.74L1202.43 1742.6C1179.94 1777.36 1221.61 1817.82 1255.7 1794.3L1489.36 1633.15C1497.23 1627.72 1507.61 1627.57 1515.64 1632.76L1722.94 1766.89C1740.5 1778.26 1763.92 1773.22 1775.28 1755.68V1755.73Z" fill="white"/>
<defs>
<filter id="filter0_f_353_7" x="829.289" y="852.887" width="1445.53" height="1445.56" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="40" result="effect1_foregroundBlur_353_7"/>
</filter>
<linearGradient id="paint0_linear_353_7" x1="341.185" y1="261.665" x2="1905.9" y2="1956.69" gradientUnits="userSpaceOnUse">
<stop offset="0.272963" stop-color="#0866FF"/>
<stop offset="0.662809" stop-color="#003995"/>
<stop offset="0.95798" stop-color="#003995"/>
</linearGradient>
<linearGradient id="paint1_linear_353_7" x1="1552.05" y1="932.887" x2="2084.58" y2="2679.58" gradientUnits="userSpaceOnUse">
<stop offset="0.15693" stop-color="#00307F"/>
<stop offset="1" stop-color="#004CC8"/>
</linearGradient>
<linearGradient id="paint2_linear_353_7" x1="1600.77" y1="952.772" x2="2133.29" y2="2699.47" gradientUnits="userSpaceOnUse">
<stop offset="0.15693" stop-color="#307FFF"/>
<stop offset="1" stop-color="#004CC8"/>
</linearGradient>
<clipPath id="clip0_353_7">
<rect width="1285.53" height="1285.53" fill="white" transform="translate(256.469 261.665)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,253 @@
import { posthogHelper } from '@botpress/common'
import { z, IntegrationDefinition } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'
import proactiveConversation from 'bp_modules/proactive-conversation'
import proactiveUser from 'bp_modules/proactive-user'
import typingIndicator from 'bp_modules/typing-indicator'
import { actions } from './definitions/actions'
import { messages } from './definitions/channels/channel/messages'
export const INTEGRATION_NAME = 'messenger'
export const INTEGRATION_VERSION = '5.1.12'
const commonConfigSchema = z.object({
downloadMedia: z
.boolean()
.default(false)
.title('Download Media')
.describe(
'Automatically download media files using the Files API for content access. If disabled, Messenger media URLs will be used.'
),
downloadedMediaExpiry: z
.number()
.default(24)
.optional()
.title('Downloaded Media Expiry')
.describe(
'Expiry time in hours for downloaded media files. An expiry time of 0 means the files will never expire.'
),
})
const replyToCommentsSchema = z.object({
replyToComments: z
.boolean()
.default(false)
.title('Reply to Comments')
.describe('Whether to reply to comments on Facebook posts (limited to 1 reply per top-level comment)'),
})
export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
title: 'Messenger and Facebook',
description:
'Give your bot access to one of the worlds largest messaging platforms and manage your Facebook page content in one place.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
schema: commonConfigSchema.merge(replyToCommentsSchema),
},
configurations: {
manual: {
title: 'Manual Configuration',
description: 'Configure by manually supplying the Meta app details',
schema: z
.object({
clientId: z.string().title('Client ID').min(1).describe('Meta app client id'),
clientSecret: z
.string()
.title('Client Secret')
.optional()
.describe('Meta App secret used for webhook signature check. Leave empty to disable signature check.'),
verifyToken: z
.string()
.title('Verify Token')
.min(1)
.describe(
'Token used for verification when subscribing to webhooks on the Meta app (enter a random string of your choice)'
),
accessToken: z
.string()
.title('Access Token')
.min(1)
.describe('Page access token that with permissions to access the Facebook page'),
pageId: z.string().min(1).describe('Id from the Facebook page').title('Page ID'),
shouldGetUserProfile: z
.boolean()
.default(true)
.optional()
.describe('Whether to get the user profile infos from Messenger when creating a new user')
.title('Get User Profile'),
})
.merge(commonConfigSchema)
.merge(replyToCommentsSchema),
},
sandbox: {
title: 'Sandbox Configuration',
description: 'Sandbox configuration, for testing purposes only',
schema: commonConfigSchema,
identifier: {
linkTemplateScript: 'sandboxLinkTemplate.vrl',
},
},
},
identifier: {
extractScript: 'extract.vrl',
fallbackHandlerScript: 'fallbackHandler.vrl',
},
channels: {
channel: {
title: 'Messenger conversation',
description: 'Channel for a Messenger conversation',
messages,
message: {
tags: {
id: { title: 'Message ID', description: 'The Messenger ID of the message' },
commentId: {
title: 'Comment ID',
description: 'The Messenger ID of the comment for which the message is a private-reply to',
},
recipientId: { title: 'Recipient ID', description: 'The Messenger ID of the recipient' },
senderId: { title: 'Sender ID', description: 'The Messenger ID of the sender' },
},
},
conversation: {
tags: {
id: { title: 'Conversation ID', description: 'The Messenger user ID of the user in the conversation' },
commentId: {
title: 'Comment ID',
description: 'The Messenger ID of the comment from which the private-reply conversation was created',
},
lastCommentId: {
title: 'Last Comment ID',
description: 'The Messenger ID of the comment from which a private-reply message was last sent',
},
},
},
},
commentReplies: {
title: 'Comment Replies',
description: 'Channel for replies to comments on Facebook posts',
messages: { text: sdk.messages.defaults.text },
message: {
tags: {
id: { title: 'Comment ID', description: 'The unique ID of the comment' },
postId: { title: 'Post ID', description: 'The Facebook post ID where the comment was posted' },
},
},
conversation: {
tags: {
id: { title: 'Comment ID', description: 'The Facebook comment ID under which the reply was posted' },
postId: { title: 'Post ID', description: 'The Facebook post ID where the comment was posted' },
userId: { title: 'User ID', description: 'The Facebook user ID of the user who posted the comment' },
},
},
},
},
actions,
events: {},
states: {
oauth: {
type: 'integration',
schema: z.object({
// TODO: Rename to 'userToken' if we bump a major
accessToken: z.string().optional().title('Access token').describe('The access token obtained by OAuth'),
pageToken: z
.string()
.optional()
.title('Page token')
.describe('The token used to authenticate API calls related to the page'),
pageId: z.string().optional().title('Page ID').describe('The page ID'),
}),
},
},
secrets: {
...posthogHelper.COMMON_SECRET_NAMES,
CLIENT_ID: {
description: 'The client ID of your Meta app',
},
CLIENT_SECRET: {
description: 'The client secret of your Meta app',
},
OAUTH_CONFIG_ID: {
description: 'The OAuth configuration ID for the OAuth Meta app',
},
VERIFY_TOKEN: {
description: 'The verify token for the Meta Webhooks subscription',
},
ACCESS_TOKEN: {
description: 'Access token for internal Meta App',
},
SHOULD_GET_USER_PROFILE: {
description: "Whether to get the user profile infos from Messenger when creating a new user ('true' or 'false')",
},
SANDBOX_CLIENT_ID: {
description: 'The client ID of the Sandbox Meta app',
},
SANDBOX_CLIENT_SECRET: {
description: 'The client secret of the Sandbox Meta app',
},
SANDBOX_VERIFY_TOKEN: {
description: 'The verify token for the Sandbox Meta App Webhooks subscription',
},
SANDBOX_ACCESS_TOKEN: {
description: 'Access token for the Sandbox Meta App',
},
SANDBOX_PAGE_ID: {
description: 'Page ID for the Sandbox Facebook page',
},
SANDBOX_SHOULD_GET_USER_PROFILE: {
description: "Whether to get the user profile infos from Messenger when creating a new user ('true' or 'false')",
},
},
user: {
tags: { id: { title: 'User ID', description: 'The Messenger ID of the user' } },
},
entities: {
user: {
schema: z
.object({
id: z.string().title('User ID').describe('The Messenger ID of the user'),
})
.title('User')
.describe('The user object fields'),
title: 'User',
description: 'A Messenger user',
},
conversation: {
schema: z
.object({
userId: z.string().title('User ID').describe('The Messenger user ID of the user in the conversation'),
commentId: z
.string()
.optional()
.title('Comment ID')
.describe('The Messenger ID of the comment for which the private-reply conversation should be created'),
})
.title('Conversation')
.describe('The conversation object fields'),
title: 'Conversation',
description: 'A conversation with a Messenger user',
},
},
attributes: {
category: 'Communication & Channels',
guideSlug: 'messenger',
repo: 'botpress',
},
})
.extend(typingIndicator, () => ({ entities: {} }))
.extend(proactiveUser, ({ entities }) => ({
entities: {
user: entities.user,
},
}))
.extend(proactiveConversation, ({ entities }) => ({
entities: {
conversation: entities.conversation,
},
}))
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@botpresshub/messenger",
"description": "Messenger integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"uploadSandboxScripts": "ts-node -T ../../scripts/upload-sandbox-scripts.ts --integrationPath='./'",
"test": "vitest --run"
},
"dependencies": {
"@botpress/cli": "workspace:*",
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"axios": "^1.6.2",
"messaging-api-messenger": "^1.1.0"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"bpDependencies": {
"typing-indicator": "../../interfaces/typing-indicator",
"proactive-user": "../../interfaces/proactive-user",
"proactive-conversation": "../../interfaces/proactive-conversation"
}
}
@@ -0,0 +1,10 @@
# https://developers.facebook.com/docs/messenger-platform/reference/webhook-events#format
expectedSignature = split(to_string!(.headers."x-hub-signature-256"), "=")[1]
actualSignature = encode_base16(hmac(to_string!(.body), to_string!(.secrets.SANDBOX_CLIENT_SECRET)))
if actualSignature != expectedSignature {
null
} else {
body = parse_json!(.body)
messagingEntry = body.entry[0].messaging[0]
messagingEntry.sender.id
}
@@ -0,0 +1,10 @@
env = to_string!(.env)
shareableId = to_string!(.shareableId)
pageId = if env == "production" {
"647427158463603"
} else {
"757871147405221"
}
"https://m.me/{{ pageId }}?text={{ shareableId }}"
@@ -0,0 +1,4 @@
# https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messages#text-message
body = parse_json!(.body)
message = body.entry[0].messaging[0].message
message.text
@@ -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 }
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config