diff --git a/docker-compose.yml b/docker-compose.yml index 3e6a54f2395..6ff9509f5cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: activepieces: - image: ghcr.io/activepieces/activepieces:0.78.0 + image: ghcr.io/activepieces/activepieces:0.78.1 container_name: activepieces restart: unless-stopped ## Enable the following line if you already use AP_EXECUTION_MODE with SANDBOX_PROCESS or old activepieces, checking the breaking change documentation for more info. diff --git a/package.json b/package.json index d119b4874b8..10c4556aadf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activepieces", - "version": "0.78.0", + "version": "0.78.1", "rcVersion": "0.78.0-rc.0", "scripts": { "prepare": "husky install", diff --git a/packages/pieces/community/google-sheets/package.json b/packages/pieces/community/google-sheets/package.json index 48481860210..b060ff31d6a 100644 --- a/packages/pieces/community/google-sheets/package.json +++ b/packages/pieces/community/google-sheets/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-sheets", - "version": "0.14.4", + "version": "0.14.5", "dependencies": { "csv-parse": "5.6.0", "googleapis": "129.0.0", diff --git a/packages/pieces/community/google-sheets/src/lib/actions/export-sheet.ts b/packages/pieces/community/google-sheets/src/lib/actions/export-sheet.ts index d25755fa677..f50ce4a15b6 100644 --- a/packages/pieces/community/google-sheets/src/lib/actions/export-sheet.ts +++ b/packages/pieces/community/google-sheets/src/lib/actions/export-sheet.ts @@ -56,6 +56,7 @@ export const exportSheetAction = createAction({ token: await getAccessToken(auth), }, responseType: 'arraybuffer', + followRedirects: true, }); if (returnAsText) { diff --git a/packages/pieces/community/kapso/.eslintrc.json b/packages/pieces/community/kapso/.eslintrc.json new file mode 100644 index 00000000000..4a4e695c547 --- /dev/null +++ b/packages/pieces/community/kapso/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../../../.eslintrc.base.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/kapso/README.md b/packages/pieces/community/kapso/README.md new file mode 100644 index 00000000000..3e97f2b682a --- /dev/null +++ b/packages/pieces/community/kapso/README.md @@ -0,0 +1,7 @@ +# pieces-kapso + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build pieces-kapso` to build the library. diff --git a/packages/pieces/community/kapso/bun.lock b/packages/pieces/community/kapso/bun.lock new file mode 100644 index 00000000000..5e50d9749e4 --- /dev/null +++ b/packages/pieces/community/kapso/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@activepieces/piece-kapso", + "dependencies": { + "@kapso/whatsapp-cloud-api": "^0.1.1", + "tslib": "^2.3.0", + }, + }, + }, + "packages": { + "@kapso/whatsapp-cloud-api": ["@kapso/whatsapp-cloud-api@0.1.1", "", { "dependencies": { "zod": "^4.1.11" } }, "sha512-TnT8Mq08twUHgermibkdpnjEdOVPr/fuXaOz5RUEnAh8QKdCYDeqTmPJO54GZ8AOxTIGPH4N+Q+YN++Gt+gDBg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/packages/pieces/community/kapso/package.json b/packages/pieces/community/kapso/package.json new file mode 100644 index 00000000000..3e2ef4fe250 --- /dev/null +++ b/packages/pieces/community/kapso/package.json @@ -0,0 +1,11 @@ +{ + "name": "@activepieces/piece-kapso", + "version": "0.0.1", + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@kapso/whatsapp-cloud-api": "^0.1.1", + "tslib": "^2.3.0" + } +} diff --git a/packages/pieces/community/kapso/project.json b/packages/pieces/community/kapso/project.json new file mode 100644 index 00000000000..9e32480dfd4 --- /dev/null +++ b/packages/pieces/community/kapso/project.json @@ -0,0 +1,66 @@ +{ + "name": "pieces-kapso", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/pieces/community/kapso/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": [ + "dist/{projectRoot}" + ], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/pieces/community/kapso", + "tsConfig": "packages/pieces/community/kapso/tsconfig.lib.json", + "packageJson": "packages/pieces/community/kapso/package.json", + "main": "packages/pieces/community/kapso/src/index.ts", + "assets": [ + "packages/pieces/community/kapso/*.md", + { + "input": "packages/pieces/community/kapso/src/i18n", + "output": "./src/i18n", + "glob": "**/!(i18n.json)" + } + ], + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true, + "clean": false + }, + "dependsOn": [ + "prebuild", + "^build" + ] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "prebuild": { + "dependsOn": [ + "^build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/pieces/community/kapso", + "command": "bun install --no-save --silent" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + } + } +} \ No newline at end of file diff --git a/packages/pieces/community/kapso/src/index.ts b/packages/pieces/community/kapso/src/index.ts new file mode 100644 index 00000000000..67ba3c4e5df --- /dev/null +++ b/packages/pieces/community/kapso/src/index.ts @@ -0,0 +1,56 @@ +import { createPiece } from '@activepieces/pieces-framework'; +import { PieceCategory } from '@activepieces/shared'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { kapsoAuth, KAPSO_BASE_URL } from './lib/common'; +import { sendTextMessage } from './lib/actions/send-text-message'; +import { sendButtons } from './lib/actions/send-buttons'; +import { sendList } from './lib/actions/send-list'; +import { sendImage } from './lib/actions/send-image'; +import { sendVideo } from './lib/actions/send-video'; +import { sendAudio } from './lib/actions/send-audio'; +import { sendDocument } from './lib/actions/send-document'; +import { sendSticker } from './lib/actions/send-sticker'; +import { sendLocation } from './lib/actions/send-location'; +import { requestLocation } from './lib/actions/request-location'; +import { sendContact } from './lib/actions/send-contact'; +import { sendReaction } from './lib/actions/send-reaction'; +import { markAsRead } from './lib/actions/mark-as-read'; +import { sendTemplate } from './lib/actions/send-template'; +import { newMessage } from './lib/triggers/new-message'; +import { messageStatusUpdate } from './lib/triggers/message-status-update'; + +export const kapso = createPiece({ + displayName: 'Kapso', + description: 'Send and receive WhatsApp messages, media, templates, and more using the Kapso WhatsApp API.', + auth: kapsoAuth, + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/kapso.png', + categories: [PieceCategory.COMMUNICATION], + authors: ['onyedikachi-david'], + actions: [ + sendTextMessage, + sendButtons, + sendList, + sendImage, + sendVideo, + sendAudio, + sendDocument, + sendSticker, + sendLocation, + requestLocation, + sendContact, + sendReaction, + markAsRead, + sendTemplate, + createCustomApiCallAction({ + auth: kapsoAuth, + baseUrl: () => KAPSO_BASE_URL, + authMapping: async (auth) => { + return { + 'X-API-Key': auth.secret_text, + }; + }, + }), + ], + triggers: [newMessage, messageStatusUpdate], +}); \ No newline at end of file diff --git a/packages/pieces/community/kapso/src/lib/actions/mark-as-read.ts b/packages/pieces/community/kapso/src/lib/actions/mark-as-read.ts new file mode 100644 index 00000000000..95dff2927ba --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/mark-as-read.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const markAsRead = createAction({ + auth: kapsoAuth, + name: 'mark_as_read', + displayName: 'Mark Message as Read', + description: 'Mark a WhatsApp message as read.', + props: { + phoneNumberId: phoneNumberIdDropdown, + messageId: Property.ShortText({ + displayName: 'Message ID', + description: 'The ID of the message to mark as read.', + required: true, + }), + }, + async run(context) { + const { phoneNumberId, messageId } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.markRead({ + phoneNumberId, + messageId, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/request-location.ts b/packages/pieces/community/kapso/src/lib/actions/request-location.ts new file mode 100644 index 00000000000..6715e1427f7 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/request-location.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const requestLocation = createAction({ + auth: kapsoAuth, + name: 'request_user_location', + displayName: 'Request User Location', + description: 'Send a location request message to a WhatsApp user.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + bodyText: Property.LongText({ + displayName: 'Body Text', + description: 'The message body displayed with the location request.', + required: true, + }), + }, + async run(context) { + const { phoneNumberId, to, bodyText } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendInteractiveLocationRequest({ + phoneNumberId, + to, + bodyText, + parameters: { + requestMessage: bodyText, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-audio.ts b/packages/pieces/community/kapso/src/lib/actions/send-audio.ts new file mode 100644 index 00000000000..43e1772a416 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-audio.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendAudio = createAction({ + auth: kapsoAuth, + name: 'send_audio', + displayName: 'Send Audio', + description: 'Send an audio message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + audioUrl: Property.ShortText({ + displayName: 'Audio URL', + description: 'Public URL of the audio file to send.', + required: false, + }), + audioId: Property.ShortText({ + displayName: 'Audio Media ID', + description: 'Media ID of a previously uploaded audio. Use either URL or Media ID.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, audioUrl, audioId } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendAudio({ + phoneNumberId, + to, + audio: { + link: audioUrl ?? undefined, + id: audioId ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-buttons.ts b/packages/pieces/community/kapso/src/lib/actions/send-buttons.ts new file mode 100644 index 00000000000..eb330f46b1d --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-buttons.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendButtons = createAction({ + auth: kapsoAuth, + name: 'send_buttons', + displayName: 'Send Button Message', + description: 'Send an interactive button message via WhatsApp (up to 3 buttons).', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + bodyText: Property.LongText({ + displayName: 'Body Text', + description: 'The message body displayed above the buttons.', + required: true, + }), + buttons: Property.Array({ + displayName: 'Buttons', + description: + 'Up to 3 buttons. Each button needs an ID and a title (max 20 characters).', + required: true, + properties: { + id: Property.ShortText({ + displayName: 'Button ID', + description: 'A unique identifier for this button (returned when the user taps it).', + required: true, + }), + title: Property.ShortText({ + displayName: 'Button Title', + description: 'The text displayed on the button (max 20 characters).', + required: true, + }), + }, + }), + footerText: Property.ShortText({ + displayName: 'Footer Text', + description: 'Optional footer text displayed below the buttons.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, bodyText, buttons, footerText } = + context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendInteractiveButtons({ + phoneNumberId, + to, + bodyText, + buttons: (buttons as { id: string; title: string }[]).map((b) => ({ + id: b.id, + title: b.title, + })), + footerText: footerText ?? undefined, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-contact.ts b/packages/pieces/community/kapso/src/lib/actions/send-contact.ts new file mode 100644 index 00000000000..6e64d9f5993 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-contact.ts @@ -0,0 +1,81 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendContact = createAction({ + auth: kapsoAuth, + name: 'send_contact', + displayName: 'Send Contact', + description: 'Send a contact card via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + formattedName: Property.ShortText({ + displayName: 'Full Name', + description: 'The contact\'s formatted full name.', + required: true, + }), + firstName: Property.ShortText({ + displayName: 'First Name', + description: 'The contact\'s first name.', + required: false, + }), + lastName: Property.ShortText({ + displayName: 'Last Name', + description: 'The contact\'s last name.', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone Number', + description: 'The contact\'s phone number.', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + description: 'The contact\'s email address.', + required: false, + }), + company: Property.ShortText({ + displayName: 'Company', + description: 'The contact\'s company name.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, formattedName, firstName, lastName, phone, email, company } = + context.propsValue; + const client = makeClient(context.auth.secret_text); + + const contact: Record = { + name: { + formattedName, + firstName: firstName ?? undefined, + lastName: lastName ?? undefined, + }, + }; + + if (phone) { + contact['phones'] = [{ phone, type: 'CELL' }]; + } + if (email) { + contact['emails'] = [{ email, type: 'WORK' }]; + } + if (company) { + contact['org'] = { company }; + } + + const response = await client.messages.sendContacts({ + phoneNumberId, + to, + contacts: [contact as any], + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-document.ts b/packages/pieces/community/kapso/src/lib/actions/send-document.ts new file mode 100644 index 00000000000..bd3214dcada --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-document.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendDocument = createAction({ + auth: kapsoAuth, + name: 'send_document', + displayName: 'Send Document', + description: 'Send a document message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + documentUrl: Property.ShortText({ + displayName: 'Document URL', + description: 'Public URL of the document to send.', + required: false, + }), + documentId: Property.ShortText({ + displayName: 'Document Media ID', + description: 'Media ID of a previously uploaded document. Use either URL or Media ID.', + required: false, + }), + filename: Property.ShortText({ + displayName: 'Filename', + description: 'The filename to display for the document.', + required: false, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'Optional caption for the document.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, documentUrl, documentId, filename, caption } = + context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendDocument({ + phoneNumberId, + to, + document: { + link: documentUrl ?? undefined, + id: documentId ?? undefined, + filename: filename ?? undefined, + caption: caption ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-image.ts b/packages/pieces/community/kapso/src/lib/actions/send-image.ts new file mode 100644 index 00000000000..d388cb87b65 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-image.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendImage = createAction({ + auth: kapsoAuth, + name: 'send_image', + displayName: 'Send Image', + description: 'Send an image message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + imageUrl: Property.ShortText({ + displayName: 'Image URL', + description: 'Public URL of the image to send.', + required: false, + }), + imageId: Property.ShortText({ + displayName: 'Image Media ID', + description: 'Media ID of a previously uploaded image. Use either URL or Media ID.', + required: false, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'Optional caption for the image.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, imageUrl, imageId, caption } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendImage({ + phoneNumberId, + to, + image: { + link: imageUrl ?? undefined, + id: imageId ?? undefined, + caption: caption ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-list.ts b/packages/pieces/community/kapso/src/lib/actions/send-list.ts new file mode 100644 index 00000000000..c713606fd28 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-list.ts @@ -0,0 +1,86 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendList = createAction({ + auth: kapsoAuth, + name: 'send_list_message', + displayName: 'Send List Message', + description: 'Send an interactive list message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + bodyText: Property.LongText({ + displayName: 'Body Text', + description: 'The message body displayed above the list.', + required: true, + }), + buttonText: Property.ShortText({ + displayName: 'Button Text', + description: 'The text on the button that opens the list (max 20 characters).', + required: true, + }), + sections: Property.Array({ + displayName: 'Sections', + description: 'List sections. Each section contains a title and rows.', + required: true, + properties: { + title: Property.ShortText({ + displayName: 'Section Title', + description: 'Title of this section.', + required: false, + }), + rows: Property.Json({ + displayName: 'Rows', + description: + 'JSON array of row objects. Each row needs: id, title, and optionally description. Example: [{"id":"row_1","title":"Option 1","description":"First option"}]', + required: true, + }), + }, + }), + headerText: Property.ShortText({ + displayName: 'Header Text', + description: 'Optional header text.', + required: false, + }), + footerText: Property.ShortText({ + displayName: 'Footer Text', + description: 'Optional footer text.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, bodyText, buttonText, sections, headerText, footerText } = + context.propsValue; + const client = makeClient(context.auth.secret_text); + + const parsedSections = ( + sections as { title?: string; rows: unknown }[] + ).map((s) => ({ + title: s.title, + rows: (typeof s.rows === 'string' ? JSON.parse(s.rows) : s.rows) as { + id: string; + title: string; + description?: string; + }[], + })); + + const response = await client.messages.sendInteractiveList({ + phoneNumberId, + to, + bodyText, + buttonText, + sections: parsedSections, + header: headerText ? { type: 'text' as const, text: headerText } : undefined, + footerText: footerText ?? undefined, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-location.ts b/packages/pieces/community/kapso/src/lib/actions/send-location.ts new file mode 100644 index 00000000000..afbf5f61ad0 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-location.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendLocation = createAction({ + auth: kapsoAuth, + name: 'send_location', + displayName: 'Send Location', + description: 'Send a location message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + latitude: Property.Number({ + displayName: 'Latitude', + description: 'Latitude of the location.', + required: true, + }), + longitude: Property.Number({ + displayName: 'Longitude', + description: 'Longitude of the location.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Location Name', + description: 'Name of the location.', + required: false, + }), + address: Property.ShortText({ + displayName: 'Address', + description: 'Address of the location.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, latitude, longitude, name, address } = + context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendLocation({ + phoneNumberId, + to, + location: { + latitude, + longitude, + name: name ?? undefined, + address: address ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-reaction.ts b/packages/pieces/community/kapso/src/lib/actions/send-reaction.ts new file mode 100644 index 00000000000..ef852cf261c --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-reaction.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendReaction = createAction({ + auth: kapsoAuth, + name: 'send_reaction', + displayName: 'Send Reaction', + description: 'React to a WhatsApp message with an emoji.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + messageId: Property.ShortText({ + displayName: 'Message ID', + description: 'The ID of the message to react to.', + required: true, + }), + emoji: Property.ShortText({ + displayName: 'Emoji', + description: 'The emoji to react with (e.g. 👍). Leave empty to remove a reaction.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, messageId, emoji } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendReaction({ + phoneNumberId, + to, + reaction: { + messageId, + emoji: emoji ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-sticker.ts b/packages/pieces/community/kapso/src/lib/actions/send-sticker.ts new file mode 100644 index 00000000000..ecdb7586c26 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-sticker.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendSticker = createAction({ + auth: kapsoAuth, + name: 'send_sticker', + displayName: 'Send Sticker', + description: 'Send a sticker message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + stickerUrl: Property.ShortText({ + displayName: 'Sticker URL', + description: 'Public URL of the sticker (WebP format).', + required: false, + }), + stickerId: Property.ShortText({ + displayName: 'Sticker Media ID', + description: 'Media ID of a previously uploaded sticker. Use either URL or Media ID.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, stickerUrl, stickerId } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendSticker({ + phoneNumberId, + to, + sticker: { + link: stickerUrl ?? undefined, + id: stickerId ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-template.ts b/packages/pieces/community/kapso/src/lib/actions/send-template.ts new file mode 100644 index 00000000000..ec0bbb84fb0 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-template.ts @@ -0,0 +1,308 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { + phoneNumberIdDropdown, + businessAccountIdDropdown, + templateDropdown, +} from '../common/props'; + +export const sendTemplate = createAction({ + auth: kapsoAuth, + name: 'send_template_message', + displayName: 'Send Template Message', + description: 'Send a pre-approved WhatsApp template message.', + props: { + businessAccountId: businessAccountIdDropdown, + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + template: templateDropdown, + headerType: Property.StaticDropdown({ + displayName: 'Header Type', + description: 'The type of header your template uses. Leave as None if no header.', + required: false, + defaultValue: 'none', + options: { + options: [ + { label: 'None', value: 'none' }, + { label: 'Text', value: 'text' }, + { label: 'Image', value: 'image' }, + { label: 'Video', value: 'video' }, + { label: 'Document', value: 'document' }, + { label: 'Location', value: 'location' }, + ], + }, + }), + headerTextParameters: Property.Array({ + displayName: 'Header Text Parameters', + description: + 'Text header parameters (only if header type is Text).', + required: false, + properties: { + parameterName: Property.ShortText({ + displayName: 'Parameter Name', + description: 'The name of the header parameter (e.g. sale_name).', + required: true, + }), + text: Property.ShortText({ + displayName: 'Value', + description: 'The value to substitute for this parameter.', + required: true, + }), + }, + }), + headerMediaUrl: Property.ShortText({ + displayName: 'Header Media URL', + description: + 'Public URL of the media file for image, video, or document headers.', + required: false, + }), + headerMediaFilename: Property.ShortText({ + displayName: 'Header Document Filename', + description: 'Filename for document headers (e.g. invoice.pdf).', + required: false, + }), + headerLocationLatitude: Property.Number({ + displayName: 'Header Location Latitude', + description: 'Latitude for location headers.', + required: false, + }), + headerLocationLongitude: Property.Number({ + displayName: 'Header Location Longitude', + description: 'Longitude for location headers.', + required: false, + }), + headerLocationName: Property.ShortText({ + displayName: 'Header Location Name', + description: 'Name of the location (e.g. Delivery Location).', + required: false, + }), + headerLocationAddress: Property.ShortText({ + displayName: 'Header Location Address', + description: 'Address of the location.', + required: false, + }), + bodyParameters: Property.Array({ + displayName: 'Body Parameters', + description: + 'Template body parameters. Each entry needs a parameter name and value.', + required: false, + properties: { + parameterName: Property.ShortText({ + displayName: 'Parameter Name', + description: 'The name of the template parameter (e.g. customer_name).', + required: true, + }), + text: Property.ShortText({ + displayName: 'Value', + description: 'The value to substitute for this parameter.', + required: true, + }), + }, + }), + buttonParameters: Property.Array({ + displayName: 'Button Parameters', + description: + 'Template button parameters. Each entry maps to a button by index.', + required: false, + properties: { + subType: Property.StaticDropdown({ + displayName: 'Button Type', + description: 'The type of button.', + required: true, + options: { + options: [ + { label: 'Quick Reply', value: 'quick_reply' }, + { label: 'URL', value: 'url' }, + { label: 'OTP', value: 'otp' }, + { label: 'Flow', value: 'flow' }, + ], + }, + }), + index: Property.ShortText({ + displayName: 'Button Index', + description: 'Zero-based index of the button (e.g. 0, 1, 2).', + required: true, + }), + value: Property.ShortText({ + displayName: 'Value', + description: + 'For quick_reply: the payload string. For url: the dynamic URL suffix. For otp: the OTP code. Leave empty for flow buttons.', + required: false, + }), + flowToken: Property.ShortText({ + displayName: 'Flow Token', + description: 'Token for flow buttons (e.g. a session identifier).', + required: false, + }), + flowActionData: Property.Json({ + displayName: 'Flow Action Data', + description: 'JSON object with data to pass to the flow (e.g. {"customer_id": "123"}).', + required: false, + }), + }, + }), + }, + async run(context) { + const { + phoneNumberId, + to, + template, + headerType, + headerTextParameters, + headerMediaUrl, + headerMediaFilename, + headerLocationLatitude, + headerLocationLongitude, + headerLocationName, + headerLocationAddress, + bodyParameters, + buttonParameters, + } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const parsed = JSON.parse(template as string) as { + name: string; + language: string; + }; + + const components: Array> = []; + + if (headerType === 'text' && headerTextParameters && (headerTextParameters as unknown[]).length > 0) { + components.push({ + type: 'header', + parameters: ( + headerTextParameters as { parameterName: string; text: string }[] + ).map((p) => ({ + type: 'text', + parameterName: p.parameterName, + text: p.text, + })), + }); + } else if (headerType === 'image' && headerMediaUrl) { + components.push({ + type: 'header', + parameters: [ + { type: 'image', image: { link: headerMediaUrl } }, + ], + }); + } else if (headerType === 'video' && headerMediaUrl) { + components.push({ + type: 'header', + parameters: [ + { type: 'video', video: { link: headerMediaUrl } }, + ], + }); + } else if (headerType === 'document' && headerMediaUrl) { + components.push({ + type: 'header', + parameters: [ + { + type: 'document', + document: { + link: headerMediaUrl, + filename: headerMediaFilename ?? undefined, + }, + }, + ], + }); + } else if (headerType === 'location' && headerLocationLatitude != null && headerLocationLongitude != null) { + components.push({ + type: 'header', + parameters: [ + { + type: 'location', + location: { + latitude: headerLocationLatitude, + longitude: headerLocationLongitude, + name: headerLocationName ?? undefined, + address: headerLocationAddress ?? undefined, + }, + }, + ], + }); + } + + if (bodyParameters && (bodyParameters as unknown[]).length > 0) { + components.push({ + type: 'body', + parameters: ( + bodyParameters as { parameterName?: string; text: string }[] + ).map((p) => { + const param: Record = { type: 'text', text: p.text }; + if (p.parameterName) { + param['parameterName'] = p.parameterName; + } + return param; + }), + }); + } + + if (buttonParameters && (buttonParameters as unknown[]).length > 0) { + for (const btn of buttonParameters as { + subType: string; + index: string; + value?: string; + flowToken?: string; + flowActionData?: unknown; + }[]) { + if (btn.subType === 'quick_reply') { + components.push({ + type: 'button', + sub_type: 'quick_reply', + index: btn.index, + parameters: [{ type: 'payload', payload: btn.value }], + }); + } else if (btn.subType === 'url') { + components.push({ + type: 'button', + sub_type: 'url', + index: btn.index, + parameters: [{ type: 'text', text: btn.value }], + }); + } else if (btn.subType === 'otp') { + components.push({ + type: 'button', + sub_type: 'otp', + index: btn.index, + parameters: [{ type: 'text', text: btn.value }], + }); + } else if (btn.subType === 'flow') { + const action: Record = {}; + if (btn.flowToken) { + action['flow_token'] = btn.flowToken; + } + if (btn.flowActionData) { + action['flow_action_data'] = + typeof btn.flowActionData === 'string' + ? JSON.parse(btn.flowActionData) + : btn.flowActionData; + } + components.push({ + type: 'button', + sub_type: 'flow', + index: btn.index, + parameters: [{ type: 'action', action }], + }); + } + } + } + + const response = await client.messages.sendTemplate({ + phoneNumberId, + to, + template: { + name: parsed.name, + language: { code: parsed.language }, + components: components as any, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-text-message.ts b/packages/pieces/community/kapso/src/lib/actions/send-text-message.ts new file mode 100644 index 00000000000..855fa8e078a --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-text-message.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendTextMessage = createAction({ + auth: kapsoAuth, + name: 'send_text_message', + displayName: 'Send Text Message', + description: 'Send a text message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + body: Property.LongText({ + displayName: 'Message', + description: 'The text message to send.', + required: true, + }), + previewUrl: Property.Checkbox({ + displayName: 'Preview URL', + description: 'Whether to show a link preview if the message contains a URL.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { phoneNumberId, to, body, previewUrl } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendText({ + phoneNumberId, + to, + body, + previewUrl: previewUrl ?? undefined, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/actions/send-video.ts b/packages/pieces/community/kapso/src/lib/actions/send-video.ts new file mode 100644 index 00000000000..22636a30edc --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/actions/send-video.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from '../common'; +import { makeClient } from '../common'; +import { phoneNumberIdDropdown } from '../common/props'; + +export const sendVideo = createAction({ + auth: kapsoAuth, + name: 'send_video', + displayName: 'Send Video', + description: 'Send a video message via WhatsApp.', + props: { + phoneNumberId: phoneNumberIdDropdown, + to: Property.ShortText({ + displayName: 'Recipient Phone Number', + description: + 'The recipient\'s phone number in international format (e.g. 15551234567).', + required: true, + }), + videoUrl: Property.ShortText({ + displayName: 'Video URL', + description: 'Public URL of the video to send.', + required: false, + }), + videoId: Property.ShortText({ + displayName: 'Video Media ID', + description: 'Media ID of a previously uploaded video. Use either URL or Media ID.', + required: false, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'Optional caption for the video.', + required: false, + }), + }, + async run(context) { + const { phoneNumberId, to, videoUrl, videoId, caption } = context.propsValue; + const client = makeClient(context.auth.secret_text); + + const response = await client.messages.sendVideo({ + phoneNumberId, + to, + video: { + link: videoUrl ?? undefined, + id: videoId ?? undefined, + caption: caption ?? undefined, + }, + }); + + return response; + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/common/index.ts b/packages/pieces/community/kapso/src/lib/common/index.ts new file mode 100644 index 00000000000..52089ceb296 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/common/index.ts @@ -0,0 +1,35 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; +import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'; + +export const KAPSO_BASE_URL = 'https://api.kapso.ai/meta/whatsapp'; + +export const kapsoAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: + 'Your Kapso API key. You can obtain it from your [Kapso dashboard](https://app.kapso.ai).', + required: true, + validate: async ({ auth }) => { + try { + const client = makeClient(auth); + await client.phoneNumbers.settings.get({ + phoneNumberId: 'me', + }); + + return { + valid: true, + }; + } catch { + return { + valid: false, + error: 'Invalid API Key', + }; + } + }, +}); + +export function makeClient(apiKey: string): WhatsAppClient { + return new WhatsAppClient({ + baseUrl: KAPSO_BASE_URL, + kapsoApiKey: apiKey, + }); +} diff --git a/packages/pieces/community/kapso/src/lib/common/props.ts b/packages/pieces/community/kapso/src/lib/common/props.ts new file mode 100644 index 00000000000..d6787761c11 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/common/props.ts @@ -0,0 +1,131 @@ +import { Property } from '@activepieces/pieces-framework'; +import { kapsoAuth } from './index'; +import { makeClient } from './index'; + +export const businessAccountIdDropdown = Property.Dropdown({ + auth: kapsoAuth, + displayName: 'Business Account', + description: 'Select the WhatsApp Business Account.', + required: true, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your Kapso account first', + options: [], + }; + } + + try { + const client = makeClient(auth.secret_text); + const response = await client.request<{ + data: Array<{ + id: string; + name: string; + }>; + }>('GET', 'business_accounts', { responseType: 'json' }); + + return { + options: response.data.map((ba) => ({ + label: ba.name, + value: ba.id, + })), + }; + } catch { + return { + disabled: true, + placeholder: 'Could not load business accounts', + options: [], + }; + } + }, +}); + +export const templateDropdown = Property.Dropdown({ + auth: kapsoAuth, + displayName: 'Template', + description: 'Select a message template to send.', + required: true, + refreshers: ['businessAccountId'], + options: async ({ auth, businessAccountId }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your Kapso account first', + options: [], + }; + } + + if (!businessAccountId) { + return { + disabled: true, + placeholder: 'Select a business account first', + options: [], + }; + } + + try { + const client = makeClient(auth.secret_text); + const response = await client.templates.list({ + businessAccountId: businessAccountId as string, + status: 'APPROVED', + limit: 100, + }); + + return { + options: response.data.map((t) => ({ + label: `${t.name} (${t.language ?? 'unknown'})`, + value: JSON.stringify({ name: t.name, language: t.language }), + })), + }; + } catch { + return { + disabled: true, + placeholder: 'Could not load templates', + options: [], + }; + } + }, +}); + +export const phoneNumberIdDropdown = Property.Dropdown({ + auth: kapsoAuth, + displayName: 'Phone Number', + description: 'Select the WhatsApp phone number to send from.', + required: true, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your Kapso account first', + options: [], + }; + } + + try { + const client = makeClient(auth.secret_text); + const response = await client.request<{ + data: Array<{ + id: string; + display_phone_number: string; + verified_name: string; + }>; + }>('GET', 'phone_numbers', { responseType: 'json' }); + + return { + options: response.data.map((pn) => ({ + label: `${pn.verified_name} (${pn.display_phone_number})`, + value: pn.id, + })), + }; + } catch { + return { + disabled: true, + placeholder: 'Could not load phone numbers', + options: [], + }; + } + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/common/webhook.ts b/packages/pieces/community/kapso/src/lib/common/webhook.ts new file mode 100644 index 00000000000..95599be2ab6 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/common/webhook.ts @@ -0,0 +1,59 @@ +export interface ParsedWebhook { + phoneNumberId?: string; + displayPhoneNumber?: string; + contacts: Array>; + messages: Array>; + statuses: Array>; +} + +export function parseWebhookPayload(payload: unknown): ParsedWebhook { + const result: ParsedWebhook = { + contacts: [], + messages: [], + statuses: [], + }; + + if (!payload || typeof payload !== 'object') { + return result; + } + + const body = payload as Record; + const entry = body['entry'] as Array> | undefined; + + if (!Array.isArray(entry)) { + return result; + } + + for (const e of entry) { + const changes = e['changes'] as Array> | undefined; + if (!Array.isArray(changes)) continue; + + for (const change of changes) { + const value = change['value'] as Record | undefined; + if (!value) continue; + + const metadata = value['metadata'] as Record | undefined; + if (metadata) { + result.phoneNumberId = metadata['phone_number_id']; + result.displayPhoneNumber = metadata['display_phone_number']; + } + + const contacts = value['contacts'] as Array> | undefined; + if (Array.isArray(contacts)) { + result.contacts.push(...contacts); + } + + const messages = value['messages'] as Array> | undefined; + if (Array.isArray(messages)) { + result.messages.push(...messages); + } + + const statuses = value['statuses'] as Array> | undefined; + if (Array.isArray(statuses)) { + result.statuses.push(...statuses); + } + } + } + + return result; +} diff --git a/packages/pieces/community/kapso/src/lib/triggers/message-status-update.ts b/packages/pieces/community/kapso/src/lib/triggers/message-status-update.ts new file mode 100644 index 00000000000..27f10cb5bcd --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/triggers/message-status-update.ts @@ -0,0 +1,86 @@ +import { + createTrigger, + Property, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { MarkdownVariant } from '@activepieces/shared'; +import { kapsoAuth } from '../common'; +import { parseWebhookPayload } from '../common/webhook'; + +const webhookSetupMarkdown = `**Setup Instructions:** + +1. Copy the **Webhook URL** below. +2. Go to your [Kapso dashboard](https://app.kapso.ai) and open your WhatsApp number settings. +3. Paste the URL as your **Webhook destination URL**. +4. Message status updates will now trigger this flow. + +**Webhook URL:** +\`\`\`text +{{webhookUrl}} +\`\`\` +`; + +export const messageStatusUpdate = createTrigger({ + auth: kapsoAuth, + name: 'message_status_update', + displayName: 'Message Status Update', + description: + 'Triggers when a message status changes (sent, delivered, read).', + props: { + setup: Property.MarkDown({ + value: webhookSetupMarkdown, + variant: MarkdownVariant.INFO, + }), + statusFilter: Property.StaticDropdown({ + displayName: 'Status Filter', + description: 'Only trigger for a specific status. Leave as All to trigger for any status change.', + required: false, + defaultValue: 'all', + options: { + options: [ + { label: 'All', value: 'all' }, + { label: 'Sent', value: 'sent' }, + { label: 'Delivered', value: 'delivered' }, + { label: 'Read', value: 'read' }, + { label: 'Failed', value: 'failed' }, + ], + }, + }), + }, + type: TriggerStrategy.WEBHOOK, + async onEnable() { + // Webhook is configured manually in the Kapso dashboard + }, + async onDisable() { + // Webhook is removed manually in the Kapso dashboard + }, + async run(context) { + const body = context.payload.body; + const parsed = parseWebhookPayload(body); + + if (parsed.statuses.length === 0) { + return []; + } + + const statusFilter = context.propsValue.statusFilter; + let statuses = parsed.statuses; + + if (statusFilter && statusFilter !== 'all') { + statuses = statuses.filter((s) => s['status'] === statusFilter); + } + + return statuses.map((status) => ({ + ...status, + phoneNumberId: parsed.phoneNumberId, + displayPhoneNumber: parsed.displayPhoneNumber, + })); + }, + sampleData: { + id: 'wamid.ABGGFlCGg0cvAgo-sJQh43L5Pe4W', + status: 'delivered', + timestamp: '1677000000', + recipientId: '15551234567', + phoneNumberId: '647015955153740', + displayPhoneNumber: '+1 555 987 6543', + }, +}); diff --git a/packages/pieces/community/kapso/src/lib/triggers/new-message.ts b/packages/pieces/community/kapso/src/lib/triggers/new-message.ts new file mode 100644 index 00000000000..5335022c391 --- /dev/null +++ b/packages/pieces/community/kapso/src/lib/triggers/new-message.ts @@ -0,0 +1,74 @@ +import { + createTrigger, + Property, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { MarkdownVariant } from '@activepieces/shared'; +import { kapsoAuth } from '../common'; +import { parseWebhookPayload } from '../common/webhook'; + +const webhookSetupMarkdown = `**Setup Instructions:** + +1. Copy the **Webhook URL** below. +2. Go to your [Kapso dashboard](https://app.kapso.ai) and open your WhatsApp number settings. +3. Paste the URL as your **Webhook destination URL**. +4. Incoming WhatsApp events will now trigger this flow. + +**Webhook URL:** +\`\`\`text +{{webhookUrl}} +\`\`\` +`; + +export const newMessage = createTrigger({ + auth: kapsoAuth, + name: 'new_message_received', + displayName: 'New Message Received', + description: + 'Triggers when a new WhatsApp message is received.', + props: { + setup: Property.MarkDown({ + value: webhookSetupMarkdown, + variant: MarkdownVariant.INFO, + }), + }, + type: TriggerStrategy.WEBHOOK, + async onEnable() { + // Webhook is configured manually in the Kapso dashboard + }, + async onDisable() { + // Webhook is removed manually in the Kapso dashboard + }, + async run(context) { + const body = context.payload.body; + const parsed = parseWebhookPayload(body); + + if (parsed.messages.length === 0) { + return []; + } + + return parsed.messages.map((message) => ({ + ...message, + phoneNumberId: parsed.phoneNumberId, + displayPhoneNumber: parsed.displayPhoneNumber, + contacts: parsed.contacts, + })); + }, + sampleData: { + id: 'wamid.ABGGFlCGg0cvAgo-sJQh43L5Pe4W', + type: 'text', + timestamp: '1677000000', + from: '15551234567', + text: { + body: 'Hello, I need help with my order.', + }, + phoneNumberId: '647015955153740', + displayPhoneNumber: '+1 555 987 6543', + contacts: [ + { + profile: { name: 'John Doe' }, + wa_id: '15551234567', + }, + ], + }, +}); diff --git a/packages/pieces/community/kapso/tsconfig.json b/packages/pieces/community/kapso/tsconfig.json new file mode 100644 index 00000000000..eff240ac143 --- /dev/null +++ b/packages/pieces/community/kapso/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/pieces/community/kapso/tsconfig.lib.json b/packages/pieces/community/kapso/tsconfig.lib.json new file mode 100644 index 00000000000..5995d959bf0 --- /dev/null +++ b/packages/pieces/community/kapso/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx b/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx index 1c167e8fb9b..c5d1d80019a 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx @@ -192,7 +192,7 @@ const CanvasControls = ({ id="canvas-controls" className="z-50 absolute bottom-2 left-0 flex items-center w-full pointer-events-none " > -
+
diff --git a/packages/react-ui/src/app/builder/index.tsx b/packages/react-ui/src/app/builder/index.tsx index a95738d4240..4666f9b01e8 100644 --- a/packages/react-ui/src/app/builder/index.tsx +++ b/packages/react-ui/src/app/builder/index.tsx @@ -85,7 +85,7 @@ const BuilderPage = () => { useState(false); return ( -
+
diff --git a/packages/react-ui/src/app/builder/state/flow-state.ts b/packages/react-ui/src/app/builder/state/flow-state.ts index e0dc2ba1761..232fb8416fd 100644 --- a/packages/react-ui/src/app/builder/state/flow-state.ts +++ b/packages/react-ui/src/app/builder/state/flow-state.ts @@ -32,8 +32,8 @@ import { flowCanvasUtils } from '../flow-canvas/utils/flow-canvas-utils'; export type FlowState = { flow: PopulatedFlow; flowVersion: FlowVersion; - outputSampleData: Record; - inputSampleData: Record; + outputSampleData: Record; + inputSampleData: Record; saving: boolean; renameFlowClientSide: (newName: string) => void; moveToFolderClientSide: (folderId: string) => void; diff --git a/packages/react-ui/src/app/builder/test-step/test-sample-data-viewer.tsx b/packages/react-ui/src/app/builder/test-step/test-sample-data-viewer.tsx index cd8ba3c35e5..23129659bf7 100644 --- a/packages/react-ui/src/app/builder/test-step/test-sample-data-viewer.tsx +++ b/packages/react-ui/src/app/builder/test-step/test-sample-data-viewer.tsx @@ -26,8 +26,8 @@ type TestSampleDataViewerProps = { currentStep?: FlowAction; isTesting: boolean; agentResult?: AgentResult; - sampleData: unknown; - sampleDataInput: unknown | null; + sampleData?: unknown; + sampleDataInput?: unknown | null; errorMessage: string | null; lastTestDate: string | undefined; children?: React.ReactNode; @@ -96,7 +96,8 @@ export const TestSampleDataViewer = React.memo( } = props; const isFailed = isRunAgent(currentStep) && - (sampleData as AgentResult).status === AgentTaskStatus.FAILED; + (sampleData as AgentResult | undefined)?.status === + AgentTaskStatus.FAILED; return ( <> diff --git a/packages/react-ui/src/app/routes/platform/setup/ai/index.tsx b/packages/react-ui/src/app/routes/platform/setup/ai/index.tsx index c342574f006..82bfb371a3c 100644 --- a/packages/react-ui/src/app/routes/platform/setup/ai/index.tsx +++ b/packages/react-ui/src/app/routes/platform/setup/ai/index.tsx @@ -5,12 +5,9 @@ import { DashboardPageHeader } from '@/app/components/dashboard-page-header'; import { aiProviderApi } from '@/features/platform-admin/lib/ai-provider-api'; import { flagsHooks } from '@/hooks/flags-hooks'; import { userHooks } from '@/hooks/user-hooks'; -import { - PlatformRole, - ApFlagId, - SUPPORTED_AI_PROVIDERS, -} from '@activepieces/shared'; +import { PlatformRole, ApFlagId } from '@activepieces/shared'; +import { SUPPORTED_AI_PROVIDERS } from '../../../../../features/agents/ai-providers'; import LockedFeatureGuard from '../../../../components/locked-feature-guard'; import { AIProviderCard } from './universal-pieces/ai-provider-card'; diff --git a/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx b/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx index c31f7f3eca5..fa62fc58813 100644 --- a/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx +++ b/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx @@ -3,10 +3,9 @@ import { Pencil, Trash } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; -import { - AiProviderInfo, - AIProviderWithoutSensitiveData, -} from '@activepieces/shared'; +import { AIProviderWithoutSensitiveData } from '@activepieces/shared'; + +import { AiProviderInfo } from '../../../../../../features/agents/ai-providers'; import { UpsertAIProviderDialog } from './upsert-provider-dialog'; diff --git a/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx b/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx index 35577b274b7..c4d4cc39862 100644 --- a/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx +++ b/packages/react-ui/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx @@ -64,7 +64,11 @@ export const UpsertProviderConfigForm = ({ render={({ field }) => (
- {t('API Key')} + + {provider === AIProviderName.CLOUDFLARE_GATEWAY + ? t('AI Gateway Token') + : t('API Key')} + {!showApiKeyInput && ( - - + ); }; +const OptionalAuthSchema = Type.Optional( + Type.Object({ + apiKey: Type.Optional(Type.String()), + }), +); + const createFormSchema = (provider: AIProviderName, editMode: boolean) => { if (provider === AIProviderName.AZURE) { return Type.Object({ provider: Type.Literal(AIProviderName.AZURE), config: AzureProviderConfig, - auth: editMode - ? Type.Optional(AzureProviderAuthConfig) - : AzureProviderAuthConfig, + auth: editMode ? OptionalAuthSchema : AzureProviderAuthConfig, }); } if (provider === AIProviderName.CLOUDFLARE_GATEWAY) { return Type.Object({ provider: Type.Literal(AIProviderName.CLOUDFLARE_GATEWAY), config: CloudflareGatewayProviderConfig, - auth: editMode - ? Type.Optional(CloudflareGatewayProviderAuthConfig) - : CloudflareGatewayProviderAuthConfig, + auth: editMode ? OptionalAuthSchema : CloudflareGatewayProviderAuthConfig, }); } if (provider === AIProviderName.CUSTOM) { return Type.Object({ provider: Type.Literal(AIProviderName.CUSTOM), config: OpenAICompatibleProviderConfig, - auth: editMode - ? Type.Optional(OpenAICompatibleProviderAuthConfig) - : OpenAICompatibleProviderAuthConfig, + auth: editMode ? OptionalAuthSchema : OpenAICompatibleProviderAuthConfig, }); } + const authSchema = Type.Union([ + AnthropicProviderAuthConfig, + GoogleProviderAuthConfig, + OpenAIProviderAuthConfig, + ]); return Type.Object({ provider: Type.Literal(provider), - auth: editMode - ? Type.Optional( - Type.Union([ - AnthropicProviderAuthConfig, - GoogleProviderAuthConfig, - OpenAIProviderAuthConfig, - ]), - ) - : Type.Union([ - AnthropicProviderAuthConfig, - GoogleProviderAuthConfig, - OpenAIProviderAuthConfig, - ]), + auth: editMode ? OptionalAuthSchema : authSchema, config: Type.Union([ AnthropicProviderConfig, GoogleProviderConfig, diff --git a/packages/react-ui/src/features/agents/ai-model/index.tsx b/packages/react-ui/src/features/agents/ai-model/index.tsx index a311c39168b..7f80870c048 100644 --- a/packages/react-ui/src/features/agents/ai-model/index.tsx +++ b/packages/react-ui/src/features/agents/ai-model/index.tsx @@ -15,8 +15,9 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { SUPPORTED_AI_PROVIDERS } from '@/features/agents/ai-providers'; import { cn } from '@/lib/utils'; -import { AIProviderName, SUPPORTED_AI_PROVIDERS } from '@activepieces/shared'; +import { AIProviderName } from '@activepieces/shared'; import { aiModelHooks } from './hooks'; diff --git a/packages/react-ui/src/features/agents/ai-providers.ts b/packages/react-ui/src/features/agents/ai-providers.ts new file mode 100644 index 00000000000..713f268fe57 --- /dev/null +++ b/packages/react-ui/src/features/agents/ai-providers.ts @@ -0,0 +1,82 @@ +import { t } from 'i18next'; + +import { AIProviderName } from '@activepieces/shared'; + +export const SUPPORTED_AI_PROVIDERS: AiProviderInfo[] = [ + { + provider: AIProviderName.OPENAI, + name: 'OpenAI', + markdown: t(`Follow these instructions to get your OpenAI API Key: + +1. Go to https://platform.openai.com/account/api-keys. +2. Once on the website, locate and click on the option to obtain your OpenAI API Key. + +It is strongly recommended that you add your credit card information to your OpenAI account and upgrade to the paid plan **before** generating the API Key. This will help you prevent 429 errors. +`), + logoUrl: 'https://cdn.activepieces.com/pieces/openai.png', + }, + { + provider: AIProviderName.ANTHROPIC, + name: 'Anthropic', + markdown: t(`Follow these instructions to get your Claude API Key: + +1. Go to https://console.anthropic.com/settings/keys. +2. Once on the website, locate and click on the option to obtain your Claude API Key. +`), + logoUrl: 'https://cdn.activepieces.com/pieces/claude.png', + }, + { + provider: AIProviderName.GOOGLE, + name: 'Google Gemini', + markdown: t(`Follow these instructions to get your Google API Key: +1. Go to https://console.cloud.google.com/apis/credentials. +2. Once on the website, locate and click on the option to obtain your Google API Key. +`), + logoUrl: 'https://cdn.activepieces.com/pieces/google-gemini.png', + }, + { + provider: AIProviderName.AZURE, + name: 'Azure', + logoUrl: 'https://cdn.activepieces.com/pieces/azure-openai.png', + markdown: t( + 'Use the Azure Portal to browse to your OpenAI resource and retrieve an API key and resource name.', + ), + }, + { + provider: AIProviderName.OPENROUTER, + name: 'OpenRouter', + logoUrl: 'https://cdn.activepieces.com/pieces/openrouter.jpg', + markdown: t(`Follow these instructions to get your OpenRouter API Key: +1. Go to https://openrouter.ai/settings/keys. +2. Once on the website, locate and click on the option to obtain your OpenRouter API Key.`), + }, + { + provider: AIProviderName.CLOUDFLARE_GATEWAY, + name: 'Cloudflare AI Gateway', + logoUrl: 'https://cdn.activepieces.com/pieces/cloudflare-gateway.png', + markdown: + t(`Follow these instructions to get your Cloudflare AI Gateway API Key: +1. Go to https://developers.cloudflare.com/ai-gateway/get-started/ to create your gateway then enter it from the dashboard. +2. Look in the overview section for this link https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/ to get your account id and gateway id. +3. Create an AI Gateway Token by checking https://developers.cloudflare.com/ai-gateway/configuration/authentication/#setting-up-authenticated-gateway-using-the-dashboard. +4. In your gateway dashboard, go to the providers tab and add your API keys for each provider. +5. After you finish all the previous steps and filled the required inputs, add models but make sure you prefix the model id with the provider name i.e (openai/gpt-4o) or (anthropic/claude-3-5-sonnet), check https://developers.cloudflare.com/ai-gateway/usage/chat-completion/ for more information.`), + }, + { + provider: AIProviderName.CUSTOM, + name: 'OpenAI Compatible', + logoUrl: 'https://cdn.activepieces.com/pieces/openai-compatible.png', + markdown: + t(`Follow these instructions to get your OpenAI Compatible API Key: +1. Set the base url to your proxy url. +2. In the api key header, set the value of your auth header name. +3. In the api key, set your auth header value (full value including the Bearer if any).`), + }, +]; + +export type AiProviderInfo = { + provider: AIProviderName; + name: string; + markdown: string; + logoUrl: string; +}; diff --git a/packages/server/shared/package.json b/packages/server/shared/package.json index f5390a26603..8adaae7a702 100644 --- a/packages/server/shared/package.json +++ b/packages/server/shared/package.json @@ -5,8 +5,8 @@ "main": "./src/index.js", "typings": "./src/index.d.ts", "dependencies": { - "@activepieces/pieces-framework": "0.25.1", - "@activepieces/shared": "0.34.0", + "@activepieces/pieces-framework": "0.25.3", + "@activepieces/shared": "0.35.0", "tslib": "2.6.2", "pino": "10.1.0", "@hyperdx/node-opentelemetry": "0.8.2", diff --git a/packages/server/worker/package.json b/packages/server/worker/package.json index 1318a08da86..27913ae8ff1 100644 --- a/packages/server/worker/package.json +++ b/packages/server/worker/package.json @@ -5,9 +5,9 @@ "main": "./src/index.js", "typings": "./src/index.d.ts", "dependencies": { - "@activepieces/pieces-framework": "0.25.2", + "@activepieces/pieces-framework": "0.25.3", "@activepieces/server-shared": "0.0.2", - "@activepieces/shared": "0.34.0", + "@activepieces/shared": "0.35.0", "write-file-atomic": "5.0.1", "tslib": "2.6.2", "@opentelemetry/api": "1.9.0", diff --git a/packages/server/worker/src/lib/compute/sandbox/websocket-server.ts b/packages/server/worker/src/lib/compute/sandbox/websocket-server.ts index fabdff88b2c..1df93455d71 100644 --- a/packages/server/worker/src/lib/compute/sandbox/websocket-server.ts +++ b/packages/server/worker/src/lib/compute/sandbox/websocket-server.ts @@ -32,7 +32,7 @@ export const sandboxWebsocketServer = { log.debug({ sandboxId, event, payload }, 'Received message from sandbox') const listener = listeners[sandboxId] if (isNil(listener)) { - log.warn({ sandboxId, event }, 'Received message from sandbox after listener was removed, ignoring') + log.error({ sandboxId, event }, 'Received message from sandbox after listener was removed') return } const promise = listener(event, payload) diff --git a/packages/shared/package.json b/packages/shared/package.json index 93332d2b17f..a17de8020f3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,5 +1,5 @@ { "name": "@activepieces/shared", - "version": "0.34.0", + "version": "0.35.0", "type": "commonjs" } diff --git a/packages/shared/src/lib/ai-providers/index.ts b/packages/shared/src/lib/ai-providers/index.ts index ac624686fd5..997adcb353d 100644 --- a/packages/shared/src/lib/ai-providers/index.ts +++ b/packages/shared/src/lib/ai-providers/index.ts @@ -1,5 +1,4 @@ import { Static, Type } from '@sinclair/typebox' -import { t } from 'i18next' import { BaseModelSchema, DiscriminatedUnion } from '../common/base-model' export enum AIProviderName { @@ -13,83 +12,6 @@ export enum AIProviderName { CUSTOM = 'custom', } -export const SUPPORTED_AI_PROVIDERS: AiProviderInfo[] = [ - { - provider: AIProviderName.OPENAI, - name: 'OpenAI', - markdown: t(`Follow these instructions to get your OpenAI API Key: - -1. Visit the following website: https://platform.openai.com/account/api-keys. -2. Once on the website, locate and click on the option to obtain your OpenAI API Key. - -It is strongly recommended that you add your credit card information to your OpenAI account and upgrade to the paid plan **before** generating the API Key. This will help you prevent 429 errors. -`), - logoUrl: 'https://cdn.activepieces.com/pieces/openai.png', - }, - { - provider: AIProviderName.ANTHROPIC, - name: 'Anthropic', - markdown: t(`Follow these instructions to get your Claude API Key: - -1. Visit the following website: https://console.anthropic.com/settings/keys. -2. Once on the website, locate and click on the option to obtain your Claude API Key. -`), - logoUrl: 'https://cdn.activepieces.com/pieces/claude.png', - }, - { - provider: AIProviderName.GOOGLE, - name: 'Google Gemini', - markdown: t(`Follow these instructions to get your Google API Key: -1. Visit the following website: https://console.cloud.google.com/apis/credentials. -2. Once on the website, locate and click on the option to obtain your Google API Key. -`), - logoUrl: 'https://cdn.activepieces.com/pieces/google-gemini.png', - }, - { - provider: AIProviderName.AZURE, - name: 'Azure', - logoUrl: 'https://cdn.activepieces.com/pieces/azure-openai.png', - markdown: t( - 'Use the Azure Portal to browse to your OpenAI resource and retrieve an API key and resource name.', - ), - }, - { - provider: AIProviderName.OPENROUTER, - name: 'OpenRouter', - logoUrl: 'https://cdn.activepieces.com/pieces/openrouter.jpg', - markdown: t(`Follow these instructions to get your OpenRouter API Key: -1. Visit the following website: https://openrouter.ai/settings/keys. -2. Once on the website, locate and click on the option to obtain your OpenRouter API Key.`), - }, - { - provider: AIProviderName.CLOUDFLARE_GATEWAY, - name: 'Cloudflare AI Gateway', - logoUrl: 'https://cdn.activepieces.com/pieces/cloudflare-gateway.png', - markdown: - t(`Follow these instructions to get your Cloudflare AI Gateway API Key: -1. Visit the following website: https://developers.cloudflare.com/ai-gateway/get-started/. -2. Once on the website, follow the instructions to get your account id, gateway id and create an API Key. -3. After creating the gateway, make sure to enable the Authenticated Gateway Option in your settings. -4. For each provider you are using, include your keys in the Provider Keys tab.`), - }, - { - provider: AIProviderName.CUSTOM, - name: 'OpenAI Compatible', - logoUrl: 'https://cdn.activepieces.com/pieces/openai-compatible.png', - markdown: - t(`Follow these instructions to get your OpenAI Compatible API Key: -1. Set the base url to your proxy url. -2. In the api key header, set the value of your auth header name. -3. In the api key, set your auth header value (full value including the Bearer if any).`), - }, -] - -export type AiProviderInfo = { - provider: AIProviderName - name: string - markdown: string - logoUrl: string -} export enum AIProviderModelType { IMAGE = 'image', diff --git a/tsconfig.base.json b/tsconfig.base.json index 83ef0cfb6e2..4e3644a019e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1789,6 +1789,9 @@ ], "@activepieces/piece-alai": [ "packages/pieces/community/alai/src/index.ts" + ], + "@activepieces/piece-kapso": [ + "packages/pieces/community/kapso/src/index.ts" ] }, "resolveJsonModule": true