chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -0,0 +1,3 @@
|
||||
export { actions } from './actions'
|
||||
export { configuration } from './configuration'
|
||||
export { events } from './events'
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M31.9 0h24.036A8 8 0 0 1 64 8.073V32.1C64 49.722 49.722 64 32.1 64h-.182A31.89 31.89 0 0 1 0 32.109C0 14.437 14.254.182 31.9 0z" fill="#25c16f"/><path d="M31.9 14.255c-8.093 0-14.654 6.56-14.654 14.654v9.964c.058 2.667 2.206 4.815 4.873 4.873h4.145V32.3h-5.6v-3.2c.34-6.026 5.327-10.74 11.364-10.74S43.04 23.065 43.38 29.1v3.2H37.7v11.454h3.745v.182c-.04 2.474-2.035 4.47-4.5 4.5h-4.473c-.364 0-.764.182-.764.545a.8.8 0 0 0 .764.764h4.5c3.205-.02 5.798-2.613 5.818-5.818v-.364a4.8 4.8 0 0 0 3.745-4.727V29.1c.182-8.254-6.364-14.836-14.654-14.836z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1,25 @@
|
||||
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.1',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration,
|
||||
actions,
|
||||
events,
|
||||
user: {
|
||||
tags: {
|
||||
freshdeskRequesterId: { title: 'Freshdesk Requester ID', description: 'The ID of the requester in Freshdesk' },
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Customer Support',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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<typeof freshdeskWebhookTicketSchema>
|
||||
type FreshdeskReply = z.infer<typeof freshdeskWebhookReplySchema>
|
||||
type TicketEvent = z.infer<typeof ticketEventSchema>
|
||||
type ReplyEvent = z.infer<typeof replyEventSchema>
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mapTicket } from './mappers'
|
||||
import { ticketCreatedBodySchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type HandlerProps = Parameters<bp.IntegrationProps['handler']>[0]
|
||||
|
||||
export const executeTicketCreated = async (props: HandlerProps & { body: Record<string, unknown> }) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mapTicket, mapReply } from './mappers'
|
||||
import { ticketRepliedBodySchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type HandlerProps = Parameters<bp.IntegrationProps['handler']>[0]
|
||||
|
||||
export const executeTicketReplied = async (props: HandlerProps & { body: Record<string, unknown> }) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mapTicket } from './mappers'
|
||||
import { ticketUpdatedBodySchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type HandlerProps = Parameters<bp.IntegrationProps['handler']>[0]
|
||||
|
||||
export const executeTicketUpdated = async (props: HandlerProps & { body: Record<string, unknown> }) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this._authHeader,
|
||||
}
|
||||
}
|
||||
|
||||
private async _request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
params?: Record<string, string | number | undefined>,
|
||||
body?: object
|
||||
): Promise<T> {
|
||||
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<string, unknown>
|
||||
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<T>
|
||||
}
|
||||
|
||||
private async _delete(path: string): Promise<void> {
|
||||
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<void> {
|
||||
await this._request('GET', '/tickets', { per_page: 1 })
|
||||
}
|
||||
|
||||
public async createTicket(input: CreateTicketInput): Promise<FreshdeskTicket> {
|
||||
const hasRequester = REQUESTER_FIELDS.some((f) => (input as Record<string, unknown>)[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<FreshdeskTicket>('POST', '/tickets', undefined, body)
|
||||
}
|
||||
|
||||
public async getTicket(input: GetTicketInput): Promise<FreshdeskTicket> {
|
||||
const params = input.include ? { include: input.include } : undefined
|
||||
return this._request<FreshdeskTicket>('GET', `/tickets/${input.id}`, params)
|
||||
}
|
||||
|
||||
public async listTickets(input: ListTicketsInput): Promise<FreshdeskTicket[]> {
|
||||
const { filter, order_by, order_type, page, per_page } = input
|
||||
return this._request<FreshdeskTicket[]>('GET', '/tickets', { filter, order_by, order_type, page, per_page })
|
||||
}
|
||||
|
||||
public async updateTicket(input: UpdateTicketInput): Promise<FreshdeskTicket> {
|
||||
const { id, ...rest } = input
|
||||
const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v != null && v !== ''))
|
||||
return this._request<FreshdeskTicket>('PUT', `/tickets/${id}`, undefined, body)
|
||||
}
|
||||
|
||||
public async deleteTicket(input: DeleteTicketInput): Promise<void> {
|
||||
await this._delete(`/tickets/${input.id}`)
|
||||
}
|
||||
|
||||
public async searchTickets(input: SearchTicketsInput): Promise<SearchTicketsOutput> {
|
||||
const query = input.query.startsWith('"') ? input.query : `"${input.query}"`
|
||||
const params: Record<string, string | number | undefined> = { query, page: input.page }
|
||||
return this._request<SearchTicketsOutput>('GET', '/search/tickets', params)
|
||||
}
|
||||
|
||||
public async addNote(ticketId: number, input: AddNoteInput): Promise<FreshdeskConversation> {
|
||||
return this._request<FreshdeskConversation>('POST', `/tickets/${ticketId}/notes`, undefined, {
|
||||
private: true,
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
public async getContact(id: number): Promise<FreshdeskContact> {
|
||||
return this._request<FreshdeskContact>('GET', `/contacts/${id}`)
|
||||
}
|
||||
|
||||
public async searchContactsByEmail(email: string, page?: number): Promise<FreshdeskContact[]> {
|
||||
return this._request<FreshdeskContact[]>('GET', '/contacts', { email, page })
|
||||
}
|
||||
|
||||
public async searchContactsByName(term: string): Promise<Array<{ id: number; name: string }>> {
|
||||
return this._request<Array<{ id: number; name: string }>>('GET', '/contacts/autocomplete', { term })
|
||||
}
|
||||
}
|
||||
@@ -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<typeof actionImpl>[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<typeof actionImpl>
|
||||
})
|
||||
|
||||
const _wrapAction = createActionWrapper<bp.IntegrationProps>()({
|
||||
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)
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 {}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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']
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
@@ -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: [] }
|
||||
}
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(req.body) as Record<string, unknown>
|
||||
} 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)}`)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user