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
+17
View File
@@ -0,0 +1,17 @@
import { ActionDefinition, IntegrationDefinitionProps } from '@botpress/sdk'
import { sendTransactionalEmailInputSchema, sendTransactionalEmailOutputSchema } from './schemas'
const sendTransactionalEmail = {
title: 'Send Transactional Email',
description: 'Send a transactional email to a client',
input: {
schema: sendTransactionalEmailInputSchema,
},
output: {
schema: sendTransactionalEmailOutputSchema,
},
} as const satisfies ActionDefinition
export const actions = {
sendTransactionalEmail,
} as const satisfies IntegrationDefinitionProps['actions']
+59
View File
@@ -0,0 +1,59 @@
import { EventDefinition, IntegrationDefinitionProps } from '@botpress/sdk'
import { campaignOrLoopEmailEventSchema, fullEmailEventSchema } from './schemas'
const emailDelivered = {
title: 'Email Delivered',
description: 'Sent when an email is delivered to its recipient.',
schema: fullEmailEventSchema,
} as const satisfies EventDefinition
const emailSoftBounced = {
title: 'Email Soft Bounced',
description:
'Sent when an email soft bounces. Soft bounces are temporary email delivery failures, for example a connection timing out. Soft bounces are retried multiple times and some times the email is delivered.',
schema: fullEmailEventSchema,
} as const satisfies EventDefinition
const emailHardBounced = {
title: 'Email Hard Bounced',
description:
"Sent when an email hard bounces. Hard bounces are persistent email delivery failures, for example a mailbox that doesn't exist. The email will not be delivered.",
schema: fullEmailEventSchema,
} as const satisfies EventDefinition
const emailOpened = {
title: 'Email Opened',
description:
'Sent when a campaign or loop email is opened. This event is not available for transactional emails because email opens are not tracked for transactional emails.',
schema: campaignOrLoopEmailEventSchema,
} as const satisfies EventDefinition
const emailClicked = {
title: 'Email Clicked',
description:
'Sent when a link in a campaign or loop email is clicked. This event is not available for transactional emails because link clicks are not tracked in transactional emails.',
schema: campaignOrLoopEmailEventSchema,
} as const satisfies EventDefinition
const emailUnsubscribed = {
title: 'Email Unsubscribed',
description:
'Sent when a recipient unsubscribes via the unsubscribe link in an email. This event is not available for transactional emails because unsubscribe links are not included or required for transactional emails.',
schema: campaignOrLoopEmailEventSchema,
} as const satisfies EventDefinition
const emailSpamReported = {
title: 'Email Spam Reported',
description: 'Sent when a recipient reports your email as spam.',
schema: fullEmailEventSchema,
} as const satisfies EventDefinition
export const events = {
emailDelivered,
emailSoftBounced,
emailHardBounced,
emailOpened,
emailClicked,
emailUnsubscribed,
emailSpamReported,
} as const satisfies IntegrationDefinitionProps['events']
+14
View File
@@ -0,0 +1,14 @@
import { ConfigurationDefinition, z } from '@botpress/sdk'
export { actions } from './actions'
export { events } from './events'
export const configuration = {
schema: z.object({
apiKey: z.string().title('API Key').describe('The API key for Loops'),
webhookSigningSecret: z
.string()
.title('Webhook Signing Secret')
.describe('The secret key for verifying incoming Loops webhook events. Must start with "whsec_".'),
}),
} as const satisfies ConfigurationDefinition
+119
View File
@@ -0,0 +1,119 @@
import { z } from '@botpress/sdk'
export const sendTransactionalEmailInputSchema = z.object({
email: z.string().describe('The email address of the recipient.').title('Email'),
transactionalId: z.string().describe('The ID of the transactional email to send.').title('Transactional ID'),
dataVariables: z
.array(
z.object({
key: z.string().title('Key').describe('The key of the data variable'),
value: z.string().title('Value').describe('The value of the data variable'),
})
)
.describe('An object containing data as defined by the data variables added to the transactional email template.')
.title('Data Variables'),
addToAudience: z
.boolean()
.optional()
.describe(
'If true, a contact will be created in your audience using the email value (if a matching contact doesnt already exist).'
)
.title('Add to Audience'),
idempotencyKey: z
.string()
.optional()
.describe(
'Optionally send an idempotency key to avoid duplicate requests. The value should be a string of up to 100 characters and should be unique for each request. We recommend using V4 UUIDs or some other method with enough guaranteed entropy to avoid collisions during a 24 hour window. The endpoint will return a 409 Conflict response if the idempotency key has been used in the previous 24 hours.'
)
.title('Idempotency Key'),
fileIds: z
.array(z.string())
.optional()
.describe(
'The Botpress client-generated IDs of the files to be attached to the email. They must have already been uploaded to your bot via the Files API. The name of the file will be used as the filename of the attachment. Use this for a list of templates the user can choose from.'
)
.title('File IDs'),
fileData: z
.array(
z.object({
filename: z.string().title('Filename').describe('The name of the file'),
contentType: z.string().title('Content Type').describe('The MIME content type of the file'),
data: z.string().title('Data').describe('The base64-encoded data of the file'),
})
)
.optional()
.describe('The name, base64-encoded data, and MIME content type of custom files to be attached to the email.')
.title('File Data'),
})
export const sendTransactionalEmailOutputSchema = z.object({})
const _commonEventSchema = z.object({
eventName: z.string().title('Event Type').describe('The type of event as defined by Loops'),
webhookSchemaVersion: z.string().title('Webhook Schema Version').describe('Will be 1.0.0 for all events'),
eventTime: z.number().title('Event Time').describe('The Unix timestamp of the time the event occurred'),
})
const _baseEmailEventSchema = _commonEventSchema.extend({
contactIdentity: z
.object({
id: z.string().title('Contact ID').describe('The ID of the contact assigned by Loops'),
email: z.string().title('Contact Email').describe('The email address of the contact'),
userId: z
.string()
.nullable()
.title('Contact User ID')
.describe('The unique user ID created by the contact. May be null'),
})
.title('Contact Identity')
.describe('The identifiers of the contact. Includes the contact ID, email address, and user ID'),
email: z
.object({
id: z.string().title('Email ID').describe('The ID of the email'),
emailMessageId: z.string().title('Email Message ID').describe('The ID of the sent version of the email'),
subject: z.string().title('Email Subject').describe('The subject of the sent version of the email'),
})
.title('Email Details')
.describe(
'The details about an individual email sent to a recipient. Includes the email ID, the ID of the sent version, and the subject'
),
})
export const campaignOrLoopEmailEventSchema = _baseEmailEventSchema.extend({
sourceType: z
.enum(['campaign', 'loop'])
.title('Source Type')
.describe('The type of email that triggered the event. One of campaign or loop'),
campaignId: z
.string()
.optional()
.title('Campaign ID')
.describe('The ID of the campaign email. Only one of Campaign ID or Loop ID must exist.'),
loopId: z
.string()
.optional()
.title('Loop ID')
.describe('The ID of the loop email. Only one of Campaign ID or Loop ID must exist.'),
})
export const fullEmailEventSchema = _baseEmailEventSchema.extend({
sourceType: z
.enum(['campaign', 'loop', 'transactional'])
.title('Source Type')
.describe('The type of email that triggered the event. One of campaign, loop, or transactional'),
campaignId: z
.string()
.optional()
.title('Campaign ID')
.describe('The ID of the campaign email. Only one of Campaign ID or Loop ID must exist.'),
loopId: z
.string()
.optional()
.title('Loop ID')
.describe('The ID of the loop email. Only one of Campaign ID or Loop ID must exist.'),
transactionalId: z
.string()
.optional()
.title('Transactional ID')
.describe('The ID of the transactional email. Only one of Campaign ID, Loop ID, or Transactional ID must exist.'),
})
+28
View File
@@ -0,0 +1,28 @@
# Loops Integration
## Configuration
- **API Key - Required:** can be retrieved at [Settings > API > Generate API key](https://app.loops.so/settings?page=api).
- **Webhooks Signing Secret - Required:** [Settings > Webhooks > Signing Secret](https://app.loops.so/settings?page=webhooks). Available only to people with Loops' beta access.
## How to use
### Actions
**Send transactional emails:** \
To send transactional emails using this integration, a template must first be [published](https://loops.so/docs/transactional/guide). The ID of this template as well as the values of its data variables must then be passed as inputs to the `Send Transactional Email` action.
**_Attachments:_** to include attachments, files can either be [uploaded to the workspace](https://botpress.com/docs/api-reference/files-api/how-tos/creating-files) and made available to the integration by adding the following to the Files API call:
```ts
await client.uploadFile({
accessPolicies: ['integrations'],
// Rest of the fields
})
```
or can be directly included by entering the file's base64-encoded data and MIME content type in the actions input. The first method is useful for sending premade templates while the second allows the user to send personalized content.
### Events
This integration currently supports events related to sent emails (via Botpress or not). Some events, such as an email being opened or a link in the email being clicked, only support campaign or Loop emails. See [Loops' official event docs](https://loops.so/docs/webhooks#email-events) for more information.
+3
View File
@@ -0,0 +1,3 @@
<svg width="98" height="85" viewBox="0 0 98 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M55.42 1.18797e-06H42.26C31.0543 0.0132386 20.3115 4.47093 12.3888 12.3955C4.46609 20.3201 0.0105861 31.0643 0 42.27C0.0132325 53.474 4.46991 64.2153 12.3923 72.1377C20.3148 80.0601 31.056 84.5166 42.26 84.5298H55.47C66.6757 84.5192 77.4197 80.0638 85.3442 72.1411C93.2688 64.2184 97.7268 53.4757 97.74 42.27C97.7294 31.0539 93.2658 20.3008 85.3301 12.3745C77.3944 4.44821 66.6362 -0.00265476 55.42 1.18797e-06ZM5.22998 42.27C5.22998 32.457 9.12817 23.0458 16.067 16.1069C23.0059 9.16809 32.417 5.27002 42.23 5.27002C44.2972 5.26926 46.361 5.43961 48.4 5.77979C57.0318 7.20742 64.8734 11.6613 70.5206 18.3438C76.1678 25.0262 79.2517 33.501 79.22 42.25C79.2097 47.8682 77.214 53.3016 73.5853 57.5908C69.9567 61.88 64.9288 64.7489 59.39 65.6899C63.0796 62.9963 66.0805 59.4688 68.1484 55.3955C70.2164 51.3222 71.2929 46.8182 71.29 42.25C71.3013 35.8051 69.1652 29.5401 65.2194 24.4443C61.2735 19.3486 55.7426 15.7122 49.5 14.1099C47.1255 13.4962 44.6825 13.1871 42.23 13.1899C34.5356 13.2137 27.1641 16.2851 21.729 21.7314C16.2939 27.1778 13.2379 34.5556 13.23 42.25C13.2105 49.3903 15.0052 56.4181 18.4458 62.6748C21.8864 68.9315 26.86 74.2119 32.9 78.02C24.9949 75.9442 17.9979 71.3127 12.9988 64.8467C7.99962 58.3807 5.27879 50.4432 5.26001 42.27H5.22998ZM48.8201 19.4399C53.7721 20.8661 58.1255 23.8649 61.223 27.9834C64.3205 32.1019 65.9937 37.1167 65.99 42.27C65.9916 47.4216 64.3174 52.4338 61.2201 56.5503C58.1228 60.6668 53.7705 63.6641 48.8201 65.0898C43.8697 63.6641 39.5172 60.6668 36.4199 56.5503C33.3226 52.4338 31.6485 47.4216 31.65 42.27C31.6463 37.1167 33.3195 32.1019 36.417 27.9834C39.5145 23.8649 43.868 20.8661 48.8201 19.4399ZM55.42 79.25H55.27C53.2597 79.239 51.2534 79.0682 49.27 78.7397C40.852 77.3437 33.18 73.0675 27.5658 66.6416C21.9515 60.2157 18.7435 52.039 18.49 43.5098C18.49 43.0898 18.49 42.68 18.49 42.27C18.4963 36.6507 20.4906 31.2148 24.1199 26.9248C27.7492 22.6348 32.7795 19.7674 38.3201 18.8301C34.6287 21.5227 31.6257 25.0492 29.556 29.1226C27.4863 33.196 26.4083 37.701 26.41 42.27C26.401 48.7132 28.5379 54.9756 32.4836 60.0693C36.4294 65.1631 41.9591 68.798 48.2 70.3999C50.5735 71.0185 53.0171 71.3276 55.47 71.3198C63.173 71.3119 70.5584 68.2491 76.0062 62.8032C81.454 57.3573 84.5194 49.973 84.53 42.27C84.5523 35.1292 82.7588 28.0999 79.318 21.8428C75.8772 15.5856 70.902 10.306 64.86 6.5C72.7634 8.57837 79.7564 13.2146 84.7476 19.6855C89.7387 26.1564 92.447 34.0979 92.45 42.27C92.4473 47.1298 91.4875 51.9415 89.625 56.4302C87.7625 60.9189 85.034 64.9966 81.5953 68.4307C78.1567 71.8647 74.0751 74.5874 69.5839 76.4438C65.0927 78.3003 60.2798 79.2539 55.42 79.25Z" fill="#FC5200"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,22 @@
import { IntegrationDefinition } from '@botpress/sdk'
import { actions, configuration, events } from './definitions'
export default new IntegrationDefinition({
name: 'loops',
title: 'Loops',
description: 'Handle transactional emails from your chatbot.',
version: '0.1.5',
readme: 'hub.md',
icon: 'icon.svg',
configuration,
actions,
events,
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'Marketing & Email',
guideSlug: 'loops',
repo: 'botpress',
},
})
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@botpresshub/loops",
"description": "Loops integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { sendTransactionalEmail } from './send-transactional-email'
import * as bp from '.botpress'
export default {
sendTransactionalEmail,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,154 @@
import { RuntimeError } from '@botpress/sdk'
import axios from 'axios'
import { LoopsApi, TransactionalEmailAttachment } from 'src/loops.api'
import * as bp from '.botpress'
const _isValidBase64 = (str: string): boolean => {
try {
// Check if the string contains only valid base64 characters
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
if (!base64Regex.test(str)) {
return false
}
// Check if the string length is a multiple of 4 (base64 requirement)
if (str.length % 4 !== 0) {
return false
}
// Try to decode and re-encode to verify it's valid base64
const decoded = Buffer.from(str, 'base64')
const reencoded = decoded.toString('base64')
// Remove padding for comparison since it can vary
const normalizedOriginal = str.replace(/=+$/, '')
const normalizedReencoded = reencoded.replace(/=+$/, '')
return normalizedOriginal === normalizedReencoded
} catch {
return false
}
}
const _encodeFileContentFromUrl = async (url: string, logger: bp.Logger): Promise<string> => {
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
})
return Buffer.from(response.data).toString('base64')
} catch (error) {
logger.error('An error occurred when trying to get file content from URL:', error)
if (axios.isAxiosError(error)) {
if (!error.response) {
throw new RuntimeError('A network error occurred when trying to get file content from URL.')
}
throw new RuntimeError('An HTTP error occurred when trying to get file content from URL.')
}
throw new RuntimeError('An unexpected error occurred when trying to get file content from URL.')
}
}
const _getAttachmentsByFileIds = async (
fileIds: string[],
client: bp.Client,
logger: bp.Logger
): Promise<TransactionalEmailAttachment[]> => {
logger.info('These are the file IDs:', { fileIds })
let files
try {
files = await Promise.all(fileIds.map(async (fileId) => client.getFile({ id: fileId })))
} catch (error) {
logger.error('An error occurred when getting the files from the Files API:', error)
throw new RuntimeError('An error occurred when getting the files from the Files API.')
}
logger.info(
'This is information about the files returned by the Files API:',
files.map(({ file }) => file)
)
const fileAttachments = await Promise.all(
files.map(async ({ file }) => {
if (!file.size) {
throw new RuntimeError('File must be uploaded before it can be attached to an email.')
}
return {
filename: file.key,
contentType: file.contentType,
data: await _encodeFileContentFromUrl(file.url, logger),
}
})
)
return fileAttachments
}
export const sendTransactionalEmail: bp.IntegrationProps['actions']['sendTransactionalEmail'] = async (props) => {
const logger = props.logger.forBot()
const {
input: {
email,
transactionalId,
dataVariables: dataVariableEntries,
addToAudience,
idempotencyKey,
fileIds,
fileData,
},
ctx: {
configuration: { apiKey },
},
client,
} = props
logger.info('This is the data variables:', { dataVariableEntries })
const dataVariables = dataVariableEntries?.reduce((acc: Record<string, string>, item) => {
if (!item.key || !item.value) {
throw new RuntimeError('Required fields are missing from the data variables.')
}
acc[item.key] = item.value
return acc
}, {})
logger.info('This is the parsed data variables for the API request:', { dataVariables })
const attachments: TransactionalEmailAttachment[] = []
if (fileIds && fileIds.length > 0) {
attachments.push(...(await _getAttachmentsByFileIds(fileIds, client, logger)))
}
if (fileData) {
fileData.forEach((file) => {
if (!file.filename || !file.contentType || !file.data) {
throw new RuntimeError('Required fields are missing from the file data.')
}
if (!_isValidBase64(file.data)) {
throw new RuntimeError('The encoded data is not a valid base64 string.')
}
attachments.push(file)
})
}
const requestBody = {
email,
transactionalId,
addToAudience,
idempotencyKey,
dataVariables: Object.keys(dataVariables).length > 0 ? dataVariables : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
}
const loops = new LoopsApi(apiKey, logger)
return await loops.sendTransactionalEmail(requestBody)
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
import { formatWebhookEventPayload, ValidWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailClicked = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
await client.createEvent({
type: 'emailClicked',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { fullEmailEventSchema } from 'definitions/schemas'
import { formatWebhookEventPayload, ValidWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailDelivered = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
await client.createEvent({
type: 'emailDelivered',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { fullEmailEventSchema } from 'definitions/schemas'
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailHardBounced = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
await client.createEvent({
type: 'emailHardBounced',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailOpened = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
await client.createEvent({
type: 'emailOpened',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { fullEmailEventSchema } from 'definitions/schemas'
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailSoftBounced = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
await client.createEvent({
type: 'emailSoftBounced',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { fullEmailEventSchema } from 'definitions/schemas'
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailSpamReported = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, fullEmailEventSchema)
await client.createEvent({
type: 'emailSpamReported',
payload: formattedPayload,
})
}
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
import { ValidWebhookEventPayload, formatWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'
export const fireEmailUnsubscribed = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: ValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)
await client.createEvent({
type: 'emailUnsubscribed',
payload: formattedPayload,
})
}
+17
View File
@@ -0,0 +1,17 @@
import { fireEmailClicked } from './email-clicked'
import { fireEmailDelivered } from './email-delivered'
import { fireEmailHardBounced } from './email-hard-bounced'
import { fireEmailOpened } from './email-opened'
import { fireEmailSoftBounced } from './email-soft-bounced'
import { fireEmailSpamReported } from './email-spam-reported'
import { fireEmailUnsubscribed } from './email-unsubscribed'
export default {
fireEmailClicked,
fireEmailDelivered,
fireEmailHardBounced,
fireEmailOpened,
fireEmailSoftBounced,
fireEmailSpamReported,
fireEmailUnsubscribed,
}
+44
View File
@@ -0,0 +1,44 @@
import events from './events'
import { getWebhookEventPayload, verifyWebhookSignature } from './loops.webhook'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
props.logger.forBot().info('Handler received request from Loops with request:', props.req)
verifyWebhookSignature(props)
const payload = getWebhookEventPayload(props.req.body)
const client = props.client
switch (payload.eventName) {
case 'email.delivered':
await events.fireEmailDelivered(client, payload)
break
case 'email.softBounced':
await events.fireEmailSoftBounced(client, payload)
break
case 'email.hardBounced':
await events.fireEmailHardBounced(client, payload)
break
case 'email.opened':
await events.fireEmailOpened(client, payload)
break
case 'email.clicked':
await events.fireEmailClicked(client, payload)
break
case 'email.unsubscribed':
await events.fireEmailUnsubscribed(client, payload)
break
case 'email.spamReported':
await events.fireEmailSpamReported(client, payload)
break
default:
props.logger
.forBot()
.error('Unsupported event type: ' + payload.eventName + ' with payload: ' + JSON.stringify(payload))
return
}
props.logger.forBot().info('Event processed successfully with payload:', payload)
}
+18
View File
@@ -0,0 +1,18 @@
import actions from './actions'
import { handler } from './handler'
import { LoopsApi } from './loops.api'
import { validateWebhookSigningSecret } from './loops.webhook'
import * as bp from '.botpress'
export default new bp.Integration({
register: async (props) => {
const loops = new LoopsApi(props.ctx.configuration.apiKey, props.logger.forBot())
await loops.verifyApiKey()
validateWebhookSigningSecret(props.ctx.configuration.webhookSigningSecret)
},
unregister: async () => {},
actions,
channels: {},
handler,
})
+101
View File
@@ -0,0 +1,101 @@
import { IntegrationLogger, RuntimeError } from '@botpress/sdk'
import axios, { type AxiosInstance } from 'axios'
const LOOPS_API_BASE_URL = 'https://app.loops.so/api/v1'
export type TransactionalEmailAttachment = {
filename: string
contentType: string
data: string
}
type SendTransactionalEmailRequest = {
email: string
transactionalId: string
dataVariables?: Record<string, string>
addToAudience?: boolean
idempotencyKey?: string
attachments?: TransactionalEmailAttachment[]
}
type SendTransactionalEmailResponse = {}
export class LoopsApi {
private _axios: AxiosInstance
public constructor(
apiKey: string,
private _logger: IntegrationLogger
) {
this._axios = axios.create({
baseURL: LOOPS_API_BASE_URL,
headers: { Authorization: `Bearer ${apiKey}` },
})
}
public async verifyApiKey(): Promise<void> {
try {
await this._axios.get('/api-key')
this._logger.info('API key verified successfully.')
} catch (error) {
if (axios.isAxiosError(error)) {
if (!error.response) {
throw new RuntimeError('A network error occurred when trying to validate the API key.')
}
if (error.response.status === 401) {
throw new RuntimeError('Invalid or missing API key.')
}
}
throw new RuntimeError('An unexpected error occurred when trying to validate the API key.')
}
}
public async sendTransactionalEmail(req: SendTransactionalEmailRequest): Promise<SendTransactionalEmailResponse> {
const { idempotencyKey, ...reqBody } = req
if (idempotencyKey && idempotencyKey.length > 100) {
throw new RuntimeError('Idempotency key must be less than 100 characters.')
}
const headers = {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
}
this._logger.info('These are the headers of the Loops API request:', headers)
const { attachments, ...rest } = reqBody
this._logger.info('This is the request body of the Loops API request:', rest)
this._logger.info('These are the attachments of the Loops API request:', attachments)
try {
await this._axios.post('/transactional', reqBody, { headers })
this._logger.info('Transactional email sent successfully.')
return {}
} catch (error) {
if (axios.isAxiosError(error)) {
if (!error.response) {
this._logger.error('A network error occurred when calling the Loops API:', error)
throw new RuntimeError('A network error occurred when calling the Loops API.')
}
this._logger.error('An HTTP error occurred when calling the Loops API:', {
code: error.response.status,
...error.response.data,
})
if (error.response.status === 409) {
throw new RuntimeError('The same idempotency key has already been used in the previous 24 hours.')
}
throw new RuntimeError('An HTTP error occurred when calling the Loops API.')
}
this._logger.error('An unexpected error occurred when calling the Loops API:', error)
throw new RuntimeError('An unexpected error occurred when calling the Loops API, see logs for more information.')
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import { RuntimeError, z } from '@botpress/sdk'
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import crypto from 'crypto'
import { TIntegration } from '.botpress'
export type ValidWebhookEventPayload = { eventName: string }
export function validateWebhookSigningSecret(value: string): void {
if (!value || !value.startsWith('whsec_')) {
throw new RuntimeError('Webhook signing secret must start with "whsec_"')
}
if (value.includes(' ')) {
throw new RuntimeError('Webhook signing secret must not contain spaces')
}
if (!value.split('_')[1]) {
throw new RuntimeError('Secret must not be empty after "whsec_"')
}
}
export const verifyWebhookSignature = (props: WebhookHandlerProps<TIntegration>): void => {
const headers = props.req.headers
const eventId = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const webhookSignature = headers['webhook-signature']
if (!eventId || !timestamp || !webhookSignature) {
throw new RuntimeError('Webhook request is missing required headers')
}
if (!props.req.body) {
throw new RuntimeError('Webhook request is missing body')
}
const signedContent = `${eventId}.${timestamp}.${props.req.body}`
const secret = props.ctx.configuration.webhookSigningSecret
const secretBytes = Buffer.from(secret.split('_')[1]!, 'base64')
const signature = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64')
const signatureFound = webhookSignature.split(' ').some((sig) => sig.includes(`,${signature}`))
if (!signatureFound) {
throw new RuntimeError('Webhook signature is invalid')
}
props.logger.forBot().info('Webhook signature of incoming request verified successfully')
}
export const getWebhookEventPayload = (
body: WebhookHandlerProps<TIntegration>['req']['body']
): ValidWebhookEventPayload => {
if (!body) {
throw new RuntimeError('Webhook request is missing body')
}
try {
const payload = JSON.parse(body)
if (!payload.hasOwnProperty('eventName')) {
throw new RuntimeError('Webhook request is missing the event name')
}
return payload
} catch {
throw new RuntimeError('Webhook request has an invalid JSON body')
}
}
export const formatWebhookEventPayload = (
payload: ValidWebhookEventPayload,
targetSchema: z.ZodSchema
): z.infer<typeof targetSchema> => {
const formattedPayload = targetSchema.safeParse(payload)
if (!formattedPayload.success) {
throw new RuntimeError(
`The payload of this webhook event does not match the expected schema of an event of type ${payload.eventName}`
)
}
return formattedPayload.data
}
+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