Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/deploy-integrations-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
uses: ./.github/actions/deploy-integrations
with:
environment: 'production'
extra_filter: "-F '!intercom'"
force: ${{ github.event.inputs.force == 'true' }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}
Expand Down
54 changes: 33 additions & 21 deletions integrations/intercom/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,42 @@ export default new IntegrationDefinition({
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
schema: z.object({
adminId: z.string().min(1).describe('The admin ID of the Bot'),
accessToken: z.string().min(1).describe('The access token of the Intercom app'),
clientSecret: z
.string()
.min(1)
.secret()
.optional()
.describe('The client secret of the Intercom app, used for event signature validation'),
}),
},
configurations: {
manual: {
title: 'Manual Configuration',
description: 'Manual configuration, use your own Intercom app (for advanced use cases only)',
schema: z.object({
adminId: z.string().min(1).describe('The admin ID of the Bot'),
accessToken: z.string().min(1).describe('The access token of the Intercom app'),
clientSecret: z
.string()
.min(1)
.secret()
.describe(
'The client secret of the Intercom app. Required for event signature validation, even if not authenticated by OAuth'
),
}),
},
// TODO: Uncomment this once the Intercom app is approved
// identifier: {
// linkTemplateScript: 'linkTemplate.vrl',
// required: true,
// },
// schema: z.object({
// adminId: z.string().min(1).describe('The admin ID of the Bot'),
// }),
// },
// configurations: {
// manual: {
// title: 'Manual Configuration',
// description: 'Manual configuration, use your own Intercom app (for advanced use cases only)',
// schema: z.object({
// adminId: z.string().min(1).describe('The admin ID of the Bot'),
// accessToken: z.string().min(1).describe('The access token of the Intercom app'),
// clientSecret: z
// .string()
// .min(1)
// .secret()
// .describe(
// 'The client secret of the Intercom app. Required for event signature validation, even if not authenticated by OAuth'
// ),
// }),
// },
// },
},
channels: {
channel: {
Expand Down
8 changes: 5 additions & 3 deletions integrations/intercom/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Client as IntercomClient } from 'intercom-client'
import * as bp from '.botpress'

export const getAuthenticatedIntercomClient = async (client: bp.Client, ctx: bp.Context): Promise<IntercomClient> => {
if (ctx.configurationType === 'manual') {
// TODO: Change null for 'manual' once the Intercom app is approved
if (ctx.configurationType === null) {
return new IntercomClient({ tokenAuth: { token: ctx.configuration.accessToken } })
}

Expand Down Expand Up @@ -42,8 +43,9 @@ const exchangeCodeForAccessToken = async (code: string): Promise<string> => {
return accessToken
}

export const getSignatureSecret = (ctx: bp.Context): string => {
if (ctx.configurationType === 'manual') {
export const getSignatureSecret = (ctx: bp.Context): string | undefined => {
// TODO: Change null for 'manual' once the Intercom app is approved
if (ctx.configurationType === null) {
return ctx.configuration.clientSecret
}
return bp.secrets.CLIENT_SECRET
Expand Down
2 changes: 1 addition & 1 deletion integrations/intercom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ function verifyRequest(req: Request, ctx: bp.Context): VerifyResult {
}
const signature = extractSignature(req)
const secret = getSignatureSecret(ctx)
if (!signature || !isSignatureValid(signature, req.body, secret)) {
if (secret && (!signature || !isSignatureValid(signature, req.body, secret))) {
return { result: 'error', isError: true, message: 'Handler received request with invalid signature' }
}

Expand Down
17 changes: 17 additions & 0 deletions integrations/loops/definitions/actions.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 59 additions & 0 deletions integrations/loops/definitions/events.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions integrations/loops/definitions/index.ts
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions integrations/loops/definitions/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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(), value: z.string() }))
.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 doesn’t 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'),
})

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.'),
})
5 changes: 5 additions & 0 deletions integrations/loops/hub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Loops Integration

## Configuration

- **API Key - Required:** can be retrieved at [Settings > API > Generate API key][https://app.loops.so/settings?page=api].
3 changes: 3 additions & 0 deletions integrations/loops/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions integrations/loops/integration.definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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.0',
readme: 'hub.md',
icon: 'icon.svg',
configuration,
actions,
events,
})
17 changes: 17 additions & 0 deletions integrations/loops/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"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"
},
"dependencies": {
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*"
}
}
6 changes: 6 additions & 0 deletions integrations/loops/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { sendTransactionalEmail } from './send-transactional-email'
import * as bp from '.botpress'

export default {
sendTransactionalEmail,
} satisfies bp.IntegrationProps['actions']
35 changes: 35 additions & 0 deletions integrations/loops/src/actions/send-transactional-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { LoopsApi } from 'src/loops.api'
import * as bp from '.botpress'

export const sendTransactionalEmail: bp.IntegrationProps['actions']['sendTransactionalEmail'] = async (props) => {
const logger = props.logger.forBot()

const {
input: { email, transactionalId, dataVariables: dataVariableEntries, addToAudience, idempotencyKey },
ctx: {
configuration: { apiKey },
},
} = props

logger.info('This is the data variables:', { dataVariableEntries })

const dataVariables = dataVariableEntries?.reduce((acc: Record<string, string>, item) => {
acc[item.key] = item.value
return acc
}, {})

logger.info('This is the parsed data variables for the API request:', { dataVariables })

const requestBody = {
email,
transactionalId,
addToAudience,
idempotencyKey,
dataVariables: Object.keys(dataVariables).length > 0 ? dataVariables : undefined,
}

logger.info('This is the request body:', { requestBody })

const loops = new LoopsApi(apiKey, logger)
return await loops.sendTransactionalEmail(requestBody)
}
16 changes: 16 additions & 0 deletions integrations/loops/src/events/email-clicked.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { WebhookHandlerProps } from '@botpress/sdk/dist/integration'
import { campaignOrLoopEmailEventSchema } from 'definitions/schemas'
import { formatWebhookEventPayload, TValidWebhookEventPayload } from 'src/loops.webhook'
import { TIntegration } from '.botpress'

export const fireEmailClicked = async (
client: WebhookHandlerProps<TIntegration>['client'],
payload: TValidWebhookEventPayload
): Promise<void> => {
const formattedPayload = formatWebhookEventPayload(payload, campaignOrLoopEmailEventSchema)

await client.createEvent({
type: 'emailClicked',
payload: formattedPayload,
})
}
Loading
Loading