diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts new file mode 100644 index 00000000..74fa9156 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -0,0 +1,135 @@ +import { z, ActionDefinition } from '@botpress/sdk' +import { customerSchema, createCustomerPayloadSchema, createCustomerResultSchema } from 'definitions/schemas' + +export const createCustomer: ActionDefinition = { + title: 'Create Customer', + description: 'Create a new customer', + input: { + schema: createCustomerPayloadSchema.extend({ + id: z.string().title('ID').describe('The id of the customer'), + }), + }, + output: { + schema: z.object({ + odooId: createCustomerResultSchema, + }), + }, +} + +export const fetchCustomerById: ActionDefinition = { + title: 'Fetch Customer By ID', + description: 'Fetch a customer by id', + input: { + schema: z.object({ + id: z.string().title('ID').describe('The id of the customer to fetch'), + }), + }, + output: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The fetched customer').optional(), + }), + }, +} + +export const fetchCustomerByOdooId: ActionDefinition = { + title: 'Fetch Customer By Odoo ID', + description: 'Fetch a customer by odoo id', + input: { + schema: z.object({ + odooId: z.number().title('Odoo ID').describe('The odoo id of the customer to fetch'), + id: z + .string() + .title('ID') + .describe('The id of the customer to fetch. If provided, the returned customer will have this id.') + .optional(), + }), + }, + output: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The fetched customer').optional(), + }), + }, +} + +export const fetchCustomerByEmail: ActionDefinition = { + title: 'Fetch Customer By Email', + description: 'Fetch a customer by email', + input: { + schema: z.object({ + email: z.string().title('Email').describe('The email of the customer to fetch'), + id: z + .string() + .title('ID') + .describe('The id of the customer to fetch. If provided, the returned customer will have this id.') + .optional(), + }), + }, + output: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The fetched customer').optional(), + }), + }, +} + +export const updateCustomerById: ActionDefinition = { + title: 'Update Customer', + description: 'Update a customer by id', + input: { + schema: z.object({ + id: z.string().title('ID').describe('The id of the customer to update'), + email: z.string().title('Email').describe('The new email of the customer').optional(), + name: z.string().title('Name').describe('The new name of the customer').optional(), + phone: z.string().title('Phone').describe('The new phone of the customer').optional(), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + }), + }, +} + +export const updateCustomerByOdooId: ActionDefinition = { + title: 'Update Customer By Odoo ID', + description: 'Update a customer by odoo id', + input: { + schema: z.object({ + odooId: z.number().title('Odoo ID').describe('The odoo id of the customer to update'), + email: z.string().title('Email').describe('The new email of the customer').optional(), + name: z.string().title('Name').describe('The new name of the customer').optional(), + phone: z.string().title('Phone').describe('The new phone of the customer').optional(), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + }), + }, +} + +export const updateCustomerByEmail: ActionDefinition = { + title: 'Update Customer By Email', + description: 'Update a customer by email', + input: { + schema: z.object({ + email: z.string().title('Email').describe('The email of the customer to update'), + name: z.string().title('Name').describe('The new name of the customer').optional(), + phone: z.string().title('Phone').describe('The new phone of the customer').optional(), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + }), + }, +} + +export const actions = { + createCustomer, + fetchCustomerById, + fetchCustomerByEmail, + fetchCustomerByOdooId, + updateCustomerById, + updateCustomerByOdooId, + updateCustomerByEmail, +} as const diff --git a/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts b/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts new file mode 100644 index 00000000..808a0d7e --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts @@ -0,0 +1,35 @@ +import { z, ActionDefinition } from '@botpress/sdk' +import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' + +export const getHelpdeskTeams: ActionDefinition = { + title: 'Get Helpdesk Teams', + description: 'Get all helpdesk teams', + input: { + schema: z.object({}), + }, + output: { + schema: z.object({ + helpdeskTeams: z.array(helpdeskTeamSchema).title('Helpdesk Teams').describe('The list of helpdesk teams'), + }), + }, +} + +export const getStages: ActionDefinition = { + title: 'Get Stages', + description: 'Get all stages', + input: { + schema: z.object({ + teamId: z.number().optional().title('Team ID').describe('The id of the team to get the stages for'), + }), + }, + output: { + schema: z.object({ + stages: z.array(stageSchema).title('Stages').describe('The list of stages'), + }), + }, +} + +export const actions = { + getHelpdeskTeams, + getStages, +} as const diff --git a/integrations/odoo-helpdesk/definitions/actions/index.ts b/integrations/odoo-helpdesk/definitions/actions/index.ts new file mode 100644 index 00000000..462568d0 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/index.ts @@ -0,0 +1,10 @@ +import * as sdk from '@botpress/sdk' +import { actions as customerActions } from './customers' +import { actions as ticketsActions } from './tickets' +import { actions as helpdeskActions } from './helpdesk' + +export const actions = { + ...customerActions, + ...ticketsActions, + ...helpdeskActions, +} as const satisfies sdk.IntegrationDefinitionProps['actions'] diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts new file mode 100644 index 00000000..a40ee019 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -0,0 +1,119 @@ +import { z, ActionDefinition } from '@botpress/sdk' +import { ticketSchema } from 'definitions/schemas' + +export const createTicket: ActionDefinition = { + title: 'Create Ticket', + description: 'Create a new ticket', + input: { + schema: z.object({ + name: z.string().title('Name').describe('The name of the ticket'), + description: z.string().title('Description').describe('The description of the ticket'), + teamId: z.number().min(1).title('Team ID').describe('The helpdesk team ID associated with the ticket'), + priority: z + .enum(['0', '1', '2', '3']) + .title('Priority') + .describe('The priority of the ticket (0 is the lowest priority)') + .optional(), + customerOdooId: z.number().title('Customer Odoo ID').describe('The Odoo customer ID associated with the ticket'), + stageId: z.number().optional().title('Stage ID').describe('The stage ID associated with the ticket'), + }), + }, + output: { + schema: z.object({ + ticketId: z.number().title('Ticket ID').describe('The ID of the created ticket'), + }), + }, +} + +export const fetchTicketById: ActionDefinition = { + title: 'Fetch Ticket', + description: 'Fetch a ticket by id', + input: { + schema: z.object({ + id: z.number().title('Ticket ID').describe('The id of the ticket to fetch'), + }), + }, + output: { + schema: z.object({ + ticket: ticketSchema.title('Ticket').describe('The fetched ticket').optional(), + }), + }, +} + +export const fetchTicketsByCustomerId: ActionDefinition = { + title: 'Fetch Tickets by Customer', + description: 'Fetch all tickets by customer', + input: { + schema: z.object({ + customerOdooId: z.number().title('Customer Odoo ID').describe('The Odoo customer ID associated with the ticket'), + page: z.number().title('Page').describe('The page of the tickets to fetch').min(1).default(1).optional(), + pageSize: z + .number() + .min(1) + .title('Page Size') + .describe('The number of tickets to fetch per page') + .default(100) + .optional(), + }), + }, + output: { + schema: z.object({ + tickets: z.array(ticketSchema).title('Tickets').describe('The list of tickets associated with the customer'), + }), + }, +} + +export const fetchTicketsByCustomerEmail: ActionDefinition = { + title: 'Fetch Tickets by Customer Email', + description: 'Fetch all tickets by customer email', + input: { + schema: z.object({ + customerEmail: z.string().title('Customer Email').describe('The email of the customer'), + page: z.number().title('Page').describe('The page of the tickets to fetch').min(1).default(1).optional(), + pageSize: z + .number() + .min(1) + .title('Page Size') + .describe('The number of tickets to fetch per page') + .default(100) + .optional(), + }), + }, + output: { + schema: z.object({ + tickets: z.array(ticketSchema).title('Tickets').describe('The list of tickets associated with the customer'), + }), + }, +} + +export const updateTicket: ActionDefinition = { + title: 'Update Ticket', + description: 'Update a ticket by id', + input: { + schema: z.object({ + ticketId: z.number().title('Ticket ID').describe('The ID of the ticket to update'), + name: z.string().title('Name').describe('The name of the ticket').optional(), + description: z.string().title('Description').describe('The description of the ticket').optional(), + teamId: z.number().min(1).title('Team ID').describe('The helpdesk team ID associated with the ticket').optional(), + priority: z + .enum(['0', '1', '2', '3']) + .title('Priority') + .describe('The priority of the ticket (0 is the lowest priority, 3 is the highest)') + .optional(), + stageId: z.number().title('Stage ID').describe('The stage ID associated with the ticket').optional(), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + }), + }, +} + +export const actions = { + createTicket, + fetchTicketById, + fetchTicketsByCustomerId, + fetchTicketsByCustomerEmail, + updateTicket, +} as const diff --git a/integrations/odoo-helpdesk/definitions/index.ts b/integrations/odoo-helpdesk/definitions/index.ts new file mode 100644 index 00000000..e945a089 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/index.ts @@ -0,0 +1,4 @@ +import { actions } from './actions' +import { states } from './states' + +export { actions, states } diff --git a/integrations/odoo-helpdesk/definitions/schemas/authentication.ts b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts new file mode 100644 index 00000000..e4a25202 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts @@ -0,0 +1,54 @@ +import { z } from '@botpress/sdk' + +const authPayloadBodySchema = z.object({ + jsonrpc: z.literal('2.0'), + params: z.object({ + db: z.string().describe('The Odoo database name (Case sensitive).'), + login: z.string().describe('The Odoo email address.'), + password: z.string().describe('The Odoo password.').secret(), + }), + id: z.number().describe('The ID of the request'), +}) + +const authHeadersSchema = z.object({ + 'Content-Type': z.literal('application/json'), +}) + +/** + * Schema for authentication response headers. + * Used to type the headers from axios responses. + */ +const authResponseHeadersSchema = z.object({ + 'set-cookie': z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('The set-cookie header from the Odoo API response.'), +}) + +const cookieSchema = z.object({ + cookie: z.string().describe('The cookie to use for the Odoo API'), + timestamp: z.number().describe('The timestamp of the cookie'), +}) + +export const authResponseDataSchema = z.object({ + jsonrpc: z.literal('2.0'), + result: z + .object({ + uid: z.number(), + }) + .optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + data: z.unknown().optional(), + }) + .optional(), + id: z.number(), +}) + +export type AuthPayloadBody = z.infer +export type AuthHeaders = z.infer +export type AuthResponseHeaders = z.infer +export type Cookie = z.infer +export type AuthResponseData = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts new file mode 100644 index 00000000..22a96dd2 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -0,0 +1,41 @@ +import { z } from '@botpress/sdk' + +export const customerSchema = z.object({ + email: z.string().describe('The email of the customer'), + id: z.string().describe('The id of the customer').optional(), + odooId: z.number().describe('The Odoo ID of the customer').optional(), + name: z.string().describe('The name of the customer').optional(), + phone: z.string().describe('The phone of the customer').optional(), +}) + +export const createCustomerPayloadSchema = z.object({ + email: z.string().describe('The email of the customer'), + name: z.string().min(1).describe('The name of the customer').optional(), + phone: z.string().min(1).describe('The phone of the customer').optional(), +}) +export const createCustomerResultSchema = z.number().describe('The Odoo ID of the created customer') + +export const fetchCustomerResultSchema = z + .array( + z + .object({ + id: z.number().describe('The Odoo ID of the customer'), + name: z.string().describe('The name of the customer'), + email: z.string().describe('The email of the customer'), + phone: z.string().describe('The phone of the customer'), + }) + .describe('Possible fields of a customer response record') + ) + .describe('The list of customers returned by a customer search') + +export const updateCustomerPayloadSchema = z.object({ + email: z.string().describe('The email of the customer').optional(), + name: z.string().describe('The name of the customer').optional(), + phone: z.string().describe('The phone of the customer').optional(), +}) + +export type Customer = z.infer +export type CreateCustomerPayload = z.infer +export type CreateCustomerResult = z.infer +export type FetchCustomerResult = z.infer +export type UpdateCustomerPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts new file mode 100644 index 00000000..b90020f0 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts @@ -0,0 +1,11 @@ +import { z } from '@botpress/sdk' + +export const helpdeskTeamSchema = z.object({ + name: z.string().describe('The name of the helpdesk team'), + id: z.number().describe('The id of the helpdesk team'), +}) + +export const fetchHelpdeskTeamResultsSchema = z.array(helpdeskTeamSchema) + +export type HelpdeskTeam = z.infer +export type FetchHelpdeskTeamResults = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts new file mode 100644 index 00000000..0fd9cb60 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -0,0 +1,130 @@ +import { z } from '@botpress/sdk' +import { + AuthPayloadBody, + AuthHeaders, + AuthResponseHeaders, + Cookie, + AuthResponseData, + authResponseDataSchema, +} from './authentication' +import { + customerSchema, + createCustomerPayloadSchema, + createCustomerResultSchema, + fetchCustomerResultSchema, + updateCustomerPayloadSchema, + Customer, + CreateCustomerPayload, + CreateCustomerResult, + FetchCustomerResult, + UpdateCustomerPayload, +} from './customer' +import { + helpdeskTeamSchema, + fetchHelpdeskTeamResultsSchema, + HelpdeskTeam, + FetchHelpdeskTeamResults, +} from './helpdesk-team' +import { stageSchema, fetchStagesResultsSchema, Stage, FetchStagesResults } from './stage' +import { + ticketSchema, + createTicketResultSchema, + createTicketPayloadSchema, + fetchTicketResultSchema, + fetchTicketResultsSchema, + updateTicketPayloadSchema, + Ticket, + CreateTicketPayload, + FetchTicketResult, + FetchTicketResults, + UpdateTicketPayload, + CreateTicketResult, +} from './ticket' +import { + odooRequestFilterSchema, + odooRequestFiltersSchema, + odooRequestFieldsSchema, + odooRequestKwargsSchema, + odooApiResponseSchema, + OdooRequestModel, + OdooRequestMethod, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooResponseFruitfulObject, + OdooResponseObject, + OdooApiResponse, +} from './odoo' + +/** + * Args schema accepts filter elements plus all payload schemas. + */ +const odooRequestArgsSchema = z + .union([ + z + .tuple([ + z.union([odooRequestFilterSchema, odooRequestFiltersSchema]), + z.union([updateCustomerPayloadSchema, updateTicketPayloadSchema, odooRequestFieldsSchema]), + ]) + .describe('The filters and fields/payload to pass to the Odoo request'), + z.tuple([ + z + .union([createCustomerPayloadSchema, createTicketPayloadSchema]) + .describe('The payload to pass to the Odoo request'), + ]), + ]) + .describe('The arguments to pass to the Odoo request') + +type OdooRequestArgs = z.infer + +export { + customerSchema, + createCustomerPayloadSchema, + createCustomerResultSchema, + fetchCustomerResultSchema, + updateCustomerPayloadSchema, + helpdeskTeamSchema, + fetchHelpdeskTeamResultsSchema, + odooRequestFilterSchema, + odooRequestFiltersSchema, + odooRequestFieldsSchema, + odooRequestKwargsSchema, + odooApiResponseSchema, + stageSchema, + fetchStagesResultsSchema, + ticketSchema, + createTicketPayloadSchema, + createTicketResultSchema, + fetchTicketResultSchema, + fetchTicketResultsSchema, + authResponseDataSchema, + AuthPayloadBody, + AuthHeaders, + AuthResponseHeaders, + AuthResponseData, + Cookie, + Customer, + CreateCustomerPayload, + CreateCustomerResult, + FetchCustomerResult, + UpdateCustomerPayload, + HelpdeskTeam, + FetchHelpdeskTeamResults, + OdooRequestModel, + OdooRequestMethod, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooRequestArgs, + OdooResponseFruitfulObject, + OdooResponseObject, + OdooApiResponse, + Stage, + FetchStagesResults, + Ticket, + CreateTicketPayload, + FetchTicketResult, + FetchTicketResults, + UpdateTicketPayload, + CreateTicketResult, +} diff --git a/integrations/odoo-helpdesk/definitions/schemas/odoo.ts b/integrations/odoo-helpdesk/definitions/schemas/odoo.ts new file mode 100644 index 00000000..5428f753 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/odoo.ts @@ -0,0 +1,75 @@ +import { z } from '@botpress/sdk' + +/** + * Base filter element schema - can be extended by other schemas. + */ +export const odooRequestFilterSchema = z.union([ + z + .tuple([ + z.string().describe('The field name'), + z.string().describe('The operator'), + z + .union([ + z.string().describe('The value to filter by'), + z.number().describe('The value to filter by'), + z.boolean().describe('The value to filter by'), + z.union([ + z.array(z.string()).describe('The strings to filter by'), + z.array(z.number()).describe('The numbers to filter by'), + ]), + ]) + .describe('The value to filter by'), + ]) + .describe('The filters to apply to the Odoo request. Must be paired with method "search_read"'), + z.number().describe('The Odoo ID. Must be paired with method "read" or "write"'), +]) + +export const odooRequestModelsSchema = z.enum(['helpdesk.ticket', 'helpdesk.stage', 'helpdesk.team', 'res.partner']) +export const odooRequestMethodsSchema = z.enum(['create', 'read', 'write', 'search', 'search_read']) +export const odooRequestFiltersSchema = z.array(odooRequestFilterSchema) +export const odooRequestFieldsSchema = z + .array(z.string()) + .describe('The fields to retrieve from the Odoo request. Must be paired with method "search_read"') + +const odooRequestKwargsValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(odooRequestKwargsValueSchema), + z.record(z.string(), odooRequestKwargsValueSchema), + ]) +) +export const odooRequestKwargsSchema = z + .record(z.string(), odooRequestKwargsValueSchema) + .optional() + .describe('The keyword arguments to pass to the Odoo request') + +export const odooResponseFruitfulObjectSchema = z.tuple([z.number(), z.string()]) +export const odooResponseObjectSchema = z.union([odooResponseFruitfulObjectSchema, z.boolean()]) + +/** + * Zod schema for Odoo API response wrapper. + * Used to validate all Odoo API responses before processing. + */ +export const odooApiResponseSchema = z.object({ + jsonrpc: z.literal('2.0'), + result: z.unknown(), + error: z + .object({ + code: z.number(), + message: z.string(), + data: z.unknown().optional(), + }) + .optional(), + id: z.number().nullable().optional().describe('The ID of the Odoo API response'), +}) + +export type OdooRequestModel = z.infer +export type OdooRequestMethod = z.infer +export type OdooRequestFilters = z.infer +export type OdooRequestFields = z.infer +export type OdooRequestKwargs = z.infer +export type OdooResponseFruitfulObject = z.infer +export type OdooResponseObject = z.infer +export type OdooApiResponse = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/stage.ts b/integrations/odoo-helpdesk/definitions/schemas/stage.ts new file mode 100644 index 00000000..a0f40f7d --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/stage.ts @@ -0,0 +1,18 @@ +import { z } from '@botpress/sdk' + +export const stageSchema = z.object({ + id: z.number().describe('The id of the stage'), + name: z.string().describe('The name of the stage'), + teamIds: z.array(z.number()).describe('The ids of the helpdesk teams that the stage belongs to'), +}) + +export const fetchStagesResultsSchema = z.array( + z.object({ + id: z.number().describe('The id of the stage'), + name: z.string().describe('The name of the stage'), + team_ids: z.array(z.number()).describe('The ids of the helpdesk teams that the stage belongs to'), + }) +) + +export type Stage = z.infer +export type FetchStagesResults = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts new file mode 100644 index 00000000..e3d34d1b --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -0,0 +1,61 @@ +import { z } from '@botpress/sdk' +import { odooResponseFruitfulObjectSchema, odooResponseObjectSchema } from './odoo' + +export const ticketSchema = z.object({ + id: z.number().describe('The ticket ID'), + name: z.string().describe('The name of the ticket'), + description: z.string().describe('The description of the ticket'), + teamId: z.number().describe('The helpdesk team ID associated with the ticket'), + priority: z.string().describe('The priority of the ticket').default('0'), + customerOdooId: z.number().describe('The Odoo customer ID associated with the ticket').optional(), + stageId: z.number().describe('The stage ID associated with the ticket').optional(), +}) + +export const createTicketPayloadSchema = z.object({ + name: z.string().describe('The name of the ticket'), + description: z.string().describe('The description of the ticket'), + team_id: z.number().describe('The helpdesk team ID associated with the ticket'), + partner_id: z.number().describe('The customer ID associated with the ticket'), + priority: z + .string() + .describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")') + .optional() + .default('0'), + stage_id: z.number().describe('The stage ID associated with the ticket').optional(), +}) + +export const createTicketResultSchema = z.number().describe('The Odoo ID of the created ticket') + +export const fetchTicketResultSchema = z.object({ + id: z.number().describe('The ticket ID'), + name: z.string().describe('The name of the ticket'), + description: z.string().describe('The description of the ticket'), + team_id: odooResponseFruitfulObjectSchema.describe('The helpdesk team ID and name as a tuple [id, name]'), + priority: z + .union([z.string(), z.boolean()]) + .describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3", or false if not set)'), + partner_id: odooResponseObjectSchema.describe( + 'The customer ID and name as a tuple [id, name], or false if no partner is assigned' + ), + stage_id: odooResponseObjectSchema.describe( + 'The stage ID and name as a tuple [id, name], or false if no stage is assigned' + ), +}) + +export const fetchTicketResultsSchema = z.array(fetchTicketResultSchema) + +export const updateTicketPayloadSchema = z.object({ + name: z.string().describe('The name of the ticket').optional(), + description: z.string().describe('The description of the ticket').optional(), + team_id: z.number().describe('The helpdesk team ID associated with the ticket').optional(), + priority: z.string().describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")').optional(), + partner_id: z.number().describe('The customer ID associated with the ticket').optional(), + stage_id: z.number().describe('The stage ID associated with the ticket').optional(), +}) + +export type Ticket = z.infer +export type CreateTicketPayload = z.infer +export type CreateTicketResult = z.infer +export type FetchTicketResult = z.infer +export type FetchTicketResults = z.infer +export type UpdateTicketPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/states.ts b/integrations/odoo-helpdesk/definitions/states.ts new file mode 100644 index 00000000..ef5807dd --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/states.ts @@ -0,0 +1,30 @@ +import { z, StateDefinition } from '@botpress/sdk' +import { helpdeskTeamSchema, stageSchema } from './schemas' + +const helpdeskIntegrationInfo = { + type: 'integration' as const, + schema: z.object({ + helpdeskIntegrationInfo: z + .object({ + helpdeskTeams: z.array(helpdeskTeamSchema), + stages: z.array(stageSchema), + }) + .title('Helpdesk Integration Info') + .describe('Helpdesk integration info populated during registration'), + }), +} + +const customerIdMapping = { + type: 'integration' as const, + schema: z.object({ + customerIdMapping: z + .record(z.string(), z.number()) + .title('Customer ID Mapping') + .describe('Maps Botpress customer IDs to Odoo customer IDs'), + }), +} + +export const states = { + helpdeskIntegrationInfo, + customerIdMapping, +} as const satisfies Record diff --git a/integrations/odoo-helpdesk/hub.md b/integrations/odoo-helpdesk/hub.md new file mode 100644 index 00000000..bda00a8c --- /dev/null +++ b/integrations/odoo-helpdesk/hub.md @@ -0,0 +1,10 @@ +# Odoo Helpdesk integration + +Connect your Botpress chatbot with Odoo Helpdesk to manage tickets and customers +directly from your bot. Create, fetch, and update helpdesk tickets and customer +records to provide seamless customer support through your chatbot. + +## Requirements + +- An Odoo instance with the Helpdesk module installed +- Odoo user credentials with appropriate permissions to manage tickets and customers diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts new file mode 100644 index 00000000..f8f0b71f --- /dev/null +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -0,0 +1,30 @@ +import { z, IntegrationDefinition } from '@botpress/sdk' +import { actions, states } from './definitions' + +export default new IntegrationDefinition({ + version: '1.0.0', + name: 'plus/odoo-helpdesk', + title: 'Odoo Helpdesk', + description: 'Connect with Odoo Helpdesk to manage tickets and customers', + readme: 'hub.md', + icon: 'odoo-logo.svg', + + configuration: { + schema: z.object({ + odooApiUrl: z.string().describe('The Odoo API URL. This is the url the botpress user sends messages to.'), + odooDb: z.string().describe('The Odoo database name (Case sensitive).'), + odooEmail: z.string().describe('The Odoo email address.'), + odooPassword: z.string().describe('The Odoo password.').secret(), + }), + }, + user: { + tags: { + id: { title: 'User ID', description: 'The ID of the user' }, + email: { title: 'Email', description: 'The email of the user' }, + odooId: { title: 'Odoo ID', description: 'The ID of the Odoo user' }, + conversationId: { title: 'Conversation ID', description: 'The ID of the conversation' }, + }, + }, + actions, + states, +}) diff --git a/integrations/odoo-helpdesk/odoo-logo.svg b/integrations/odoo-helpdesk/odoo-logo.svg new file mode 100644 index 00000000..8dd18a08 --- /dev/null +++ b/integrations/odoo-helpdesk/odoo-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/integrations/odoo-helpdesk/package.json b/integrations/odoo-helpdesk/package.json new file mode 100644 index 00000000..eab659f1 --- /dev/null +++ b/integrations/odoo-helpdesk/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bp-templates/odoo-helpdesk", + "scripts": { + "check:type": "tsc --noEmit", + "build": "bp add -y && bp build", + "deploy": "bp deploy -y", + "version-bump-and-deploy": "node version-bump-and-deploy.js" + }, + "private": true, + "dependencies": { + "@botpress/client": "1.28.0", + "@botpress/sdk": "5.1.1", + "axios": "^1.6.8", + "axios-retry": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.16.4", + "typescript": "^5.6.3" + } +} diff --git a/integrations/odoo-helpdesk/pnpm-lock.yaml b/integrations/odoo-helpdesk/pnpm-lock.yaml new file mode 100644 index 00000000..48944060 --- /dev/null +++ b/integrations/odoo-helpdesk/pnpm-lock.yaml @@ -0,0 +1,369 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@botpress/client': + specifier: 1.28.0 + version: 1.28.0 + '@botpress/sdk': + specifier: 5.1.1 + version: 5.1.1(@bpinternal/zui@1.3.2) + axios: + specifier: ^1.6.8 + version: 1.13.2 + devDependencies: + '@types/node': + specifier: ^22.16.4 + version: 22.19.7 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@botpress/client@1.28.0': + resolution: {integrity: sha512-gU7j04Efv15qHf9zE1zOcnSBNvM/5+9BhyRZudt9WgJBN4YH0QZpc3GmGy+lOxQU563YKHf166xRrFhkicqiug==} + engines: {node: '>=18.0.0'} + + '@botpress/sdk@5.1.1': + resolution: {integrity: sha512-RhRiAA/jKh0S+/ViCS97KG54PCO+z7ca2jH03ULqRxeYCmkoBMyBCnrU/GKQjKJrGC49D4PwlpPlIyUINzAOBg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@bpinternal/zui': ^1.3.2 + esbuild: ^0.16.12 + peerDependenciesMeta: + esbuild: + optional: true + + '@bpinternal/zui@1.3.2': + resolution: {integrity: sha512-lzjvRsuSjglAWbbS4B+o5xyNbrGujhWTOX3iX3hsqLQFvo9gtaM/EETBhlPM4tJJJWi/THhCmDJFoWjSPX+CXg==} + engines: {node: '>=18.0.0'} + + '@types/node@22.19.7': + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@botpress/client@1.28.0': + dependencies: + axios: 1.13.2 + axios-retry: 4.5.0(axios@1.13.2) + browser-or-node: 2.1.1 + qs: 6.14.1 + transitivePeerDependencies: + - debug + + '@botpress/sdk@5.1.1(@bpinternal/zui@1.3.2)': + dependencies: + '@botpress/client': 1.28.0 + '@bpinternal/zui': 1.3.2 + browser-or-node: 2.1.1 + semver: 7.7.3 + transitivePeerDependencies: + - debug + + '@bpinternal/zui@1.3.2': {} + + '@types/node@22.19.7': + dependencies: + undici-types: 6.21.0 + + asynckit@0.4.0: {} + + axios-retry@4.5.0(axios@1.13.2): + dependencies: + axios: 1.13.2 + is-retry-allowed: 2.2.0 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + browser-or-node@2.1.1: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + is-retry-allowed@2.2.0: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + object-inspect@1.13.4: {} + + proxy-from-env@1.1.0: {} + + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + + semver@7.7.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts new file mode 100644 index 00000000..38fbb185 --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -0,0 +1,276 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { Customer, CreateCustomerPayload, UpdateCustomerPayload } from 'definitions/schemas' +import { CustomerIdMappingService } from 'src/services/customerIdMapping' +import { createCustomerRepository, CustomerRepository } from 'src/services/customerRepository' + +/** + * Creates a new customer in Odoo and stores the ID mapping. + * + * @param ctx - The Botpress context + * @param client - The Botpress client + * @param input - Customer creation input with id, email, name, and phone + * @param logger - The logger instance + * @returns The created customer's Odoo ID + */ +export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ + ctx, + client, + input: { id, email, name, phone }, + logger, +}): Promise<{ odooId: number }> => { + logger.forBot().debug(`Creating customer: id=${id}, email=${email}`) + + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) + + const customerPayload: CreateCustomerPayload = { + email, + phone, + name, + } + + const odooId = await repository.create(customerPayload) + await idMappingService.setMapping(id, odooId) + + logger.forBot().info(`Customer created successfully: id=${id}, odooId=${odooId}`) + return { odooId } +} + +/** + * Helper function to fetch a customer using the repository. + * Adds the Botpress ID to the customer object if provided. + * + * @param repository - The customer repository instance + * @param id - Optional Botpress customer ID + * @param odooId - Optional Odoo customer ID + * @param email - Optional customer email + * @returns The customer object with optional Botpress ID + * @throws RuntimeError if neither odooId nor email is provided + */ +async function fetchCustomerWithId( + repository: CustomerRepository, + id: string | undefined, + odooId: number | undefined, + email: string | undefined +): Promise<{ customer: Customer }> { + let customer: Customer + + if (odooId) { + customer = await repository.findByOdooId(odooId) + } else if (email) { + customer = await repository.findByEmail(email) + } else { + throw new RuntimeError('Must provide either odooId or email to fetch customer') + } + + if (id) { + customer = { ...customer, id } + } + + return { customer } +} + +/** + * Fetches a customer by Botpress customer ID. + * + * @param ctx - The Botpress context + * @param client - The Botpress client + * @param input - Input containing the Botpress customer ID + * @param logger - The logger instance + * @returns The customer object + * @throws RuntimeError if customer is not found + */ +export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = async ({ + ctx, + client, + input, + logger, +}): Promise<{ customer: Customer }> => { + logger.forBot().debug(`Fetching customer by id: ${input.id}`) + + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) + + const odooId = await idMappingService.getOdooId(input.id) + return fetchCustomerWithId(repository, input.id, odooId, undefined) +} + +/** + * Fetches a customer by Odoo customer ID. + * + * @param ctx - The Botpress context + * @param input - Input containing the Odoo customer ID and optional Botpress ID + * @param logger - The logger instance + * @returns The customer object + * @throws RuntimeError if customer is not found + */ +export const fetchCustomerByOdooId: bp.Integration['actions']['fetchCustomerByOdooId'] = async ({ + ctx, + input, + logger, +}): Promise<{ customer: Customer }> => { + logger.forBot().debug(`Fetching customer by odoo id: ${input.odooId}`) + + const repository = createCustomerRepository(ctx, logger) + return fetchCustomerWithId(repository, input.id, input.odooId, undefined) +} + +/** + * Fetches a customer by email address. + * + * @param ctx - The Botpress context + * @param input - Input containing the customer email and optional Botpress ID + * @param logger - The logger instance + * @returns The customer object + * @throws RuntimeError if customer is not found + */ +export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEmail'] = async ({ + ctx, + input, + logger, +}): Promise<{ customer: Customer }> => { + logger.forBot().debug(`Fetching customer by email: ${input.email}`) + + const repository = createCustomerRepository(ctx, logger) + return fetchCustomerWithId(repository, input.id, undefined, input.email) +} + +/** + * Helper function to determine the Odoo ID from various input options. + * Follows Single Responsibility Principle - only handles ID resolution logic. + * + * @param repository - The customer repository instance + * @param idMappingService - The ID mapping service instance + * @param input - Input object containing id, email, or odooId + * @returns The resolved Odoo ID + * @throws RuntimeError if no valid identifier is provided or customer is not found + */ +async function resolveOdooId( + repository: CustomerRepository, + idMappingService: CustomerIdMappingService, + input: { id?: string; email?: string; odooId?: number } +): Promise { + if (input.id) { + return idMappingService.getOdooId(input.id) + } + + if (input.odooId) { + return input.odooId + } + + if (input.email) { + const customer = await repository.findByEmail(input.email) + if (customer.odooId === undefined) { + throw new RuntimeError('Customer not found or missing Odoo ID') + } + return customer.odooId + } + + throw new RuntimeError('Must provide an id, odooId, or email to update a customer') +} + +/** + * Helper function to update a customer. + * Follows Single Responsibility Principle - only handles update orchestration. + * + * @param repository - The customer repository instance + * @param idMappingService - The ID mapping service instance + * @param input - Input object containing customer identifier and fields to update + * @returns Success status of the update operation + * @throws RuntimeError if no valid identifier is provided or customer is not found + */ +async function updateCustomer( + repository: CustomerRepository, + idMappingService: CustomerIdMappingService, + input: { id?: string; email?: string; name?: string; phone?: string; odooId?: number } +): Promise<{ success: boolean }> { + const odooId = await resolveOdooId(repository, idMappingService, input) + + const customerPayload: UpdateCustomerPayload = { + ...(input.email !== undefined && { email: input.email }), + ...(input.name !== undefined && { name: input.name }), + ...(input.phone !== undefined && { phone: input.phone }), + } + + const success = await repository.update(odooId, customerPayload) + return { success } +} + +/** + * Updates a customer by Botpress customer ID. + * + * @param ctx - The Botpress context + * @param client - The Botpress client + * @param input - Input containing the Botpress customer ID and fields to update + * @param logger - The logger instance + * @returns Success status of the update operation + * @throws RuntimeError if customer is not found or no fields are provided + */ +export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] = async ({ + ctx, + client, + input, + logger, +}) => { + logger.forBot().debug(`Updating customer by id: ${input.id}`) + + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) + + const result = await updateCustomer(repository, idMappingService, input) + logger.forBot().info(`Customer updated successfully: id=${input.id}`) + return result +} + +/** + * Updates a customer by Odoo customer ID. + * + * @param ctx - The Botpress context + * @param client - The Botpress client + * @param input - Input containing the Odoo customer ID and fields to update + * @param logger - The logger instance + * @returns Success status of the update operation + * @throws RuntimeError if customer is not found or no fields are provided + */ +export const updateCustomerByOdooId: bp.Integration['actions']['updateCustomerByOdooId'] = async ({ + ctx, + client, + input, + logger, +}) => { + logger.forBot().debug(`Updating customer by odoo id: ${input.odooId}`) + + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) + + const result = await updateCustomer(repository, idMappingService, input) + logger.forBot().info(`Customer updated successfully: odooId=${input.odooId}`) + return result +} + +/** + * Updates a customer by email address. + * + * @param ctx - The Botpress context + * @param client - The Botpress client + * @param input - Input containing the customer email and fields to update + * @param logger - The logger instance + * @returns Success status of the update operation + * @throws RuntimeError if customer is not found or no fields are provided + */ +export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByEmail'] = async ({ + ctx, + client, + input, + logger, +}) => { + logger.forBot().debug(`Updating customer by email: ${input.email}`) + + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) + + const result = await updateCustomer(repository, idMappingService, input) + logger.forBot().info(`Customer updated successfully: email=${input.email}`) + return result +} diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts new file mode 100644 index 00000000..e1a673eb --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -0,0 +1,74 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { safeGetState } from 'src/utils' +import { HelpdeskTeam, Stage } from 'definitions/schemas' + +type HelpdeskIntegrationInfo = { + helpdeskIntegrationInfo: { + helpdeskTeams: HelpdeskTeam[] + stages: Stage[] + } +} + +export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = async ({ ctx, client, logger }) => { + logger.forBot().debug(`Getting cached helpdesk teams`) + + const { state } = await safeGetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }) + + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') + } + if (!('helpdeskIntegrationInfo' in state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo property missing') + } + + const payload: HelpdeskIntegrationInfo = state.payload + const helpdeskTeams = payload.helpdeskIntegrationInfo.helpdeskTeams + + logger.forBot().info(`Retrieved ${helpdeskTeams.length} helpdesk teams`) + + return { helpdeskTeams } +} + +export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, client, input, logger }) => { + logger.forBot().debug(`Getting cached stages${input.teamId ? ` for teamId=${input.teamId}` : ''}`) + + const { state } = await safeGetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }) + + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') + } + if (!('helpdeskIntegrationInfo' in state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') + } + + const payload: HelpdeskIntegrationInfo = state.payload + let stages = payload.helpdeskIntegrationInfo.stages + + if (input.teamId !== undefined) { + const teamId = input.teamId + stages = payload.helpdeskIntegrationInfo.stages.filter((stage) => stage.teamIds.includes(teamId)) + logger.forBot().debug(`Filtered to ${stages.length} stages for teamId=${teamId}`) + } + + logger.forBot().info(`Retrieved ${stages.length} stages`) + return { stages } +} diff --git a/integrations/odoo-helpdesk/src/actions/index.ts b/integrations/odoo-helpdesk/src/actions/index.ts new file mode 100644 index 00000000..1fade83a --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/index.ts @@ -0,0 +1,10 @@ +import * as bp from '.botpress' +import * as ticketsActions from './tickets' +import * as customersActions from './customers' +import * as helpdeskActions from './helpdesk' + +export default { + ...ticketsActions, + ...customersActions, + ...helpdeskActions, +} satisfies bp.IntegrationProps['actions'] diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts new file mode 100644 index 00000000..b75443ef --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -0,0 +1,142 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { createTicketRepository, TicketRepository } from 'src/services/ticketRepository' +import { CreateTicketPayload, UpdateTicketPayload } from 'definitions/schemas' + +/** + * Creates a new ticket in Odoo. + * + * @param ctx - The Botpress context + * @param input - Ticket creation input with name, description, teamId, priority, stageId, and customerOdooId + * @param logger - The logger instance + * @returns The created ticket's Odoo ID + */ +export const createTicket: bp.Integration['actions']['createTicket'] = async ({ + ctx, + input: { name, description, teamId, priority, stageId, customerOdooId }, + logger, +}) => { + logger.forBot().debug(`Creating ticket: name=${name}, teamId=${teamId}`) + + const repository = createTicketRepository(ctx, logger) + + const ticketPayload: CreateTicketPayload = { + name, + description, + team_id: teamId, + partner_id: customerOdooId, + priority: priority !== undefined ? String(priority) : '0', + ...(stageId ? { stage_id: stageId } : {}), + } + + const ticketId = await repository.create(ticketPayload) + + // Fetch the created ticket to return complete data. + const ticket = await repository.findById(ticketId) + + logger.forBot().info(`Ticket created successfully: ticketId=${ticket.id}`) + return { + ticketId: ticket.id, + } +} + +/** + * Fetches a ticket by Odoo ticket ID. + * + * @param ctx - The Botpress context + * @param input - Input containing the ticket ID + * @param logger - The logger instance + * @returns The ticket object + * @throws RuntimeError if ticket is not found + */ +export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input: { id }, logger }) => { + logger.forBot().debug(`Fetching ticket by id: ${id}`) + + const repository = createTicketRepository(ctx, logger) + const ticket = await repository.findById(id) + + logger.forBot().info(`Ticket fetched successfully: ticketId=${ticket.id}`) + return { + ticket, + } +} + +/** + * Fetches tickets by customer Odoo ID with pagination. + * + * @param ctx - The Botpress context + * @param input - Input containing customerOdooId, page, and pageSize + * @param logger - The logger instance + * @returns Array of tickets + */ +export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsByCustomerId'] = async ({ + ctx, + input: { customerOdooId, page, pageSize }, + logger, +}) => { + logger.forBot().debug(`Fetching tickets by customer id: ${customerOdooId}, page=${page}, pageSize=${pageSize}`) + + const repository = createTicketRepository(ctx, logger) + const tickets = await repository.findByCustomerId(customerOdooId, page, pageSize) + + logger.forBot().info(`Fetched ${tickets.length} tickets for customer: ${customerOdooId}`) + return { + tickets, + } +} + +/** + * Fetches tickets by customer email with pagination. + * + * @param ctx - The Botpress context + * @param input - Input containing customerEmail, page, and pageSize + * @param logger - The logger instance + * @returns Array of tickets + */ +export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicketsByCustomerEmail'] = async ({ + ctx, + input: { customerEmail, page, pageSize }, + logger, +}) => { + logger.forBot().debug(`Fetching tickets by customer email: ${customerEmail}, page=${page}, pageSize=${pageSize}`) + + const repository = createTicketRepository(ctx, logger) + const tickets = await repository.findByCustomerEmail(customerEmail, page, pageSize) + + logger.forBot().info(`Fetched ${tickets.length} tickets for customer email: ${customerEmail}`) + return { + tickets, + } +} + +/** + * Updates a ticket in Odoo. + * + * @param ctx - The Botpress context + * @param input - Input containing ticketId and fields to update + * @param logger - The logger instance + * @returns Success status of the update operation + * @throws RuntimeError if no fields are provided to update + */ +export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ + ctx, + input: { ticketId, name, description, teamId, priority, stageId }, + logger, +}) => { + logger.forBot().debug(`Updating ticket: ticketId=${ticketId}`) + + const repository = createTicketRepository(ctx, logger) + + const updatePayload: Partial = { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(teamId !== undefined && { team_id: teamId }), + ...(stageId !== undefined && { stage_id: stageId }), + ...(priority !== undefined && { priority }), + } + + const success = await repository.update(ticketId, updatePayload) + + logger.forBot().info(`Ticket updated successfully: ticketId=${ticketId}`) + return { success } +} diff --git a/integrations/odoo-helpdesk/src/index.ts b/integrations/odoo-helpdesk/src/index.ts new file mode 100644 index 00000000..ae061330 --- /dev/null +++ b/integrations/odoo-helpdesk/src/index.ts @@ -0,0 +1,11 @@ +import * as bp from '.botpress' +import actions from './actions' +import { register, unregister } from './setup' + +export default new bp.Integration({ + register, + unregister, + actions, + channels: {}, + handler: async () => {}, +}) diff --git a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts new file mode 100644 index 00000000..233f7d19 --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts @@ -0,0 +1,83 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { z } from '@botpress/sdk' +import { safeGetOrSetState, safeSetState } from 'src/utils' + +/** + * Service responsible for managing the mapping between Botpress customer IDs and Odoo customer IDs. + */ +export class CustomerIdMappingService { + private readonly client: bp.Client + private readonly integrationId: string + + constructor(client: bp.Client, integrationId: string) { + this.client = client + this.integrationId = integrationId + } + + /** + * Retrieves the Odoo ID for a given Botpress customer ID. + * + * @param bpId - The Botpress customer ID + * @returns The corresponding Odoo customer ID + * @throws RuntimeError if the mapping doesn't exist + */ + async getOdooId(bpId: string): Promise { + const mapping = await this.getMapping() + const odooId = mapping[bpId] + + if (!odooId) { + throw new RuntimeError(`No Odoo ID found for customer ID: ${bpId}`) + } + + return odooId + } + + /** + * Stores a mapping between Botpress customer ID and Odoo customer ID. + * + * @param bpId - The Botpress customer ID + * @param odooId - The Odoo customer ID + */ + async setMapping(bpId: string, odooId: number): Promise { + const mapping = await this.getMapping() + mapping[bpId] = odooId + + await safeSetState(this.client, { + type: 'integration', + name: 'customerIdMapping', + id: this.integrationId, + payload: { customerIdMapping: mapping }, + }) + } + + /** + * Retrieves the current ID mapping from state. + * + * @returns The mapping object with Botpress IDs as keys and Odoo IDs as values + */ + private async getMapping(): Promise> { + const { state } = await safeGetOrSetState(this.client, { + type: 'integration', + name: 'customerIdMapping', + id: this.integrationId, + payload: { customerIdMapping: {} }, + }) + + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { + throw new RuntimeError('Invalid state payload: customerIdMapping not found') + } + + if ('customerIdMapping' in state.payload) { + const mapping: Record = z.record(z.string(), z.number()).parse(state.payload.customerIdMapping) + return mapping + } + + throw new RuntimeError('Invalid state payload: customerIdMapping not found') + } +} diff --git a/integrations/odoo-helpdesk/src/services/customerRepository.ts b/integrations/odoo-helpdesk/src/services/customerRepository.ts new file mode 100644 index 00000000..c7043e58 --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/customerRepository.ts @@ -0,0 +1,167 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { executeOdooMethod, getAuthenticatedCookie } from './odoo' +import { + customerSchema, + createCustomerPayloadSchema, + createCustomerResultSchema, + fetchCustomerResultSchema, + updateCustomerPayloadSchema, + Customer, + CreateCustomerPayload, + CreateCustomerResult, + UpdateCustomerPayload, + OdooRequestFilters, + OdooRequestFields, + OdooRequestArgs, +} from 'definitions/schemas' +import { z } from '@botpress/sdk' + +/** + * Repository responsible for customer data operations with Odoo. + * Follows Single Responsibility Principle - only handles Odoo API interactions for customers. + * Follows Dependency Inversion Principle - depends on abstractions (Odoo service functions). + */ +export class CustomerRepository { + private readonly odooApiUrl: string + private readonly logger: bp.Logger + private readonly getCookie: () => Promise + + constructor(odooApiUrl: string, logger: bp.Logger, getCookie: () => Promise) { + this.odooApiUrl = odooApiUrl + this.logger = logger + this.getCookie = getCookie + } + + /** + * Creates a new customer in Odoo. + * + * @param payload - The customer data to create + * @returns The created customer's Odoo ID + */ + async create(payload: CreateCustomerPayload): Promise { + const cookie = await this.getCookie() + const validatedPayload = createCustomerPayloadSchema.parse(payload) + + const odooId: CreateCustomerResult = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'res.partner', + method: 'create', + args: [validatedPayload], + schema: createCustomerResultSchema, + logger: this.logger, + }) + + return odooId + } + + /** + * Fetches a customer from Odoo by Odoo ID. + * + * @param odooId - The Odoo customer ID + * @returns The customer object + * @throws RuntimeError if customer is not found or multiple customers found + */ + async findByOdooId(odooId: number): Promise { + return this.findByFilter([['id', '=', odooId]]) + } + + /** + * Fetches a customer from Odoo by email. + * + * @param email - The customer email address + * @returns The customer object + * @throws RuntimeError if customer is not found or multiple customers found + */ + async findByEmail(email: string): Promise { + return this.findByFilter([['email', '=', email]]) + } + + /** + * Updates a customer in Odoo. + * + * @param odooId - The Odoo customer ID + * @param payload - The customer data to update + * @returns Success status of the update operation + * @throws RuntimeError if no fields are provided to update + */ + async update(odooId: number, payload: UpdateCustomerPayload): Promise { + const cookie = await this.getCookie() + const validatedPayload = updateCustomerPayloadSchema.parse(payload) + + if (Object.keys(validatedPayload).length === 0) { + throw new RuntimeError('No fields provided to update') + } + + const success: boolean = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'res.partner', + method: 'write', + args: [[odooId], validatedPayload], + logger: this.logger, + schema: z.boolean(), + }) + + return success + } + + /** + * Internal method to find a customer by filter. + * Throws if no customer found or multiple customers found. + * + * @param filters - The Odoo request filters + * @returns The customer object + * @throws RuntimeError if customer is not found or multiple customers found + */ + private async findByFilter(filters: OdooRequestFilters): Promise { + const cookie = await this.getCookie() + const fields: OdooRequestFields = ['id', 'email', 'name', 'phone'] + const args: OdooRequestArgs = [filters, fields] + + const rawCustomer: z.infer = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'res.partner', + method: 'search_read', + args, + logger: this.logger, + schema: fetchCustomerResultSchema, + }) + + if (rawCustomer.length === 0) { + throw new RuntimeError('Customer not found') + } + + if (rawCustomer.length > 1) { + throw new RuntimeError(`Multiple customers found: ${rawCustomer.length} results`) + } + + const firstCustomer = rawCustomer[0] + if (!firstCustomer || typeof firstCustomer.id !== 'number') { + throw new RuntimeError('Invalid customer data: missing or invalid id') + } + + return customerSchema.parse({ + odooId: firstCustomer.id, + email: firstCustomer.email, + name: firstCustomer.name, + phone: firstCustomer.phone, + }) + } +} + +/** + * Factory function to create a CustomerRepository instance. + * This follows Dependency Inversion Principle by injecting dependencies. + * + * @param ctx - The Botpress context + * @param logger - The logger instance + * @returns A new CustomerRepository instance + */ +export function createCustomerRepository(ctx: bp.Context, logger: bp.Logger): CustomerRepository { + return new CustomerRepository(ctx.configuration.odooApiUrl, logger, async () => { + return getAuthenticatedCookie({ ...ctx.configuration, logger }) + }) +} diff --git a/integrations/odoo-helpdesk/src/services/helpdeskRepository.ts b/integrations/odoo-helpdesk/src/services/helpdeskRepository.ts new file mode 100644 index 00000000..5887b64a --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/helpdeskRepository.ts @@ -0,0 +1,112 @@ +import * as bp from '.botpress' +import { executeOdooMethod, getAuthenticatedCookie } from './odoo' +import { + fetchHelpdeskTeamResultsSchema, + fetchStagesResultsSchema, + HelpdeskTeam, + FetchHelpdeskTeamResults, + FetchStagesResults, + Stage, + OdooRequestFilters, +} from 'definitions/schemas' + +/** + * Repository responsible for helpdesk data operations with Odoo. + * Follows Single Responsibility Principle - only handles Odoo API interactions for helpdesk teams and stages. + * Follows Dependency Inversion Principle - depends on abstractions (Odoo service functions). + */ +export class HelpdeskRepository { + private readonly odooApiUrl: string + private readonly logger: bp.Logger + private readonly getCookie: () => Promise + + constructor(odooApiUrl: string, logger: bp.Logger, getCookie: () => Promise) { + this.odooApiUrl = odooApiUrl + this.logger = logger + this.getCookie = getCookie + } + + /** + * Fetches all active helpdesk teams from Odoo. + * + * @returns Array of helpdesk teams with valid IDs (teams with null IDs are filtered out) + */ + async getTeams(): Promise { + const cookie = await this.getCookie() + const filters: OdooRequestFilters = [['active', '=', true]] + const fields: string[] = ['name', 'id'] + + const teams: FetchHelpdeskTeamResults = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'helpdesk.team', + method: 'search_read', + args: [filters, fields], + logger: this.logger, + schema: fetchHelpdeskTeamResultsSchema, + }) + + // Filter out teams with null IDs (Odoo data issue) + const validTeams = teams.filter((team): team is HelpdeskTeam & { id: number } => { + if (team.id === null) { + this.logger.forBot().warn(`Skipping helpdesk team "${team.name}" with null ID`) + return false + } + return true + }) + + this.logger + .forBot() + .info(`Fetched ${validTeams.length} helpdesk teams (${teams.length - validTeams.length} skipped due to null IDs)`) + return validTeams + } + + /** + * Fetches ticket stages from Odoo, optionally filtered by team IDs. + * + * @param teamIds - Optional array of team IDs to filter stages + * @returns Array of stages + */ + async getStages(teamIds?: number[]): Promise { + const cookie = await this.getCookie() + const filters: OdooRequestFilters = [['active', '=', true]] + + if (teamIds && teamIds.length > 0) { + filters.push(['team_ids', 'in', teamIds]) + } + + const fields: string[] = ['name', 'id', 'team_ids'] + + const rawStages: FetchStagesResults = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'helpdesk.stage', + method: 'search_read', + args: [filters, fields], + logger: this.logger, + schema: fetchStagesResultsSchema, + }) + + this.logger.forBot().info(`Fetched ${rawStages.length} stages`) + + return rawStages.map((stage) => ({ + name: stage.name, + id: stage.id, + teamIds: stage.team_ids, + })) + } +} + +/** + * Factory function to create a HelpdeskRepository instance. + * This follows Dependency Inversion Principle by injecting dependencies. + * + * @param ctx - The Botpress context + * @param logger - The logger instance + * @returns A new HelpdeskRepository instance + */ +export function createHelpdeskRepository(ctx: bp.Context, logger: bp.Logger): HelpdeskRepository { + return new HelpdeskRepository(ctx.configuration.odooApiUrl, logger, async () => { + return getAuthenticatedCookie({ ...ctx.configuration, logger }) + }) +} diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts new file mode 100644 index 00000000..454165c0 --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -0,0 +1,208 @@ +import { z } from '@botpress/sdk' +import axios, { AxiosInstance } from 'axios' +import axiosRetry from 'axios-retry' +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/sdk' +import { + AuthPayloadBody, + AuthHeaders, + AuthResponseHeaders, + Cookie, + authResponseDataSchema, + OdooRequestArgs, + OdooRequestKwargs, + OdooRequestModel, + OdooRequestMethod, + odooApiResponseSchema, +} from 'definitions/schemas' + +// Cache cookies per configuration to avoid re-authenticating. +// 30 minutes TTL. +const COOKIE_TTL_MS = 30 * 60 * 1000 + +const cookieCache = new Map() + +/** + * Creates an axios instance with retry logic configured (similar to Zendesk pattern). + * Uses exponential backoff for retries. + * + * @returns Configured axios instance with retry logic + */ +function createAxiosInstance(): AxiosInstance { + const instance = axios.create() + + axiosRetry(instance, { + retries: 3, + retryDelay: axiosRetry.exponentialDelay, + retryCondition: (error) => { + const rateLimitReached = error.response?.status === 429 + return axiosRetry.isNetworkOrIdempotentRequestError(error) || rateLimitReached + }, + }) + + return instance +} + +const axiosInstance = createAxiosInstance() + +/** + * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string. + * + * @param headers - The response headers containing Set-Cookie headers + * @returns Cookie header string with name=value pairs + */ +const extractCookiesFromHeaders = (headers: AuthResponseHeaders): Cookie['cookie'] => { + // Axios normalizes headers to lowercase. + const setCookieHeaders = headers['set-cookie'] + + if (!setCookieHeaders) return '' + + // Handle both array and string formats. + const cookies = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders] + + // Extract cookie name=value pairs from Set-Cookie headers. + // Set-Cookie format: "name=value; Path=/; HttpOnly" + // We only need "name=value". + return cookies + .map((cookie: string) => { + const match = cookie.match(/^([^=]+=[^;]+)/) + return match ? match[1] : null + }) + .filter(Boolean) + .join('; ') +} + +export const getAuthenticatedCookie = async ({ + odooApiUrl, + odooDb, + odooEmail, + odooPassword, + logger, +}: { + odooApiUrl: string + odooDb: string + odooEmail: string + odooPassword: string + logger: bp.Logger +}): Promise => { + // Create a cache key from configuration. + const cacheKey = `${odooApiUrl}-${odooDb}-${odooEmail}` + + // Check if cached cookie exists and is still valid. + const cached = cookieCache.get(cacheKey) + if (cached) { + const age = Date.now() - cached.timestamp + if (age < COOKIE_TTL_MS) { + logger.forBot().debug(`Returning cached Odoo authentication cookie`) + return cached.cookie + } else { + // Cookie expired, remove from cache. + cookieCache.delete(cacheKey) + logger.forBot().debug(`Cached Odoo authentication cookie expired`) + } + } + + // Authenticate using axios.post directly. + logger.forBot().debug(`Authenticating with Odoo`) + const payloadBody: AuthPayloadBody = { + jsonrpc: '2.0', + params: { + db: odooDb, + login: odooEmail, + password: odooPassword, + }, + id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance + } + const headers: AuthHeaders = { + 'Content-Type': 'application/json', + } + + const response = await axiosInstance.post(`${odooApiUrl}/web/session/authenticate`, payloadBody, { headers }) + + const validatedResponse = authResponseDataSchema.parse(response.data) + + if (validatedResponse.error) { + throw new RuntimeError(`Authentication failed: ${validatedResponse.error.message}`) + } + + if (validatedResponse.result === undefined || validatedResponse.result.uid === undefined) { + throw new RuntimeError('Authentication failed - no uid in response') + } + + const cookie = extractCookiesFromHeaders(response.headers) + if (cookie === '') { + throw new RuntimeError('No cookies found in authentication response') + } + + logger.forBot().info(`Authentication successful. UID: ${validatedResponse.result.uid}`) + + cookieCache.set(cacheKey, { + cookie, + timestamp: Date.now(), + }) + + return cookie +} + +export const executeOdooMethod = async ({ + odooApiUrl, + cookie, + model, + method, + args, + schema, + kwargs, + logger, +}: { + odooApiUrl: string + cookie: string + model: OdooRequestModel + method: OdooRequestMethod + args: OdooRequestArgs + schema: T + kwargs?: OdooRequestKwargs + logger?: bp.Logger +}): Promise> => { + logger?.forBot().debug(`Executing Odoo method: ${method} on model: ${model}`) + + const url = `${odooApiUrl}/web/dataset/call_kw` + const body = { + jsonrpc: '2.0', + params: { + model, + method, + args: args ?? [], + kwargs: kwargs ?? {}, + }, + } + const headers = { + 'Content-Type': 'application/json', + Cookie: cookie, + } + + const response = await axiosInstance.post(url, body, { headers }) + + const validatedResponse = odooApiResponseSchema.parse(response.data) + if (validatedResponse.error) { + const errorMessage = validatedResponse.error.message + logger?.forBot().error(`Odoo API error: ${errorMessage}`) + throw new RuntimeError(`Odoo API error: ${errorMessage}`) + } + + return schema.parse(validatedResponse.result) +} + +export const clearCookieCache = async ({ + odooApiUrl, + odooDb, + odooEmail, + logger, +}: { + odooApiUrl: string + odooDb: string + odooEmail: string + logger: bp.Logger +}): Promise => { + cookieCache.delete(`${odooApiUrl}-${odooDb}-${odooEmail}`) + logger.forBot().info(`Cleared cookie cache`) +} diff --git a/integrations/odoo-helpdesk/src/services/ticketRepository.ts b/integrations/odoo-helpdesk/src/services/ticketRepository.ts new file mode 100644 index 00000000..94ea5aca --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/ticketRepository.ts @@ -0,0 +1,254 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { executeOdooMethod, getAuthenticatedCookie } from './odoo' +import { + createTicketResultSchema, + createTicketPayloadSchema, + fetchTicketResultsSchema, + ticketSchema, + Ticket, + CreateTicketPayload, + CreateTicketResult, + FetchTicketResult, + FetchTicketResults, + UpdateTicketPayload, + OdooRequestArgs, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooRequestMethod, + OdooResponseObject, +} from 'definitions/schemas' +import { z } from '@botpress/sdk' + +/** + * Repository responsible for ticket data operations with Odoo. + * Follows Single Responsibility Principle - only handles Odoo API interactions for tickets. + * Follows Dependency Inversion Principle - depends on abstractions (Odoo service functions). + */ +export class TicketRepository { + private readonly odooApiUrl: string + private readonly logger: bp.Logger + private readonly getCookie: () => Promise + + constructor(odooApiUrl: string, logger: bp.Logger, getCookie: () => Promise) { + this.odooApiUrl = odooApiUrl + this.logger = logger + this.getCookie = getCookie + } + + /** + * Creates a new ticket in Odoo. + * + * @param payload - The ticket data to create + * @returns The created ticket's Odoo ID + */ + async create(payload: CreateTicketPayload): Promise { + const cookie = await this.getCookie() + const validatedPayload = createTicketPayloadSchema.parse(payload) + + const ticketId: CreateTicketResult = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'helpdesk.ticket', + method: 'create', + args: [validatedPayload], + schema: createTicketResultSchema, + logger: this.logger, + }) + + return ticketId + } + + /** + * Fetches a ticket from Odoo by ticket ID. + * + * @param ticketId - The Odoo ticket ID + * @returns The ticket object + * @throws RuntimeError if ticket is not found or multiple tickets found + */ + async findById(ticketId: number): Promise { + const rawTickets = await this.findByFilter([ticketId], 'read') + + if (rawTickets.length === 0) { + throw new RuntimeError(`Ticket with id ${ticketId} not found`) + } + + if (rawTickets.length > 1) { + throw new RuntimeError(`Multiple tickets found for id ${ticketId}: ${rawTickets.length} results`) + } + + const ticketResult = rawTickets[0] + if (!ticketResult || typeof ticketResult.id !== 'number') { + throw new RuntimeError('Invalid ticket result: missing or invalid id') + } + + return this.mapTicketResponseToTicket(ticketResult) + } + + /** + * Fetches tickets from Odoo by customer Odoo ID with pagination. + * + * @param customerOdooId - The Odoo customer ID + * @param page - Page number (1-indexed) + * @param pageSize - Number of tickets per page + * @returns Array of tickets + */ + async findByCustomerId(customerOdooId: number, page: number = 1, pageSize: number = 100): Promise { + const rawTickets = await this.findByFilter([['partner_id', '=', customerOdooId]], 'search_read', page, pageSize) + + return rawTickets.map((ticket) => this.mapTicketResponseToTicket(ticket)) + } + + /** + * Fetches tickets from Odoo by customer email with pagination. + * + * @param customerEmail - The customer email address + * @param page - Page number (1-indexed) + * @param pageSize - Number of tickets per page + * @returns Array of tickets + */ + async findByCustomerEmail(customerEmail: string, page: number = 1, pageSize: number = 100): Promise { + const rawTickets = await this.findByFilter( + [['partner_id.email', '=', customerEmail]], + 'search_read', + page, + pageSize + ) + + return rawTickets.map((ticket) => this.mapTicketResponseToTicket(ticket)) + } + + /** + * Updates a ticket in Odoo. + * + * @param ticketId - The Odoo ticket ID + * @param payload - The ticket data to update + * @returns Success status of the update operation + * @throws RuntimeError if no fields are provided to update + */ + async update(ticketId: number, payload: Partial): Promise { + const cookie = await this.getCookie() + + // Build update payload with only provided fields. + const updatePayload: Partial = {} + if (payload.name !== undefined) updatePayload.name = payload.name + if (payload.description !== undefined) updatePayload.description = payload.description + if (payload.team_id !== undefined) updatePayload.team_id = payload.team_id + if (payload.stage_id !== undefined) updatePayload.stage_id = payload.stage_id + if (payload.priority !== undefined) updatePayload.priority = payload.priority + + if (Object.keys(updatePayload).length === 0) { + throw new RuntimeError('No fields provided to update a ticket') + } + + const success: boolean = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'helpdesk.ticket', + method: 'write', + args: [[ticketId], updatePayload], + logger: this.logger, + schema: z.boolean(), + }) + + return success + } + + /** + * Internal method to find tickets by filter. + * + * @param filters - The Odoo request filters + * @param method - The Odoo request method ('read' or 'search_read') + * @param page - Page number (1-indexed, only for search_read) + * @param pageSize - Number of tickets per page (only for search_read) + * @returns Array of raw ticket results + */ + private async findByFilter( + filters: OdooRequestFilters, + method: OdooRequestMethod, + page: number = 1, + pageSize: number = 100 + ): Promise { + const cookie = await this.getCookie() + const fields: OdooRequestFields = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] + const args: OdooRequestArgs = [filters, fields] + const kwargs: OdooRequestKwargs = + method === 'search_read' + ? { + limit: pageSize, + offset: (page - 1) * pageSize, + order: 'create_date DESC', + } + : {} + + this.logger.forBot().debug(`Fetching tickets: method=${method}, page=${page}, pageSize=${pageSize}`) + + const tickets: FetchTicketResults = await executeOdooMethod({ + odooApiUrl: this.odooApiUrl, + cookie, + model: 'helpdesk.ticket', + method, + args, + kwargs, + schema: fetchTicketResultsSchema, + logger: this.logger, + }) + + this.logger.forBot().debug(`Fetched ${tickets.length} tickets`) + return tickets + } + + /** + * Maps Odoo TicketResponse to our Ticket schema. + * Odoo returns relational fields as tuples [id, name], so we extract just the ID. + * Some fields can be false when not set (e.g., partner_id, stage_id). + * + * @param response - The Odoo ticket response to map + * @returns The mapped Ticket object + * @throws RuntimeError if team_id is invalid + */ + private mapTicketResponseToTicket(response: FetchTicketResult): Ticket { + const extractId = (field: OdooResponseObject): number | undefined => { + if (Array.isArray(field) && field.length > 0 && typeof field[0] === 'number') { + return field[0] + } + return undefined + } + + const customerOdooId = extractId(response.partner_id) + const stageId = extractId(response.stage_id) + + // Validate team_id is a tuple with a number. + let teamId: number + if (Array.isArray(response.team_id) && response.team_id.length > 0 && typeof response.team_id[0] === 'number') { + teamId = response.team_id[0] + } else { + throw new RuntimeError('Invalid team_id in ticket response') + } + + return ticketSchema.parse({ + id: response.id, + customerOdooId, + name: response.name, + description: response.description, + teamId, + priority: response.priority === false ? '0' : String(response.priority), + stageId, + }) + } +} + +/** + * Factory function to create a TicketRepository instance. + * This follows Dependency Inversion Principle by injecting dependencies. + * + * @param ctx - The Botpress context + * @param logger - The logger instance + * @returns A new TicketRepository instance + */ +export function createTicketRepository(ctx: bp.Context, logger: bp.Logger): TicketRepository { + return new TicketRepository(ctx.configuration.odooApiUrl, logger, async () => { + return getAuthenticatedCookie({ ...ctx.configuration, logger }) + }) +} diff --git a/integrations/odoo-helpdesk/src/setup/index.ts b/integrations/odoo-helpdesk/src/setup/index.ts new file mode 100644 index 00000000..d38de813 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/index.ts @@ -0,0 +1,4 @@ +import { register } from './register' +import { unregister } from './unregister' + +export { register, unregister } diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts new file mode 100644 index 00000000..dcbf2009 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -0,0 +1,42 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/sdk' +import { safeSetState } from 'src/utils' +import { clearCookieCache } from 'src/services/odoo' +import { createHelpdeskRepository } from 'src/services/helpdeskRepository' + +export const register: bp.IntegrationProps['register'] = async ({ ctx, client, logger }) => { + try { + logger.forBot().info(`Registering Odoo Helpdesk Integration`) + + // Invalidate cookie cache on config changes. + await clearCookieCache({ + odooApiUrl: ctx.configuration.odooApiUrl, + odooDb: ctx.configuration.odooDb, + odooEmail: ctx.configuration.odooEmail, + logger, + }) + + // Get the Odoo helpdesk teams and ticket stages. + const repository = createHelpdeskRepository(ctx, logger) + const helpdeskTeams = await repository.getTeams() + logger.forBot().info(`Retrieved ${helpdeskTeams.length} helpdesk teams`) + + const stages = await repository.getStages(helpdeskTeams.map((team) => team.id)) + logger.forBot().info(`Retrieved ${stages.length} ticket stages`) + + // Store ticket stages in integration state. + await safeSetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, + }) + + logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) + } catch (error) { + logger.forBot().error(`Failed to register Odoo Helpdesk Integration`, error) + throw new RuntimeError( + `Failed to register Odoo Helpdesk Integration: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts new file mode 100644 index 00000000..3ae20481 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -0,0 +1,40 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/sdk' +import { clearCookieCache } from 'src/services/odoo' +import { safeSetState } from 'src/utils' + +export const unregister: bp.IntegrationProps['unregister'] = async ({ logger, client, ctx }) => { + logger.forBot().info(`Unregistering Odoo Helpdesk Integration`) + + await safeSetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { + helpdeskIntegrationInfo: { + helpdeskTeams: [], + stages: [], + }, + }, + }) + logger.forBot().info(`Cleared integration state`) + + await safeSetState(client, { + type: 'integration', + name: 'customerIdMapping', + id: ctx.integrationId, + payload: { + customerIdMapping: {}, + }, + }) + logger.forBot().info(`Cleared customer ID mapping state`) + + await clearCookieCache({ + odooApiUrl: ctx.configuration.odooApiUrl, + odooDb: ctx.configuration.odooDb, + odooEmail: ctx.configuration.odooEmail, + logger, + }) + + logger.forBot().info(`Odoo Helpdesk Integration unregistered successfully`) +} diff --git a/integrations/odoo-helpdesk/src/utils/attempt.ts b/integrations/odoo-helpdesk/src/utils/attempt.ts new file mode 100644 index 00000000..46a9a5b5 --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/attempt.ts @@ -0,0 +1,116 @@ +import { RuntimeError } from '@botpress/sdk' + +/** + * Attempts to execute a function and throws a custom error if it fails. + * + * @param fn - The function to execute + * @param error - The error to throw if the function fails. Can be: + * - A string (will be wrapped in RuntimeError) + * - An Error instance (will be thrown directly) + * - A function that returns an Error or string (useful for dynamic error messages) + * @param retries - Optional number of retry attempts. Defaults to 0 (no retries). + * If set to n, the function will be attempted up to n+1 times total (initial attempt + n retries). + * @param timeoutMs - Optional timeout in milliseconds. Each attempt will timeout after this duration. + * Defaults to 1000ms (1 second). The timeout applies to each individual attempt, not the total retry time. + * @returns The result of the function execution + * @throws The custom error if the function fails after all retry attempts or if a timeout occurs + * + * @example + * ```ts + * // With a string error message (no retries, default 1s timeout) + * const result = await attempt( + * () => someAsyncFunction(), + * 'Failed to execute function' + * ) + * + * // With retries (will attempt up to 3 times total, default 1s timeout per attempt) + * const result = await attempt( + * () => someAsyncFunction(), + * 'Failed to execute function', + * 2 + * ) + * + * // With custom timeout (5 seconds per attempt) + * const result = await attempt( + * () => someAsyncFunction(), + * 'Failed to execute function', + * 0, + * 5000 + * ) + * + * // With retries and custom timeout + * const result = await attempt( + * () => someAsyncFunction(), + * 'Failed to execute function', + * 2, + * 3000 + * ) + * + * // With an Error instance, retries, and custom timeout + * const result = await attempt( + * () => someAsyncFunction(), + * new RuntimeError('Custom error'), + * 3, + * 10000 + * ) + * + * // With a function that generates the error dynamically + * const result = await attempt( + * () => someAsyncFunction(), + * (originalError) => `Failed to execute: ${originalError.message}`, + * 1, + * 5000 + * ) + * ``` + */ +export async function attempt({ + fn, + error, + retries = 0, + timeoutMs = 1000, +}: { + fn: () => Promise | T + error: RuntimeError | ((originalError: unknown) => RuntimeError) + retries?: number + timeoutMs?: number +}): Promise { + let lastError: unknown + + const executeWithTimeout = async (): Promise => { + const fnPromise = Promise.resolve(fn()) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Operation timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }) + + return Promise.race([fnPromise, timeoutPromise]) + } + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await executeWithTimeout() + } catch (originalError) { + lastError = originalError + + // If this was the last attempt, throw the custom error. + if (attempt === retries) { + let errorToThrow: RuntimeError + if (error instanceof RuntimeError) { + errorToThrow = error + } else { + // Error is a function. + const result = error(originalError) + errorToThrow = typeof result === 'string' ? new RuntimeError(result) : result + } + + throw errorToThrow + } + // Otherwise, continue to the next retry attempt. + } + } + + // This should never be reached, but TypeScript needs it. + throw new RuntimeError('Unexpected error in attempt function') +} diff --git a/integrations/odoo-helpdesk/src/utils/index.ts b/integrations/odoo-helpdesk/src/utils/index.ts new file mode 100644 index 00000000..82cb6c9e --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/index.ts @@ -0,0 +1,2 @@ +export { attempt } from './attempt' +export { safeGetState, safeGetOrSetState, safeSetState } from './state' diff --git a/integrations/odoo-helpdesk/src/utils/state.ts b/integrations/odoo-helpdesk/src/utils/state.ts new file mode 100644 index 00000000..64e8c7a7 --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/state.ts @@ -0,0 +1,67 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' + +/** + * Safely executes getOrSetState with error handling. + * Follows Single Responsibility Principle - only handles state retrieval with error handling. + * + * @param client - The Botpress client instance + * @param params - The state parameters + * @param logger - The logger instance + * @returns The state result + * @throws RuntimeError if state operation fails + */ +export async function safeGetOrSetState( + client: bp.Client, + params: Parameters[0] +): Promise>> { + try { + return await client.getOrSetState(params) + } catch (error) { + throw new RuntimeError( + `Failed to get or set state for ${params.name}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} + +/** + * Safely executes getState with error handling. + * Follows Single Responsibility Principle - only handles state retrieval with error handling. + * + * @param client - The Botpress client instance + * @param params - The state parameters + * @param logger - The logger instance + * @returns The state result + * @throws RuntimeError if state operation fails + */ +export async function safeGetState( + client: bp.Client, + params: Parameters[0] +): Promise>> { + try { + return await client.getState(params) + } catch (error) { + throw new RuntimeError( + `Failed to get state for ${params.name}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} + +/** + * Safely executes setState with error handling. + * Follows Single Responsibility Principle - only handles state updates with error handling. + * + * @param client - The Botpress client instance + * @param params - The state parameters + * @param logger - The logger instance + * @throws RuntimeError if state operation fails + */ +export async function safeSetState(client: bp.Client, params: Parameters[0]): Promise { + try { + await client.setState(params) + } catch (error) { + throw new RuntimeError( + `Failed to set state for ${params.name}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} diff --git a/integrations/odoo-helpdesk/tsconfig.json b/integrations/odoo-helpdesk/tsconfig.json new file mode 100644 index 00000000..86f886db --- /dev/null +++ b/integrations/odoo-helpdesk/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["es2022"], + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedParameters": true, + "target": "es2017", + "outDir": "dist", + "checkJs": false, + "exactOptionalPropertyTypes": false, + "resolveJsonModule": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": false, + "baseUrl": "." + }, + "include": [".botpress/**/*", "src/**/*", "definitions/**/*", "*.ts", "*.json"] +}