diff --git a/apps/docs/examples/examples.mdx b/apps/docs/examples/examples.mdx index c1bd47e1383..df619289540 100644 --- a/apps/docs/examples/examples.mdx +++ b/apps/docs/examples/examples.mdx @@ -20,4 +20,7 @@ You can use these workflows in your product by following the instructions on eac Create a welcome email drip campaign. + + Listen for WhatsApp messages and reply. + diff --git a/apps/docs/examples/whatsapp.mdx b/apps/docs/examples/whatsapp.mdx new file mode 100644 index 00000000000..ae298f8cb3a --- /dev/null +++ b/apps/docs/examples/whatsapp.mdx @@ -0,0 +1,48 @@ +--- +title: "WhatsApp" +sidebarTitle: "WhatsApp" +description: "Here are some example workflows you could create using the WhatsApp integration." +--- + +For more information about the WhatsApp integration, check out the [integration page](/integrations/apis/whatsapp). + +## Listen for WhatsApp messages and reply + +Integrations required: WhatsApp + +```ts +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendReaction, sendText } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "whatsapp-webhook", + name: "Listen for WhatsApp messages and reply", + apiKey: "", + //this listens for all WhatsApp messages sent to your WhatsApp Business account + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //these logs will appear on the run page + await ctx.logger.info(`Message data`, event.message); + await ctx.logger.info(`Phone number`, event.contacts[0]); + + //add a 🥰 reaction to the original message + const reactionResponse = await sendReaction("reaction", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + isReplyTo: event.message.id, + emoji: "🥰", + }); + + //send a text message in response + const textResponse = await sendText("text-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + text: "Hello! This is a text sent automatically from https://www.trigger.dev", + }); + + //we support all other types of WhatsApp messages (audio, video, document, location, etc) + }, +}).listen(); +``` diff --git a/apps/docs/integrations/apis/whatsapp/actions/audio-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/audio-message.mdx new file mode 100644 index 00000000000..e031bcaeb6f --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/audio-message.mdx @@ -0,0 +1,94 @@ +--- +title: "Send Audio Message" +sidebarTitle: "Audio Message" +description: "Post a audio message to WhatsApp" +--- + +Send WhatsApp audio messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The url of the audio file to be sent. + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendAudio } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic audio reply to the message + const audioResponse = await sendAudio("audio", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://ssl.gstatic.com/dictionary/static/pronunciation/2022-03-02/audio/wi/wikipedia_en_gb_1.mp3", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/contacts-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/contacts-message.mdx new file mode 100644 index 00000000000..c3aa6c8b3b0 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/contacts-message.mdx @@ -0,0 +1,103 @@ +--- +title: "Send a Contacts Message" +sidebarTitle: "Contacts Message" +description: "Post a contacts message to WhatsApp" +--- + +Send WhatsApp contacts messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + An array of contacts to be sent. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendVideo } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an contacts message + const contactsResponse = await sendContacts("contacts", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + contacts: [ + { + name: { + first_name: "Matt", + last_name: "Aitken", + formatted_name: "Matt Aitken", + }, + phones: [ + { + phone: "+12345678901", + }, + ], + }, + ], + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/document-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/document-message.mdx new file mode 100644 index 00000000000..2663d513994 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/document-message.mdx @@ -0,0 +1,99 @@ +--- +title: "Send Document Message" +sidebarTitle: "Document Message" +description: "Post a document message to WhatsApp" +--- + +Send WhatsApp document messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The url of the document to be sent. + + + + Optional caption that sits below the video + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendDocument } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic document reply to the message + const documentResponse = await sendDocument("doc", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf", + caption: "A pdf for you", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/image-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/image-message.mdx new file mode 100644 index 00000000000..0c8ded7029c --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/image-message.mdx @@ -0,0 +1,99 @@ +--- +title: "Send Image Message" +sidebarTitle: "Image Message" +description: "Post a image message to WhatsApp" +--- + +Send WhatsApp image messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The url of the image to be sent. + + + + Optional caption that sits below the video + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendImage } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic image reply to the message + const imageResponse = await sendImage("image", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://app.trigger.dev/emails/logo.png", + caption: "This is a great caption", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/location-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/location-message.mdx new file mode 100644 index 00000000000..5ccda6f9e30 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/location-message.mdx @@ -0,0 +1,109 @@ +--- +title: "Send a Location Message" +sidebarTitle: "Location Message" +description: "Post a location message to WhatsApp" +--- + +Send WhatsApp location messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The latitude of the location to be sent. + + + + The latitude of the location to be sent. + + + + Name of the location. An address must be specified for this to appear. + + + + The address of the location, in a single string. + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendVideo } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an location message + const locationResponse = await sendLocation("location", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + latitude: 37.422, + longitude: -122.084, + name: "Trigger.dev HQ", + address: "123 Main St, San Francisco, CA 94105", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/reaction-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/reaction-message.mdx new file mode 100644 index 00000000000..ec80438a9c2 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/reaction-message.mdx @@ -0,0 +1,95 @@ +--- +title: "Send a Reaction Message" +sidebarTitle: "Reaction Message" +description: "Post a reaction to a WhatsApp message" +--- + +Send WhatsApp reaction messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The emoji you want to react to the message with 🔥 + + + + This is required: add the id of the message you want to reply to. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendVideo } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic reaction to the message + const reactionResponse = await sendReaction("reaction", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + isReplyTo: event.message.id, + emoji: "🔥", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/sticker-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/sticker-message.mdx new file mode 100644 index 00000000000..c5f863fef07 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/sticker-message.mdx @@ -0,0 +1,98 @@ +--- +title: "Send a Sticker Message" +sidebarTitle: "Sticker Message" +description: "Post a sticker message to WhatsApp" +--- + +Send WhatsApp sticker messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The url of the image to be sent. Please note that only webp files will work for stickers. + + + + Optional caption that sits below the video + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendVideo } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic sticker to the message + const stickerResponse = await sendSticker("stick", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://www.tyntec.com/sites/default/files/2020-07/tyntec_rocket_sticker_512px_001_.webp", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/template-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/template-message.mdx new file mode 100644 index 00000000000..09b54fec58f --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/template-message.mdx @@ -0,0 +1,178 @@ +--- +title: "Send Template Message" +sidebarTitle: "Template Message" +description: "Post a template message to WhatsApp" +--- + +Send WhatsApp template messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The name of the template you want to send. Note that these have to be approved by Meta before they can be used. You can find this in the your WhatsApp Business dashboard. + + + + The language code of the template you want to send, e.g. "en_US". You can find this in the your WhatsApp Business dashboard. + + + + You can customise the message by providing values for variables in the template. There are three sections header, body and buttons. [Full WhatsApp documentation](https://developers.facebook.com/micro_site/url/?click_from_context_menu=true&country=GB&destination=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fwhatsapp%2Fapi%2Fmessages%2Fmessage-templates%2F%23local&event_type=click&last_nav_impression_id=1gi9JORcnblgd3gKs&max_percent_page_viewed=52&max_viewport_height_px=1272&max_viewport_width_px=2560&orig_http_referrer=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fwhatsapp%2Fapi%2Fmessages%2Fmessage-templates%2F&orig_request_uri=https%3A%2F%2Fdevelopers.facebook.com%2Fajax%2Fdocs%2Fnav%2F%3Fpath1%3Dwhatsapp%26path2%3Dapi%26path3%3Dmessages%26path4%3Dmessage-templates®ion=emea&scrolled=true&session_id=1p7ElKefs7QDpKC36&site=developers) + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendText } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send a templated message, with variables + //in this example we have included are the possible variables (mostly commented out) + const templateResponse = await sendTemplate("template-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + //this template must be approved in your WhatsApp Business Account + template: "hello_world", + languageCode: "en_US", + //you only need to include these if your template has variables + parameters: { + //only include this if your header has variables, they can be text, image, video or document + header: [ + { + type: "text", + text: "Matt", + }, + // { + // type: "image", + // image: { + // link: "https://app.trigger.dev/emails/logo.png", + // caption: "This is a logo", + // }, + // }, + // { + // type: "video", + // video: { + // link: "https://media.giphy.com/media/5i7umUqAOYYEw/giphy.mp4", + // caption: "This is a fun video", + // }, + // }, + // { + // type: "document", + // video: { + // link: "https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf", + // caption: "This is a document", + // }, + // }, + ], + //only include this if the body of your message has variables. They can be text, currency, date_time + body: [ + { + type: "text", + text: "Matt", + }, + // { + // type: "currency", + // currency: { + // amount_1000: 1000, + // code: "USD", + // fallback_value: "1000 USD", + // }, + // }, + // { + // type: "date_time", + // date_time: { + // fallback_value: "2021-01-01", + // }, + // } + ], + //only include this if the buttons of your message have variables. They can be quick_reply or call_to_action + buttons: [ + { + sub_type: "quick_reply", + parameters: [ + { + type: "payload", + payload: "buy", + }, + // { + // type: "text", + // text: "Buy now!", + // }, + ], + }, + { + sub_type: "call_to_action", + parameters: [ + { + type: "text", + text: "Buy now!", + }, + ], + }, + ], + }, + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/text-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/text-message.mdx new file mode 100644 index 00000000000..2a07935c06e --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/text-message.mdx @@ -0,0 +1,96 @@ +--- +title: "Send Text Message" +sidebarTitle: "Text Message" +description: "Post a text message to WhatsApp" +--- + +Send WhatsApp text messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The text of the message to be sent. + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendText } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic reply to the message + const textResponse = await sendText("text-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + text: "Hello! This is a text sent automatically from https://www.trigger.dev", + //this is optional – if set, the message will appear quoting the original message + isReplyTo: event.message.id, + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/actions/video-message.mdx b/apps/docs/integrations/apis/whatsapp/actions/video-message.mdx new file mode 100644 index 00000000000..4c5b306cfb4 --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/actions/video-message.mdx @@ -0,0 +1,99 @@ +--- +title: "Send Video Message" +sidebarTitle: "Video Message" +description: "Post a video message to WhatsApp" +--- + +Send WhatsApp video messages from your WhatsApp Business Account. + +## Params + + + A unique string. Please see the [Keys and Resumability](/guides/resumability) + doc for more info. + + + + + + The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number. + + + + The phone number you want to send the message to. + + + + The url of the video to be sent. + + + + Optional caption that sits below the video + + + + Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat. + + + + + +## Response + + + The contact information of who received the message + + + + An array of message ids that were sent + + + + Always `whatsapp` + + +## Example Workflows + +### Notify Slack on New GitHub Star + + + +```typescript Automated reply to WhatsApp messages +import { Trigger } from "@trigger.dev/sdk"; +import { events, sendVideo } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //send an automatic video reply to the message + const videoResponse = await sendVideo("video", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://media.giphy.com/media/5i7umUqAOYYEw/giphy.mp4", + caption: "OMGGGG CATTTT", + }); + }, +}).listen(); +``` + +```json Example response +{ + "contacts": [ + { + "input": "12345678901", + "wa_id": "12345678901" + } + ], + "messages": [ + { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + } + ], + "messaging_product": "whatsapp" +} +``` + + diff --git a/apps/docs/integrations/apis/whatsapp/events/message.mdx b/apps/docs/integrations/apis/whatsapp/events/message.mdx new file mode 100644 index 00000000000..c15d446a58e --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/events/message.mdx @@ -0,0 +1,106 @@ +--- +title: "Message Event" +sidebarTitle: "Message" +description: "Trigger a workflow whenever a WhatsApp message is received in your WhatsApp Business Account" +--- + +## Usage + +```ts +import { Trigger } from "@trigger.dev/sdk"; +import { events } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //your workflow code here + }, +}).listen(); +``` + +## Params + + + Your WhatsApp Business Account id + + +## Event + + + The message object has the type of message (e.g. text, video, audio etc) and + the message content. + + + + The contact information of the sender. + + + + Information about the account and phone number that received the message, i.e. + your WhatsApp Business Account details. + + +## Example Workflows + + + +```typescript post a message to your support Slack channel +import { Trigger } from "@trigger.dev/sdk"; +import { events } from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "demo", + on: events.messageEvent({ + accountId: "", + }), + run: async (event, ctx) => { + //get the user from your database, using the phone number + const user = await db.getUser({ phoneNumber: event.message.from }); + + if (!user) { + await ctx.logger.error(`No user to phone number ${event.message.from}`); + return; + } + + //post a message to your support Slack channel + await slack.postMessage({ + channel: "support", + text: `Support message from ${user.name} (${user.email})\n${event.message.body}`, + }); + }, +}).listen(); +``` + + + +## Example Event payload + +```json +{ + "type": "message", + "message": { + "id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "from": "12345678901", + "text": { + "body": "The quick brown fox jumps over the lazy dog" + }, + "type": "text", + "timestamp": 1675257050000 + }, + "contacts": [ + { + "wa_id": "12345678901", + "profile": { + "name": "Matt Aitken" + } + } + ], + "metadata": { + "phone_number_id": "987654321012", + "display_phone_number": "15550676999" + } +} +``` diff --git a/apps/docs/integrations/apis/whatsapp/index.mdx b/apps/docs/integrations/apis/whatsapp/index.mdx new file mode 100644 index 00000000000..4ae94d9a3ca --- /dev/null +++ b/apps/docs/integrations/apis/whatsapp/index.mdx @@ -0,0 +1,9 @@ +--- +title: "WhatsApp reference" +sidebarTitle: "Reference" +description: "Reference for the WhatsApp integration" +--- + +Navigate through the following pages for the full reference for the WhatsApp integration. + +Or [view the examples](/examples/whatsapp) of how to use the WhatsApp integration. diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 2f53c06a1f9..3ded96c3a52 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -55,10 +55,11 @@ "group": "Example workflows", "pages": [ "examples/examples", - "examples/slack", "examples/github", "examples/shopify", - "examples/resend" + "examples/slack", + "examples/resend", + "examples/whatsapp" ] }, { @@ -75,6 +76,23 @@ { "group": "APIs", "pages": [ + { + "group": "GitHub", + "pages": [ + { + "group": "Events", + "pages": [ + "integrations/apis/github/events/issue-comments", + "integrations/apis/github/events/issues", + "integrations/apis/github/events/new-star", + "integrations/apis/github/events/pull-request-comments", + "integrations/apis/github/events/pull-request-reviews", + "integrations/apis/github/events/pull-requests", + "integrations/apis/github/events/push" + ] + } + ] + }, { "group": "Slack", "pages": ["integrations/apis/slack/actions/post-message"] @@ -82,6 +100,30 @@ { "group": "Resend.com", "pages": ["integrations/apis/resend/actions/send-email"] + }, + { + "group": "WhatsApp", + "pages": [ + "integrations/apis/whatsapp/index", + { + "group": "Events", + "pages": ["integrations/apis/whatsapp/events/message"] + }, + { + "group": "Actions", + "pages": [ + "integrations/apis/whatsapp/actions/text-message", + "integrations/apis/whatsapp/actions/reaction-message", + "integrations/apis/whatsapp/actions/audio-message", + "integrations/apis/whatsapp/actions/video-message", + "integrations/apis/whatsapp/actions/document-message", + "integrations/apis/whatsapp/actions/sticker-message", + "integrations/apis/whatsapp/actions/template-message", + "integrations/apis/whatsapp/actions/location-message", + "integrations/apis/whatsapp/actions/contacts-message" + ] + } + ] } ] }, diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx index 377d5bce542..e533adcda24 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx @@ -107,7 +107,10 @@ export default function Page() { URL
- + @@ -125,6 +128,7 @@ export default function Page() { { - const requestUrl = new URL(request.url); - const rawSearchParams = requestUrl.searchParams; - const rawBody = await request.text(); - const rawHeaders = Object.fromEntries(request.headers.entries()); - - return { - rawBody, - body: this.#safeJsonParse(rawBody), - headers: rawHeaders, - searchParams: rawSearchParams, - }; - } - - #safeJsonParse(json: string): any { - try { - return JSON.parse(json); - } catch (error) { - return null; - } - } - public async call( externalSource: NonNullable, serviceIdentifier: string, request: Request ) { - const normalizedRequest = await this.#createNormalizedRequest(request); - + const normalizedRequest = await createNormalizedRequest(request); const possibleEvent = await this.#handleExternalSource( externalSource, serviceIdentifier, @@ -78,28 +57,30 @@ export class HandleExternalSource { switch (possibleEvent.status) { case "ok": { - const { id, payload, event, timestamp, context } = possibleEvent.data; - - const ingestService = new IngestEvent(); - - await ingestService.call( - { - id, - payload, - name: event, - type: externalSource.type, - service: serviceIdentifier, - timestamp, - context, - }, - externalSource.organization - ); + for (let index = 0; index < possibleEvent.data.length; index++) { + const { id, payload, event, timestamp, context } = + possibleEvent.data[index]; + + const ingestService = new IngestEvent(); + + await ingestService.call( + { + id, + payload, + name: event, + type: externalSource.type, + service: serviceIdentifier, + timestamp, + context, + }, + externalSource.organization + ); + } return true; } case "ignored": { console.log(`Ignored external event: ${possibleEvent.reason}`); - return true; } case "error": { @@ -135,14 +116,6 @@ export class HandleExternalSource { serviceIdentifier: string, request: NormalizedRequest ): Promise { - if (externalSource.manualRegistration) { - return this.#handleManualWebhook( - externalSource, - serviceIdentifier, - request - ); - } - switch (serviceIdentifier) { case "github": { return github.internalIntegration.webhooks!.handleWebhookRequest({ @@ -150,12 +123,26 @@ export class HandleExternalSource { secret: externalSource.secret ?? undefined, }); } - default: { - throw new Error( - `Could not handle webhook with unsupported service identifier: ${serviceIdentifier}` - ); + case "whatsapp": { + return whatsapp.internalIntegration.webhooks!.handleWebhookRequest({ + request, + secret: externalSource.secret ?? undefined, + }); } } + + if (externalSource.manualRegistration) { + return this.#handleManualWebhook( + externalSource, + serviceIdentifier, + request + ); + } + + return { + status: "ignored" as const, + reason: `Could not handle external source with unsupported service: ${serviceIdentifier}`, + }; } async #handleManualWebhook( @@ -190,15 +177,17 @@ export class HandleExternalSource { return { status: "ok", - data: { - id: ulid(), - payload: request.body, - event: source.event, - context: { - headers: request.headers, - externalSourceId: externalSource.id, + data: [ + { + id: ulid(), + payload: request.body, + event: source.event, + context: { + headers: request.headers, + externalSourceId: externalSource.id, + }, }, - }, + ], }; } } diff --git a/apps/webapp/app/services/externalSources/utils.ts b/apps/webapp/app/services/externalSources/utils.ts new file mode 100644 index 00000000000..6b486f37a44 --- /dev/null +++ b/apps/webapp/app/services/externalSources/utils.ts @@ -0,0 +1,38 @@ +import type { + HTTPMethod, + NormalizedRequest, +} from "@trigger.dev/integration-sdk"; +import { httpMethods } from "@trigger.dev/integration-sdk"; + +export async function createNormalizedRequest( + request: Request +): Promise { + const requestUrl = new URL(request.url); + const rawSearchParams = requestUrl.searchParams; + const rawBody = await request.text(); + const rawHeaders = Object.fromEntries(request.headers.entries()); + + if (!isMethod(request.method)) { + throw new Error(`Invalid method: ${request.method}`); + } + + return { + rawBody, + body: safeJsonParse(rawBody), + headers: rawHeaders, + searchParams: rawSearchParams, + method: request.method, + }; +} + +function safeJsonParse(json: string): any { + try { + return JSON.parse(json); + } catch (error) { + return null; + } +} + +function isMethod(str: string): str is HTTPMethod { + return !!httpMethods.find((method) => str === method); +} diff --git a/apps/webapp/app/services/externalSources/verifyExternalSource.server.ts b/apps/webapp/app/services/externalSources/verifyExternalSource.server.ts new file mode 100644 index 00000000000..163b2b5ad28 --- /dev/null +++ b/apps/webapp/app/services/externalSources/verifyExternalSource.server.ts @@ -0,0 +1,114 @@ +import * as github from "@trigger.dev/github/internal"; +import type { NormalizedRequest } from "@trigger.dev/integration-sdk"; +import * as whatsapp from "@trigger.dev/whatsapp/internal"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import type { ExternalSourceWithConnection } from "~/models/externalSource.server"; +import { createNormalizedRequest } from "./utils"; + +type ErrorEventResponse = { + status: "error"; + error: string; +}; +type IgnoredEventResponse = { + status: "ignored"; + reason: string; +}; + +type TriggeredEventResponse = { + status: "ok"; + data: any; +}; + +export type VerifyExternalEventResponse = + | TriggeredEventResponse + | IgnoredEventResponse + | ErrorEventResponse; + +export class VerifyExternalSource { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call( + externalSource: NonNullable, + serviceIdentifier: string, + request: Request + ): Promise { + const normalizedRequest = await createNormalizedRequest(request); + return this.#verifyExternalSource( + externalSource, + serviceIdentifier, + normalizedRequest + ); + } + + async #verifyExternalSource( + externalSource: NonNullable, + serviceIdentifier: string, + normalizedRequest: NormalizedRequest + ): Promise { + switch (externalSource.type) { + case "WEBHOOK": { + return this.#verifyWebhook( + externalSource, + serviceIdentifier, + normalizedRequest + ); + } + default: { + return { + status: "error", + error: `Could not handle external source with unsupported type: ${externalSource.type}`, + }; + } + } + } + + async #verifyWebhook( + externalSource: NonNullable, + serviceIdentifier: string, + request: NormalizedRequest + ): Promise { + switch (serviceIdentifier) { + case "github": { + return github.internalIntegration.webhooks!.verifyWebhookRequest({ + request, + secret: externalSource.secret ?? undefined, + }); + } + case "whatsapp": { + return whatsapp.internalIntegration.webhooks!.verifyWebhookRequest({ + request, + secret: externalSource.secret ?? undefined, + }); + } + } + + if (externalSource.manualRegistration) { + return this.#handleManualWebhook( + externalSource, + serviceIdentifier, + request + ); + } + + return { + status: "ignored" as const, + reason: `Could not handle external source with unsupported service: ${serviceIdentifier}`, + }; + } + + async #handleManualWebhook( + externalSource: NonNullable, + serviceIdentifier: string, + request: NormalizedRequest + ): Promise { + return { + status: "ok" as const, + data: undefined, + }; + } +} diff --git a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts index 1bc56205f47..4c9f5e006ec 100644 --- a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts +++ b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts @@ -13,6 +13,7 @@ import { prisma } from "~/db.server"; import type { IntegrationRequest } from "~/models/integrationRequest.server"; import { getAccessInfo } from "../accessInfo.server"; import { RedisCacheService } from "../cacheService.server"; +import { getIntegrations } from "~/models/integrations.server"; type CallResponse = | { @@ -227,37 +228,26 @@ export class PerformIntegrationRequest { integrationRequest: IntegrationRequest, cache: CacheService ): Promise { - switch (service) { - case "slack": { - return slack.internalIntegration.requests!.perform({ - accessInfo, - endpoint: integrationRequest.endpoint, - params: integrationRequest.params, - cache, - metadata: { requestId: integrationRequest.id }, - }); - } - case "shopify": { - return shopify.internalIntegration.requests!.perform({ - accessInfo, - endpoint: integrationRequest.endpoint, - params: integrationRequest.params, - cache, - metadata: { requestId: integrationRequest.id }, - }); - } - case "resend": { - return resend.internalIntegration.requests!.perform({ - accessInfo, - endpoint: integrationRequest.endpoint, - params: integrationRequest.params, - cache, - metadata: { requestId: integrationRequest.id }, - }); - } - default: { - throw new Error(`Unknown service: ${service}`); - } + const integrationInfo = getIntegrations(true).find( + (i) => i.metadata.slug === service + ); + + if (!integrationInfo) { + throw new Error(`Unknown service: ${service}`); + } + + const { requests } = integrationInfo; + + if (!requests) { + throw new Error(`Service ${service} does not support requests`); } + + return requests.perform({ + accessInfo, + endpoint: integrationRequest.endpoint, + params: integrationRequest.params, + cache, + metadata: { requestId: integrationRequest.id }, + }); } } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 824f1e39a71..042354710ac 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -29,7 +29,6 @@ "db:migrate:deploy": "prisma migrate deploy", "db:migrate:dev": "prisma migrate dev" }, - "prettier": {}, "eslintIgnore": [ "/node_modules", "/build", @@ -63,11 +62,11 @@ "@tailwindcss/forms": "^0.5.2", "@tanstack/react-table": "^8.0.0-alpha.87", "@trigger.dev/common-schemas": "workspace:*", - "integration-catalog": "workspace:*", "@trigger.dev/github": "workspace:*", - "@trigger.dev/slack": "workspace:*", "@trigger.dev/resend": "workspace:*", "@trigger.dev/shopify": "workspace:*", + "@trigger.dev/slack": "workspace:*", + "@trigger.dev/whatsapp": "workspace:*", "@typeform/embed-react": "^2.14.1", "@uiw/react-codemirror": "^4.13.2", "bcryptjs": "^2.4.3", @@ -83,6 +82,7 @@ "emails": "workspace:*", "express": "^4.18.1", "humanize-duration": "^3.27.3", + "integration-catalog": "workspace:*", "internal-bridge": "workspace:*", "internal-platform": "workspace:*", "internal-pulsar": "workspace:*", @@ -136,8 +136,8 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", - "@trigger.dev/tailwind-config": "workspace:*", "@trigger.dev/integration-sdk": "workspace:*", + "@trigger.dev/tailwind-config": "workspace:*", "@types/bcryptjs": "^2.4.2", "@types/compression": "^1.7.2", "@types/eslint": "^8.4.6", @@ -175,7 +175,7 @@ "npm-run-all": "^4.1.5", "postcss": "^8.4.14", "prettier": "^2.6.2", - "prettier-plugin-tailwindcss": "^0.1.11", + "prettier-plugin-tailwindcss": "^0.1.13", "prisma": "^4.3.0", "react-date-range": "^1.4.0", "start-server-and-test": "^1.14.0", @@ -190,4 +190,4 @@ "engines": { "node": ">=16.0.0" } -} \ No newline at end of file +} diff --git a/apps/webapp/prettier.config.js b/apps/webapp/prettier.config.js new file mode 100644 index 00000000000..d83c9951c5b --- /dev/null +++ b/apps/webapp/prettier.config.js @@ -0,0 +1,3 @@ +module.exports = { + plugins: [require('prettier-plugin-tailwindcss')], +} \ No newline at end of file diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index fdec63efed0..ad1b2a1cb7d 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -44,6 +44,8 @@ "@trigger.dev/resend/*": ["../../integrations/resend/src/*"], "@trigger.dev/shopify": ["../../integrations/shopify/src/index"], "@trigger.dev/shopify/*": ["../../integrations/shopify/src/*"], + "@trigger.dev/whatsapp": ["../../integrations/whatsapp/src/index"], + "@trigger.dev/whatsapp/*": ["../../integrations/whatsapp/src/*"], "integration-catalog": ["../../packages/integration-catalog/src/index"], "integration-catalog/*": ["../../packages/integration-catalog/src/*"] }, diff --git a/config-packages/tsconfig/examples.json b/config-packages/tsconfig/examples.json index 6426fe4eaa9..20c484b39c5 100644 --- a/config-packages/tsconfig/examples.json +++ b/config-packages/tsconfig/examples.json @@ -47,6 +47,12 @@ ], "@trigger.dev/shopify/*": [ "../../integrations/shopify/src/*" + ], + "@trigger.dev/whatsapp": [ + "../../integrations/whatsapp/src/index" + ], + "@trigger.dev/whatsapp/*": [ + "../../integrations/whatsapp/src/*" ] } } diff --git a/examples/whatsapp/package.json b/examples/whatsapp/package.json new file mode 100644 index 00000000000..5f74d45c81a --- /dev/null +++ b/examples/whatsapp/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "@examples/whatsapp", + "version": "0.0.1", + "description": "Example trigger.dev workflow that uses the WhatsApp Webhook integration", + "dependencies": { + "@trigger.dev/whatsapp": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "zod": "^3.20.2" + }, + "devDependencies": { + "@trigger.dev/tsconfig": "workspace:*", + "@types/node": "16", + "tsx": "^3.12.0" + }, + "scripts": { + "dev": "tsx src/index.ts" + } +} diff --git a/examples/whatsapp/src/index.ts b/examples/whatsapp/src/index.ts new file mode 100644 index 00000000000..559b409e3aa --- /dev/null +++ b/examples/whatsapp/src/index.ts @@ -0,0 +1,117 @@ +import { Trigger } from "@trigger.dev/sdk"; +import { + events, + sendAudio, + sendContacts, + sendDocument, + sendImage, + sendLocation, + sendReaction, + sendSticker, + sendTemplate, + sendText, + sendVideo, +} from "@trigger.dev/whatsapp"; + +new Trigger({ + id: "whatsapp-webhook", + name: "WhatsApp webhook", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: events.messageEvent({ + accountId: "114848614845931", + }), + run: async (event, ctx) => { + await ctx.logger.info(`Message data`, event.message); + await ctx.logger.info(`Phone number`, event.contacts[0]); + + const reactionResponse = await sendReaction("reaction", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + isReplyTo: event.message.id, + emoji: "🥰", + }); + + const templateResponse = await sendTemplate("template-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + template: "hello_world", + languageCode: "en_US", + }); + + const textResponse = await sendText("text-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + text: "Hello! This is a text sent automatically from https://www.trigger.dev", + }); + + const replyResponse = await sendText("reply-text-msg", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + text: "Hi, this is a reply to the automated message that was just sent", + isReplyTo: textResponse.messages[0].id, + }); + + const imageResponse = await sendImage("image", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://app.trigger.dev/emails/logo.png", + caption: "This is a genius caption", + }); + + const videoResponse = await sendVideo("video", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://media.giphy.com/media/5i7umUqAOYYEw/giphy.mp4", + caption: "OMGGGG CATTTT", + }); + + const audioResponse = await sendAudio("audio", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://ssl.gstatic.com/dictionary/static/pronunciation/2022-03-02/audio/wi/wikipedia_en_gb_1.mp3", + }); + + const documentResponse = await sendDocument("doc", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf", + caption: "A pdf", + }); + + const stickerResponse = await sendSticker("stick", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + url: "https://www.tyntec.com/sites/default/files/2020-07/tyntec_rocket_sticker_512px_001_.webp", + }); + + const locationResponse = await sendLocation("location", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + latitude: 37.422, + longitude: -122.084, + name: "Trigger.dev HQ", + address: "123 Main St, San Francisco, CA 94105", + }); + + const contactsResponse = await sendContacts("contacts", { + fromId: event.metadata.phone_number_id, + to: event.message.from, + contacts: [ + { + name: { + first_name: "Matt", + last_name: "Aitken", + formatted_name: "Matt Aitken", + }, + phones: [ + { + phone: "+12345678901", + }, + ], + }, + ], + }); + }, +}).listen(); diff --git a/examples/whatsapp/tsconfig.json b/examples/whatsapp/tsconfig.json new file mode 100644 index 00000000000..00ad22f1a85 --- /dev/null +++ b/examples/whatsapp/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@trigger.dev/tsconfig/examples.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "**/*.test.*"] +} diff --git a/integrations/github/src/internal/webhooks.ts b/integrations/github/src/internal/webhooks.ts index 24dde60e1ba..672294f3c6d 100644 --- a/integrations/github/src/internal/webhooks.ts +++ b/integrations/github/src/internal/webhooks.ts @@ -39,6 +39,13 @@ export class GitHubWebhookIntegration implements WebhookIntegration { } } + verifyWebhookRequest(options: HandleWebhookOptions) { + return { + status: "ok" as const, + data: undefined, + }; + } + handleWebhookRequest(options: HandleWebhookOptions) { const deliveryId = options.request.headers["x-github-delivery"]; const hookId = options.request.headers["x-github-hook-id"]; @@ -80,7 +87,7 @@ export class GitHubWebhookIntegration implements WebhookIntegration { return { status: "ok" as const, - data: { id, payload: options.request.body, event, context }, + data: [{ id, payload: options.request.body, event, context }], }; } diff --git a/integrations/whatsapp/README.md b/integrations/whatsapp/README.md new file mode 100644 index 00000000000..ae6e2cc584e --- /dev/null +++ b/integrations/whatsapp/README.md @@ -0,0 +1,3 @@ +# Trigger.dev WhatsApp Business integration + +View more documentation [here](https://docs.trigger.dev) diff --git a/integrations/whatsapp/package.json b/integrations/whatsapp/package.json new file mode 100644 index 00000000000..bbed470c5c4 --- /dev/null +++ b/integrations/whatsapp/package.json @@ -0,0 +1,37 @@ +{ + "name": "@trigger.dev/whatsapp", + "version": "0.1.16", + "description": "The official WhatsApp Business integration for Trigger.dev", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/index.js", + "dist/index.d.ts", + "dist/index.js.map" + ], + "devDependencies": { + "@trigger.dev/integration-sdk": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "@trigger.dev/tsconfig": "workspace:*", + "@types/node": "16", + "rimraf": "^3.0.2", + "tsup": "^6.5.0", + "@types/debug": "^4.1.7" + }, + "peerDependencies": { + "@trigger.dev/sdk": "workspace:*" + }, + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && npm run build:tsup", + "build:tsup": "tsup" + }, + "dependencies": { + "ulid": "^2.3.0", + "zod": "^3.20.2", + "debug": "^4.3.4" + } +} diff --git a/integrations/whatsapp/src/events.ts b/integrations/whatsapp/src/events.ts new file mode 100644 index 00000000000..11ef09e22db --- /dev/null +++ b/integrations/whatsapp/src/events.ts @@ -0,0 +1,29 @@ +import type { TriggerEvent } from "@trigger.dev/sdk"; +import * as schemas from "./schemas"; + +export function messageEvent(params: { + accountId: string; +}): TriggerEvent { + return { + metadata: { + type: "WEBHOOK", + service: "whatsapp", + name: "messages", + filter: { + service: ["whatsapp"], + payload: {}, + event: ["messages"], + }, + source: schemas.WebhookSourceSchema.parse({ + subresource: "messages", + accountId: params.accountId, + verifyPayload: { + enabled: true, + }, + event: "messages", + }), + manualRegistration: true, + }, + schema: schemas.messageEvents.messageEventSchema, + }; +} diff --git a/integrations/whatsapp/src/index.ts b/integrations/whatsapp/src/index.ts new file mode 100644 index 00000000000..f40fa18d725 --- /dev/null +++ b/integrations/whatsapp/src/index.ts @@ -0,0 +1,305 @@ +import { getTriggerRun } from "@trigger.dev/sdk/index"; +import { z } from "zod"; +import * as events from "./events"; +import { schemas } from "./internal"; +export { events }; + +export type SendTemplateMessageOptions = z.infer< + typeof schemas.messages.SendTemplateMessageBodySchema +>; + +export type SendTemplateMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendTemplate( + key: string, + message: SendTemplateMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendTemplate outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendTemplate", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendTextMessageOptions = z.infer< + typeof schemas.messages.SendTextMessageBodySchema +>; + +export type SendTextMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendText( + key: string, + message: SendTextMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendText outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendText", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendReactionMessageOptions = z.infer< + typeof schemas.messages.SendReactionMessageBodySchema +>; + +export type SendReactionMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendReaction( + key: string, + message: SendReactionMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendReaction outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendReaction", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendImageMessageOptions = z.infer< + typeof schemas.messages.SendImageMessageBodySchema +>; + +export type SendImageMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendImage( + key: string, + message: SendImageMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendImage outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendImage", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendAudioMessageOptions = z.infer< + typeof schemas.messages.SendAudioMessageBodySchema +>; + +export type SendAudioMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendAudio( + key: string, + message: SendAudioMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendAudio outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendAudio", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendVideoMessageOptions = z.infer< + typeof schemas.messages.SendVideoMessageBodySchema +>; + +export type SendVideoMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendVideo( + key: string, + message: SendVideoMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendVideo outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendVideo", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendDocumentMessageOptions = z.infer< + typeof schemas.messages.SendDocumentMessageBodySchema +>; + +export type SendDocumentMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendDocument( + key: string, + message: SendDocumentMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendDocument outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendDocument", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendStickerMessageOptions = z.infer< + typeof schemas.messages.SendStickerMessageBodySchema +>; + +export type SendStickerMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendSticker( + key: string, + message: SendStickerMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendSticker outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendSticker", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendLocationMessageOptions = z.infer< + typeof schemas.messages.SendLocationMessageBodySchema +>; + +export type SendLocationMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendLocation( + key: string, + message: SendLocationMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendLocation outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendLocation", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type SendContactsMessageOptions = z.infer< + typeof schemas.messages.SendContactsMessageBodySchema +>; + +export type SendContactsMessageResponse = z.infer< + typeof schemas.messages.SendMessageSuccessResponseSchema +>; + +export async function sendContacts( + key: string, + message: SendContactsMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call sendContacts outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "whatsapp", + endpoint: "message.sendContacts", + params: message, + response: { + schema: schemas.messages.SendMessageSuccessResponseSchema, + }, + }); + + return output; +} diff --git a/integrations/whatsapp/src/internal.ts b/integrations/whatsapp/src/internal.ts new file mode 100644 index 00000000000..6d894e9c95a --- /dev/null +++ b/integrations/whatsapp/src/internal.ts @@ -0,0 +1,30 @@ +import type { + IntegrationMetadata, + InternalIntegration, +} from "@trigger.dev/integration-sdk"; +import { WhatsAppWebhookIntegration } from "./internal/webhooks"; +import { WhatsAppRequestIntegration } from "./internal/requests"; + +const webhooks = new WhatsAppWebhookIntegration(); +const requests = new WhatsAppRequestIntegration(); + +const metadata: IntegrationMetadata = { + name: "WhatsApp Business Account", + slug: "whatsapp", + icon: "/integrations/whatsapp.png", + enabledFor: "admins", + authentication: { + type: "api_key", + header_name: "Authorization", + header_type: "access_token", + documentation: `You need to generate a "permanent access token"\nFollow the steps in [this documentation](https://developers.facebook.com/docs/whatsapp/business-management-api/get-started#1--acquire-an-access-token-using-a-system-user-or-facebook-login).`, + }, +}; + +export const internalIntegration: InternalIntegration = { + metadata, + webhooks, + requests, +}; + +export * as schemas from "./schemas"; diff --git a/integrations/whatsapp/src/internal/requests.ts b/integrations/whatsapp/src/internal/requests.ts new file mode 100644 index 00000000000..535eb09440d --- /dev/null +++ b/integrations/whatsapp/src/internal/requests.ts @@ -0,0 +1,1094 @@ +import { HttpEndpoint, HttpService } from "@trigger.dev/integration-sdk"; +import type { + DisplayProperties, + CacheService, + PerformedRequestResponse, + PerformRequestOptions, + RequestIntegration, + AccessInfo, +} from "@trigger.dev/integration-sdk"; +import debug from "debug"; +import { getAccessToken } from "@trigger.dev/integration-sdk"; +import { z } from "zod"; +import { + SendTemplateMessageBodySchema, + SendTemplateMessageRequestBodySchema, + SendTextMessageBodySchema, + SendTextMessageRequestBodySchema, + SendReactionMessageBodySchema, + SendReactionMessageRequestBodySchema, + SendImageMessageBodySchema, + SendImageMessageRequestBodySchema, + SendLocationMessageRequestBodySchema, + SendLocationMessageBodySchema, + SendContactsMessageRequestBodySchema, + SendContactsMessageBodySchema, + SendMessageResponseSchema, + SendAudioMessageRequestBodySchema, + SendVideoMessageRequestBodySchema, + SendDocumentMessageRequestBodySchema, + SendStickerMessageRequestBodySchema, + SendAudioMessageBodySchema, + SendVideoMessageBodySchema, + SendDocumentMessageBodySchema, + SendStickerMessageBodySchema, +} from "../schemas/messages"; + +const log = debug("trigger:integrations:whatsapp"); + +type SendTemplateMessageRequestBody = z.infer< + typeof SendTemplateMessageRequestBodySchema +>; + +type SendTextMessageRequestBody = z.infer< + typeof SendTextMessageRequestBodySchema +>; + +type SendReactionMessageRequestBody = z.infer< + typeof SendReactionMessageRequestBodySchema +>; + +type SendImageMessageRequestBody = z.infer< + typeof SendImageMessageRequestBodySchema +>; + +type SendAudioMessageRequestBody = z.infer< + typeof SendAudioMessageRequestBodySchema +>; + +type SendVideoMessageRequestBody = z.infer< + typeof SendVideoMessageRequestBodySchema +>; + +type SendDocumentMessageRequestBody = z.infer< + typeof SendDocumentMessageRequestBodySchema +>; + +type SendStickerMessageRequestBody = z.infer< + typeof SendStickerMessageRequestBodySchema +>; + +type SendLocationMessageRequestBody = z.infer< + typeof SendLocationMessageRequestBodySchema +>; + +type SendContactsMessageRequestBody = z.infer< + typeof SendContactsMessageRequestBodySchema +>; + +export class WhatsAppRequestIntegration implements RequestIntegration { + #sendTemplateMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendTemplateMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendTextMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendTextMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendReactionMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendReactionMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendImageMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendImageMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendAudioMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendAudioMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendVideoMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendVideoMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendDocumentMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendDocumentMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendStickerMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendStickerMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendLocationMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendLocationMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + #sendContactsMessageEndpoint = new HttpEndpoint< + typeof SendMessageResponseSchema, + typeof SendContactsMessageRequestBodySchema + >({ + response: SendMessageResponseSchema, + method: "POST", + path: "/messages", + }); + + constructor( + private readonly baseUrl: string = "https://graph.facebook.com/v15.0" + ) {} + + perform(options: PerformRequestOptions): Promise { + switch (options.endpoint) { + case "message.sendTemplate": { + return this.#sendTemplateMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendText": { + return this.#sendTextMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendReaction": { + return this.#sendReactionMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendImage": { + return this.#sendImageMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendAudio": { + return this.#sendAudioMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendVideo": { + return this.#sendVideoMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendDocument": { + return this.#sendDocumentMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendSticker": { + return this.#sendStickerMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendLocation": { + return this.#sendLocationMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "message.sendContacts": { + return this.#sendContactsMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + default: { + throw new Error(`Unknown endpoint: ${options.endpoint}`); + } + } + } + + displayProperties(endpoint: string, params: any): DisplayProperties { + switch (endpoint) { + case "message.sendTemplate": { + const parsedParams = SendTemplateMessageBodySchema.parse(params); + return { + title: `Send template (${parsedParams.template}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendText": { + const parsedParams = SendTextMessageBodySchema.parse(params); + return { + title: `Send text to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendReaction": { + const parsedParams = SendReactionMessageBodySchema.parse(params); + return { + title: `Send ${parsedParams.emoji} reaction to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendImage": { + const parsedParams = SendImageMessageBodySchema.parse(params); + return { + title: `Send image (${parsedParams.url}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendAudio": { + const parsedParams = SendAudioMessageBodySchema.parse(params); + return { + title: `Send audio (${parsedParams.url}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendVideo": { + const parsedParams = SendVideoMessageBodySchema.parse(params); + return { + title: `Send video (${parsedParams.url}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendDocument": { + const parsedParams = SendDocumentMessageBodySchema.parse(params); + return { + title: `Send document (${parsedParams.url}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendSticker": { + const parsedParams = SendStickerMessageBodySchema.parse(params); + return { + title: `Send sticker (${parsedParams.url}) to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendLocation": { + const parsedParams = SendLocationMessageBodySchema.parse(params); + return { + title: `Send location to ${parsedParams.to}`, + properties: [], + }; + } + case "message.sendContacts": { + const parsedParams = SendContactsMessageBodySchema.parse(params); + return { + title: `Send contacts to ${parsedParams.to}`, + properties: [], + }; + } + default: { + throw new Error(`Unknown endpoint: ${endpoint}`); + } + } + } + + async #sendTemplateMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendTemplateMessageBodySchema.parse(params); + + log("message.sendTemplate %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const components: SendTemplateMessageRequestBody["template"]["components"] = + []; + + if (parsedParams.parameters?.header) { + components.push({ + type: "header", + parameters: parsedParams.parameters.header, + }); + } + + if (parsedParams.parameters?.body) { + components.push({ + type: "body", + parameters: parsedParams.parameters.body, + }); + } + + if (parsedParams.parameters?.buttons) { + parsedParams.parameters.buttons.forEach((button, index) => { + components.push({ + type: "button", + sub_type: button.sub_type, + index: index, + parameters: button.parameters, + }); + }); + } + + const request: SendTemplateMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "template", + template: { + name: parsedParams.template, + language: { + policy: "deterministic", + code: parsedParams.languageCode, + }, + components, + }, + }; + + const response = await service.performRequest( + this.#sendTemplateMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendTemplate failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendTemplate performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendTextMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendTextMessageBodySchema.parse(params); + + log("message.sendText %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendTextMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "text", + text: { + body: parsedParams.text, + preview_url: parsedParams.preview_url ?? true, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendTextMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendText failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendText performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendReactionMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendReactionMessageBodySchema.parse(params); + + log("message.sendReaction %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendReactionMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "reaction", + reaction: { + message_id: parsedParams.isReplyTo, + emoji: parsedParams.emoji, + }, + }; + + const response = await service.performRequest( + this.#sendReactionMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendReaction failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendReaction performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendImageMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendImageMessageBodySchema.parse(params); + + log("message.sendImage %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendImageMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "image", + image: { + link: parsedParams.url, + caption: parsedParams.caption, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendImageMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendImage failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendImage performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendAudioMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendAudioMessageBodySchema.parse(params); + + log("message.sendAudio %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendAudioMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "audio", + audio: { + link: parsedParams.url, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendAudioMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendAudio failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendAudio performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendVideoMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendVideoMessageBodySchema.parse(params); + + log("message.sendVideo %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendVideoMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "video", + video: { + link: parsedParams.url, + caption: parsedParams.caption, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendVideoMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendVideo failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendVideo performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendDocumentMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendDocumentMessageBodySchema.parse(params); + + log("message.sendDocument %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendDocumentMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "document", + document: { + link: parsedParams.url, + caption: parsedParams.caption, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendDocumentMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendDocument failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendDocument performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendStickerMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendStickerMessageBodySchema.parse(params); + + log("message.sendSticker %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendStickerMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "sticker", + sticker: { + link: parsedParams.url, + caption: parsedParams.caption, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendStickerMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendSticker failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendSticker performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendLocationMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendLocationMessageBodySchema.parse(params); + + log("message.sendLocation %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendLocationMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "location", + location: { + latitude: parsedParams.latitude, + longitude: parsedParams.longitude, + name: parsedParams.name, + address: parsedParams.address, + }, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendLocationMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendLocation failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendLocation performedRequest %O", performedRequest); + + return performedRequest; + } + + async #sendContactsMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = SendContactsMessageBodySchema.parse(params); + + log("message.sendContacts %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: `${this.baseUrl}/${parsedParams.fromId}`, + }); + + //transform the data from the nice input format into the format that the API expects + const request: SendContactsMessageRequestBody = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsedParams.to, + type: "contacts", + contacts: parsedParams.contacts, + context: parsedParams.isReplyTo + ? { message_id: parsedParams.isReplyTo } + : undefined, + }; + + const response = await service.performRequest( + this.#sendContactsMessageEndpoint, + request + ); + + if (!response.success) { + log("message.sendContacts failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + const ok = !("error" in response.data); + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("message.sendContacts performedRequest %O", performedRequest); + + return performedRequest; + } + + #isRetryable(statusCode: number): boolean { + return ( + statusCode === 408 || + statusCode === 429 || + statusCode === 500 || + statusCode === 502 || + statusCode === 503 || + statusCode === 504 + ); + } +} diff --git a/integrations/whatsapp/src/internal/webhooks.ts b/integrations/whatsapp/src/internal/webhooks.ts new file mode 100644 index 00000000000..e5c6daec3f3 --- /dev/null +++ b/integrations/whatsapp/src/internal/webhooks.ts @@ -0,0 +1,133 @@ +import crypto from "crypto"; +import { ulid } from "ulid"; +import { getAccessToken, ReceivedWebhook } from "@trigger.dev/integration-sdk"; +import type { + DisplayProperty, + HandleWebhookOptions, + WebhookConfig, + WebhookIntegration, +} from "@trigger.dev/integration-sdk"; +import { WebhookSourceSchema } from "../schemas"; + +export class WhatsAppWebhookIntegration implements WebhookIntegration { + keyForSource(source: unknown): string { + const whatsAppSource = parseWebhookSource(source); + + switch (whatsAppSource.subresource) { + case "messages": + return `messages.${whatsAppSource.accountId}.${whatsAppSource.event}`; + default: + throw new Error(`Unknown subresource`); + } + } + + registerWebhook(config: WebhookConfig, source: unknown) { + return Promise.reject("Not implemented"); + } + + verifyWebhookRequest(options: HandleWebhookOptions) { + if (!options.request.searchParams.has("hub.verify_token")) { + return { + status: "ignored" as const, + reason: "Missing hub.verify_token", + }; + } + + if ( + options.secret !== options.request.searchParams.get("hub.verify_token") + ) { + return { + status: "error" as const, + error: "Invalid secret", + }; + } + + return { + status: "ok" as const, + data: options.request.searchParams.get("hub.challenge"), + }; + } + + handleWebhookRequest(options: HandleWebhookOptions) { + const context = omit(options.request.headers, [ + "x-hub-signature-256", + "x-hub-signature", + "content-type", + "content-length", + "accept", + "accept-encoding", + "x-forwarded-proto", + ]); + + const data = getData(options.request.body, context); + + return { + status: "ok" as const, + data, + }; + } + + displayProperties(source: unknown) { + return { title: "WhatsApp", properties: [] }; + } +} + +export const webhooks = new WhatsAppWebhookIntegration(); + +function parseWebhookSource(source: unknown) { + return WebhookSourceSchema.parse(source); +} + +function getData( + body: any, + context: Record +): ReceivedWebhook[] { + const webhooks: ReceivedWebhook[] = []; + for (const entry of body.entry) { + for (const change of entry.changes) { + if (change.field === "messages") { + const messageData = change.value; + + const metadata = messageData.metadata; + const contacts = messageData.contacts; + + for (const message of messageData.messages) { + const timestamp = `${message.timestamp}000`; + webhooks.push({ + id: message.id as string, + payload: { + type: "message", + contacts, + metadata, + message: { + ...message, + timestamp: parseInt(timestamp), + }, + }, + event: "messages", + context, + }); + } + } else { + console.error(`Unknown field ${change.field}`); + } + } + } + + return webhooks; +} + +function omit, K extends keyof T>( + obj: T, + keys: K[] +): Omit { + const result: any = {}; + + for (const key of Object.keys(obj)) { + if (!keys.includes(key as K)) { + result[key] = obj[key]; + } + } + + return result; +} diff --git a/integrations/whatsapp/src/schemas/index.ts b/integrations/whatsapp/src/schemas/index.ts new file mode 100644 index 00000000000..eadce3bd322 --- /dev/null +++ b/integrations/whatsapp/src/schemas/index.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import * as messageEvents from "./messageEvents"; +import * as messages from "./messages"; + +export const WebhookSourceSchema = z.object({ + subresource: z.literal("messages"), + accountId: z.string(), + event: z.string(), + verifyPayload: z.object({ + enabled: z.boolean(), + }), +}); + +export { messageEvents, messages }; diff --git a/integrations/whatsapp/src/schemas/messageEvents.ts b/integrations/whatsapp/src/schemas/messageEvents.ts new file mode 100644 index 00000000000..aec37d17753 --- /dev/null +++ b/integrations/whatsapp/src/schemas/messageEvents.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { sharedContactSchema } from "./shared"; + +const metadataSchema = z.object({ + display_phone_number: z.string(), + phone_number_id: z.string(), +}); + +const contactSchema = z.object({ + profile: z.object({ name: z.string() }), + wa_id: z.string(), +}); + +const commonMessageData = z.object({ + id: z.string(), + from: z.string(), + timestamp: z.coerce.date(), + context: z + .object({ + id: z.string(), + from: z.string(), + }) + .optional(), +}); + +const textMessageEventSchema = z.object({ + type: z.literal("text"), + text: z.object({ body: z.string() }), +}); + +const audioMessageEventSchema = z.object({ + type: z.literal("audio"), + audio: z.object({ + id: z.string(), + mime_type: z.string(), + sha256: z.string().optional(), + voice: z.boolean().optional(), + }), +}); + +const videoMessageEventSchema = z.object({ + type: z.literal("video"), + video: z.object({ + id: z.string(), + mime_type: z.string(), + sha256: z.string().optional(), + }), +}); + +const imageMessageEventSchema = z.object({ + type: z.literal("image"), + image: z.object({ + id: z.string(), + mime_type: z.string(), + sha256: z.string().optional(), + caption: z.string().optional(), + }), +}); + +const reactionMessageEventSchema = z.object({ + type: z.literal("reaction"), + reaction: z.object({ + emoji: z.string(), + message_id: z.string(), + }), +}); + +const stickerMessageEventSchema = z.object({ + type: z.literal("sticker"), + sticker: z.object({ + id: z.string(), + sha256: z.string(), + animated: z.boolean(), + mime_type: z.string(), + }), +}); + +const documentMessageEventSchema = z.object({ + type: z.literal("document"), + document: z.object({ + id: z.string(), + mime_type: z.string(), + sha256: z.string().optional(), + filename: z.string().optional(), + caption: z.string().optional(), + }), +}); + +const locationMessageEventSchema = z.object({ + type: z.literal("location"), + location: z.object({ + latitude: z.number(), + longitude: z.number(), + name: z.string().optional(), + address: z.string().optional(), + url: z.string().optional(), + }), +}); + +const contactsMessageEventSchema = z.object({ + type: z.literal("contacts"), + contacts: z.array(sharedContactSchema), +}); + +const unsupportedMessageEventSchema = z.object({ + type: z.literal("unsupported"), + errors: z.array(z.object({ code: z.number(), title: z.string() })), +}); + +const messageSchema = z + .discriminatedUnion("type", [ + textMessageEventSchema, + audioMessageEventSchema, + imageMessageEventSchema, + videoMessageEventSchema, + reactionMessageEventSchema, + stickerMessageEventSchema, + documentMessageEventSchema, + locationMessageEventSchema, + contactsMessageEventSchema, + unsupportedMessageEventSchema, + ]) + .and(commonMessageData); + +export const messageEventSchema = z.object({ + type: z.literal("message"), + contacts: z.array(contactSchema), + metadata: metadataSchema, + message: messageSchema, +}); diff --git a/integrations/whatsapp/src/schemas/messages.ts b/integrations/whatsapp/src/schemas/messages.ts new file mode 100644 index 00000000000..106824a3cc2 --- /dev/null +++ b/integrations/whatsapp/src/schemas/messages.ts @@ -0,0 +1,326 @@ +import { z } from "zod"; +import { sharedContactSchema } from "./shared"; + +const TextParameter = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +const CurrencyParameter = z.object({ + type: z.literal("currency"), + currency: z.object({ + amount_1000: z.number(), + code: z.string(), + fallback_value: z.string(), + }), +}); + +const DateTimeParameter = z.object({ + type: z.literal("date_time"), + date_time: z.object({ + fallback_value: z.string(), + }), +}); + +const MediaObject = z.object({ + link: z.string(), + caption: z.string().optional(), +}); + +const ImageParameter = z.object({ + type: z.literal("image"), + image: MediaObject, +}); + +const VideoParameter = z.object({ + type: z.literal("video"), + video: MediaObject, +}); + +const DocumentParameter = z.object({ + type: z.literal("document"), + video: MediaObject, +}); + +const ButtonTemplateComponent = z.object({ + type: z.literal("button"), + sub_type: z.union([z.literal("quick_reply"), z.literal("call_to_action")]), + index: z.number(), + parameters: z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("payload"), + payload: z.string(), + }), + z.object({ + type: z.literal("text"), + text: z.string(), + }), + ]) + ), +}); + +const LocationObject = z.object({ + latitude: z.number(), + longitude: z.number(), + name: z.string().optional(), + address: z.string().optional(), +}); + +const HeaderParameters = z + .discriminatedUnion("type", [ + TextParameter, + ImageParameter, + VideoParameter, + DocumentParameter, + ]) + .array(); + +const HeaderTemplateComponent = z.object({ + type: z.literal("header"), + parameters: HeaderParameters, +}); + +const BodyParameters = z.array( + z.discriminatedUnion("type", [ + TextParameter, + CurrencyParameter, + DateTimeParameter, + ]) +); + +const BodyTemplateComponent = z.object({ + type: z.literal("body"), + parameters: BodyParameters, +}); + +export const SendTemplateMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("template"), + template: z.object({ + name: z.string(), + language: z.object({ + code: z.string(), + policy: z.literal("deterministic"), + }), + components: z + .array( + z.discriminatedUnion("type", [ + HeaderTemplateComponent, + BodyTemplateComponent, + ButtonTemplateComponent, + ]) + ) + .optional(), + }), +}); + +export const SendTemplateMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + template: z.string(), + languageCode: z.string(), + parameters: z + .object({ + header: HeaderParameters.optional(), + body: BodyParameters.optional(), + buttons: z + .array(ButtonTemplateComponent.omit({ type: true, index: true })) + .optional(), + }) + .optional(), +}); + +export const SendMessageErrorResponseSchema = z + .object({ + error: z.object({ + message: z.string(), + type: z.string(), + code: z.number(), + error_data: z + .object({ + messaging_product: z.string(), + details: z.string(), + }) + .optional(), + error_subcode: z.number().optional(), + fbtrace_id: z.string(), + }), + }) + .passthrough(); + +export const SendMessageSuccessResponseSchema = z.object({ + messaging_product: z.literal("whatsapp"), + contacts: z.array(z.object({ input: z.string(), wa_id: z.string() })), + messages: z.array(z.object({ id: z.string() })), +}); + +export const SendMessageResponseSchema = z.union([ + SendMessageErrorResponseSchema, + SendMessageSuccessResponseSchema, +]); + +const MessageContextSchema = z + .object({ + message_id: z.string(), + }) + .optional(); + +export const SendTextMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("text"), + text: z.object({ + body: z.string(), + preview_url: z.boolean(), + }), + context: MessageContextSchema, +}); + +export const SendTextMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + text: z.string(), + preview_url: z.boolean().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendReactionMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("reaction"), + reaction: z.object({ + message_id: z.string(), + emoji: z.string(), + }), +}); + +export const SendReactionMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + isReplyTo: z.string(), + emoji: z.string(), +}); + +export const SendImageMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("image"), + image: MediaObject, + context: MessageContextSchema, +}); + +export const SendImageMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + url: z.string(), + caption: z.string().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendVideoMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("video"), + video: MediaObject, + context: MessageContextSchema, +}); + +export const SendVideoMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + url: z.string(), + caption: z.string().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendAudioMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("audio"), + audio: MediaObject.omit({ caption: true }), + context: MessageContextSchema, +}); + +export const SendAudioMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + url: z.string(), + isReplyTo: z.string().optional(), +}); + +export const SendDocumentMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("document"), + document: MediaObject, + context: MessageContextSchema, +}); + +export const SendDocumentMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + url: z.string(), + caption: z.string().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendStickerMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("sticker"), + sticker: MediaObject, + context: MessageContextSchema, +}); + +export const SendStickerMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + url: z.string(), + caption: z.string().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendLocationMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("location"), + location: LocationObject, + context: MessageContextSchema, +}); + +export const SendLocationMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + latitude: z.number(), + longitude: z.number(), + name: z.string().optional(), + address: z.string().optional(), + isReplyTo: z.string().optional(), +}); + +export const SendContactsMessageRequestBodySchema = z.object({ + messaging_product: z.literal("whatsapp"), + recipient_type: z.literal("individual"), + to: z.string(), + type: z.literal("contacts"), + contacts: z.array(sharedContactSchema), + context: MessageContextSchema, +}); + +export const SendContactsMessageBodySchema = z.object({ + fromId: z.string(), + to: z.string(), + contacts: z.array(sharedContactSchema), + isReplyTo: z.string().optional(), +}); diff --git a/integrations/whatsapp/src/schemas/shared.ts b/integrations/whatsapp/src/schemas/shared.ts new file mode 100644 index 00000000000..0b47b7fc981 --- /dev/null +++ b/integrations/whatsapp/src/schemas/shared.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const sharedContactSchema = z.object({ + name: z + .object({ + formatted_name: z.string(), + first_name: z.string().optional(), + middle_name: z.string().optional(), + last_name: z.string().optional(), + suffix: z.string().optional(), + prefix: z.string().optional(), + }) + .optional(), + emails: z + .array(z.object({ type: z.string().optional(), email: z.string() })) + .optional(), + phones: z + .array( + z.object({ + type: z.string().optional(), + phone: z.string(), + wa_id: z.string().optional(), + }) + ) + .optional(), + birthday: z.string().optional(), + addresses: z + .array( + z.object({ + street: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zip: z.string().optional(), + country: z.string().optional(), + country_code: z.string().optional(), + type: z.string().optional(), + }) + ) + .optional(), + org: z + .object({ + company: z.string().optional(), + department: z.string().optional(), + title: z.string().optional(), + }) + .optional(), + urls: z + .array( + z.object({ + url: z.string().optional(), + type: z.string().optional(), + }) + ) + .optional(), +}); diff --git a/integrations/whatsapp/tsconfig.json b/integrations/whatsapp/tsconfig.json new file mode 100644 index 00000000000..e159cec9c07 --- /dev/null +++ b/integrations/whatsapp/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@trigger.dev/tsconfig/node16.json", + "include": ["./src/**/*.ts", "tsup.config.ts"], + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "paths": { + "@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"], + "@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"], + "@trigger.dev/integration-sdk/*": [ + "../../packages/integration-sdk/src/*" + ], + "@trigger.dev/integration-sdk": [ + "../../packages/integration-sdk/src/index" + ] + } + }, + "exclude": ["node_modules"] +} diff --git a/integrations/whatsapp/tsup.config.ts b/integrations/whatsapp/tsup.config.ts new file mode 100644 index 00000000000..a74fd911579 --- /dev/null +++ b/integrations/whatsapp/tsup.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + name: "main", + entry: ["./src/index.ts"], + outDir: "./dist", + platform: "node", + format: ["cjs"], + legacyOutput: true, + sourcemap: true, + clean: true, + bundle: true, + splitting: false, + dts: true, + treeshake: { + preset: "smallest", + }, + esbuildPlugins: [], + noExternal: ["@trigger.dev/common-schemas"], + external: ["http", "https", "util", "events", "tty", "os", "timers"], + }, + { + name: "internal", + entry: ["./src/internal.ts"], + outDir: "./dist", + platform: "node", + format: ["cjs"], + legacyOutput: true, + sourcemap: true, + clean: true, + bundle: true, + splitting: false, + dts: true, + esbuildPlugins: [], + noExternal: ["@trigger.dev/common-schemas"], + external: ["http", "https", "util", "events", "tty", "os", "timers"], + }, +]); diff --git a/packages/integration-catalog/package.json b/packages/integration-catalog/package.json index abdaebe62ea..649ec5072f6 100644 --- a/packages/integration-catalog/package.json +++ b/packages/integration-catalog/package.json @@ -10,8 +10,9 @@ "@trigger.dev/github": "workspace:*", "@trigger.dev/shopify": "workspace:*", "@trigger.dev/resend": "workspace:*", + "@trigger.dev/whatsapp": "workspace:*", "@trigger.dev/integration-sdk": "workspace:*" }, "scripts": {}, "dependencies": {} -} \ No newline at end of file +} diff --git a/packages/integration-catalog/src/index.ts b/packages/integration-catalog/src/index.ts index 90a0ecb874d..20af2d93273 100644 --- a/packages/integration-catalog/src/index.ts +++ b/packages/integration-catalog/src/index.ts @@ -2,6 +2,7 @@ import { internalIntegration as slack } from "@trigger.dev/slack/internal"; import { internalIntegration as github } from "@trigger.dev/github/internal"; import { internalIntegration as resend } from "@trigger.dev/resend/internal"; import { internalIntegration as shopify } from "@trigger.dev/shopify/internal"; +import { internalIntegration as whatsapp } from "@trigger.dev/whatsapp/internal"; import type { InternalIntegration } from "@trigger.dev/integration-sdk"; @@ -37,6 +38,7 @@ const catalog = { resend, shopify, slack, + whatsapp, }, }; diff --git a/packages/integration-catalog/tsconfig.json b/packages/integration-catalog/tsconfig.json index eb4f69841e9..b7bba83d6b3 100644 --- a/packages/integration-catalog/tsconfig.json +++ b/packages/integration-catalog/tsconfig.json @@ -19,7 +19,9 @@ "@trigger.dev/resend": ["../../integrations/resend/src/index"], "@trigger.dev/resend/*": ["../../integrations/resend/src/*"], "@trigger.dev/shopify": ["../../integrations/shopify/src/index"], - "@trigger.dev/shopify/*": ["../../integrations/shopify/src/*"] + "@trigger.dev/shopify/*": ["../../integrations/shopify/src/*"], + "@trigger.dev/whatsapp": ["../../integrations/whatsapp/src/index"], + "@trigger.dev/whatsapp/*": ["../../integrations/whatsapp/src/*"] } }, "exclude": ["node_modules"] diff --git a/packages/integration-sdk/src/types.ts b/packages/integration-sdk/src/types.ts index 2d4239df000..d9eeea717da 100644 --- a/packages/integration-sdk/src/types.ts +++ b/packages/integration-sdk/src/types.ts @@ -12,11 +12,25 @@ export interface WebhookConfig { secret: string; } +export const httpMethods = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "CONNECT", +]; +export type HTTPMethod = (typeof httpMethods)[number]; + export interface NormalizedRequest { rawBody: string; body: any; headers: Record; searchParams: URLSearchParams; + method: HTTPMethod; } export interface NormalizedResponse { @@ -71,7 +85,13 @@ export interface WebhookIntegration { handleWebhookRequest: ( options: HandleWebhookOptions ) => - | { status: "ok"; data: ReceivedWebhook } + | { status: "ok"; data: ReceivedWebhook[] } + | { status: "ignored"; reason: string } + | { status: "error"; error: string }; + verifyWebhookRequest: ( + options: HandleWebhookOptions + ) => + | { status: "ok"; data: any } | { status: "ignored"; reason: string } | { status: "error"; error: string }; displayProperties: (source: unknown) => DisplayProperties; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f0b03c5b8..492b5ef161a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,7 @@ importers: '@trigger.dev/shopify': workspace:* '@trigger.dev/slack': workspace:* '@trigger.dev/tailwind-config': workspace:* + '@trigger.dev/whatsapp': workspace:* '@typeform/embed-react': ^2.14.1 '@types/bcryptjs': ^2.4.2 '@types/compression': ^1.7.2 @@ -151,7 +152,7 @@ importers: postcss-import: ^14.1.0 posthog-js: ^1.31.0 prettier: ^2.6.2 - prettier-plugin-tailwindcss: ^0.1.11 + prettier-plugin-tailwindcss: ^0.1.13 pretty-bytes: ^6.0.0 prism-react-renderer: ^1.3.5 prisma: ^4.3.0 @@ -188,7 +189,7 @@ importers: '@aws-sdk/client-s3': 3.245.0 '@aws-sdk/s3-request-presigner': 3.245.0 '@cfworker/json-schema': 1.12.5 - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/lang-javascript': 6.1.2 '@codemirror/lang-json': 6.0.1 @@ -216,8 +217,9 @@ importers: '@trigger.dev/resend': link:../../integrations/resend '@trigger.dev/shopify': link:../../integrations/shopify '@trigger.dev/slack': link:../../integrations/slack + '@trigger.dev/whatsapp': link:../../integrations/whatsapp '@typeform/embed-react': 2.14.1_react@18.2.0 - '@uiw/react-codemirror': 4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle + '@uiw/react-codemirror': 4.19.5_aguurb4bmecpxzejz52amioxne bcryptjs: 2.4.3 classnames: 2.3.2 clsx: 1.2.1 @@ -561,6 +563,23 @@ importers: '@types/node': 16.18.11 tsx: 3.12.2 + examples/whatsapp: + specifiers: + '@trigger.dev/sdk': workspace:* + '@trigger.dev/tsconfig': workspace:* + '@trigger.dev/whatsapp': workspace:* + '@types/node': '16' + tsx: ^3.12.0 + zod: ^3.20.2 + dependencies: + '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/whatsapp': link:../../integrations/whatsapp + zod: 3.20.2 + devDependencies: + '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@types/node': 16.18.11 + tsx: 3.12.2 + integrations/github: specifiers: '@octokit/webhooks': ^10.4.0 @@ -596,7 +615,7 @@ importers: tsup: ^6.5.0 zod: ^3.20.2 dependencies: - '@react-email/render': 0.0.3 + '@react-email/render': 0.0.3_react@18.2.0 debug: 4.3.4 zod: 3.20.2 devDependencies: @@ -667,6 +686,31 @@ importers: rimraf: 3.0.2 tsup: 6.5.0 + integrations/whatsapp: + specifiers: + '@trigger.dev/integration-sdk': workspace:* + '@trigger.dev/sdk': workspace:* + '@trigger.dev/tsconfig': workspace:* + '@types/debug': ^4.1.7 + '@types/node': '16' + debug: ^4.3.4 + rimraf: ^3.0.2 + tsup: ^6.5.0 + ulid: ^2.3.0 + zod: ^3.20.2 + dependencies: + debug: 4.3.4 + ulid: 2.3.0 + zod: 3.20.2 + devDependencies: + '@trigger.dev/integration-sdk': link:../../packages/integration-sdk + '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@types/debug': 4.1.7 + '@types/node': 16.18.11 + rimraf: 3.0.2 + tsup: 6.5.0 + packages/common-schemas: specifiers: '@trigger.dev/tsconfig': workspace:* @@ -731,6 +775,7 @@ importers: '@trigger.dev/shopify': workspace:* '@trigger.dev/slack': workspace:* '@trigger.dev/tsconfig': workspace:* + '@trigger.dev/whatsapp': workspace:* '@types/node': '16' devDependencies: '@trigger.dev/github': link:../../integrations/github @@ -739,6 +784,7 @@ importers: '@trigger.dev/shopify': link:../../integrations/shopify '@trigger.dev/slack': link:../../integrations/slack '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@trigger.dev/whatsapp': link:../../integrations/whatsapp '@types/node': 16.18.11 packages/integration-sdk: @@ -3523,12 +3569,13 @@ packages: prettier: 2.8.2 dev: false - /@codemirror/autocomplete/6.4.0_czcfkg2f66rxeiodoti7r2gulu: + /@codemirror/autocomplete/6.4.0_eo6pz6bvsllvatnnwfprpuflde: resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==} peerDependencies: '@codemirror/language': ^6.0.0 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 + '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.3.2 '@codemirror/state': 6.2.0 @@ -3548,7 +3595,7 @@ packages: /@codemirror/lang-javascript/6.1.2: resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==} dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 '@codemirror/state': 6.2.0 @@ -4746,16 +4793,6 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: false - /@react-email/render/0.0.3: - resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==} - engines: {node: '>=18.0.0'} - dependencies: - pretty: 2.0.0 - react-dom: 18.2.0 - transitivePeerDependencies: - - react - dev: false - /@react-email/render/0.0.3_react@18.2.0: resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==} engines: {node: '>=18.0.0'} @@ -4871,7 +4908,7 @@ packages: eslint: 8.31.0 eslint-import-resolver-node: 0.3.6 eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy - eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny + eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq eslint-plugin-jest: 26.9.0_ohsifnwenhmxgcp7mend4dnv74 eslint-plugin-jest-dom: 4.0.3_eslint@8.31.0 eslint-plugin-jsx-a11y: 6.7.1_eslint@8.31.0 @@ -6008,17 +6045,18 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@uiw/codemirror-extensions-basic-setup/4.19.5_wd2tsis3in55bkaiwnc2c46tom: + /@uiw/codemirror-extensions-basic-setup/4.19.5_tbeldtdcrf45b35pezgkzq2u4e: resolution: {integrity: sha512-1zt7ZPJ01xKkSW/KDy0FZNga0bngN1fC594wCVG7FBi60ehfcAucpooQ+JSPScKXopxcb+ugPKZvVLzr9/OfzA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' '@codemirror/commands': '>=6.0.0' '@codemirror/language': '>=6.0.0' + '@codemirror/lint': '>=6.0.0' '@codemirror/search': '>=6.0.0' '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 @@ -6027,11 +6065,14 @@ packages: '@codemirror/view': 6.7.2 dev: false - /@uiw/react-codemirror/4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle: + /@uiw/react-codemirror/4.19.5_aguurb4bmecpxzejz52amioxne: resolution: {integrity: sha512-ZCHh8d7beXbF8/t7F1+yHht6A9Y6CdKeOkZq4A09lxJEnyTQrj1FMf2zvfaqc7K23KNjkTCtSlbqKKbVDgrWaw==} peerDependencies: + '@babel/runtime': '>=7.11.0' '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: @@ -6040,13 +6081,14 @@ packages: '@codemirror/state': 6.2.0 '@codemirror/theme-one-dark': 6.1.0 '@codemirror/view': 6.7.2 - '@uiw/codemirror-extensions-basic-setup': 4.19.5_wd2tsis3in55bkaiwnc2c46tom - codemirror: 6.0.1 + '@uiw/codemirror-extensions-basic-setup': 4.19.5_tbeldtdcrf45b35pezgkzq2u4e + codemirror: 6.0.1_@lezer+common@1.0.2 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 transitivePeerDependencies: - '@codemirror/autocomplete' - '@codemirror/language' + - '@codemirror/lint' - '@codemirror/search' dev: false @@ -7357,16 +7399,18 @@ packages: engines: {node: '>=0.10.0'} dev: false - /codemirror/6.0.1: + /codemirror/6.0.1_@lezer+common@1.0.2: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 '@codemirror/search': 6.2.3 '@codemirror/state': 6.2.0 '@codemirror/view': 6.7.2 + transitivePeerDependencies: + - '@lezer/common' dev: false /collection-visit/1.0.0: @@ -8702,7 +8746,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.12.0 eslint: 8.31.0 - eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny + eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq get-tsconfig: 4.3.0 globby: 13.1.3 is-core-module: 2.11.0 @@ -8712,7 +8756,7 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.4_v73lhamtbyinynmwa5fn7kpmfq: + /eslint-module-utils/2.7.4_sqt5xxn4ciiurbqrzlaarm6ama: resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} engines: {node: '>=4'} peerDependencies: @@ -8737,7 +8781,6 @@ packages: debug: 3.2.7 eslint: 8.31.0 eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy transitivePeerDependencies: - supports-color dev: true @@ -8762,7 +8805,7 @@ packages: regexpp: 3.2.0 dev: true - /eslint-plugin-import/2.27.4_2ac3tknkazjoq5fxmuugu665ny: + /eslint-plugin-import/2.27.4_qdjeohovcytra7xto5vgmxssaq: resolution: {integrity: sha512-Z1jVt1EGKia1X9CnBCkpAOhWy8FgQ7OmJ/IblEkT82yrFU/xJaxwujaTzLWqigewwynRQ9mmHfX9MtAfhxm0sA==} engines: {node: '>=4'} peerDependencies: @@ -8780,7 +8823,7 @@ packages: doctrine: 2.1.0 eslint: 8.31.0 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4_v73lhamtbyinynmwa5fn7kpmfq + eslint-module-utils: 2.7.4_sqt5xxn4ciiurbqrzlaarm6ama has: 1.0.3 is-core-module: 2.11.0 is-glob: 4.0.3 @@ -13882,15 +13925,6 @@ packages: shallow-equal: 1.2.1 dev: false - /react-dom/18.2.0: - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} - peerDependencies: - react: ^18.2.0 - dependencies: - loose-envify: 1.4.0 - scheduler: 0.23.0 - dev: false - /react-dom/18.2.0_react@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: