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,33 @@
import { z } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'
const commentIdSchema = z.object({
commentId: z
.string()
.optional()
.title('Comment ID')
.describe('The Instagram comment ID under which the direct message conversation was started'),
})
const channelMessages = {
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 channelMessages>
type ChannelMessage = sdk.z.infer<ChannelMessageDefinition['schema']>
type AssertMessageTypeHasCommentId<_T extends z.infer<typeof commentIdSchema>> = true
type _AssertMessageTypesHaveCommentId = AssertMessageTypeHasCommentId<ChannelMessage>
// File message type unsupported both ways in DM
export const { file: _file, ...dmChannelMessages } = channelMessages
+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 == "instagram" {
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"
}
}
}
+1
View File
@@ -0,0 +1 @@
This integration enables your bot to seamlessly interact with **Instagram**, allowing you to harness the power of social media in your conversational AI experiences. Connect your AI-powered chatbot to Instagram to automate interactions, engage with your audience, and deliver personalized experiences. Leverage the Instagram integration to send and receive messages, manage comments, handle direct messages, and respond in real-time. Streamline your social media strategy and elevate your customer engagement with the Instagram Integration for Botpress.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 2499.899999999999 2500"><defs><radialGradient id="a" cx="332.14" cy="2511.81" r="3263.54" gradientUnits="userSpaceOnUse"><stop offset=".09" stop-color="#fa8f21"/><stop offset=".78" stop-color="#d82d7e"/></radialGradient><radialGradient id="b" cx="1516.14" cy="2623.81" r="2572.12" gradientUnits="userSpaceOnUse"><stop offset=".64" stop-color="#8c3aaa" stop-opacity="0"/><stop offset="1" stop-color="#8c3aaa"/></radialGradient></defs><path d="M833.4 1250c0-230.11 186.49-416.7 416.6-416.7s416.7 186.59 416.7 416.7-186.59 416.7-416.7 416.7-416.6-186.59-416.6-416.7m-225.26 0c0 354.5 287.36 641.86 641.86 641.86s641.86-287.36 641.86-641.86S1604.5 608.14 1250 608.14 608.14 895.5 608.14 1250m1159.13-667.31a150 150 0 1 0 150.06-149.94h-.06a150.07 150.07 0 0 0-150 149.94M745 2267.47c-121.87-5.55-188.11-25.85-232.13-43-58.36-22.72-100-49.78-143.78-93.5s-70.88-85.32-93.5-143.68c-17.16-44-37.46-110.26-43-232.13-6.06-131.76-7.27-171.34-7.27-505.15s1.31-373.28 7.27-505.15c5.55-121.87 26-188 43-232.13 22.72-58.36 49.78-100 93.5-143.78s85.32-70.88 143.78-93.5c44-17.16 110.26-37.46 232.13-43 131.76-6.06 171.34-7.27 505-7.27s373.28 1.31 505.15 7.27c121.87 5.55 188 26 232.13 43 58.36 22.62 100 49.78 143.78 93.5s70.78 85.42 93.5 143.78c17.16 44 37.46 110.26 43 232.13 6.06 131.87 7.27 171.34 7.27 505.15s-1.21 373.28-7.27 505.15c-5.55 121.87-25.95 188.11-43 232.13-22.72 58.36-49.78 100-93.5 143.68s-85.42 70.78-143.78 93.5c-44 17.16-110.26 37.46-232.13 43-131.76 6.06-171.34 7.27-505.15 7.27s-373.28-1.21-505-7.27M734.65 7.57c-133.07 6.06-224 27.16-303.41 58.06C349 97.54 279.38 140.35 209.81 209.81S97.54 349 65.63 431.24c-30.9 79.46-52 170.34-58.06 303.41C1.41 867.93 0 910.54 0 1250s1.41 382.07 7.57 515.35c6.06 133.08 27.16 223.95 58.06 303.41 31.91 82.19 74.62 152 144.18 221.43S349 2402.37 431.24 2434.37c79.56 30.9 170.34 52 303.41 58.06C868 2498.49 910.54 2500 1250 2500s382.07-1.41 515.35-7.57c133.08-6.06 223.95-27.16 303.41-58.06 82.19-32 151.86-74.72 221.43-144.18s112.18-139.24 144.18-221.43c30.9-79.46 52.1-170.34 58.06-303.41 6.06-133.38 7.47-175.89 7.47-515.35s-1.41-382.07-7.47-515.35c-6.06-133.08-27.16-224-58.06-303.41-32-82.19-74.72-151.86-144.18-221.43S2150.95 97.54 2068.86 65.63c-79.56-30.9-170.44-52.1-303.41-58.06C1632.17 1.51 1589.56 0 1250.1 0S868 1.41 734.65 7.57" fill="url(#a)"/><path d="M833.4 1250c0-230.11 186.49-416.7 416.6-416.7s416.7 186.59 416.7 416.7-186.59 416.7-416.7 416.7-416.6-186.59-416.6-416.7m-225.26 0c0 354.5 287.36 641.86 641.86 641.86s641.86-287.36 641.86-641.86S1604.5 608.14 1250 608.14 608.14 895.5 608.14 1250m1159.13-667.31a150 150 0 1 0 150.06-149.94h-.06a150.07 150.07 0 0 0-150 149.94M745 2267.47c-121.87-5.55-188.11-25.85-232.13-43-58.36-22.72-100-49.78-143.78-93.5s-70.88-85.32-93.5-143.68c-17.16-44-37.46-110.26-43-232.13-6.06-131.76-7.27-171.34-7.27-505.15s1.31-373.28 7.27-505.15c5.55-121.87 26-188 43-232.13 22.72-58.36 49.78-100 93.5-143.78s85.32-70.88 143.78-93.5c44-17.16 110.26-37.46 232.13-43 131.76-6.06 171.34-7.27 505-7.27s373.28 1.31 505.15 7.27c121.87 5.55 188 26 232.13 43 58.36 22.62 100 49.78 143.78 93.5s70.78 85.42 93.5 143.78c17.16 44 37.46 110.26 43 232.13 6.06 131.87 7.27 171.34 7.27 505.15s-1.21 373.28-7.27 505.15c-5.55 121.87-25.95 188.11-43 232.13-22.72 58.36-49.78 100-93.5 143.68s-85.42 70.78-143.78 93.5c-44 17.16-110.26 37.46-232.13 43-131.76 6.06-171.34 7.27-505.15 7.27s-373.28-1.21-505-7.27M734.65 7.57c-133.07 6.06-224 27.16-303.41 58.06C349 97.54 279.38 140.35 209.81 209.81S97.54 349 65.63 431.24c-30.9 79.46-52 170.34-58.06 303.41C1.41 867.93 0 910.54 0 1250s1.41 382.07 7.57 515.35c6.06 133.08 27.16 223.95 58.06 303.41 31.91 82.19 74.62 152 144.18 221.43S349 2402.37 431.24 2434.37c79.56 30.9 170.34 52 303.41 58.06C868 2498.49 910.54 2500 1250 2500s382.07-1.41 515.35-7.57c133.08-6.06 223.95-27.16 303.41-58.06 82.19-32 151.86-74.72 221.43-144.18s112.18-139.24 144.18-221.43c30.9-79.46 52.1-170.34 58.06-303.41 6.06-133.38 7.47-175.89 7.47-515.35s-1.41-382.07-7.47-515.35c-6.06-133.08-27.16-224-58.06-303.41-32-82.19-74.72-151.86-144.18-221.43S2150.95 97.54 2068.86 65.63c-79.56-30.9-170.44-52.1-303.41-58.06C1632.17 1.51 1589.56 0 1250.1 0S868 1.41 734.65 7.57" fill="url(#b)"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,245 @@
import { posthogHelper } from '@botpress/common'
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
import proactiveConversation from 'bp_modules/proactive-conversation'
import proactiveUser from 'bp_modules/proactive-user'
import { dmChannelMessages } from './definitions/channel'
export const INTEGRATION_NAME = 'instagram'
export const INTEGRATION_VERSION = '4.1.11'
const commonConfigSchema = z.object({
replyToComments: z
.boolean()
.default(false)
.title('Reply to Comments')
.describe('If enabled, the bot will reply to comments on posts.'),
})
export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
title: 'Instagram',
description: 'Automate interactions, manage comments, and send/receive messages all in real-time.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: commonConfigSchema,
},
configurations: {
manual: {
title: 'Manual Configuration',
description: 'Configure by manually supplying the Meta app details',
schema: z
.object({
clientSecret: z
.string()
.secret()
.title('Client Secret')
.describe('Instagram App secret from API setup View used for webhook signature check'),
verifyToken: z
.string()
.secret()
.title('Verify Token')
.describe('Token used for verifying the Callback URL at API setup View'),
accessToken: z
.string()
.secret()
.title('Access token')
.describe('Access Token for the Instagram Account from the API setup View'),
instagramId: z.string().title('Instagram account ID').describe('Instagram Account Id from API setup View'),
})
.merge(commonConfigSchema),
},
sandbox: {
title: 'Sandbox Configuration',
description: 'Sandbox configuration, for testing purposes only',
schema: z.object({}),
identifier: {
linkTemplateScript: 'sandboxLinkTemplate.vrl',
},
},
},
states: {
oauth: {
type: 'integration',
schema: z.object({
accessToken: z
.string()
.title('Access token')
.describe('Access token used to authenticate requests to the Instagram API'),
instagramId: z
.string()
.title('Instagram account ID')
.describe('The Instagram account ID associated with the access token'),
expirationTime: z
.number()
.title('Expiration time')
.describe('The time when the access token expires, expressed as a Unix timestamp (epoch) in milliseconds'),
}),
},
},
identifier: {
extractScript: 'extract.vrl',
fallbackHandlerScript: 'fallbackHandler.vrl',
},
channels: {
channel: {
title: 'Direct Message',
description: 'Direct message conversation between an Instagram user and the bot',
messages: dmChannelMessages,
message: {
tags: {
id: {
title: 'Message ID',
description: 'The Instagram message ID',
},
senderId: {
title: 'Sender ID',
description: 'The Instagram user ID of the message sender',
},
recipientId: {
title: 'Recipient ID',
description: 'The Instagram user ID of the message recipient',
},
commentId: {
title: 'Comment ID',
description: 'The Instagram comment ID under which the direct message conversation was started',
},
},
},
conversation: {
tags: {
id: {
title: 'Conversation ID',
description: 'The Instagram user ID of the user in the conversation',
},
commentId: {
title: 'Comment ID',
description: 'The Instagram comment ID under which the direct message conversation was started',
},
lastCommentId: {
title: 'Last Comment ID',
description: 'The Instagram comment ID of the last comment from which a direct message was sent',
},
},
},
},
commentReplies: {
title: 'Comment Replies',
description: 'Replies to comments on an Instagram post',
messages: {
text: messages.defaults.text,
},
message: {
tags: {
id: {
title: 'Comment ID',
description: 'The unique ID of the comment',
},
postId: {
title: 'Post ID',
description: 'The Instagram post ID where the comment was posted',
},
},
},
conversation: {
tags: {
id: {
title: 'Comment ID',
description: 'The Instagram comment ID under which the reply was posted',
},
postId: {
title: 'Post ID',
description: 'The Instagram post ID where the comment was posted',
},
userId: {
title: 'User ID',
description: 'The Instagram user ID of the user who sent the comment',
},
},
},
},
},
actions: {},
events: {},
secrets: {
...posthogHelper.COMMON_SECRET_NAMES,
CLIENT_ID: {
description: 'The client ID of the OAuth Meta app.',
},
CLIENT_SECRET: {
description: 'The client secret of the OAuth Meta app.',
},
VERIFY_TOKEN: {
description: 'The verify token of the OAuth 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_INSTAGRAM_ID: {
description: 'Instagram ID for the Sandbox Instagram profile',
},
},
user: {
tags: {
id: {
title: 'User ID',
description: 'The Instagram user ID',
},
},
},
entities: {
user: {
title: 'User',
description: 'An Instagram user',
schema: z
.object({
id: z.string().title('ID').describe('The Instagram user ID'),
})
.title('User')
.describe('The user object fields'),
},
dm: {
title: 'Direct Message',
description: 'An Instagram direct message conversation',
schema: z
.object({
id: z.string().title('User ID').describe('The Instagram user ID of the user in the conversation'),
commentId: z
.string()
.optional()
.title('Comment ID')
.describe('The Instagram comment ID under which the direct message conversation was started'),
})
.title('Conversation')
.describe('The conversation object fields'),
},
},
attributes: {
category: 'Communication & Channels',
guideSlug: 'instagram',
repo: 'botpress',
},
})
.extend(proactiveUser, ({ entities }) => ({
entities: {
user: entities.user,
},
}))
.extend(proactiveConversation, ({ entities }) => ({
entities: {
conversation: entities.dm,
},
actions: {
getOrCreateConversation: { name: 'getOrCreateConversationDm' },
},
}))
+13
View File
@@ -0,0 +1,13 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
env = to_string!(.env)
clientId = "469201159404720"
if env == "production" {
clientId = "1280853706663377"
}
redirectUri = "{{ webhookUrl }}/oauth"
scope = "instagram_business_basic,instagram_business_manage_messages,instagram_business_content_publish,instagram_business_manage_insights,instagram_business_manage_comments"
"https://www.instagram.com/oauth/authorize?enable_fb_login=1&force_authentication=0&client_id={{ clientId }}&redirect_uri={{ redirectUri }}&state={{ webhookId }}&response_type=code&scope={{ scope }}"
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@botpresshub/instagram",
"description": "Instagram 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='./'",
"refreshTokens": "ts-node -T ./refreshTokens.ts",
"test": "vitest --run"
},
"dependencies": {
"@botpress/cli": "workspace:*",
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"axios": "^1.6.2"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"bpDependencies": {
"proactive-user": "../../interfaces/proactive-user",
"proactive-conversation": "../../interfaces/proactive-conversation"
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'dotenv/config'
import { Client } from '@botpress/client'
import { z } from '@botpress/sdk'
import axios from 'axios'
import { parseArgs } from 'node:util'
const argsSchema = z.object({
apiUrl: z.string(),
workspaceId: z.string(),
token: z.string(),
refreshToken: z.string(),
json: z.boolean(),
useProvidedToken: z.boolean(),
})
type RefreshSecretArgs = Pick<z.infer<typeof argsSchema>, 'refreshToken' | 'json'> & {
client: Client
useProvidedToken: boolean
log: (message: string) => void
}
const INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN = 'SANDBOX_ACCESS_TOKEN'
const DEFAULT_API_URL = 'https://api.botpress.cloud'
const INSTAGRAM_GRAPH_API_URL = 'https://graph.instagram.com'
async function refreshSandboxAccessToken(args: RefreshSecretArgs) {
const { refreshToken, client, useProvidedToken, log } = args
const { integrations: integrationsList } = await client.listIntegrations({
name: 'instagram',
})
if (integrationsList.length === 0) {
console.error('Integration not found')
process.exit(1)
}
const integrations = (
await Promise.all(
integrationsList.map(async (i) => {
const { integration } = await client.getIntegration({ id: i.id })
if (!integration.secrets.some((secret) => secret === INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN)) {
return null
}
return integration
})
)
).filter((i) => i !== null)
if (integrations.length === 0) {
console.error('No integration with secret', INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN, 'found')
process.exit(1)
}
let data: { access_token: string }
if (useProvidedToken) {
log('Using provided token, skipping refresh on Instagram API')
data = {
access_token: refreshToken,
}
} else {
log('Refreshing access token on Instagram API')
const response = await axios.get(`${INSTAGRAM_GRAPH_API_URL}/refresh_access_token`, {
params: {
grant_type: 'ig_refresh_token',
access_token: refreshToken,
},
})
data = response.data
}
for (const integration of integrations) {
await client.updateIntegration({
id: integration.id,
visibility: integration.visibility,
secrets: {
[INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN]: data.access_token,
},
})
log(
`Updated integration secret ${INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN} for ${integration.name} v${integration.version} (${integration.id}) with new access token`
)
}
return {
refreshedToken: data.access_token,
}
}
async function main() {
const { values } = parseArgs({
options: {
apiUrl: { type: 'string', default: process.env.BP_API_URL || DEFAULT_API_URL },
workspaceId: { type: 'string', default: process.env.BP_WORKSPACE_ID },
token: { type: 'string', default: process.env.BP_TOKEN },
refreshToken: { type: 'string' },
json: { type: 'boolean', default: false },
useProvidedToken: { type: 'boolean', default: false },
},
})
try {
const args = argsSchema.parse(values)
const { apiUrl, token, workspaceId, refreshToken, json, useProvidedToken } = args
const messages: string[] = []
const log = (message: string) => {
if (!json) {
console.info(message)
} else {
messages.push(message)
}
}
if (apiUrl !== DEFAULT_API_URL) {
log(`🔗 Using custom API URL: ${apiUrl}`)
}
const client = new Client({ apiUrl, token, workspaceId })
const refreshResult = await refreshSandboxAccessToken({
refreshToken,
client,
json,
useProvidedToken,
log,
})
const output = json
? JSON.stringify({ message: messages.join('\n'), messages, ...refreshResult }, null, 2)
: `New token: ${refreshResult.refreshedToken}`
console.info(output)
process.exit(0)
} catch (error) {
console.error('Error:', error)
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error('Unhandled error:', error)
process.exit(1)
})
}
@@ -0,0 +1,16 @@
# https://developers.facebook.com/docs/instagram-platform/webhooks/examples#message
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]
message = messagingEntry.message
identifier = if to_bool!(message.is_echo) {
null
} else {
messagingEntry.sender.id
}
identifier
}
@@ -0,0 +1,9 @@
env = to_string!(.env)
profile = if env == "production" {
"botpresssandbox"
} else {
"botpresssandboxstaging"
}
"https://ig.me/m/{{ profile }}"
@@ -0,0 +1,8 @@
# https://developers.facebook.com/docs/instagram-platform/webhooks/examples#message
body = parse_json!(.body)
message = body.entry[0].messaging[0].message
if to_bool!(message.is_echo) {
null
} else {
message.text
}
@@ -0,0 +1,8 @@
import { getOrCreateConversationDm } from './proactive-conversation'
import { getOrCreateUser } from './proactive-user'
import * as bp from '.botpress'
export default {
getOrCreateUser,
getOrCreateConversationDm,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,26 @@
import { RuntimeError } from '@botpress/sdk'
import { getCredentials, InstagramClient } from 'src/misc/client'
import * as bp from '.botpress'
export const getOrCreateConversationDm: bp.IntegrationProps['actions']['getOrCreateConversationDm'] = 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 { id: userId, commentId } = input.conversation
if (commentId) {
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: { id: userId, commentId },
discriminateByTags: ['id'],
})
return { conversationId: conversation.id }
}
const credentials = await getCredentials(client, ctx)
const metaClient = new InstagramClient(logger, credentials)
const profile = await metaClient.getUserProfile(input.conversation.id)
const { conversation } = await client.getOrCreateConversation({ channel: 'channel', tags: { id: profile.id } })
return { conversationId: conversation.id }
}
@@ -0,0 +1,20 @@
import { RuntimeError } from '@botpress/sdk'
import { getCredentials, InstagramClient } from 'src/misc/client'
import * as bp from '.botpress'
export const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({
client,
ctx,
input,
logger,
}) => {
if (ctx.configurationType === 'sandbox') {
throw new RuntimeError('Starting a conversation is not supported in sandbox mode')
}
const credentials = await getCredentials(client, ctx)
const metaClient = new InstagramClient(logger, credentials)
const profile = await metaClient.getUserProfile(input.user.id)
const { user } = await client.getOrCreateUser({ tags: { id: profile.id } })
return { userId: user.id }
}
@@ -0,0 +1,137 @@
import { RuntimeError } from '@botpress/sdk'
import { getCredentials, InstagramClient } from 'src/misc/client'
import { InstagramRecipientId } from 'src/misc/types'
import { formatGoogleMapLink, getCarouselMessage, getChoiceMessage } from 'src/misc/utils'
import * as bp from '.botpress'
export const channel: bp.IntegrationProps['channels']['channel'] = {
messages: {
text: async (props) =>
_sendMessage(props, async (client, recipientId) => {
return client.sendTextMessage(recipientId, props.payload.text)
}),
image: async (props) =>
_sendMessage(props, async (client, recipientId) => {
return client.sendImageMessage(recipientId, props.payload.imageUrl)
}),
audio: async (props) =>
_sendMessage(props, async (client, recipientId) => {
return client.sendAudioMessage(recipientId, props.payload.audioUrl)
}),
video: async (props) =>
_sendMessage(props, async (client, recipientId) => {
return client.sendVideoMessage(recipientId, props.payload.videoUrl)
}),
location: async (props) =>
_sendMessage(props, async (client, recipientId) => {
const googleMapLink = formatGoogleMapLink(props.payload)
return client.sendTextMessage(recipientId, googleMapLink)
}),
carousel: async (props) =>
_sendMessage(props, async (instagram, recipientId) => {
return instagram.sendMessage(recipientId, getCarouselMessage(props.payload))
}),
card: async (props) =>
_sendMessage(props, async (instagram, recipientId) => {
const cardMessage = getCarouselMessage({ items: [props.payload] })
return instagram.sendMessage(recipientId, cardMessage)
}),
dropdown: async (props) =>
_sendMessage(props, async (instagram, recipientId) => {
const choiceMessage = getChoiceMessage(props.payload)
return instagram.sendMessage(recipientId, choiceMessage)
}),
choice: async (props) =>
_sendMessage(props, async (instagram, recipientId) => {
return instagram.sendMessage(recipientId, getChoiceMessage(props.payload))
}),
bloc: async (props) => {
for (const item of props.payload.items) {
if (item.type === 'text') {
await _sendMessage(props, async (instagram, recipientId) => {
return instagram.sendTextMessage(recipientId, item.payload.text)
})
} else if (item.type === 'image') {
await _sendMessage(props, async (instagram, recipientId) => {
return instagram.sendImageMessage(recipientId, item.payload.imageUrl)
})
} else if (item.type === 'audio') {
await _sendMessage(props, async (instagram, recipientId) => {
return instagram.sendAudioMessage(recipientId, item.payload.audioUrl)
})
} else if (item.type === 'video') {
await _sendMessage(props, async (instagram, recipientId) => {
return instagram.sendVideoMessage(recipientId, item.payload.videoUrl)
})
} else if (item.type === 'location') {
await _sendMessage(props, async (instagram, recipientId) => {
const googleMapLink = formatGoogleMapLink(item.payload)
return instagram.sendTextMessage(recipientId, googleMapLink)
})
} else {
props.logger.forBot().warn(`Unsupported bloc item type: ${item.type}`)
}
}
},
},
}
type Channel = bp.Integration['channels']['channel']
type Messages = Channel['messages']
type MessageHandler = Messages[keyof Messages]
type SendMessageProps = Parameters<MessageHandler>[0]
async function _sendMessage(
{ ack, ctx, client, conversation, logger, payload, type }: SendMessageProps,
sendTypeSpecificMessage: (
client: InstagramClient,
toInstagramId: InstagramRecipientId
) => Promise<{ message_id: string }>
) {
const commentId = payload.commentId
let recipientId: InstagramRecipientId
logger.forBot().debug(`Received payload: ${JSON.stringify(payload)}`)
logger.forBot().debug(`Comment ID: ${commentId}`)
if (commentId) {
recipientId = { comment_id: commentId }
} else {
recipientId = { id: getRecipientId(conversation) }
}
const { accessToken, instagramId } = await getCredentials(client, ctx)
const metaClient = new InstagramClient(logger, { accessToken, instagramId })
try {
logger.forBot().debug(`Sending message of type ${type} from bot to Instagram user ${recipientId}:`, payload)
const { message_id } = await sendTypeSpecificMessage(metaClient, recipientId)
await ack({
tags: {
id: message_id,
commentId,
},
})
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().error('Error while sending message to Instagram', error)
return
}
if (commentId && conversation.tags.lastCommentId !== commentId) {
await client.updateConversation({
id: conversation.id,
tags: {
lastCommentId: commentId,
},
})
}
}
function getRecipientId(conversation: SendMessageProps['conversation']): string {
const recipientId = conversation.tags.id
if (!recipientId) {
throw new RuntimeError(`No recipient id found for user ${conversation.id}`)
}
return recipientId
}
@@ -0,0 +1,37 @@
import { RuntimeError } from '@botpress/sdk'
import { getCredentials, InstagramClient } from 'src/misc/client'
import * as bp from '.botpress'
export const commentReplies: bp.IntegrationProps['channels']['commentReplies'] = {
messages: {
text: async (props) => {
const { ack, ctx, client, logger, payload, conversation } = props
const { id } = conversation.tags
if (!id) {
throw new RuntimeError('No id found for conversation')
}
await _sendCommentMessage(ack, ctx, client, logger, payload.text, id)
},
},
}
async function _sendCommentMessage(
ack: bp.AnyAckFunction,
ctx: bp.Context,
client: bp.Client,
logger: bp.Logger,
message: string,
id: string
) {
const { accessToken, instagramId } = await getCredentials(client, ctx)
const instagramClient = new InstagramClient(logger, { accessToken, instagramId })
const { message_id } = await instagramClient.replyToComment(id, message)
await ack({
tags: {
id: message_id,
},
})
}
@@ -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']
+24
View File
@@ -0,0 +1,24 @@
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,
},
integrationConfig
)
+283
View File
@@ -0,0 +1,283 @@
import { handleErrorsDecorator as handleErrors } from '@botpress/common'
import { RuntimeError, z } from '@botpress/sdk'
import axios from 'axios'
import { InstagramRecipientId } from './types'
import * as bp from '.botpress'
type InstagramClientConfig = { accessToken?: string; instagramId?: string }
export class InstagramClient {
private _clientId: string
private _clientSecret: string
private _version: string = 'v21.0'
private _baseGraphApiUrl = 'https://graph.instagram.com'
public constructor(
private _logger: bp.Logger,
private _authConfig?: InstagramClientConfig
) {
this._clientId = bp.secrets.CLIENT_ID
this._clientSecret = bp.secrets.CLIENT_SECRET
}
@handleErrors('Failed to obtain Instagram OAuth access token from authorization code')
public async getAccessTokenFromCode(code: string): Promise<{
accessToken: string
expirationTime: number
}> {
const formData = {
client_id: this._clientId,
client_secret: this._clientSecret,
grant_type: 'authorization_code',
redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`,
code,
}
const queryString = new URLSearchParams(formData)
let res = await axios.post('https://api.instagram.com/oauth/access_token', queryString.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
const shortLivedTokenData = z
.object({
access_token: z.string(),
})
.parse(res.data)
const query = new URLSearchParams({
grant_type: 'ig_exchange_token',
client_secret: this._clientSecret,
access_token: shortLivedTokenData.access_token,
})
res = await axios.get(`${this._baseGraphApiUrl}/access_token?${query.toString()}`)
const { access_token, expires_in } = z
.object({
access_token: z.string(),
expires_in: z.number(),
})
.parse(res.data)
return { accessToken: access_token, expirationTime: Date.now() + expires_in * 1000 }
}
@handleErrors('Failed to refresh Instagram OAuth access token')
public async refreshAccessToken(): Promise<{ accessToken: string; expirationTime: number }> {
const query = new URLSearchParams({
grant_type: 'ig_refresh_token',
access_token: this._getAccessToken(),
})
const response = await axios.get(`${this._baseGraphApiUrl}/refresh_access_token?${query.toString()}`)
const { access_token, expires_in } = z
.object({
access_token: z.string(),
expires_in: z.number(),
})
.parse(response.data)
return {
accessToken: access_token,
expirationTime: Date.now() + expires_in * 1000,
}
}
public async subscribeToWebhooks(accessToken: string) {
try {
const response = await axios.post(`${this._baseGraphApiUrl}/me/subscribed_apps?access_token=${accessToken}`, {
subscribed_fields: ['messages', 'messaging_postbacks', 'comments'],
})
if (!response?.data?.success) {
throw new RuntimeError('No Success subscribing')
}
} catch (e: any) {
this._logger
.forBot()
.error(`(OAuth registration) Error subscribing to webhooks: ${e.message} -> ${e?.response?.data}`)
throw new RuntimeError('Issue subscribing to Webhooks for Page, please try again.')
}
}
public async getUserProfile(instagramId: string, additionalFields: string[] = []) {
const query = new URLSearchParams({
access_token: this._getAccessToken(),
fields: ['id', 'name', 'username', ...additionalFields].join(),
})
const url = `${this._baseGraphApiUrl}/${this._version}/${instagramId}?${query.toString()}`
const response = await axios.get<{ id: string; name: string; username: string } & Record<string, any>>(url)
return response.data
}
public updateAuthConfig(newConfig: InstagramClientConfig) {
this._authConfig = { ...this._authConfig, ...newConfig }
}
private _getAccessToken() {
if (!this._authConfig?.accessToken) {
throw new RuntimeError('The Instagram meta client is missing the accessToken')
}
return this._authConfig.accessToken
}
private _getInstagramId() {
if (!this._authConfig?.instagramId) {
throw new RuntimeError('The Instagram meta client is messing the instagramId')
}
return this._authConfig.instagramId
}
public async sendMessage(toInstagramId: InstagramRecipientId, message: any) {
const url = `${this._baseGraphApiUrl}/${this._version}/${this._getInstagramId()}/messages`
const response = await axios.post<{ recipient_id: string; message_id: string }>(
url,
{
recipient: toInstagramId,
message,
},
{
headers: {
Authorization: 'Bearer ' + this._getAccessToken(),
},
}
)
return response.data
}
public async sendTextMessage(toInstagramId: InstagramRecipientId, text: string) {
return this.sendMessage(toInstagramId, {
text,
})
}
public async sendImageMessage(toInstagramId: InstagramRecipientId, imageUrl: string) {
return this.sendMessage(toInstagramId, {
attachment: {
type: 'image',
payload: {
url: imageUrl,
},
},
})
}
public async sendAudioMessage(toInstagramId: InstagramRecipientId, audioUrl: string) {
return this.sendMessage(toInstagramId, {
attachment: {
type: 'audio',
payload: {
url: audioUrl,
},
},
})
}
public async sendVideoMessage(toInstagramId: InstagramRecipientId, videoUrl: string) {
return this.sendMessage(toInstagramId, {
attachment: {
type: 'video',
payload: {
url: videoUrl,
},
},
})
}
public async sendFileMessage(toInstagramId: InstagramRecipientId, fileUrl: string) {
return this.sendMessage(toInstagramId, {
attachment: {
type: 'file',
payload: {
url: fileUrl,
},
},
})
}
public async replyToComment(commentId: string, text: string) {
const fields = new URLSearchParams({
message: text,
})
const url = `${this._baseGraphApiUrl}/${this._version}/${commentId}/replies?${fields.toString()}`
const response = await axios.post<{ id: string }>(
url,
{},
{
headers: {
Authorization: 'Bearer ' + this._getAccessToken(),
},
}
)
const { id } = z
.object({
id: z.string(),
})
.parse(response.data)
return { message_id: id }
}
}
type GetCredentialsOutput = Required<InstagramClientConfig>
export async function getCredentials(client: bp.Client, ctx: bp.Context): Promise<GetCredentialsOutput> {
let credentials: GetCredentialsOutput
if (ctx.configurationType === 'manual') {
credentials = {
instagramId: ctx.configuration.instagramId,
accessToken: ctx.configuration.accessToken,
}
} else if (ctx.configurationType === 'sandbox') {
credentials = {
instagramId: bp.secrets.SANDBOX_INSTAGRAM_ID,
accessToken: bp.secrets.SANDBOX_ACCESS_TOKEN,
}
} else {
const {
state: {
payload: { accessToken, instagramId },
},
} = await client.getState({ type: 'integration', name: 'oauth', id: ctx.integrationId }).catch((err) => {
throw new RuntimeError(`Could not get OAuth credentials, please reauthorize (${err})`)
})
credentials = {
instagramId,
accessToken,
}
}
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
}
+166
View File
@@ -0,0 +1,166 @@
import { z } from '@botpress/sdk'
import * as bp from '.botpress'
// recepient id
export type InstagramRecipientId = { id: string } | { comment_id: string }
// Incoming message types
const instagramEntryBaseSchema = z.object({
id: z.string(),
time: z.number(),
})
const instagramMessagingItemBaseSchema = z.object({
sender: z.object({ id: z.string() }),
recipient: z.object({ id: z.string() }),
timestamp: z.number(),
})
const instagramMessagingItemAttachmentTypeSchema = z.enum([
'audio',
'file',
'image',
'share',
'story_mention',
'video',
'ig_reel',
'reel',
])
const instagramMessagingItemMessageSchema = instagramMessagingItemBaseSchema.extend({
message: z.object({
mid: z.string(),
attachments: z
.array(z.object({ type: instagramMessagingItemAttachmentTypeSchema, payload: z.object({ url: z.string() }) }))
.optional(),
is_echo: z.boolean().optional(),
quick_reply: z.object({ payload: z.string() }).optional(),
text: z.string().optional(),
}),
})
const instagramMessagingItemPostbackSchema = instagramMessagingItemBaseSchema.extend({
postback: z.object({
mid: z.string(),
title: z.string(),
payload: z.string(),
}),
})
const instagramMessagingItemOtherSchema = instagramMessagingItemBaseSchema
const instagramMessagingItemSchema = z.union([
instagramMessagingItemMessageSchema,
instagramMessagingItemPostbackSchema,
instagramMessagingItemOtherSchema,
])
const instagramMessagingEntrySchema = instagramEntryBaseSchema.extend({
messaging: z.array(instagramMessagingItemSchema),
})
const instagramCommentEntrySchema = instagramEntryBaseSchema.extend({
field: z.literal('comments'),
value: z.object({
id: z.string(),
from: z.object({
id: z.string(),
username: z.string(),
}),
text: z.string(),
media: z.object({
id: z.string(),
media_product_type: z.string(),
}),
}),
})
// For some reason, as of API version 24.0, the comment events received use the "Facebook Login for Business" format
// See: https://developers.facebook.com/docs/instagram-platform/webhooks/examples#comments
const instagramLegacyCommentEntrySchema = instagramEntryBaseSchema.extend({
changes: z.array(
z.object({
field: z.literal('comments'),
value: z.object({
from: z.object({
id: z.string(),
username: z.string(),
}),
id: z.string(),
text: z.string(),
media: z.object({
id: z.string(),
media_product_type: z.string(),
}),
}),
})
),
})
const instagramEntrySchema = z.union([
instagramMessagingEntrySchema,
instagramCommentEntrySchema,
instagramLegacyCommentEntrySchema,
])
export const instagramPayloadSchema = z.object({
object: z.literal('instagram'),
entry: z.array(instagramEntrySchema),
})
export type InstagramMessaging = z.infer<typeof instagramMessagingEntrySchema>['messaging']
export type InstagramMessagingItemMessage = z.infer<typeof instagramMessagingItemMessageSchema>
export type InstagramMessagingItemPostback = z.infer<typeof instagramMessagingItemPostbackSchema>
export type InstagramMessagingItem = z.infer<typeof instagramMessagingItemSchema>
export type InstagramCommentEntry = z.infer<typeof instagramCommentEntrySchema>
export type InstagramComment = z.infer<typeof instagramCommentEntrySchema>['value']
export type InstagramLegacyCommentEntry = z.infer<typeof instagramLegacyCommentEntrySchema>
export type InstagramLegacyComment = z.infer<typeof instagramLegacyCommentEntrySchema>['changes'][number]['value']
// Outgoing message 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
export type InstagramActionBase = { title: string }
type InstagramActionPostback = {
type: 'postback'
title: string
payload: string
} & InstagramActionBase
type InstagramActionAttachment = {
type: 'web_url'
url: string
} & InstagramActionBase
export type InstagramAction = InstagramActionPostback | InstagramActionAttachment
export type TextMessageWithQuickReplies = {
text: string
quick_replies?: {
content_type: string
title: string
payload: string
}[]
}
export type GenericTemplateElement = {
title: string
image_url?: string
subtitle?: string
default_action?: InstagramAction
buttons: InstagramAction[]
}
export type GenericTemplateMessage = {
attachment: {
type: 'template'
payload: {
template_type: 'generic'
elements: GenericTemplateElement[]
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import {
Card,
Carousel,
Choice,
Dropdown,
GenericTemplateElement,
GenericTemplateMessage,
InstagramAction,
Location,
TextMessageWithQuickReplies,
} from './types'
export function formatCardElement(payload: Card): GenericTemplateElement {
const buttons: InstagramAction[] = []
payload.actions.forEach((action) => {
switch (action.action) {
case 'postback':
buttons.push({
type: 'postback',
title: action.label,
payload: `postback:${action.value}`,
})
break
case 'say':
buttons.push({
type: 'postback',
title: action.label,
payload: `say:${action.value}`,
})
break
case 'url':
buttons.push({
type: 'web_url',
title: action.label,
url: action.value,
})
break
default:
break
}
})
return {
title: payload.title,
image_url: payload.imageUrl,
subtitle: payload.subtitle,
buttons,
}
}
export function formatGoogleMapLink(payload: Location) {
return `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}`
}
export function getCarouselMessage(payload: Carousel): GenericTemplateMessage {
return {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
elements: payload.items.map(formatCardElement),
},
},
}
}
export function getChoiceMessage(payload: Choice | Dropdown): TextMessageWithQuickReplies {
if (!payload.options.length) {
return { text: payload.text }
}
if (payload.options.length > 13) {
return {
text: `${payload.text}\n\n${payload.options.map((o) => `- ${o.label}`).join('\n')}`,
}
}
return {
text: payload.text,
quick_replies: payload.options.map((option) => ({
content_type: 'text',
title: option.label,
payload: option.value,
})),
}
}
export function safeJsonParse(x: any) {
try {
return { data: JSON.parse(x), success: true }
} catch {
return { data: x, success: false }
}
}
+88
View File
@@ -0,0 +1,88 @@
import { RuntimeError } from '@botpress/sdk'
import { InstagramClient } from 'src/misc/client'
import * as bp from '.botpress'
type RegisterProps = Parameters<bp.IntegrationProps['register']>[0]
export const register: bp.IntegrationProps['register'] = async (props: RegisterProps) => {
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 _registerOAuth = async (props: RegisterProps) => {
const { client, ctx, logger } = props
// Remove existing sandbox identifiers
await client.configureIntegration({
sandboxIdentifiers: null,
})
// Refresh token if needed
const { state } = await client
.getState({
type: 'integration',
name: 'oauth',
id: ctx.integrationId,
})
.catch(() => ({ state: undefined }))
if (!state?.payload.accessToken) {
// No access token has been set, disable refreshes
await _stopRefresh(props)
return
}
if (Date.now() > state.payload.expirationTime) {
const message = 'Access token has expired, please reauthorize'
logger.forBot().error(message)
throw new RuntimeError(message)
}
logger.debug('Refreshing access token for bot', ctx.botId)
const credentials = state.payload
const instagramClient = new InstagramClient(logger, credentials)
const { accessToken, expirationTime } = await instagramClient.refreshAccessToken().catch((err) => {
throw new RuntimeError('Error while refreshing access token from Instagram', err)
})
await client.setState({
type: 'integration',
name: 'oauth',
id: ctx.integrationId,
payload: {
...credentials,
accessToken,
expirationTime,
},
})
}
const _registerManual = async (props: RegisterProps) => {
await _clearAllIdentifiers(props)
}
const _registerSandbox = async (props: RegisterProps) => {
await _clearAllIdentifiers(props)
}
const _clearAllIdentifiers = async (props: RegisterProps) => {
const { client } = props
// Clear all OAuth and sandbox identifiers
await client.configureIntegration({
sandboxIdentifiers: null,
identifier: null,
})
}
const _stopRefresh = async ({ client, ctx, logger }: RegisterProps) => {
logger.debug('Stopping token refresh for bot', ctx.botId)
await client.configureIntegration({ scheduleRegisterCall: undefined })
}
@@ -0,0 +1,129 @@
import { isSandboxCommand } from '@botpress/common'
import { Request } from '@botpress/sdk'
import * as crypto from 'crypto'
import { getClientSecret } from 'src/misc/client'
import {
instagramPayloadSchema,
InstagramLegacyCommentEntry,
InstagramCommentEntry,
InstagramComment,
InstagramLegacyComment,
} from 'src/misc/types'
import { safeJsonParse } from 'src/misc/utils'
import { commentsHandler } from './handlers/comments'
import { messagingHandler } from './handlers/messages'
import { oauthCallbackHandler } from './handlers/oauth'
import { sandboxHandler } from './handlers/sandbox'
import { subscribeHandler } from './handlers/subscribe'
import * as bp from '.botpress'
const _canReplyToComments = (ctx: bp.Context) => {
if (ctx.configurationType === 'sandbox') {
return false
}
return ctx.configuration.replyToComments
}
const _handler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { req, logger } = props
if (req.path.startsWith('/oauth')) {
return await oauthCallbackHandler(props)
}
if (isSandboxCommand(props)) {
return await sandboxHandler(props)
}
const queryParams = new URLSearchParams(req.query)
if (queryParams.has('hub.mode')) {
return await subscribeHandler(props)
}
logger.forBot().debug('Received request with body:', req.body ?? '[empty]')
const validationResult = _validateRequestAuthentication(req, props)
if (validationResult.error) {
return { status: 401, body: validationResult.message }
}
const { data, success } = safeJsonParse(req.body)
if (!success) {
const errorMsg = 'Unable to parse request payload as JSON'
return { status: 400, body: errorMsg }
}
const payloadResult = instagramPayloadSchema.safeParse(data)
if (!payloadResult.success) {
logger.forBot().warn('Unsupported Event Payload: ' + payloadResult.error.message)
return { status: 200 }
}
const payload = payloadResult.data
for (const entry of payload.entry) {
if ('messaging' in entry) {
await messagingHandler(entry.messaging, props)
} else {
await _commentEntryHandler(entry, props)
}
}
return { status: 200 }
}
const _commentEntryHandler = async (
entry: InstagramCommentEntry | InstagramLegacyCommentEntry,
props: bp.HandlerProps
) => {
if (!_canReplyToComments(props.ctx)) {
return
}
let comments: InstagramComment[] | InstagramLegacyComment[] = []
if ('value' in entry) {
comments = [entry.value]
} else {
// Legacy "Facebook Login for Business" format
comments = entry.changes.map((change) => change.value)
}
await commentsHandler(comments, props)
}
const _validateRequestAuthentication = (
req: Request,
{ ctx }: { ctx: bp.Context }
): { error: true; message: string } | { error: false } => {
const secret = getClientSecret(ctx)
if (!secret) {
return { error: false }
}
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body ?? '')
.digest('hex')
const signature = req.headers['x-hub-signature-256']?.split('=')[1]
if (signature !== expectedSignature) {
return { error: true, message: `Invalid signature (got ${signature ?? 'none'}, expected ${expectedSignature})` }
}
return { error: false }
}
const _handlerWrapper: typeof _handler = async (props: bp.HandlerProps) => {
try {
const response = await _handler(props)
if (response && response.status !== 200) {
const errorMessage = `Instagram handler failed with status ${response.status}: ${response.body}`
props.logger.error(errorMessage)
}
return response
} catch (thrown: unknown) {
const errorMsg = thrown instanceof Error ? thrown.message : String(thrown)
const errorMessage = `Instagram handler failed with error: ${errorMsg}`
props.logger.error(errorMessage)
return { status: 500, body: errorMessage }
}
}
export const handler = _handlerWrapper
@@ -0,0 +1,63 @@
import { getCredentials } from 'src/misc/client'
import { InstagramComment, InstagramLegacyComment } from 'src/misc/types'
import * as bp from '.botpress'
export const commentsHandler = async (
comments: InstagramComment[] | InstagramLegacyComment[],
handlerProps: bp.HandlerProps
) => {
for (const comment of comments) {
await _commentHandler(comment, handlerProps)
}
}
const _commentHandler = async (comment: InstagramComment | InstagramLegacyComment, handlerProps: bp.HandlerProps) => {
const { logger, client, ctx } = handlerProps
logger.forBot().debug('Received comment from Instagram:', {
id: comment.id,
text: comment.text,
from: comment.from.username,
mediaId: comment.media.id,
})
const { from, id, text, media } = comment
const postId = media.id
// Get bot's Instagram ID to check if this is an echo (bot's own comment)
const { instagramId: botInstagramId } = await getCredentials(client, ctx)
if (from.id === botInstagramId) {
logger.forBot().debug('Ignoring echo comment from bot')
return
}
const { conversation } = await client.getOrCreateConversation({
channel: 'commentReplies',
tags: {
id,
postId,
userId: from.id,
},
discriminateByTags: ['id'],
})
const { user } = await client.getOrCreateUser({
tags: {
id: from.id,
},
})
await client.getOrCreateMessage({
type: 'text',
tags: {
id,
postId,
},
discriminateByTags: ['id'],
userId: user.id,
conversationId: conversation.id,
payload: {
text,
},
})
}
@@ -0,0 +1,173 @@
import { getCredentials, InstagramClient } from 'src/misc/client'
import {
InstagramMessaging,
InstagramMessagingItem,
InstagramMessagingItemMessage,
InstagramMessagingItemPostback,
} from 'src/misc/types'
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]
// Entry-level handler for single message entry
export const messagingHandler = async (messaging: InstagramMessaging, props: bp.HandlerProps) => {
for (const messagingItem of messaging) {
if ('message' in messagingItem) {
await _messageHandler(messagingItem, props)
}
if ('postback' in messagingItem) {
await _postbackHandler(messagingItem, props)
}
}
}
const _decodePostbackPayload = (payload: string): string => {
const VALID_PREFIXES = ['postback', 'say']
const prefix = payload.split(':')[0]
if (!prefix || !VALID_PREFIXES.includes(prefix)) {
return payload
}
return payload.slice(prefix.length + 1).trim()
}
const _postbackHandler = async (messagingItem: InstagramMessagingItemPostback, handlerProps: bp.HandlerProps) => {
const { postback } = messagingItem
handlerProps.logger.forBot().debug('Received postback from Instagram:', postback.payload)
const decodedPayload = _decodePostbackPayload(postback.payload)
await _commonMessagingHandler({
incomingMessage: { type: 'text', payload: { text: decodedPayload } },
messageId: postback.mid,
messagingItem,
handlerProps,
})
}
const _messageHandler = async (messagingItem: InstagramMessagingItemMessage, handlerProps: bp.HandlerProps) => {
const { message } = messagingItem
if (message.is_echo) {
return
}
handlerProps.logger
.forBot()
.debug(
`Received message from Instagram: 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) {
if (attachment.type === 'image') {
incomingMessages.push({ type: 'image', payload: { imageUrl: attachment.payload.url } })
} else if (attachment.type === 'video') {
incomingMessages.push({ type: 'video', payload: { videoUrl: attachment.payload.url } })
} else if (attachment.type === 'audio') {
incomingMessages.push({ type: 'audio', payload: { audioUrl: attachment.payload.url } })
} else {
handlerProps.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) {
handlerProps.logger.forBot().debug('No incoming message to process')
return
}
await _commonMessagingHandler({
incomingMessage,
messageId: message.mid,
messagingItem,
handlerProps,
})
}
const _commonMessagingHandler = async ({
incomingMessage: { type, payload },
messageId,
messagingItem,
handlerProps,
}: {
incomingMessage: IncomingMessage
messageId: string
messagingItem: InstagramMessagingItem
handlerProps: bp.HandlerProps
}) => {
const { client, ctx, logger } = 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)
logger.error(`Failed to get or create conversation for Instagram 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)
logger.error(`Failed to get or create user for Instagram user ${sender.id}: ${errorMessage}`)
throw thrown
})
if (!user.name || !user.pictureUrl) {
try {
const { accessToken } = await getCredentials(client, ctx)
const instagramClient = new InstagramClient(logger, { accessToken })
const userProfile = await instagramClient.getUserProfile(sender.id, ['profile_pic'])
logger.forBot().debug('Fetched latest Instagram user profile: ', userProfile)
if (userProfile?.name || userProfile?.profile_pic) {
await client.updateUser({ id: user.id, name: userProfile?.name, pictureUrl: userProfile?.profile_pic })
}
} catch (error) {
logger.forBot().error('Error while fetching user profile from Instagram', error)
}
}
await client.getOrCreateMessage({
type,
tags: {
id: messageId,
senderId: sender.id,
recipientId: recipient.id,
},
userId: user.id,
conversationId: conversation.id,
payload,
})
}
@@ -0,0 +1,58 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { InstagramClient } from 'src/misc/client'
import * as bp from '.botpress'
export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { client, ctx, req, logger } = props
try {
const queryParams = new URLSearchParams(req.query)
const error = queryParams.get('error')
if (error) {
throw new Error(`${error} - ${queryParams.get('error_description') ?? ''}`)
}
const code = queryParams.get('code')
if (!code) {
throw new Error('Authorization code not present in OAuth callback')
}
const instagramClient = new InstagramClient(logger)
const accessTokenInfo = await instagramClient.getAccessTokenFromCode(code)
const { accessToken, expirationTime } = accessTokenInfo
instagramClient.updateAuthConfig({ accessToken })
const profile = await instagramClient.getUserProfile('me', ['user_id'])
const instagramId = profile.user_id
instagramClient.updateAuthConfig({ instagramId })
await instagramClient.subscribeToWebhooks(accessToken)
await client.setState({
type: 'integration',
name: 'oauth',
id: ctx.integrationId,
payload: {
accessToken,
instagramId,
expirationTime,
},
})
// Refresh token before 60 days, as indicated in the documentation:
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#long-lived
await client.configureIntegration({
identifier: instagramId,
scheduleRegisterCall: 'monthly',
})
logger.debug('Token refresh scheduled for Instagram user', instagramId)
await client.updateUser({ id: ctx.botUserId, tags: { id: instagramId } })
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,76 @@
import {
CONVERSATION_CONNECTED_MESSAGE,
CONVERSATION_DISCONNECTED_MESSAGE,
extractSandboxCommand,
} from '@botpress/common'
import { getCredentials, InstagramClient } from 'src/misc/client'
import { InstagramMessagingItem, instagramPayloadSchema } from 'src/misc/types'
import * as bp from '.botpress'
const NO_MESSAGE_ERROR = { status: 400, body: 'No message found in request' } as const
export const sandboxHandler: bp.IntegrationProps['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) => {
const { client, ctx, logger } = props
const messagingItem = _extractMessagingItemFromRequest(props)
if (!messagingItem) {
return NO_MESSAGE_ERROR
}
const { accessToken, instagramId } = await getCredentials(client, ctx)
const metaClient = new InstagramClient(logger, { accessToken, instagramId })
await metaClient.sendTextMessage({ id: messagingItem.sender.id }, CONVERSATION_CONNECTED_MESSAGE)
return
}
const _handleLeaveCommand = async (props: bp.HandlerProps) => {
const { client, ctx, logger } = props
const messagingItem = _extractMessagingItemFromRequest(props)
if (!messagingItem) {
return NO_MESSAGE_ERROR
}
const { accessToken, instagramId } = await getCredentials(client, ctx)
const metaClient = new InstagramClient(logger, { accessToken, instagramId })
await metaClient.sendTextMessage({ id: messagingItem.sender.id }, CONVERSATION_DISCONNECTED_MESSAGE)
return
}
const _extractMessagingItemFromRequest = (props: bp.HandlerProps): InstagramMessagingItem | undefined => {
const { req, logger } = props
if (!req.body) {
return undefined
}
try {
const data = JSON.parse(req.body)
const payload = instagramPayloadSchema.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 (e: any) {
logger.error('Error while extracting message from request:', e?.message ?? '[unknown error]')
return undefined
}
}
@@ -0,0 +1,27 @@
import { getVerifyToken } from 'src/misc/client'
import * as bp from '.botpress'
export const subscribeHandler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) => {
const { req, ctx } = props
const queryParams = new URLSearchParams(req.query)
const mode = queryParams.get('hub.mode')
if (mode !== 'subscribe') {
return { status: 400, body: "Mode should be set to 'subscribe'" }
}
const token = queryParams.get('hub.verify_token')
const challenge = queryParams.get('hub.challenge')
if (!token || !challenge) {
return { status: 400, body: 'Missing required query parameters' }
}
if (token !== getVerifyToken(ctx)) {
return { status: 403, body: 'Invalid verify token' }
}
return {
status: 200,
body: challenge,
}
}
@@ -0,0 +1 @@
export * from './handler'
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config