From 1044e26293437855919dec2e274ac09f490a6103 Mon Sep 17 00:00:00 2001 From: Janelle Tam <64625892+janelletam@users.noreply.github.com> Date: Tue, 19 May 2026 14:22:15 -0400 Subject: [PATCH 1/2] feat(integrations/freshdesk): add Freshdesk integration (#15184) --- integrations/freshdesk/definitions/actions.ts | 323 ++++++++++++++++++ .../freshdesk/definitions/configuration.ts | 25 ++ integrations/freshdesk/definitions/events.ts | 47 +++ integrations/freshdesk/definitions/index.ts | 3 + integrations/freshdesk/hub.md | 88 +++++ integrations/freshdesk/icon.svg | 1 + .../freshdesk/integration.definition.ts | 21 ++ integrations/freshdesk/package.json | 20 ++ integrations/freshdesk/src/events/mappers.ts | 29 ++ integrations/freshdesk/src/events/schemas.ts | 28 ++ .../freshdesk/src/events/ticketCreated.ts | 33 ++ .../freshdesk/src/events/ticketReplied.ts | 33 ++ .../freshdesk/src/events/ticketUpdated.ts | 33 ++ .../src/freshdesk-client/FreshdeskClient.ts | 157 +++++++++ .../actions/action-wrapper.ts | 41 +++ .../src/freshdesk-client/actions/errors.ts | 6 + .../actions/implementations/addNote.ts | 16 + .../actions/implementations/const.ts | 4 + .../actions/implementations/createTicket.ts | 26 ++ .../actions/implementations/deleteTicket.ts | 9 + .../actions/implementations/getContact.ts | 18 + .../actions/implementations/getTicket.ts | 23 ++ .../actions/implementations/index.ts | 22 ++ .../actions/implementations/listTickets.ts | 24 ++ .../actions/implementations/searchContacts.ts | 50 +++ .../actions/implementations/searchTickets.ts | 44 +++ .../actions/implementations/updateTicket.ts | 22 ++ .../freshdesk/src/freshdesk-client/types.ts | 113 ++++++ integrations/freshdesk/src/handler.ts | 66 ++++ integrations/freshdesk/src/index.ts | 28 ++ integrations/freshdesk/tsconfig.json | 8 + pnpm-lock.yaml | 25 +- 32 files changed, 1383 insertions(+), 3 deletions(-) create mode 100644 integrations/freshdesk/definitions/actions.ts create mode 100644 integrations/freshdesk/definitions/configuration.ts create mode 100644 integrations/freshdesk/definitions/events.ts create mode 100644 integrations/freshdesk/definitions/index.ts create mode 100644 integrations/freshdesk/hub.md create mode 100644 integrations/freshdesk/icon.svg create mode 100644 integrations/freshdesk/integration.definition.ts create mode 100644 integrations/freshdesk/package.json create mode 100644 integrations/freshdesk/src/events/mappers.ts create mode 100644 integrations/freshdesk/src/events/schemas.ts create mode 100644 integrations/freshdesk/src/events/ticketCreated.ts create mode 100644 integrations/freshdesk/src/events/ticketReplied.ts create mode 100644 integrations/freshdesk/src/events/ticketUpdated.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/errors.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts create mode 100644 integrations/freshdesk/src/freshdesk-client/types.ts create mode 100644 integrations/freshdesk/src/handler.ts create mode 100644 integrations/freshdesk/src/index.ts create mode 100644 integrations/freshdesk/tsconfig.json diff --git a/integrations/freshdesk/definitions/actions.ts b/integrations/freshdesk/definitions/actions.ts new file mode 100644 index 00000000000..c6574c571bf --- /dev/null +++ b/integrations/freshdesk/definitions/actions.ts @@ -0,0 +1,323 @@ +import { IntegrationDefinitionProps, z } from '@botpress/sdk' + +const ticketSchema = z.object({ + id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'), + subject: z.string().title('Subject').describe('Subject of the ticket.'), + description: z.string().nullish().title('Description').describe('HTML content of the ticket description.'), + description_text: z.string().nullish().title('Description Text').describe('Plain-text ticket description.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'), + source: z.number().nullish().title('Source').describe('Channel through which the ticket was created.'), + email: z.string().nullish().title('Email').describe('Requester email address.'), + name: z.string().nullish().title('Name').describe('Requester name.'), + requester_id: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'), + responder_id: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'), + group_id: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'), + type: z.string().nullish().title('Type').describe('Ticket category type.'), + tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'), + cc_emails: z.array(z.string()).nullish().title('CC Emails').describe('List of CC email addresses.'), + due_by: z.string().nullish().title('Due By').describe('ISO 8601 timestamp for when the ticket is due.'), + created_at: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'), + updated_at: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'), + custom_fields: z + .record(z.string(), z.unknown()) + .nullish() + .title('Custom Fields') + .describe('Custom field key-value pairs.'), +}) + +export const actions = { + createTicket: { + title: 'Create Ticket', + description: 'Creates a new ticket in Freshdesk.', + input: { + schema: z.object({ + subject: z.string().title('Subject').describe('Subject of the ticket.'), + description: z.string().title('Description').describe('HTML content of the ticket description.'), + email: z.string().title('Email').describe('Requester email address.'), + priority: z + .enum(['low', 'medium', 'high', 'urgent']) + .default('medium') + .title('Priority') + .describe('Ticket priority: "low", "medium", "high", or "urgent".'), + status: z + .enum(['open', 'pending', 'resolved', 'closed']) + .default('open') + .title('Status') + .describe('Ticket status: "open", "pending", "resolved", or "closed".'), + tags: z.array(z.string()).optional().title('Tags').describe('Tags to associate with the ticket.'), + custom_fields: z + .record(z.string(), z.unknown()) + .optional() + .title('Custom Fields') + .describe('Custom field key-value pairs.'), + }), + }, + output: { + schema: z.object({ + id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'), + subject: z.string().title('Subject').describe('Subject of the ticket.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'), + createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'), + url: z.string().title('URL').describe('URL to view the ticket in Freshdesk.'), + }), + }, + }, + getTicket: { + title: 'Get Ticket', + description: 'Retrieves a single Freshdesk ticket by ID.', + input: { + schema: z.object({ + ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID.'), + }), + }, + output: { + schema: z.object({ + id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'), + subject: z.string().title('Subject').describe('Subject of the ticket.'), + description: z.string().nullish().title('Description').describe('HTML content of the ticket description.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'), + requesterId: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'), + responderId: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'), + groupId: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'), + createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'), + updatedAt: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'), + tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'), + customFields: z + .record(z.string(), z.unknown()) + .nullish() + .title('Custom Fields') + .describe('Custom field key-value pairs.'), + }), + }, + }, + listTickets: { + title: 'List Tickets', + description: 'Lists Freshdesk tickets with optional filters and pagination.', + input: { + schema: z.object({ + filter: z + .string() + .optional() + .title('Filter') + .describe('Predefined filter name: new_and_my_open, watching, spam, deleted.'), + order_by: z + .string() + .optional() + .title('Order By') + .describe('Field to sort by: created_at, due_by, updated_at, status.'), + order_type: z + .enum(['asc', 'desc']) + .optional() + .title('Order Type') + .describe('Sort direction. Defaults to desc.'), + per_page: z.number().optional().title('Per Page').describe('Tickets per page (max 100, default 30).'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to continue from the previous page of results.'), + }), + }, + output: { + schema: z.object({ + tickets: z.array(ticketSchema).title('Tickets').describe('List of matching tickets.'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to fetch the next page. Absent when there are no more results.'), + }), + }, + }, + updateTicket: { + title: 'Update Ticket', + description: 'Updates an existing Freshdesk ticket.', + input: { + schema: z.object({ + ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID to update.'), + status: z + .enum(['open', 'pending', 'resolved', 'closed']) + .optional() + .title('Status') + .describe('Updated ticket status: "open", "pending", "resolved", or "closed".'), + priority: z + .enum(['low', 'medium', 'high', 'urgent']) + .optional() + .title('Priority') + .describe('Updated ticket priority: "low", "medium", "high", or "urgent".'), + responderId: z.number().optional().title('Responder ID').describe('ID of the agent to assign the ticket to.'), + groupId: z.number().optional().title('Group ID').describe('ID of the group to assign the ticket to.'), + custom_fields: z + .record(z.string(), z.unknown()) + .optional() + .title('Custom Fields') + .describe('Custom field key-value pairs.'), + }), + }, + output: { + schema: z.object({ + id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Updated ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Updated ticket priority.'), + updatedAt: z.string().title('Updated At').describe('ISO 8601 timestamp of last update.'), + }), + }, + }, + deleteTicket: { + title: 'Delete Ticket', + description: 'Deletes a Freshdesk ticket. Deleted tickets can be restored from the Freshdesk UI.', + input: { + schema: z.object({ + ticketId: z.coerce.number().int().positive().title('Ticket ID').describe('The Freshdesk ticket ID to delete.'), + }), + }, + output: { + schema: z.object({}), + }, + }, + searchTickets: { + title: 'Search Tickets', + description: 'Searches Freshdesk tickets by agent, tag, status, or priority.', + input: { + schema: z.object({ + agent_id: z + .number() + .optional() + .title('Agent ID') + .describe('Filter by the ID of the agent the ticket is assigned to.'), + tag: z.string().optional().title('Tag').describe('Filter by a tag associated with the ticket.'), + status: z + .enum(['open', 'pending', 'resolved', 'closed']) + .optional() + .title('Status') + .describe('Filter by ticket status: "open", "pending", "resolved", or "closed".'), + priority: z + .enum(['low', 'medium', 'high', 'urgent']) + .optional() + .title('Priority') + .describe('Filter by ticket priority: "low", "medium", "high", or "urgent".'), + limit: z + .number() + .default(20) + .title('Limit') + .describe('Maximum number of tickets to return (default 20, max 100).'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to continue from the previous page of results.'), + }), + }, + output: { + schema: z.object({ + tickets: z + .array( + z.object({ + id: z.number().title('ID').describe('Unique Freshdesk ticket ID.'), + subject: z.string().title('Subject').describe('Subject of the ticket.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).title('Status').describe('Ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).title('Priority').describe('Ticket priority.'), + createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of ticket creation.'), + requesterEmail: z.string().nullish().title('Requester Email').describe('Email address of the requester.'), + }) + ) + .title('Tickets') + .describe('Matching tickets.'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to fetch the next page. Absent when there are no more results.'), + }), + }, + }, + // TODO: re-add replyToTicket action + addNote: { + title: 'Add Note', + description: 'Adds an internal note to a Freshdesk ticket (not visible to the requester by default).', + input: { + schema: z.object({ + ticketId: z.coerce + .number() + .int() + .positive() + .title('Ticket ID') + .describe('The Freshdesk ticket ID to add a note to.'), + body: z.string().title('Body').describe('HTML content of the note.'), + private: z + .boolean() + .optional() + .default(true) + .title('Private') + .describe('Set to false to make the note public. Defaults to true.'), + }), + }, + output: { + schema: z.object({ + id: z.number().title('ID').describe('Unique ID of the note.'), + body: z.string().title('Body').describe('HTML content of the note.'), + createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of note creation.'), + }), + }, + }, + getContact: { + title: 'Get Contact', + description: 'Retrieves a Freshdesk contact by ID.', + input: { + schema: z.object({ + contactId: z.coerce.number().int().positive().title('Contact ID').describe('The Freshdesk contact ID.'), + }), + }, + output: { + schema: z.object({ + id: z.number().title('ID').describe('Unique Freshdesk contact ID.'), + name: z.string().title('Name').describe('Full name of the contact.'), + email: z.string().nullish().title('Email').describe('Email address of the contact.'), + phone: z.string().nullish().title('Phone').describe('Phone number of the contact.'), + mobile: z.string().nullish().title('Mobile').describe('Mobile number of the contact.'), + companyId: z.number().nullish().title('Company ID').describe('ID of the associated company.'), + tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the contact.'), + createdAt: z.string().title('Created At').describe('ISO 8601 timestamp of contact creation.'), + }), + }, + }, + searchContacts: { + title: 'Search Contacts', + description: 'Finds Freshdesk contacts by email or name.', + input: { + schema: z.object({ + email: z.string().optional().title('Email').describe('Filter contacts by exact email address.'), + name: z.string().optional().title('Name').describe('Search contacts by name prefix (case-insensitive).'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to continue from the previous page of results.'), + }), + }, + output: { + schema: z.object({ + contacts: z + .array( + z.object({ + id: z.number().title('ID').describe('Unique Freshdesk contact ID.'), + name: z.string().title('Name').describe('Full name of the contact.'), + email: z.string().nullish().title('Email').describe('Email address of the contact.'), + phone: z.string().nullish().title('Phone').describe('Phone number of the contact.'), + companyId: z.number().nullish().title('Company ID').describe('ID of the associated company.'), + }) + ) + .title('Contacts') + .describe('Matching contacts.'), + nextToken: z + .string() + .optional() + .title('Next Token') + .describe('Token to fetch the next page. Absent when there are no more results.'), + }), + }, + }, +} as const satisfies IntegrationDefinitionProps['actions'] diff --git a/integrations/freshdesk/definitions/configuration.ts b/integrations/freshdesk/definitions/configuration.ts new file mode 100644 index 00000000000..9637ac24885 --- /dev/null +++ b/integrations/freshdesk/definitions/configuration.ts @@ -0,0 +1,25 @@ +import { IntegrationDefinitionProps, z } from '@botpress/sdk' + +export const configuration = { + schema: z.object({ + domain: z + .string() + .min(1) + .title('Freshdesk Subdomain') + .describe('E.g. "yourcompany" from yourcompany.freshdesk.com'), + apiKey: z + .string() + .secret() + .min(1) + .title('API Key') + .describe('Your Freshdesk API key, found under Profile Settings.'), + webhookSecret: z + .string() + .secret() + .optional() + .title('Webhook Secret') + .describe( + 'Optional shared secret to authenticate incoming webhooks. Set this value and add it as the X-Webhook-Secret header in each Freshdesk Automation webhook action.' + ), + }), +} as const satisfies IntegrationDefinitionProps['configuration'] diff --git a/integrations/freshdesk/definitions/events.ts b/integrations/freshdesk/definitions/events.ts new file mode 100644 index 00000000000..f32fb89ae48 --- /dev/null +++ b/integrations/freshdesk/definitions/events.ts @@ -0,0 +1,47 @@ +import { IntegrationDefinitionProps, z } from '@botpress/sdk' + +export const ticketEventSchema = z.object({ + id: z.number().title('Ticket ID').describe('Freshdesk ticket ID.'), + subject: z.string().nullish().title('Subject').describe('Ticket subject.'), + status: z.enum(['open', 'pending', 'resolved', 'closed']).nullish().title('Status').describe('Ticket status.'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).nullish().title('Priority').describe('Ticket priority.'), + requesterId: z.number().nullish().title('Requester ID').describe('Freshdesk requester user ID.'), + responderId: z.number().nullish().title('Responder ID').describe('Agent assigned to the ticket.'), + groupId: z.number().nullish().title('Group ID').describe('Group the ticket is assigned to.'), + type: z.string().nullish().title('Type').describe('Ticket category type.'), + tags: z.array(z.string()).nullish().title('Tags').describe('Tags associated with the ticket.'), +}) + +export const replyEventSchema = z.object({ + body: z.string().title('Body').describe('HTML content of the reply.'), + bodyText: z.string().optional().title('Body Text').describe('Plain-text content of the reply.'), + customerEmail: z.string().optional().title('Customer Email').describe('Email of the customer who replied.'), +}) + +export const events = { + ticketCreated: { + title: 'Ticket Created', + description: 'Triggered when a new ticket is created in Freshdesk.', + schema: z.object({ + ticket: ticketEventSchema.title('Ticket').describe('The newly created ticket.'), + }), + ui: {}, + }, + ticketUpdated: { + title: 'Ticket Updated', + description: 'Triggered when a ticket is updated (status, priority, or assignment change).', + schema: z.object({ + ticket: ticketEventSchema.title('Ticket').describe('The updated ticket.'), + }), + ui: {}, + }, + ticketReplied: { + title: 'Ticket Replied', + description: 'Triggered when a customer adds a reply to a ticket.', + schema: z.object({ + ticket: ticketEventSchema.title('Ticket').describe('The ticket that received the reply.'), + reply: replyEventSchema.title('Reply').describe('The reply that was added.'), + }), + ui: {}, + }, +} as const satisfies IntegrationDefinitionProps['events'] diff --git a/integrations/freshdesk/definitions/index.ts b/integrations/freshdesk/definitions/index.ts new file mode 100644 index 00000000000..f377da9f8d8 --- /dev/null +++ b/integrations/freshdesk/definitions/index.ts @@ -0,0 +1,3 @@ +export { actions } from './actions' +export { configuration } from './configuration' +export { events } from './events' diff --git a/integrations/freshdesk/hub.md b/integrations/freshdesk/hub.md new file mode 100644 index 00000000000..da4250342d2 --- /dev/null +++ b/integrations/freshdesk/hub.md @@ -0,0 +1,88 @@ +# Freshdesk + +Connect Botpress to Freshdesk to manage support tickets and react to ticket lifecycle events from your bots. + +## Ticket Properties + +`status` and `priority` are represented as string enums. The integration handles conversion to Freshdesk's internal numeric values. + +| Status | String value | Numeric value | +| -------- | ------------ | ------------- | +| Open | `open` | 2 | +| Pending | `pending` | 3 | +| Resolved | `resolved` | 4 | +| Closed | `closed` | 5 | + +| Priority | String value | Numeric value | +| -------- | ------------ | ------------- | +| Low | `low` | 1 | +| Medium | `medium` | 2 | +| High | `high` | 3 | +| Urgent | `urgent` | 4 | + +## Events + +Events are triggered by Freshdesk **Automation Rules** which you configure manually. Each event corresponds to a different webhook path. + +### Webhook setup + +1. In your Freshdesk dashboard, go to **Admin → Automations** +2. Create a new rule for each event you want to receive +3. Under the rule's **Actions**, add a **Trigger Webhook** action +4. Set the **Request Type** to `POST` and **encoding** to `json` +5. Use the following URLs (replace `{webhook-url}` with the URL shown in Botpress after installing the integration): + +| Event | Webhook URL | +| ------------- | ------------------------------ | +| ticketCreated | `{webhook-url}/ticket-created` | +| ticketUpdated | `{webhook-url}/ticket-updated` | +| ticketReplied | `{webhook-url}/ticket-replied` | + +6. _(Recommended)_ If you set a **Webhook Secret** in the integration configuration, add a custom request header named `X-Webhook-Secret` with that same value in each Freshdesk Automation webhook action. The integration will reject any webhook that omits or mismatches the secret. + +7. In the webhook body, include at minimum the ticket fields your bot needs. The `ticket.id` field is **required** — webhooks missing it will be rejected. All other ticket fields are optional. + + For `ticketCreated` and `ticketUpdated`: + +```json +{ + "ticket": { + "id": "{{ticket.id}}", + "subject": "{{ticket.subject}}", + "status": "{{ticket.status_id}}", + "priority": "{{ticket.priority_id}}", + "requester_id": "{{ticket.requester.id}}", + "responder_id": "{{ticket.agent.id}}", + "group_id": "{{ticket.group.id}}", + "type": "{{ticket.ticket_type}}" + } +} +``` + +For `ticketReplied`, also include reply fields. The `reply.body` field is **required**: + +```json +{ + "ticket": { + "id": "{{ticket.id}}", + "status": "{{ticket.status_id}}", + "requester_id": "{{ticket.requester.id}}" + }, + "reply": { + "body": "{{ticket.latest_public_comment_html}}", + "body_text": "{{ticket.latest_public_comment}}", + "customer_email": "{{ticket.contact.email}}" + } +} +``` + +## Limitations + +- The Search Tickets action scans up to 4 pages (120 results) of Freshdesk search results before applying the `limit` cap +- Freshdesk webhook setup requires manual configuration via Automation Rules. The integration cannot create them automatically +- Deleted tickets can be found in the trash page of the Freshdesk UI and can be restored for up to 30 days +- Ticket attachments are not supported in this integration + +## Changelog + +- 0.1.0: Initial release with `createTicket`, `getTicket`, `listTickets`, `updateTicket`, `deleteTicket`, `addNote`, `searchTickets`, `searchContacts`, `getContact` actions and `ticketCreated`, `ticketUpdated`, `ticketReplied` events. diff --git a/integrations/freshdesk/icon.svg b/integrations/freshdesk/icon.svg new file mode 100644 index 00000000000..ecb5c6e0c6f --- /dev/null +++ b/integrations/freshdesk/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integrations/freshdesk/integration.definition.ts b/integrations/freshdesk/integration.definition.ts new file mode 100644 index 00000000000..e6eb846d90b --- /dev/null +++ b/integrations/freshdesk/integration.definition.ts @@ -0,0 +1,21 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { actions, configuration, events } from './definitions' + +// TODO(HITL): add back the ticket channel with freshdeskTicketId conversation tag to enable the bot to send messages into ticket threads + +export default new IntegrationDefinition({ + name: 'freshdesk', + title: 'Freshdesk', + description: 'Connect Botpress to Freshdesk to create, read, update, delete, and search support tickets.', + version: '0.1.0', + readme: 'hub.md', + icon: 'icon.svg', + configuration, + actions, + events, + user: { + tags: { + freshdeskRequesterId: { title: 'Freshdesk Requester ID', description: 'The ID of the requester in Freshdesk' }, + }, + }, +}) diff --git a/integrations/freshdesk/package.json b/integrations/freshdesk/package.json new file mode 100644 index 00000000000..d1058a0e55a --- /dev/null +++ b/integrations/freshdesk/package.json @@ -0,0 +1,20 @@ +{ + "name": "@botpresshub/freshdesk", + "integrationName": "freshdesk/freshdesk", + "scripts": { + "check:type": "tsc --noEmit", + "build": "bp add -y && bp build", + "check:bplint": "bp lint" + }, + "private": true, + "dependencies": { + "@botpress/client": "workspace:*", + "@botpress/common": "workspace:*", + "@botpress/sdk": "workspace:*", + "@botpress/sdk-addons": "workspace:*" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@botpress/sdk": "workspace:*" + } +} diff --git a/integrations/freshdesk/src/events/mappers.ts b/integrations/freshdesk/src/events/mappers.ts new file mode 100644 index 00000000000..b0bd45a4102 --- /dev/null +++ b/integrations/freshdesk/src/events/mappers.ts @@ -0,0 +1,29 @@ +import { z } from '@botpress/sdk' +import { ticketEventSchema, replyEventSchema } from '../../definitions/events' +import { freshdeskWebhookTicketSchema, freshdeskWebhookReplySchema } from './schemas' + +const STATUS_MAP = { 2: 'open', 3: 'pending', 4: 'resolved', 5: 'closed' } as const +const PRIORITY_MAP = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' } as const + +type FreshdeskTicket = z.infer +type FreshdeskReply = z.infer +type TicketEvent = z.infer +type ReplyEvent = z.infer + +export const mapTicket = (ticket: FreshdeskTicket): TicketEvent => ({ + id: ticket.id, + subject: ticket.subject, + status: ticket.status != null ? (STATUS_MAP[ticket.status as keyof typeof STATUS_MAP] ?? null) : null, + priority: ticket.priority != null ? (PRIORITY_MAP[ticket.priority as keyof typeof PRIORITY_MAP] ?? null) : null, + requesterId: ticket.requester_id, + responderId: ticket.responder_id, + groupId: ticket.group_id, + type: ticket.type, + tags: ticket.tags, +}) + +export const mapReply = (reply: FreshdeskReply): ReplyEvent => ({ + body: reply.body, + bodyText: reply.body_text, + customerEmail: reply.customer_email, +}) diff --git a/integrations/freshdesk/src/events/schemas.ts b/integrations/freshdesk/src/events/schemas.ts new file mode 100644 index 00000000000..e5905593826 --- /dev/null +++ b/integrations/freshdesk/src/events/schemas.ts @@ -0,0 +1,28 @@ +import { z } from '@botpress/sdk' + +// Raw Freshdesk webhook payload shapes — numeric fields are coerced because +// Freshdesk sends all template values as strings. +export const freshdeskWebhookTicketSchema = z.object({ + id: z.coerce.number(), + subject: z.string().nullish(), + status: z.coerce.number().nullish(), + priority: z.coerce.number().nullish(), + requester_id: z.coerce.number().nullish(), + responder_id: z.coerce.number().nullish(), + group_id: z.coerce.number().nullish(), + type: z.string().nullish(), + tags: z.array(z.string()).nullish(), +}) + +export const freshdeskWebhookReplySchema = z.object({ + body: z.string(), + body_text: z.string().optional(), + customer_email: z.string().optional(), +}) + +export const ticketCreatedBodySchema = z.object({ ticket: freshdeskWebhookTicketSchema }) +export const ticketUpdatedBodySchema = z.object({ ticket: freshdeskWebhookTicketSchema }) +export const ticketRepliedBodySchema = z.object({ + ticket: freshdeskWebhookTicketSchema, + reply: freshdeskWebhookReplySchema, +}) diff --git a/integrations/freshdesk/src/events/ticketCreated.ts b/integrations/freshdesk/src/events/ticketCreated.ts new file mode 100644 index 00000000000..9fbd4a33c10 --- /dev/null +++ b/integrations/freshdesk/src/events/ticketCreated.ts @@ -0,0 +1,33 @@ +import { mapTicket } from './mappers' +import { ticketCreatedBodySchema } from './schemas' +import * as bp from '.botpress' + +type HandlerProps = Parameters[0] + +export const executeTicketCreated = async (props: HandlerProps & { body: Record }) => { + const { client, body, logger } = props + const log = logger.forBot() + + const parsed = ticketCreatedBodySchema.safeParse(body) + if (!parsed.success) { + log.warn(`ticketCreated webhook has invalid payload: ${parsed.error.message}`) + return + } + const { ticket } = parsed.data + + if (!ticket.requester_id) { + log.warn('ticketCreated webhook has no requester_id, skipping event') + return + } + + const { user } = await client.getOrCreateUser({ + tags: { freshdeskRequesterId: String(ticket.requester_id) }, + }) + + // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent + await client.createEvent({ + type: 'ticketCreated', + payload: { ticket: mapTicket(ticket) }, + userId: user.id, + }) +} diff --git a/integrations/freshdesk/src/events/ticketReplied.ts b/integrations/freshdesk/src/events/ticketReplied.ts new file mode 100644 index 00000000000..a72503560e5 --- /dev/null +++ b/integrations/freshdesk/src/events/ticketReplied.ts @@ -0,0 +1,33 @@ +import { mapTicket, mapReply } from './mappers' +import { ticketRepliedBodySchema } from './schemas' +import * as bp from '.botpress' + +type HandlerProps = Parameters[0] + +export const executeTicketReplied = async (props: HandlerProps & { body: Record }) => { + const { client, body, logger } = props + const log = logger.forBot() + + const parsed = ticketRepliedBodySchema.safeParse(body) + if (!parsed.success) { + log.warn(`ticketReplied webhook has invalid payload: ${parsed.error.message}`) + return + } + const { ticket, reply } = parsed.data + + if (!ticket.requester_id) { + log.warn('ticketReplied webhook has no requester_id, skipping event') + return + } + + const { user } = await client.getOrCreateUser({ + tags: { freshdeskRequesterId: String(ticket.requester_id) }, + }) + + // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent + await client.createEvent({ + type: 'ticketReplied', + payload: { ticket: mapTicket(ticket), reply: mapReply(reply) }, + userId: user.id, + }) +} diff --git a/integrations/freshdesk/src/events/ticketUpdated.ts b/integrations/freshdesk/src/events/ticketUpdated.ts new file mode 100644 index 00000000000..df387266137 --- /dev/null +++ b/integrations/freshdesk/src/events/ticketUpdated.ts @@ -0,0 +1,33 @@ +import { mapTicket } from './mappers' +import { ticketUpdatedBodySchema } from './schemas' +import * as bp from '.botpress' + +type HandlerProps = Parameters[0] + +export const executeTicketUpdated = async (props: HandlerProps & { body: Record }) => { + const { client, body, logger } = props + const log = logger.forBot() + + const parsed = ticketUpdatedBodySchema.safeParse(body) + if (!parsed.success) { + log.warn(`ticketUpdated webhook has invalid payload: ${parsed.error.message}`) + return + } + const { ticket } = parsed.data + + if (!ticket.requester_id) { + log.warn('ticketUpdated webhook has no requester_id, skipping event') + return + } + + const { user } = await client.getOrCreateUser({ + tags: { freshdeskRequesterId: String(ticket.requester_id) }, + }) + + // TODO(HITL): get or create a conversation on the ticket channel and pass conversationId to createEvent + await client.createEvent({ + type: 'ticketUpdated', + payload: { ticket: mapTicket(ticket) }, + userId: user.id, + }) +} diff --git a/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts b/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts new file mode 100644 index 00000000000..4a5289f945b --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/FreshdeskClient.ts @@ -0,0 +1,157 @@ +import * as sdk from '@botpress/sdk' +import type { + AddNoteInput, + CreateTicketInput, + DeleteTicketInput, + FreshdeskContact, + FreshdeskConversation, + FreshdeskTicket, + GetTicketInput, + ListTicketsInput, + SearchTicketsInput, + SearchTicketsOutput, + UpdateTicketInput, +} from './types' + +const REQUESTER_FIELDS = ['email', 'phone', 'twitter_id', 'facebook_id', 'unique_external_id', 'requester_id'] as const + +export class FreshdeskClient { + private _baseUrl: string + private _authHeader: string + + public constructor(domain: string, apiKey: string) { + this._baseUrl = `https://${domain}.freshdesk.com/api/v2` + this._authHeader = `Basic ${Buffer.from(`${apiKey}:X`).toString('base64')}` + } + + private _getHeaders(): Record { + return { + 'Content-Type': 'application/json', + Authorization: this._authHeader, + } + } + + private async _request( + method: string, + path: string, + params?: Record, + body?: object + ): Promise { + let url = `${this._baseUrl}${path}` + + if (params) { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== '') { + searchParams.set(key, String(value)) + } + } + const qs = searchParams.toString() + if (qs) { + url = `${url}?${qs}` + } + } + + const response = await fetch(url, { + method, + headers: this._getHeaders(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const text = await response.text() + let detail = text + try { + const parsed = JSON.parse(text) as Record + if (typeof parsed['description'] === 'string') { + detail = parsed['description'] + } + if (Array.isArray(parsed['errors']) && parsed['errors'].length > 0) { + detail += ` | errors: ${JSON.stringify(parsed['errors'])}` + } + } catch { + // use raw text + } + if (response.status === 429) { + throw new sdk.RuntimeError(`Freshdesk rate limit reached. ${detail}`) + } + throw new sdk.RuntimeError(`Freshdesk API error ${response.status}: ${detail}`) + } + + return response.json() as Promise + } + + private async _delete(path: string): Promise { + const url = `${this._baseUrl}${path}` + const response = await fetch(url, { method: 'DELETE', headers: this._getHeaders() }) + + if (!response.ok) { + const text = await response.text() + throw new sdk.RuntimeError(`Freshdesk API error ${response.status}: ${text}`) + } + } + + public async validateCredentials(): Promise { + await this._request('GET', '/tickets', { per_page: 1 }) + } + + public async createTicket(input: CreateTicketInput): Promise { + const hasRequester = REQUESTER_FIELDS.some((f) => (input as Record)[f] != null) + if (!hasRequester) { + throw new sdk.RuntimeError( + 'At least one requester field must be provided: email, phone, twitter_id, facebook_id, unique_external_id, or requester_id.' + ) + } + const body = { + status: 2, + priority: 1, + ...Object.fromEntries(Object.entries(input).filter(([, v]) => v != null && v !== '')), + } + return this._request('POST', '/tickets', undefined, body) + } + + public async getTicket(input: GetTicketInput): Promise { + const params = input.include ? { include: input.include } : undefined + return this._request('GET', `/tickets/${input.id}`, params) + } + + public async listTickets(input: ListTicketsInput): Promise { + const { filter, order_by, order_type, page, per_page } = input + return this._request('GET', '/tickets', { filter, order_by, order_type, page, per_page }) + } + + public async updateTicket(input: UpdateTicketInput): Promise { + const { id, ...rest } = input + const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v != null && v !== '')) + return this._request('PUT', `/tickets/${id}`, undefined, body) + } + + public async deleteTicket(input: DeleteTicketInput): Promise { + await this._delete(`/tickets/${input.id}`) + } + + public async searchTickets(input: SearchTicketsInput): Promise { + const query = input.query.startsWith('"') ? input.query : `"${input.query}"` + const params: Record = { query, page: input.page } + return this._request('GET', '/search/tickets', params) + } + + public async addNote(ticketId: number, input: AddNoteInput): Promise { + return this._request('POST', `/tickets/${ticketId}/notes`, undefined, { + private: true, + ...input, + }) + } + + public async getContact(id: number): Promise { + return this._request('GET', `/contacts/${id}`) + } + + public async searchContactsByEmail(email: string, page?: number): Promise { + return this._request('GET', '/contacts', { email, page }) + } + + public async searchContactsByName(term: string): Promise> { + return this._request>('GET', '/contacts/autocomplete', { term }) + } +} diff --git a/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts b/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts new file mode 100644 index 00000000000..6d270249466 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/action-wrapper.ts @@ -0,0 +1,41 @@ +import { createActionWrapper, createAsyncFnWrapperWithErrorRedaction } from '@botpress/common' +import * as sdk from '@botpress/sdk' +import { FreshdeskClient } from '../FreshdeskClient' +import { createFreshdeskRuntimeError } from './errors' +import * as bp from '.botpress' + +export const wrapAction: typeof _wrapAction = (meta, actionImpl) => + _wrapAction(meta, (props) => { + const logger = props.logger.forBot() + + logger.debug(`Running action "${meta.actionName}" [bot id: ${props.ctx.botId}]`, { input: props.input }) + + return _wrapAsyncFnWithTryCatch(async () => { + try { + return await actionImpl(props as Parameters[0], props.input) + } catch (thrown: unknown) { + if (!(thrown instanceof sdk.RuntimeError)) { + logger.warn(`Action Error: ${meta.errorMessage}`, { + error: thrown instanceof Error ? thrown.message : String(thrown), + }) + } + throw thrown + } + }, `Action Error: ${meta.errorMessage}`)() as ReturnType + }) + +const _wrapAction = createActionWrapper()({ + toolFactories: { + freshdeskClient: ({ ctx }) => new FreshdeskClient(ctx.configuration.domain, ctx.configuration.apiKey), + }, + extraMetadata: {} as { + errorMessage: string + }, +}) + +const _wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction((error: Error) => { + if (error instanceof sdk.RuntimeError) { + return error + } + return createFreshdeskRuntimeError(error) +}) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/errors.ts b/integrations/freshdesk/src/freshdesk-client/actions/errors.ts new file mode 100644 index 00000000000..5c81d37967b --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/errors.ts @@ -0,0 +1,6 @@ +import * as sdk from '@botpress/sdk' + +export const getErrorMessage = (thrown: unknown): string => (thrown instanceof Error ? thrown.message : String(thrown)) + +export const createFreshdeskRuntimeError = (thrown: unknown): sdk.RuntimeError => + new sdk.RuntimeError(getErrorMessage(thrown)) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts new file mode 100644 index 00000000000..81b2df3c7e9 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/addNote.ts @@ -0,0 +1,16 @@ +import { wrapAction } from '../action-wrapper' + +export const addNote = wrapAction( + { actionName: 'addNote', errorMessage: 'Failed to add note to Freshdesk ticket' }, + async ({ freshdeskClient }, input) => { + const note = await freshdeskClient.addNote(input.ticketId, { + body: input.body, + private: input.private ?? true, + }) + return { + id: note.id, + body: note.body, + createdAt: note.created_at, + } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts new file mode 100644 index 00000000000..583873c7380 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/const.ts @@ -0,0 +1,4 @@ +export const PRIORITY_TO_NUM = { low: 1, medium: 2, high: 3, urgent: 4 } as const +export const STATUS_TO_NUM = { open: 2, pending: 3, resolved: 4, closed: 5 } as const +export const NUM_TO_PRIORITY = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' } as const +export const NUM_TO_STATUS = { 2: 'open', 3: 'pending', 4: 'resolved', 5: 'closed' } as const diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts new file mode 100644 index 00000000000..eae4087d393 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/createTicket.ts @@ -0,0 +1,26 @@ +import { wrapAction } from '../action-wrapper' +import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const' + +export const createTicket = wrapAction( + { actionName: 'createTicket', errorMessage: 'Failed to create Freshdesk ticket' }, + async ({ freshdeskClient, ctx }, input) => { + const ticket = await freshdeskClient.createTicket({ + subject: input.subject, + description: input.description, + email: input.email, + priority: PRIORITY_TO_NUM[input.priority ?? 'medium'], + status: STATUS_TO_NUM[input.status ?? 'open'], + tags: input.tags, + custom_fields: input.custom_fields, + }) + + return { + id: ticket.id, + subject: ticket.subject, + status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS], + priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY], + createdAt: ticket.created_at, + url: `https://${ctx.configuration.domain}.freshdesk.com/helpdesk/tickets/${ticket.id}`, + } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts new file mode 100644 index 00000000000..80e0e2a961d --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/deleteTicket.ts @@ -0,0 +1,9 @@ +import { wrapAction } from '../action-wrapper' + +export const deleteTicket = wrapAction( + { actionName: 'deleteTicket', errorMessage: 'Failed to delete Freshdesk ticket' }, + async ({ freshdeskClient }, input) => { + await freshdeskClient.deleteTicket({ id: input.ticketId }) + return {} + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts new file mode 100644 index 00000000000..60c43d42927 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getContact.ts @@ -0,0 +1,18 @@ +import { wrapAction } from '../action-wrapper' + +export const getContact = wrapAction( + { actionName: 'getContact', errorMessage: 'Failed to get Freshdesk contact' }, + async ({ freshdeskClient }, input) => { + const contact = await freshdeskClient.getContact(input.contactId) + return { + id: contact.id, + name: contact.name, + email: contact.email ?? null, + phone: contact.phone ?? null, + mobile: contact.mobile ?? null, + companyId: contact.company_id ?? null, + tags: contact.tags ?? null, + createdAt: contact.created_at, + } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts new file mode 100644 index 00000000000..2c171e78c83 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/getTicket.ts @@ -0,0 +1,23 @@ +import { wrapAction } from '../action-wrapper' +import { NUM_TO_PRIORITY, NUM_TO_STATUS } from './const' + +export const getTicket = wrapAction( + { actionName: 'getTicket', errorMessage: 'Failed to get Freshdesk ticket' }, + async ({ freshdeskClient }, input) => { + const ticket = await freshdeskClient.getTicket({ id: input.ticketId }) + return { + id: ticket.id, + subject: ticket.subject, + description: ticket.description ?? null, + status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS], + priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY], + requesterId: ticket.requester_id ?? null, + responderId: ticket.responder_id ?? null, + groupId: ticket.group_id ?? null, + createdAt: ticket.created_at, + updatedAt: ticket.updated_at, + tags: ticket.tags ?? null, + customFields: ticket.custom_fields ?? null, + } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts new file mode 100644 index 00000000000..8193aefe9b7 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/index.ts @@ -0,0 +1,22 @@ +import { addNote } from './addNote' +import { createTicket } from './createTicket' +import { deleteTicket } from './deleteTicket' +import { getContact } from './getContact' +import { getTicket } from './getTicket' +import { listTickets } from './listTickets' +import { searchContacts } from './searchContacts' +import { searchTickets } from './searchTickets' +import { updateTicket } from './updateTicket' +import * as bp from '.botpress' + +export default { + addNote, + createTicket, + deleteTicket, + getContact, + getTicket, + listTickets, + searchContacts, + searchTickets, + updateTicket, +} as const satisfies bp.IntegrationProps['actions'] diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts new file mode 100644 index 00000000000..0db91997d48 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/listTickets.ts @@ -0,0 +1,24 @@ +import { wrapAction } from '../action-wrapper' +import { NUM_TO_PRIORITY, NUM_TO_STATUS } from './const' + +export const listTickets = wrapAction( + { actionName: 'listTickets', errorMessage: 'Failed to list Freshdesk tickets' }, + async ({ freshdeskClient }, input) => { + const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1 + const perPage = input.per_page && input.per_page > 0 ? input.per_page : 30 + const raw = await freshdeskClient.listTickets({ + filter: input.filter, + order_by: input.order_by, + order_type: input.order_type, + page, + per_page: perPage, + }) + const tickets = raw.map((t) => ({ + ...t, + status: NUM_TO_STATUS[t.status as keyof typeof NUM_TO_STATUS], + priority: NUM_TO_PRIORITY[t.priority as keyof typeof NUM_TO_PRIORITY], + })) + const nextToken = raw.length === perPage ? String(page + 1) : undefined + return { tickets, nextToken } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts new file mode 100644 index 00000000000..8bee71824f1 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchContacts.ts @@ -0,0 +1,50 @@ +import { wrapAction } from '../action-wrapper' + +// Name search (autocomplete) returns results without email/phone; +// each needs a separate getContact call to fill in those missing fields. +// Cap at 5 to avoid unbounded API requests. +const MAX_NAME_ENRICHMENT = 5 + +export const searchContacts = wrapAction( + { actionName: 'searchContacts', errorMessage: 'Failed to search Freshdesk contacts' }, + async ({ freshdeskClient }, input) => { + const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1 + + if (input.email) { + const contacts = await freshdeskClient.searchContactsByEmail(input.email, page) + // Freshdesk returns up to 30 contacts per page + const nextToken = contacts.length === 30 ? String(page + 1) : undefined + return { + contacts: contacts.map((c) => ({ + id: c.id, + name: c.name, + email: c.email ?? null, + phone: c.phone ?? null, + companyId: c.company_id ?? null, + })), + nextToken, + } + } + + if (input.name) { + // Autocomplete endpoint has no pagination + const results = await freshdeskClient.searchContactsByName(input.name) + const settled = await Promise.allSettled( + results.slice(0, MAX_NAME_ENRICHMENT).map(async (r) => { + const contact = await freshdeskClient.getContact(r.id) + return { + id: contact.id, + name: contact.name, + email: contact.email ?? null, + phone: contact.phone ?? null, + companyId: contact.company_id ?? null, + } + }) + ) + const contacts = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value) + return { contacts } + } + + return { contacts: [] } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts new file mode 100644 index 00000000000..95d84c08c5e --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/searchTickets.ts @@ -0,0 +1,44 @@ +import { wrapAction } from '../action-wrapper' +import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const' + +export const searchTickets = wrapAction( + { actionName: 'searchTickets', errorMessage: 'Failed to search Freshdesk tickets' }, + async ({ freshdeskClient }, input) => { + const page = input.nextToken ? Math.max(1, parseInt(input.nextToken, 10) || 1) : 1 + + const queryParts: string[] = [] + if (input.agent_id) queryParts.push(`agent_id:${input.agent_id}`) + if (input.tag) queryParts.push(`tag:'${input.tag.replace(/'/g, "\\'")}'`) + if (input.status) queryParts.push(`status:${STATUS_TO_NUM[input.status]}`) + if (input.priority) queryParts.push(`priority:${PRIORITY_TO_NUM[input.priority]}`) + + const toTicket = (t: { + id: number + subject: string + status: number + priority: number + created_at: string + email?: string + }) => ({ + id: t.id, + subject: t.subject, + status: NUM_TO_STATUS[t.status as keyof typeof NUM_TO_STATUS], + priority: NUM_TO_PRIORITY[t.priority as keyof typeof NUM_TO_PRIORITY], + createdAt: t.created_at, + requesterEmail: t.email ?? null, + }) + + if (queryParts.length > 0) { + const query = queryParts.join(' AND ') + const { results } = await freshdeskClient.searchTickets({ query, page }) + // Freshdesk search pages are always 30 results max + const nextToken = results.length === 30 ? String(page + 1) : undefined + return { tickets: results.map(toTicket), nextToken } + } + + const limit = Math.min(input.limit ?? 20, 100) + const raw = await freshdeskClient.listTickets({ per_page: limit, page }) + const nextToken = raw.length === limit ? String(page + 1) : undefined + return { tickets: raw.map(toTicket), nextToken } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts b/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts new file mode 100644 index 00000000000..080299da634 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/actions/implementations/updateTicket.ts @@ -0,0 +1,22 @@ +import { wrapAction } from '../action-wrapper' +import { NUM_TO_PRIORITY, NUM_TO_STATUS, PRIORITY_TO_NUM, STATUS_TO_NUM } from './const' + +export const updateTicket = wrapAction( + { actionName: 'updateTicket', errorMessage: 'Failed to update Freshdesk ticket' }, + async ({ freshdeskClient }, input) => { + const ticket = await freshdeskClient.updateTicket({ + id: input.ticketId, + ...(input.status != null && { status: STATUS_TO_NUM[input.status] }), + ...(input.priority != null && { priority: PRIORITY_TO_NUM[input.priority] }), + ...(input.responderId != null && input.responderId > 0 && { responder_id: input.responderId }), + ...(input.groupId != null && input.groupId > 0 && { group_id: input.groupId }), + ...(input.custom_fields != null && { custom_fields: input.custom_fields }), + }) + return { + id: ticket.id, + status: NUM_TO_STATUS[ticket.status as keyof typeof NUM_TO_STATUS], + priority: NUM_TO_PRIORITY[ticket.priority as keyof typeof NUM_TO_PRIORITY], + updatedAt: ticket.updated_at, + } + } +) diff --git a/integrations/freshdesk/src/freshdesk-client/types.ts b/integrations/freshdesk/src/freshdesk-client/types.ts new file mode 100644 index 00000000000..9def55b9ac1 --- /dev/null +++ b/integrations/freshdesk/src/freshdesk-client/types.ts @@ -0,0 +1,113 @@ +export type FreshdeskTicket = { + id: number + subject: string + description?: string + description_text?: string + status: number + priority: number + source?: number + email?: string + name?: string + requester_id?: number + responder_id?: number + group_id?: number + type?: string + tags?: string[] + cc_emails?: string[] + due_by?: string + created_at: string + updated_at: string + custom_fields?: Record +} + +export type CreateTicketInput = { + subject: string + description?: string + email?: string + phone?: string + twitter_id?: string + facebook_id?: string + unique_external_id?: string + requester_id?: number + priority?: number + status?: number + type?: string + tags?: string[] + group_id?: number + responder_id?: number + cc_emails?: string[] + custom_fields?: Record +} + +export type GetTicketInput = { + id: number + include?: string +} + +export type ListTicketsInput = { + filter?: string + order_by?: string + order_type?: 'asc' | 'desc' + page?: number + per_page?: number +} + +export type UpdateTicketInput = { + id: number + subject?: string + description?: string + email?: string + phone?: string + twitter_id?: string + facebook_id?: string + unique_external_id?: string + requester_id?: number + priority?: number + status?: number + type?: string + tags?: string[] + group_id?: number + responder_id?: number + cc_emails?: string[] + custom_fields?: Record +} + +export type DeleteTicketInput = { + id: number +} + +export type SearchTicketsInput = { + query: string + page?: number +} + +export type SearchTicketsOutput = { + results: FreshdeskTicket[] + total?: number +} + +export type FreshdeskConversation = { + id: number + body: string + body_text?: string + created_at: string + updated_at: string + ticket_id: number + user_id: number +} + +export type AddNoteInput = { + body: string + private?: boolean +} + +export type FreshdeskContact = { + id: number + name: string + email?: string + phone?: string + mobile?: string + company_id?: number + tags?: string[] + created_at: string +} diff --git a/integrations/freshdesk/src/handler.ts b/integrations/freshdesk/src/handler.ts new file mode 100644 index 00000000000..b28256619a4 --- /dev/null +++ b/integrations/freshdesk/src/handler.ts @@ -0,0 +1,66 @@ +import { timingSafeEqual } from 'crypto' +import { executeTicketCreated } from './events/ticketCreated' +import { executeTicketReplied } from './events/ticketReplied' +import { executeTicketUpdated } from './events/ticketUpdated' +import * as bp from '.botpress' + +export const handler: bp.IntegrationProps['handler'] = async (props) => { + const { req, logger, ctx } = props + const log = logger.forBot() + + log.info(`Webhook received: ${req.method} ${req.path}`) + log.debug(`Webhook body: ${req.body ?? '(empty)'}`) + + const { webhookSecret } = ctx.configuration + if (webhookSecret) { + const providedSecret = req.headers?.['x-webhook-secret'] + const providedBuf = typeof providedSecret === 'string' ? Buffer.from(providedSecret, 'utf8') : null + const secretBuf = Buffer.from(webhookSecret, 'utf8') + const secretsMatch = + providedBuf !== null && providedBuf.byteLength === secretBuf.byteLength && timingSafeEqual(providedBuf, secretBuf) + if (!secretsMatch) { + log.warn('Webhook received with invalid or missing secret, rejecting') + return + } + } + + try { + if (!req.body) { + log.warn('Webhook received with empty body, ignoring') + return + } + + let body: Record + try { + body = JSON.parse(req.body) as Record + } catch (e: unknown) { + log.error(`Webhook body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`) + return + } + + if (req.path === '/ticket-created') { + log.debug(`Firing ticketCreated, ticket=${JSON.stringify(body['ticket'])}`) + const result = await executeTicketCreated({ ...props, body }) + log.info('ticketCreated event fired successfully') + return result + } + + if (req.path === '/ticket-updated') { + log.debug(`Firing ticketUpdated, ticket=${JSON.stringify(body['ticket'])}`) + const result = await executeTicketUpdated({ ...props, body }) + log.info('ticketUpdated event fired successfully') + return result + } + + if (req.path === '/ticket-replied') { + log.debug(`Firing ticketReplied, ticket=${JSON.stringify(body['ticket'])}`) + const result = await executeTicketReplied({ ...props, body }) + log.info('ticketReplied event fired successfully') + return result + } + + log.warn(`Unhandled webhook path: ${req.path}`) + } catch (e: unknown) { + log.error(`Unhandled error in webhook handler: ${e instanceof Error ? e.message : String(e)}`) + } +} diff --git a/integrations/freshdesk/src/index.ts b/integrations/freshdesk/src/index.ts new file mode 100644 index 00000000000..4d7c9072e7f --- /dev/null +++ b/integrations/freshdesk/src/index.ts @@ -0,0 +1,28 @@ +import * as sdk from '@botpress/sdk' +import { createFreshdeskRuntimeError } from './freshdesk-client/actions/errors' +import actions from './freshdesk-client/actions/implementations' +import { FreshdeskClient } from './freshdesk-client/FreshdeskClient' +import { handler } from './handler' +import * as bp from '.botpress' + +export default new bp.Integration({ + register: async (props) => { + const { domain, apiKey } = props.ctx.configuration + const logger = props.logger.forBot() + + logger.info(`Validating Freshdesk configuration for domain=${domain}`) + + try { + await new FreshdeskClient(domain, apiKey).validateCredentials() + logger.info('Freshdesk configuration validated successfully') + } catch (thrown) { + const error = createFreshdeskRuntimeError(thrown) + logger.warn('Freshdesk configuration validation failed', { error: error.message }) + throw new sdk.RuntimeError(`Invalid Freshdesk configuration: ${error.message}`) + } + }, + unregister: async () => {}, + actions, + channels: {}, + handler, +}) diff --git a/integrations/freshdesk/tsconfig.json b/integrations/freshdesk/tsconfig.json new file mode 100644 index 00000000000..d46abc5b88f --- /dev/null +++ b/integrations/freshdesk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { "*": ["./*"] }, + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f9852230bb..963d8dabe72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -990,6 +990,25 @@ importers: specifier: ^4.14.191 version: 4.14.195 + integrations/freshdesk: + dependencies: + '@botpress/client': + specifier: workspace:* + version: link:../../packages/client + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@botpress/sdk-addons': + specifier: workspace:* + version: link:../../packages/sdk-addons + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + integrations/github: dependencies: '@botpress/common': @@ -19600,7 +19619,7 @@ snapshots: extend: 3.0.2 gaxios: 5.1.0 google-auth-library: 8.8.0 - qs: 6.13.0 + qs: 6.15.0 url-template: 2.0.8 uuid: 9.0.1 transitivePeerDependencies: @@ -19612,7 +19631,7 @@ snapshots: extend: 3.0.2 gaxios: 6.1.1 google-auth-library: 9.0.0 - qs: 6.13.0 + qs: 6.15.0 url-template: 2.0.8 uuid: 9.0.1 transitivePeerDependencies: @@ -23089,7 +23108,7 @@ snapshots: formidable: 1.2.6 methods: 1.1.2 mime: 1.6.0 - qs: 6.13.0 + qs: 6.15.0 readable-stream: 2.3.8 transitivePeerDependencies: - supports-color From fa4bf6d958b9f27a226b758a67653e6192dd3772 Mon Sep 17 00:00:00 2001 From: vini <117116432+vinishamanek@users.noreply.github.com> Date: Tue, 19 May 2026 16:34:11 -0400 Subject: [PATCH 2/2] feat(integrations): add n8n integration (#15188) --- integrations/n8n/hub.md | 35 ++++ integrations/n8n/icon.svg | 1 + integrations/n8n/integration.definition.ts | 129 ++++++++++++++ integrations/n8n/package.json | 18 ++ integrations/n8n/src/action-wrapper.ts | 15 ++ integrations/n8n/src/actions/get-workflow.ts | 6 + integrations/n8n/src/actions/index.ts | 9 + .../n8n/src/actions/list-workflows.ts | 5 + .../n8n/src/actions/trigger-workflow.ts | 11 ++ integrations/n8n/src/client.ts | 160 ++++++++++++++++++ integrations/n8n/src/errors.ts | 41 +++++ integrations/n8n/src/index.ts | 84 +++++++++ integrations/n8n/src/types.ts | 33 ++++ integrations/n8n/tsconfig.json | 8 + pnpm-lock.yaml | 16 ++ 15 files changed, 571 insertions(+) create mode 100644 integrations/n8n/hub.md create mode 100644 integrations/n8n/icon.svg create mode 100644 integrations/n8n/integration.definition.ts create mode 100644 integrations/n8n/package.json create mode 100644 integrations/n8n/src/action-wrapper.ts create mode 100644 integrations/n8n/src/actions/get-workflow.ts create mode 100644 integrations/n8n/src/actions/index.ts create mode 100644 integrations/n8n/src/actions/list-workflows.ts create mode 100644 integrations/n8n/src/actions/trigger-workflow.ts create mode 100644 integrations/n8n/src/client.ts create mode 100644 integrations/n8n/src/errors.ts create mode 100644 integrations/n8n/src/index.ts create mode 100644 integrations/n8n/src/types.ts create mode 100644 integrations/n8n/tsconfig.json diff --git a/integrations/n8n/hub.md b/integrations/n8n/hub.md new file mode 100644 index 00000000000..d0897eb7de0 --- /dev/null +++ b/integrations/n8n/hub.md @@ -0,0 +1,35 @@ +# n8n + +This integration connects your Botpress bot to n8n, letting you trigger workflows from your bot and receive results back as events. + +## Prerequisites + +- An n8n instance +- An n8n API key — found under **Settings → n8n API**. Generating an API key requires a paid n8n plan and is not available on the free trial. + +## Receiving data back from n8n + +To send data back to Botpress after an n8n workflow finishes, add an **HTTP Request** node at the end of your workflow and configure it to POST to your integration's webhook URL. + +You can find the webhook URL in your Botpress dashboard under **Integrations → n8n → Webhook URL**. + +**Trigger Workflow** automatically sends `conversationId` to n8n, so `{{ $json.body.conversationId }}` is available in your workflow without any extra configuration. +Set the action's **Conversation ID** input to `{{ event.conversationId }}`. + +Set the request body to: + +```json +{ + "conversationId": "{{ $json.body.conversationId }}", + "workflowId": "{{ $workflow.id }}", + "workflowName": "{{ $workflow.name }}", + "data": { + "anyKey": "anyValue" + } +} +``` + +## Limitations + +- **Trigger Workflow** only works on workflows that have a **Webhook** node (`n8n-nodes-base.webhook`) with a path configured. +- **List Workflows** returns a paginated response. Use the `limit` and `cursor` inputs to page through results for many workflows. diff --git a/integrations/n8n/icon.svg b/integrations/n8n/icon.svg new file mode 100644 index 00000000000..82f0a6da2e2 --- /dev/null +++ b/integrations/n8n/icon.svg @@ -0,0 +1 @@ +n8n \ No newline at end of file diff --git a/integrations/n8n/integration.definition.ts b/integrations/n8n/integration.definition.ts new file mode 100644 index 00000000000..1b55e4cefb2 --- /dev/null +++ b/integrations/n8n/integration.definition.ts @@ -0,0 +1,129 @@ +import { z, IntegrationDefinition } from '@botpress/sdk' + +export default new IntegrationDefinition({ + name: 'n8n', + title: 'n8n', + description: 'This integration allows you to interact with n8n workflows.', + version: '0.1.0', + readme: 'hub.md', + icon: 'icon.svg', + configuration: { + schema: z.object({ + baseUrl: z + .string() + .url() + .title('Base URL') + .describe('The base URL of your n8n instance, for example https://example.app.n8n.cloud'), + accessKey: z + .string() + .min(1) + .title('Access Key') + .describe('Your n8n API key. Found in n8n under Settings → n8n API.'), + }), + }, + actions: { + listWorkflows: { + title: 'List Workflows', + description: 'Retrieves workflows from n8n.', + input: { + schema: z.object({ + active: z.boolean().optional().title('Active').describe('Filter by workflow active state'), + name: z.string().optional().title('Name').describe('Filter by workflow name'), + tags: z.string().optional().title('Tags').describe('Comma-separated tag filter'), + projectId: z.string().optional().title('Project ID').describe('Filter by project ID'), + excludePinnedData: z + .boolean() + .optional() + .default(true) + .title('Exclude Pinned Data') + .describe('Exclude pinned data from the response'), + limit: z + .number() + .int() + .max(250) + .optional() + .title('Limit') + .describe('Maximum number of workflows to return (1-250)'), + cursor: z.string().optional().title('Cursor').describe('Pagination cursor from a previous request'), + }), + }, + output: { + schema: z.object({ + data: z.array(z.any()).title('Workflows').describe('List of workflows returned by n8n'), + nextCursor: z + .string() + .optional() + .title('Next Cursor') + .describe('Cursor for fetching the next page of results'), + }), + }, + }, + getWorkflow: { + title: 'Get Workflow', + description: 'Retrieves a single workflow from n8n by ID.', + input: { + schema: z.object({ + workflowId: z.string().min(1).title('Workflow ID').describe('The workflow ID'), + excludePinnedData: z + .boolean() + .optional() + .default(true) + .title('Exclude Pinned Data') + .describe('Exclude pinned data from the response'), + }), + }, + output: { + schema: z.object({ + workflow: z.any().title('Workflow').describe('The full workflow object returned by n8n'), + }), + }, + }, + triggerWorkflow: { + title: 'Trigger Workflow', + description: 'Resolves an n8n workflow webhook and posts data to it.', + input: { + schema: z.object({ + workflowIdOrName: z.string().min(1).title('Workflow ID or Name').describe('The workflow ID or name'), + conversationId: z + .string() + .min(1) + .placeholder('{{ event.conversationId }}') + .title('Conversation ID') + .describe('The current Botpress conversation ID — use {{event.conversationId}}'), + payload: z + .record(z.string(), z.any()) + .optional() + .default({}) + .title('Payload') + .describe('The JSON payload to send to the workflow'), + }), + }, + output: { + schema: z.object({ + workflowId: z.string().optional().title('Workflow ID').describe('The ID of the triggered workflow'), + workflowName: z.string().optional().title('Workflow Name').describe('The name of the triggered workflow'), + response: z.any().optional().title('Response').describe('The response body returned by the n8n webhook'), + }), + }, + }, + }, + events: { + n8nEvent: { + title: 'n8n event', + description: 'Triggered when n8n posts data back to Botpress.', + schema: z.object({ + workflowId: z + .string() + .optional() + .title('Workflow ID') + .describe('The ID of the workflow that posted this event'), + workflowName: z + .string() + .optional() + .title('Workflow Name') + .describe('The name of the workflow that posted this event'), + data: z.record(z.string(), z.any()).title('Data').describe('Arbitrary data payload sent by the n8n workflow'), + }), + }, + }, +}) diff --git a/integrations/n8n/package.json b/integrations/n8n/package.json new file mode 100644 index 00000000000..8e4fe6b0961 --- /dev/null +++ b/integrations/n8n/package.json @@ -0,0 +1,18 @@ +{ + "name": "@botpresshub/n8n", + "scripts": { + "check:type": "tsc --noEmit", + "build": "bp add -y && bp build", + "check:bplint": "bp lint" + }, + "private": true, + "dependencies": { + "@botpress/common": "workspace:*", + "@botpress/sdk": "workspace:*", + "axios": "^1.7.9" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@botpress/sdk": "workspace:*" + } +} diff --git a/integrations/n8n/src/action-wrapper.ts b/integrations/n8n/src/action-wrapper.ts new file mode 100644 index 00000000000..cf95724c6d1 --- /dev/null +++ b/integrations/n8n/src/action-wrapper.ts @@ -0,0 +1,15 @@ +import { createActionWrapper } from '@botpress/common' +import { N8nClient } from './client' +import * as bp from '.botpress' + +const _wrapAction = createActionWrapper()({ + toolFactories: { + n8nClient: ({ ctx }) => new N8nClient(ctx.configuration), + }, +}) + +export const wrapAction: typeof _wrapAction = (meta, actionImpl) => + _wrapAction(meta, (props) => { + props.logger.forBot().debug(`Running action "${meta.actionName}"`, { input: props.input }) + return actionImpl(props as Parameters[0], props.input) + }) diff --git a/integrations/n8n/src/actions/get-workflow.ts b/integrations/n8n/src/actions/get-workflow.ts new file mode 100644 index 00000000000..95ff04d893d --- /dev/null +++ b/integrations/n8n/src/actions/get-workflow.ts @@ -0,0 +1,6 @@ +import { wrapAction } from '../action-wrapper' + +export const getWorkflow = wrapAction({ actionName: 'getWorkflow' }, async ({ n8nClient }, input) => { + const workflow = await n8nClient.getWorkflow(input.workflowId, input.excludePinnedData ?? true) + return { workflow } +}) diff --git a/integrations/n8n/src/actions/index.ts b/integrations/n8n/src/actions/index.ts new file mode 100644 index 00000000000..07d59215fdd --- /dev/null +++ b/integrations/n8n/src/actions/index.ts @@ -0,0 +1,9 @@ +import { getWorkflow } from './get-workflow' +import { listWorkflows } from './list-workflows' +import { triggerWorkflow } from './trigger-workflow' + +export default { + listWorkflows, + getWorkflow, + triggerWorkflow, +} diff --git a/integrations/n8n/src/actions/list-workflows.ts b/integrations/n8n/src/actions/list-workflows.ts new file mode 100644 index 00000000000..e494f7511bb --- /dev/null +++ b/integrations/n8n/src/actions/list-workflows.ts @@ -0,0 +1,5 @@ +import { wrapAction } from '../action-wrapper' + +export const listWorkflows = wrapAction({ actionName: 'listWorkflows' }, async ({ n8nClient }, input) => + n8nClient.listWorkflows(input) +) diff --git a/integrations/n8n/src/actions/trigger-workflow.ts b/integrations/n8n/src/actions/trigger-workflow.ts new file mode 100644 index 00000000000..545bfe13079 --- /dev/null +++ b/integrations/n8n/src/actions/trigger-workflow.ts @@ -0,0 +1,11 @@ +import { wrapAction } from '../action-wrapper' + +export const triggerWorkflow = wrapAction({ actionName: 'triggerWorkflow' }, async ({ n8nClient }, input) => + n8nClient.triggerWorkflowWebhook({ + workflowIdOrName: input.workflowIdOrName, + body: { + conversationId: input.conversationId, + data: input.payload ?? {}, + }, + }) +) diff --git a/integrations/n8n/src/client.ts b/integrations/n8n/src/client.ts new file mode 100644 index 00000000000..6afcb85c7be --- /dev/null +++ b/integrations/n8n/src/client.ts @@ -0,0 +1,160 @@ +import * as sdk from '@botpress/sdk' +import axios, { type AxiosInstance } from 'axios' +import { isNotFoundResponse, wrapN8nError, wrapRegistrationError } from './errors' +import type { N8nConfiguration, N8nWorkflow, ListWorkflowsInput, TriggerWorkflowWebhookInput } from './types' + +const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook' + +export class N8nClient { + private readonly _baseUrl: string + private readonly _client: AxiosInstance + + public constructor(configuration: N8nConfiguration) { + this._baseUrl = configuration.baseUrl + this._client = axios.create({ + baseURL: this._buildApiUrl('/'), + headers: { + 'Content-Type': 'application/json', + 'X-N8N-API-KEY': configuration.accessKey, + }, + }) + } + + public async validateConnection(): Promise { + try { + await this._client.get('/workflows', { + params: { + limit: 1, + excludePinnedData: true, + }, + }) + } catch (error) { + return wrapRegistrationError(error, this._baseUrl) + } + } + + public async listWorkflows(input: ListWorkflowsInput) { + try { + const { data } = await this._client.get('/workflows', { + params: { + active: input.active, + name: input.name, + tags: input.tags, + projectId: input.projectId, + excludePinnedData: input.excludePinnedData, + limit: input.limit, + cursor: input.cursor, + }, + }) + + return { + data: data?.data ?? [], + nextCursor: data?.nextCursor ?? undefined, + } + } catch (error) { + return wrapN8nError(error) + } + } + + public async getWorkflow(workflowId: string, excludePinnedData = true): Promise { + try { + return await this._getWorkflowById(workflowId, excludePinnedData) + } catch (error) { + if (isNotFoundResponse(error)) { + throw new sdk.RuntimeError(`n8n workflow "${workflowId}" not found`) + } + return wrapN8nError(error) + } + } + + public async triggerWorkflowWebhook(input: TriggerWorkflowWebhookInput) { + const workflow = await this._resolveWorkflowByIdOrName(input.workflowIdOrName) + const webhookPath = this._getWebhookPath(workflow) + + if (!webhookPath) { + throw new sdk.RuntimeError('Unable to find an n8n webhook node with a path parameter in the selected workflow') + } + + const webhookUrl = this._buildPublicUrl(`/webhook/${webhookPath.replace(/^\/+/, '')}`) + + try { + // Webhook URLs are public endpoints, no need to use the API key. + const response = await axios.post(webhookUrl, { + ...input.body, + workflowId: workflow.id, + workflowName: workflow.name, + }) + + return { + workflowId: workflow.id, + workflowName: workflow.name, + response: response.data, + } + } catch (error) { + return wrapN8nError(error, 'n8n webhook trigger') + } + } + + private async _resolveWorkflowByIdOrName(workflowIdOrName: string): Promise { + try { + return await this._getWorkflowById(workflowIdOrName) + } catch (error) { + if (!isNotFoundResponse(error)) { + return wrapN8nError(error) + } + } + + try { + const { data } = await this._client.get<{ data?: N8nWorkflow[] }>('/workflows', { + params: { + name: workflowIdOrName, + excludePinnedData: true, + }, + }) + + const exactMatch = (data?.data ?? []).find( + (workflow) => workflow?.name === workflowIdOrName || workflow?.id === workflowIdOrName + ) + + if (exactMatch?.id) { + return await this._getWorkflowById(exactMatch.id) + } + } catch (error) { + return wrapN8nError(error) + } + + throw new sdk.RuntimeError(`Unable to resolve n8n workflow "${workflowIdOrName}"`) + } + + private async _getWorkflowById(workflowId: string, excludePinnedData = true): Promise { + const { data } = await this._client.get(`/workflows/${encodeURIComponent(workflowId)}`, { + params: { + excludePinnedData, + }, + }) + return data + } + + private _getWebhookPath(workflow: N8nWorkflow): string | undefined { + const nodes = workflow?.nodes ?? workflow?.activeVersion?.nodes ?? [] + const webhookNode = nodes.find((node) => node?.type === WEBHOOK_NODE_TYPE) + const path = webhookNode?.parameters?.path + + return typeof path === 'string' ? path : undefined + } + + private _buildApiUrl(path: string): string { + return this._buildUrl(path, true) + } + + private _buildPublicUrl(path: string): string { + return this._buildUrl(path, false) + } + + private _buildUrl(path: string, api: boolean): string { + const base = this._baseUrl.replace(/\/+$/, '') + const prefix = api ? '/api/v1' : '' + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${base}${prefix}${normalizedPath}` + } +} diff --git a/integrations/n8n/src/errors.ts b/integrations/n8n/src/errors.ts new file mode 100644 index 00000000000..e8353de78f2 --- /dev/null +++ b/integrations/n8n/src/errors.ts @@ -0,0 +1,41 @@ +import * as sdk from '@botpress/sdk' +import axios from 'axios' + +export const isNotFoundResponse = (error: unknown): boolean => + axios.isAxiosError(error) && error.response?.status === 404 + +export const wrapN8nError = (error: unknown, context = 'n8n request'): never => { + if (axios.isAxiosError(error)) { + const status = error.response?.status + const detail = status ? `HTTP ${status}` : (error.code ?? error.message ?? 'network error') + throw new sdk.RuntimeError(`${context} failed: ${detail}`) + } + throw new sdk.RuntimeError(error instanceof Error ? error.message : String(error)) +} + +export const wrapRegistrationError = (error: unknown, baseUrl: string): never => { + if (axios.isAxiosError(error)) { + const status = error.response?.status + const code = error.code + const message = status ? `n8n responded with HTTP ${status}` : (code ?? error.message ?? 'network error') + + if (status === 401 || status === 403) { + throw new sdk.RuntimeError( + `Registration failed: authentication rejected (${message}). Check your Access Key and permissions.` + ) + } + + if (status === 404) { + throw new sdk.RuntimeError( + `Registration failed: the n8n API was not found at ${baseUrl}/api/v1 (HTTP 404). Ensure your Base URL points to the root of your n8n instance (for example https://example.app.n8n.cloud).` + ) + } + + throw new sdk.RuntimeError( + `Registration failed: unable to reach n8n (${message}). Verify the Base URL is correct and reachable from this host.` + ) + } + + const message = error instanceof Error ? error.message : String(error) + throw new sdk.RuntimeError(`Registration failed: ${message}`) +} diff --git a/integrations/n8n/src/index.ts b/integrations/n8n/src/index.ts new file mode 100644 index 00000000000..9dda7456ff9 --- /dev/null +++ b/integrations/n8n/src/index.ts @@ -0,0 +1,84 @@ +import { z } from '@botpress/sdk' +import actions from './actions' +import { N8nClient } from './client' +import * as bp from '.botpress' + +const webhookPayloadSchema = z.object({ + conversationId: z.string().optional(), + workflowId: z.string().optional(), + workflowName: z.string().optional(), + data: z.union([z.record(z.string(), z.any()), z.string()]).optional(), +}) + +const parseJsonBody = (body: unknown): unknown | null => { + if (typeof body !== 'string') { + return body + } + try { + return JSON.parse(body) + } catch { + return null + } +} + +export default new bp.Integration({ + register: async ({ ctx }) => { + await new N8nClient(ctx.configuration).validateConnection() + }, + unregister: async () => undefined, + actions, + channels: {}, + handler: async ({ req, client, logger }) => { + const log = logger.forBot() + + const body = parseJsonBody(req.body) + if (!body || typeof body !== 'object') { + log.error('n8n webhook request must contain a valid JSON object body') + return + } + + const result = webhookPayloadSchema.safeParse(body) + if (!result.success) { + log.error('Invalid n8n webhook payload structure') + return + } + + const payload = result.data + + if (!payload.conversationId) { + log.error('Missing conversationId in n8n webhook payload — ensure your n8n workflow echoes it back') + return + } + + let data: Record + if (typeof payload.data === 'string') { + try { + data = JSON.parse(payload.data) + } catch { + log.error('n8n webhook payload.data contained invalid JSON') + return + } + } else if (typeof payload.data === 'object' && payload.data !== null) { + data = payload.data + } else { + data = {} + } + + try { + await client.createEvent({ + type: 'n8nEvent', + conversationId: payload.conversationId, + payload: { + workflowId: payload.workflowId, + workflowName: payload.workflowName, + data, + }, + }) + } catch (err) { + log.error(`Failed to create n8n event: ${err instanceof Error ? err.message : String(err)}`) + return + } + + return { status: 200, body: JSON.stringify({ ok: true }) } + }, +}) diff --git a/integrations/n8n/src/types.ts b/integrations/n8n/src/types.ts new file mode 100644 index 00000000000..98d26ce3b3c --- /dev/null +++ b/integrations/n8n/src/types.ts @@ -0,0 +1,33 @@ +export type N8nNode = { + type: string + parameters?: Record +} + +export type N8nWorkflow = { + id?: string + name?: string + nodes?: N8nNode[] + activeVersion?: { + nodes?: N8nNode[] + } +} + +export type N8nConfiguration = { + baseUrl: string + accessKey: string +} + +export type ListWorkflowsInput = { + active?: boolean + name?: string + tags?: string + projectId?: string + excludePinnedData?: boolean + limit?: number + cursor?: string +} + +export type TriggerWorkflowWebhookInput = { + workflowIdOrName: string + body: Record +} diff --git a/integrations/n8n/tsconfig.json b/integrations/n8n/tsconfig.json new file mode 100644 index 00000000000..d46abc5b88f --- /dev/null +++ b/integrations/n8n/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { "*": ["./*"] }, + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 963d8dabe72..c8bca488b62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1642,6 +1642,22 @@ importers: specifier: workspace:* version: link:../../packages/cli + integrations/n8n: + dependencies: + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + axios: + specifier: ^1.7.9 + version: 1.13.6 + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + integrations/notion: dependencies: '@botpress/client':