chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -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'
+88
View File
@@ -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.
+1
View File
@@ -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',
},
})
+20
View File
@@ -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
}
+66
View File
@@ -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)}`)
}
}
+28
View File
@@ -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,
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}