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
+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
}