From df68a5206c16e6f6141c205c9454119bb0b12b53 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Tue, 13 Jan 2026 17:17:28 -0500 Subject: [PATCH 01/29] Created initial odoo helpdesk typing and outline --- .../definitions/actions/customers.ts | 52 ++++++++++ .../definitions/actions/index.ts | 8 ++ .../definitions/actions/tickets.ts | 95 +++++++++++++++++++ .../definitions/schemas/customer.ts | 10 ++ .../definitions/schemas/index.ts | 4 + .../definitions/schemas/ticket.ts | 13 +++ integrations/odoo-helpdesk/hub.md | 33 +++++++ integrations/odoo-helpdesk/icon.svg | 8 ++ .../odoo-helpdesk/integration.definition.ts | 31 ++++++ integrations/odoo-helpdesk/package.json | 16 ++++ .../odoo-helpdesk/src/actions/customers.ts | 26 +++++ .../odoo-helpdesk/src/actions/index.ts | 8 ++ .../odoo-helpdesk/src/actions/tickets.ts | 41 ++++++++ .../odoo-helpdesk/src/handlers/index.ts | 0 integrations/odoo-helpdesk/src/index.ts | 11 +++ integrations/odoo-helpdesk/src/setup/index.ts | 4 + .../odoo-helpdesk/src/setup/register.ts | 7 ++ .../odoo-helpdesk/src/setup/unregister.ts | 7 ++ integrations/odoo-helpdesk/src/utils/debug.ts | 7 ++ integrations/odoo-helpdesk/src/utils/index.ts | 1 + integrations/odoo-helpdesk/tsconfig.json | 27 ++++++ 21 files changed, 409 insertions(+) create mode 100644 integrations/odoo-helpdesk/definitions/actions/customers.ts create mode 100644 integrations/odoo-helpdesk/definitions/actions/index.ts create mode 100644 integrations/odoo-helpdesk/definitions/actions/tickets.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/customer.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/index.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/ticket.ts create mode 100644 integrations/odoo-helpdesk/hub.md create mode 100644 integrations/odoo-helpdesk/icon.svg create mode 100644 integrations/odoo-helpdesk/integration.definition.ts create mode 100644 integrations/odoo-helpdesk/package.json create mode 100644 integrations/odoo-helpdesk/src/actions/customers.ts create mode 100644 integrations/odoo-helpdesk/src/actions/index.ts create mode 100644 integrations/odoo-helpdesk/src/actions/tickets.ts create mode 100644 integrations/odoo-helpdesk/src/handlers/index.ts create mode 100644 integrations/odoo-helpdesk/src/index.ts create mode 100644 integrations/odoo-helpdesk/src/setup/index.ts create mode 100644 integrations/odoo-helpdesk/src/setup/register.ts create mode 100644 integrations/odoo-helpdesk/src/setup/unregister.ts create mode 100644 integrations/odoo-helpdesk/src/utils/debug.ts create mode 100644 integrations/odoo-helpdesk/src/utils/index.ts create mode 100644 integrations/odoo-helpdesk/tsconfig.json diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts new file mode 100644 index 00000000..aa1e9b44 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -0,0 +1,52 @@ +import { z, ActionDefinition } from '@botpress/sdk' +import { customerSchema } from 'definitions/schemas' + +export const createCustomer: ActionDefinition = { + title: 'Create Customer', + description: 'Create a new customer', + input: { + schema: z.object({}), + }, + output: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The created customer').optional(), + }), + }, +} + +export const fetchCustomer: ActionDefinition = { + title: 'Fetch Customer', + description: 'Fetch a customer by id', + input: { + schema: z.object({ + customerId: z.number().title('Customer ID').describe('The id of the customer to fetch'), + }), + }, + output: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The fetched customer').optional(), + }), + }, +} + +export const updateCustomer: ActionDefinition = { + title: 'Update Customer', + description: 'Update a customer by id', + input: { + schema: z.object({ + customer: customerSchema.title('Customer').describe('The customer to update'), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + error: z.string().title('Error').describe('The error message if the update failed').optional(), + }), + }, +} + +export const actions = { + createCustomer, + fetchCustomer, + updateCustomer, +} as const \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/actions/index.ts b/integrations/odoo-helpdesk/definitions/actions/index.ts new file mode 100644 index 00000000..d7d2ceb7 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/index.ts @@ -0,0 +1,8 @@ +import * as sdk from '@botpress/sdk' +import {actions as customerActions} from './customers' +import {actions as ticketActions} from './tickets' + +export const actions = { + ...customerActions, + ...ticketActions, +} 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..58efa634 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -0,0 +1,95 @@ +import { z, ActionDefinition } from '@botpress/sdk' +import { customerSchema, ticketSchema } from 'definitions/schemas' + +export const createTicket: ActionDefinition = { + title: 'Create Ticket', + description: 'Create a new ticket', + input: { + schema: z.object({ + subject: z.string().title('Subject').describe('The subject of the ticket'), + description: z.string().title('Description').describe('The description of the ticket'), + status: z.string().title('Status').describe('The status of the ticket'), + priority: z.string().title('Priority').describe('The priority of the ticket'), + customer: customerSchema.title('Customer').describe('The customer associated with the ticket'), + }), + }, + output: { + schema: z.object({ + odooTicket: ticketSchema.title('Odoo Ticket').describe('The created ticket').optional(), + }), + }, +} + +export const fetchTicket: ActionDefinition = { + title: 'Fetch Ticket', + description: 'Fetch a ticket by id', + input: { + schema: z.object({ + odooTicketId: z.number().title('Odoo Ticket ID').describe('The id of the ticket to fetch'), + }), + }, + output: { + schema: z.object({ + odooTicket: ticketSchema.title('Odoo Ticket').describe('The fetched ticket').optional(), + }), + }, +} + +export const fetchTickets: ActionDefinition = { + title: 'Fetch Tickets by Customer', + description: 'Fetch all tickets by customer', + input: { + schema: z.object({ + customerId: z.number().title('Customer ID').describe('The id of the customer'), + }), + }, + output: { + schema: z.object({ + odooTickets: z.array(ticketSchema).title('Odoo 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({ + odooTicket: ticketSchema.title('Odoo Ticket').describe('The ticket to update'), + subject: z.string().title('Subject').describe('The subject of the ticket'), + description: z.string().title('Description').describe('The description of the ticket'), + status: z.string().title('Status').describe('The status of the ticket'), + priority: z.string().title('Priority').describe('The priority of the ticket'), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('The success of the update'), + error: z.string().title('Error').describe('The error message if the update failed').optional(), + }), + }, +} + +export const closeTicket: ActionDefinition = { + title: 'Close Ticket', + description: 'Close a ticket by id', + input: { + schema: z.object({ + odooTicketId: z.number().title('Odoo Ticket ID').describe('The id of the ticket to close'), + }), + }, + output: { + schema: z.object({ + odooTicket: ticketSchema.title('Odoo Ticket').describe('The closed ticket').optional(), + error: z.string().title('Error').describe('The error message if the ticket was not closed successfully').optional(), + }), + }, +} + +export const actions = { + createTicket, + fetchTicket, + fetchTickets, + updateTicket, + closeTicket, +} as const \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts new file mode 100644 index 00000000..85d6cf0d --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -0,0 +1,10 @@ +import { z } from '@botpress/sdk' + +export const customerSchema = z.object({ + id: z.number().describe('The botpress user ID of the customer'), + email: z.string().describe('The email of the customer'), + odooUserId: z.number().describe('The Odoo user ID of the customer').optional(), + firstName: z.string().describe('The first name of the customer').optional(), + lastName: z.string().describe('The last name of the customer').optional(), + phone: z.string().describe('The phone of the customer').optional(), +}) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts new file mode 100644 index 00000000..9132274d --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -0,0 +1,4 @@ +import { customerSchema } from './customer' +import { ticketSchema } from './ticket' + +export { customerSchema, ticketSchema } \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts new file mode 100644 index 00000000..729407a8 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -0,0 +1,13 @@ +import { z } from '@botpress/sdk' +import { customerSchema } from './customer' + +export const ticketSchema = z.object({ + customer: customerSchema.describe('The customer associated with the ticket'), + odooTicketSubject: z.string().describe('The Odoo ticket subject'), + odooTicketDescription: z.string().describe('The Odoo ticket description'), + odooTicketStatus: z.string().describe('The Odoo ticket status'), + odooTicketPriority: z.string().describe('The Odoo ticket priority'), + odooTicketId: z.number().describe('The Odoo ticket ID').optional(), + odooTicketCreatedAt: z.string().describe('The Odoo ticket created at').optional(), + odooTicketUpdatedAt: z.string().describe('The Odoo ticket updated at').optional(), +}) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/hub.md b/integrations/odoo-helpdesk/hub.md new file mode 100644 index 00000000..3874c6d6 --- /dev/null +++ b/integrations/odoo-helpdesk/hub.md @@ -0,0 +1,33 @@ +# Integration Title + +> Describe the integration's purpose. + +## Configuration + +> Explain how to configure your integration and list prerequisites `ex: accounts, etc.`. +> You might also want to add configuration details for specific use cases. + +## Usage + +> Explain how to use your integration. +> You might also want to include an example if there is a specific use case. + +## Limitations + +> List the known bugs. +> List known limits `ex: rate-limiting, payload sizes, etc.` +> List unsupported use cases. + +## Changelog + +> If some versions of your integration introduce changes worth mentionning (breaking changes, bug fixes), describe them here. This will help users to know what to expect when updating the integration. + +### Integration publication checklist + +- [ ] The register handler is implemented and validates the configuration. +- [ ] Title and descriptions for all schemas are present in `integration.definition.ts`. +- [ ] Events store `conversationId`, `userId` and `messageId` when available. +- [ ] Implement events & actions that are related to `channels`, `entities`, `user`, `conversations` and `messages`. +- [ ] Events related to messages are implemented as messages. +- [ ] When an action is required by the bot developer, a `RuntimeError` is thrown with instructions to fix the problem. +- [ ] Bot name and bot avatar URL fields are available in the integration configuration. diff --git a/integrations/odoo-helpdesk/icon.svg b/integrations/odoo-helpdesk/icon.svg new file mode 100644 index 00000000..91ad303b --- /dev/null +++ b/integrations/odoo-helpdesk/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts new file mode 100644 index 00000000..e9d3787b --- /dev/null +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -0,0 +1,31 @@ +import { z, IntegrationDefinition } from '@botpress/sdk' +import { integrationName } from './package.json' +import { actions } from './definitions/actions' + +export default new IntegrationDefinition({ + version: '0.1.0', + name: integrationName, + title: 'Odoo Helpdesk', + description: 'Connect with Odoo Helpdesk to manage tickets and customers', + readme: 'hub.md', + icon: 'icon.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(), + odooTicketStatuses: z.array(z.string()).describe('The Odoo ticket statuses.'), + }), + }, + user: { + tags: { + id: { title: 'User ID', description: 'The ID of the user' }, + email: { title: 'Email', description: 'The email of the user' }, + odooUserId: { title: 'Odoo User ID', description: 'The ID of the Odoo user' }, + conversationId: { title: 'Conversation ID', description: 'The ID of the conversation' }, + }, + }, + actions, +}) diff --git a/integrations/odoo-helpdesk/package.json b/integrations/odoo-helpdesk/package.json new file mode 100644 index 00000000..2e84bd25 --- /dev/null +++ b/integrations/odoo-helpdesk/package.json @@ -0,0 +1,16 @@ +{ + "name": "@bp-templates/empty-integration", + "integrationName": "erichuang/odoo-helpdesk-integration", + "scripts": { + "check:type": "tsc --noEmit" + }, + "private": true, + "dependencies": { + "@botpress/client": "1.28.0", + "@botpress/sdk": "5.1.1" + }, + "devDependencies": { + "@types/node": "^22.16.4", + "typescript": "^5.6.3" + } +} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts new file mode 100644 index 00000000..24764de1 --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -0,0 +1,26 @@ +import * as bp from '.botpress' +import { print } from 'src/utils' +import axios from 'axios' +import { RuntimeError } from '@botpress/client' + +export const createCustomer: bp.Integration['actions']['createCustomer'] = async (params) => { + print(`Creating customer: ${JSON.stringify(params)}`) + return { + customer: undefined, + } +} + +export const fetchCustomer: bp.Integration['actions']['fetchCustomer'] = async (params) => { + print(`Fetching customer: ${JSON.stringify(params)}`) + return { + customer: undefined, + } +} + +export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async (params) => { + print(`Updating customer: ${JSON.stringify(params)}`) + return { + success: false, + error: 'Not implemented', + } +} diff --git a/integrations/odoo-helpdesk/src/actions/index.ts b/integrations/odoo-helpdesk/src/actions/index.ts new file mode 100644 index 00000000..38828f15 --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/index.ts @@ -0,0 +1,8 @@ +import * as bp from '.botpress' +import * as tickets from './tickets' +import * as customers from './customers' + +export default { + ...tickets, + ...customers, +} satisfies bp.IntegrationProps['actions'] \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts new file mode 100644 index 00000000..199e8b33 --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -0,0 +1,41 @@ +import * as bp from '.botpress' +import { print } from 'src/utils' +import axios from 'axios' +import { RuntimeError } from '@botpress/client' + +export const createTicket: bp.Integration['actions']['createTicket'] = async (params) => { + print(`Creating ticket: ${JSON.stringify(params)}`) + return { + odooTicket: undefined, + } +} + +export const fetchTicket: bp.Integration['actions']['fetchTicket'] = async (params) => { + print(`Fetching ticket: ${JSON.stringify(params)}`) + return { + odooTicket: undefined, + } +} + +export const fetchTickets: bp.Integration['actions']['fetchTickets'] = async (params) => { + print(`Fetching tickets: ${JSON.stringify(params)}`) + return { + odooTickets: [], + } +} + +export const updateTicket: bp.Integration['actions']['updateTicket'] = async (params) => { + print(`Updating ticket: ${JSON.stringify(params)}`) + return { + success: false, + error: 'Not implemented', + } +} + +export const closeTicket: bp.Integration['actions']['closeTicket'] = async (params) => { + print(`Closing ticket: ${JSON.stringify(params)}`) + return { + success: false, + error: 'Not implemented', + } +} diff --git a/integrations/odoo-helpdesk/src/handlers/index.ts b/integrations/odoo-helpdesk/src/handlers/index.ts new file mode 100644 index 00000000..e69de29b 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/setup/index.ts b/integrations/odoo-helpdesk/src/setup/index.ts new file mode 100644 index 00000000..65590a7a --- /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 } \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts new file mode 100644 index 00000000..f1045763 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -0,0 +1,7 @@ +import * as bp from '.botpress' +import { print } from 'src/utils' + +export const register: bp.IntegrationProps['register'] = async (params) => { + print(`Registering Odoo Helpdesk Integration...`) + print(`Params: ${JSON.stringify(params)}`) +} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts new file mode 100644 index 00000000..820db014 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -0,0 +1,7 @@ +import * as bp from '.botpress' +import { print } from 'src/utils' + +export const unregister: bp.IntegrationProps['unregister'] = async (params) => { + print(`Unregistering Odoo Helpdesk Integration...`) + print(`Params: ${JSON.stringify(params)}`) +} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/utils/debug.ts b/integrations/odoo-helpdesk/src/utils/debug.ts new file mode 100644 index 00000000..2d8d9a77 --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/debug.ts @@ -0,0 +1,7 @@ +import axios from 'axios' + +const debugUrl = process.env.DEBUG_URL +export const print = async (message: string) => { + if (!debugUrl) {throw new Error('DEBUG_URL is not set')} + return axios.post(debugUrl, { message }) +} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/utils/index.ts b/integrations/odoo-helpdesk/src/utils/index.ts new file mode 100644 index 00000000..e555ba7a --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/index.ts @@ -0,0 +1 @@ +export { print } from './debug' \ No newline at end of file diff --git a/integrations/odoo-helpdesk/tsconfig.json b/integrations/odoo-helpdesk/tsconfig.json new file mode 100644 index 00000000..c18bb01d --- /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"], +} From 808d2b1dafd7ede951cd309a9d12e4c0377eff55 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Thu, 15 Jan 2026 11:59:14 -0500 Subject: [PATCH 02/29] Created helpdesk and ticketing actions --- .../definitions/actions/customers.ts | 9 +- .../definitions/actions/helpdesk.ts | 35 ++++ .../definitions/actions/index.ts | 6 +- .../definitions/actions/tickets.ts | 58 +++--- .../odoo-helpdesk/definitions/index.ts | 4 + .../definitions/schemas/customer.ts | 4 +- .../definitions/schemas/helpdesk-team.ts | 6 + .../definitions/schemas/index.ts | 4 +- .../definitions/schemas/stage.ts | 7 + .../definitions/schemas/ticket.ts | 16 +- .../odoo-helpdesk/definitions/states.ts | 19 ++ .../odoo-helpdesk/integration.definition.ts | 9 +- integrations/odoo-helpdesk/package.json | 7 +- .../odoo-helpdesk/src/actions/customers.ts | 15 +- .../odoo-helpdesk/src/actions/helpdesk.ts | 42 +++++ .../odoo-helpdesk/src/actions/index.ts | 12 +- .../odoo-helpdesk/src/actions/tickets.ts | 175 ++++++++++++++++-- .../odoo-helpdesk/src/handlers/index.ts | 0 .../odoo-helpdesk/src/services/odoo.ts | 102 ++++++++++ .../odoo-helpdesk/src/setup/helpdesk.ts | 72 +++++++ .../odoo-helpdesk/src/setup/register.ts | 23 ++- .../odoo-helpdesk/src/setup/unregister.ts | 9 +- integrations/odoo-helpdesk/src/utils/debug.ts | 7 - integrations/odoo-helpdesk/src/utils/index.ts | 1 - 24 files changed, 543 insertions(+), 99 deletions(-) create mode 100644 integrations/odoo-helpdesk/definitions/actions/helpdesk.ts create mode 100644 integrations/odoo-helpdesk/definitions/index.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/stage.ts create mode 100644 integrations/odoo-helpdesk/definitions/states.ts create mode 100644 integrations/odoo-helpdesk/src/actions/helpdesk.ts delete mode 100644 integrations/odoo-helpdesk/src/handlers/index.ts create mode 100644 integrations/odoo-helpdesk/src/services/odoo.ts create mode 100644 integrations/odoo-helpdesk/src/setup/helpdesk.ts delete mode 100644 integrations/odoo-helpdesk/src/utils/debug.ts delete mode 100644 integrations/odoo-helpdesk/src/utils/index.ts diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index aa1e9b44..86ec19c3 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -5,7 +5,14 @@ export const createCustomer: ActionDefinition = { title: 'Create Customer', description: 'Create a new customer', input: { - schema: z.object({}), + schema: z.object({ + bpId: z.number().title('BP ID').describe('The id of the customer'), + odooId: z.number().title('Odoo ID').describe('The Odoo ID of the customer').optional(), + email: z.string().title('Email').describe('The email of the customer'), + firstName: z.string().title('First Name').describe('The first name of the customer').optional(), + lastName: z.string().title('Last Name').describe('The last name of the customer').optional(), + phone: z.string().title('Phone').describe('The phone of the customer').optional(), + }), }, output: { schema: z.object({ diff --git a/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts b/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts new file mode 100644 index 00000000..ca9f6a19 --- /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().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 index d7d2ceb7..a69ccffa 100644 --- a/integrations/odoo-helpdesk/definitions/actions/index.ts +++ b/integrations/odoo-helpdesk/definitions/actions/index.ts @@ -1,8 +1,10 @@ import * as sdk from '@botpress/sdk' import {actions as customerActions} from './customers' -import {actions as ticketActions} from './tickets' +import {actions as ticketsActions} from './tickets' +import {actions as helpdeskActions} from './helpdesk' export const actions = { ...customerActions, - ...ticketActions, + ...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 index 58efa634..e3d40bf5 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -1,41 +1,42 @@ import { z, ActionDefinition } from '@botpress/sdk' -import { customerSchema, ticketSchema } from 'definitions/schemas' +import { ticketSchema } from 'definitions/schemas' export const createTicket: ActionDefinition = { title: 'Create Ticket', description: 'Create a new ticket', input: { schema: z.object({ - subject: z.string().title('Subject').describe('The subject of the ticket'), + name: z.string().title('Name').describe('The name of the ticket'), description: z.string().title('Description').describe('The description of the ticket'), - status: z.string().title('Status').describe('The status of the ticket'), + teamId: z.number().title('Team ID').describe('The helpdesk team ID associated with the ticket'), priority: z.string().title('Priority').describe('The priority of the ticket'), - customer: customerSchema.title('Customer').describe('The customer associated with the ticket'), + customerId: z.number().title('Customer ID').describe('The customer ID associated with the ticket'), + stageId: z.number().title('Stage ID').describe('The stage ID associated with the ticket').optional(), }), }, output: { schema: z.object({ - odooTicket: ticketSchema.title('Odoo Ticket').describe('The created ticket').optional(), + ticket: ticketSchema.title('Ticket').describe('The created ticket').optional(), }), }, } -export const fetchTicket: ActionDefinition = { +export const fetchTicketById: ActionDefinition = { title: 'Fetch Ticket', description: 'Fetch a ticket by id', input: { schema: z.object({ - odooTicketId: z.number().title('Odoo Ticket ID').describe('The id of the ticket to fetch'), + id: z.number().title('Ticket ID').describe('The id of the ticket to fetch'), }), }, output: { schema: z.object({ - odooTicket: ticketSchema.title('Odoo Ticket').describe('The fetched ticket').optional(), + ticket: ticketSchema.title('Ticket').describe('The fetched ticket').optional(), }), }, } -export const fetchTickets: ActionDefinition = { +export const fetchTicketsByCustomerId: ActionDefinition = { title: 'Fetch Tickets by Customer', description: 'Fetch all tickets by customer', input: { @@ -45,51 +46,50 @@ export const fetchTickets: ActionDefinition = { }, output: { schema: z.object({ - odooTickets: z.array(ticketSchema).title('Odoo Tickets').describe('The list of tickets associated with the customer'), + 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', +export const fetchTicketsByCustomerEmail: ActionDefinition = { + title: 'Fetch Tickets by Customer Email', + description: 'Fetch all tickets by customer email', input: { schema: z.object({ - odooTicket: ticketSchema.title('Odoo Ticket').describe('The ticket to update'), - subject: z.string().title('Subject').describe('The subject of the ticket'), - description: z.string().title('Description').describe('The description of the ticket'), - status: z.string().title('Status').describe('The status of the ticket'), - priority: z.string().title('Priority').describe('The priority of the ticket'), + customerEmail: z.string().title('Customer Email').describe('The email of the customer'), }), }, output: { schema: z.object({ - success: z.boolean().title('Success').describe('The success of the update'), - error: z.string().title('Error').describe('The error message if the update failed').optional(), + tickets: z.array(ticketSchema).title('Tickets').describe('The list of tickets associated with the customer'), }), }, } -export const closeTicket: ActionDefinition = { - title: 'Close Ticket', - description: 'Close a ticket by id', +export const updateTicket: ActionDefinition = { + title: 'Update Ticket', + description: 'Update a ticket by id', input: { schema: z.object({ - odooTicketId: z.number().title('Odoo Ticket ID').describe('The id of the ticket to close'), + ticket: ticketSchema.title('Ticket').describe('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().title('Team ID').describe('The helpdesk team ID associated with the ticket').optional(), + priority: z.string().title('Priority').describe('The priority of the ticket').optional(), + stageId: z.number().title('Stage ID').describe('The stage ID associated with the ticket').optional(), }), }, output: { schema: z.object({ - odooTicket: ticketSchema.title('Odoo Ticket').describe('The closed ticket').optional(), - error: z.string().title('Error').describe('The error message if the ticket was not closed successfully').optional(), + success: z.boolean().title('Success').describe('The success of the update') }), }, } export const actions = { createTicket, - fetchTicket, - fetchTickets, + fetchTicketById, + fetchTicketsByCustomerId, + fetchTicketsByCustomerEmail, updateTicket, - closeTicket, } as const \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/index.ts b/integrations/odoo-helpdesk/definitions/index.ts new file mode 100644 index 00000000..e2180ab2 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/index.ts @@ -0,0 +1,4 @@ +import { actions } from './actions' +import { states } from './states' + +export { actions, states } \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index 85d6cf0d..943b6f03 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -1,9 +1,9 @@ import { z } from '@botpress/sdk' export const customerSchema = z.object({ - id: z.number().describe('The botpress user ID of the customer'), + bpId: z.number().describe('The botpress ID of the customer'), email: z.string().describe('The email of the customer'), - odooUserId: z.number().describe('The Odoo user ID of the customer').optional(), + odooId: z.number().describe('The Odoo ID of the customer').optional(), firstName: z.string().describe('The first name of the customer').optional(), lastName: z.string().describe('The last name of the customer').optional(), phone: z.string().describe('The phone of the customer').optional(), 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..50bc8ffa --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts @@ -0,0 +1,6 @@ +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'), +}) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts index 9132274d..03258fea 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/index.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -1,4 +1,6 @@ import { customerSchema } from './customer' import { ticketSchema } from './ticket' +import { helpdeskTeamSchema } from './helpdesk-team' +import { stageSchema } from './stage' -export { customerSchema, ticketSchema } \ No newline at end of file +export { customerSchema, ticketSchema, helpdeskTeamSchema, stageSchema } \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/stage.ts b/integrations/odoo-helpdesk/definitions/schemas/stage.ts new file mode 100644 index 00000000..bd11d57a --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/stage.ts @@ -0,0 +1,7 @@ +import { z } from '@botpress/sdk' + +export const stageSchema = z.object({ + name: z.string().describe('The name of the stage'), + id: z.number().describe('The id of the stage'), + teamIds: z.array(z.number()).describe('The ids of the helpdesk teams that the stage belongs to'), +}) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts index 729407a8..9c614a8f 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -1,13 +1,11 @@ import { z } from '@botpress/sdk' -import { customerSchema } from './customer' export const ticketSchema = z.object({ - customer: customerSchema.describe('The customer associated with the ticket'), - odooTicketSubject: z.string().describe('The Odoo ticket subject'), - odooTicketDescription: z.string().describe('The Odoo ticket description'), - odooTicketStatus: z.string().describe('The Odoo ticket status'), - odooTicketPriority: z.string().describe('The Odoo ticket priority'), - odooTicketId: z.number().describe('The Odoo ticket ID').optional(), - odooTicketCreatedAt: z.string().describe('The Odoo ticket created at').optional(), - odooTicketUpdatedAt: z.string().describe('The Odoo ticket updated at').optional(), + customerId: z.number().describe('The customer ID associated with the ticket'), + 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').optional(), + stageId: z.number().describe('The stage ID associated with the ticket').optional(), }) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/states.ts b/integrations/odoo-helpdesk/definitions/states.ts new file mode 100644 index 00000000..3215e443 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/states.ts @@ -0,0 +1,19 @@ +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'), + }), +} + +export const states = { + helpdeskIntegrationInfo, +} as const satisfies Record diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index e9d3787b..f740829b 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -1,9 +1,10 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { integrationName } from './package.json' -import { actions } from './definitions/actions' +import { actions, states } from './definitions' +// import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.0', + version: '0.1.4', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', @@ -16,16 +17,16 @@ export default new IntegrationDefinition({ 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(), - odooTicketStatuses: z.array(z.string()).describe('The Odoo ticket statuses.'), }), }, user: { tags: { id: { title: 'User ID', description: 'The ID of the user' }, email: { title: 'Email', description: 'The email of the user' }, - odooUserId: { title: 'Odoo User ID', description: 'The ID of the Odoo 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/package.json b/integrations/odoo-helpdesk/package.json index 2e84bd25..050ed6c0 100644 --- a/integrations/odoo-helpdesk/package.json +++ b/integrations/odoo-helpdesk/package.json @@ -2,12 +2,15 @@ "name": "@bp-templates/empty-integration", "integrationName": "erichuang/odoo-helpdesk-integration", "scripts": { - "check:type": "tsc --noEmit" + "check:type": "tsc --noEmit", + "build": "bp add -y && bp build", + "deploy": "bp deploy -y" }, "private": true, "dependencies": { "@botpress/client": "1.28.0", - "@botpress/sdk": "5.1.1" + "@botpress/sdk": "5.1.1", + "axios": "^1.6.8" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 24764de1..8dd35628 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,24 +1,25 @@ import * as bp from '.botpress' -import { print } from 'src/utils' import axios from 'axios' import { RuntimeError } from '@botpress/client' -export const createCustomer: bp.Integration['actions']['createCustomer'] = async (params) => { - print(`Creating customer: ${JSON.stringify(params)}`) +export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ ctx, input, logger }) => { + + logger.forBot().info(`Creating customer: ${JSON.stringify(input)}`) + return { customer: undefined, } } -export const fetchCustomer: bp.Integration['actions']['fetchCustomer'] = async (params) => { - print(`Fetching customer: ${JSON.stringify(params)}`) +export const fetchCustomer: bp.Integration['actions']['fetchCustomer'] = async ({ ctx, input, logger }) => { + logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) return { customer: undefined, } } -export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async (params) => { - print(`Updating customer: ${JSON.stringify(params)}`) +export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async ({ ctx, input, logger }) => { + logger.forBot().info(`Updating customer: ${JSON.stringify(input)}`) return { success: false, error: 'Not implemented', diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts new file mode 100644 index 00000000..8adc18c4 --- /dev/null +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -0,0 +1,42 @@ +import * as bp from '.botpress' +import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' +import { z } from '@botpress/sdk' + +export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = async ({ ctx, client, logger }) => { + logger.forBot().info(`Getting cached helpdesk teams...`) + const { state } = (await client.getState({ + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + })) as { + state: { payload: { helpdeskIntegrationInfo: { helpdeskTeams: Array> } } } + } + + const helpdeskTeams = state.payload.helpdeskIntegrationInfo.helpdeskTeams + + logger.forBot().info(`Cached helpdesk teams: ${JSON.stringify(helpdeskTeams)}`) + + return { + helpdeskTeams, + } +} + +export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, client, input, logger }) => { + const { state } = (await client.getState({ + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + })) as { state: { payload: { helpdeskIntegrationInfo: { stages: Array> } } } } + + let stages = state.payload.helpdeskIntegrationInfo.stages + logger.forBot().info(`Cached stages: ${JSON.stringify(stages)}`) + + if (input && input.teamId) { + stages = state.payload.helpdeskIntegrationInfo.stages.filter((stage) => + stage.teamIds.includes(input.teamId) + ) as Array> + logger.forBot().info(`Filtered stages: ${JSON.stringify(stages)}`) + } + + return { stages } +} diff --git a/integrations/odoo-helpdesk/src/actions/index.ts b/integrations/odoo-helpdesk/src/actions/index.ts index 38828f15..1fade83a 100644 --- a/integrations/odoo-helpdesk/src/actions/index.ts +++ b/integrations/odoo-helpdesk/src/actions/index.ts @@ -1,8 +1,10 @@ import * as bp from '.botpress' -import * as tickets from './tickets' -import * as customers from './customers' +import * as ticketsActions from './tickets' +import * as customersActions from './customers' +import * as helpdeskActions from './helpdesk' export default { - ...tickets, - ...customers, -} satisfies bp.IntegrationProps['actions'] \ No newline at end of file + ...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 index 199e8b33..845139f5 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,41 +1,178 @@ import * as bp from '.botpress' -import { print } from 'src/utils' -import axios from 'axios' import { RuntimeError } from '@botpress/client' +import { executeOdooMethod, getAuthenticatedOdooClient } from 'src/services/odoo' + +export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, input, logger }) => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const { name, description, teamId, priority, stageId, customerId } = input + + const ticketPayload: Record = { + name, + description, + team_id: teamId, + priority, + partner_id: customerId, + ...(stageId ? { stage_id: stageId } : {}), + } + + const ticketId = (await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.ticket', + method: 'create', + args: [ticketPayload], + logger, + })) as number -export const createTicket: bp.Integration['actions']['createTicket'] = async (params) => { - print(`Creating ticket: ${JSON.stringify(params)}`) return { - odooTicket: undefined, + ticket: { + customerId, + name, + description, + teamId, + priority, + stageId, + id: ticketId, + }, } } -export const fetchTicket: bp.Integration['actions']['fetchTicket'] = async (params) => { - print(`Fetching ticket: ${JSON.stringify(params)}`) +export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input, logger }) => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const { id } = input + + const filters = [id] + const fields = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] + + const rawTicket = (await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.ticket', + method: 'read', + args: [filters, fields], + logger, + })) as Array> + + if (rawTicket.length === 0) { + throw new RuntimeError('Ticket not found') + } + if (rawTicket.length > 1) { + throw new RuntimeError('Multiple tickets found for the same id') + } + + // At this point, we've verified rawTicket has exactly one element + const ticket = rawTicket[0] + if (!ticket) { + throw new RuntimeError('Ticket not found') + } + return { - odooTicket: undefined, + ticket: { + customerId: ticket.partner_id, + id: ticket.id, + name: ticket.name, + description: ticket.description, + teamId: ticket.team_id, + priority: ticket.priority, + stageId: ticket.stage_id, + }, } } -export const fetchTickets: bp.Integration['actions']['fetchTickets'] = async (params) => { - print(`Fetching tickets: ${JSON.stringify(params)}`) +export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsByCustomerId'] = async ({ + ctx, + input, + logger, +}) => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const { customerId } = input + const filters: any[] = [['partner_id', '=', customerId]] + const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] + + const rawTickets = await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.ticket', + method: 'search_read', + args: [filters, fields], + logger, + }) return { - odooTickets: [], + tickets: rawTickets.map((ticket: any) => ({ + customerId: ticket.partner_id, + id: ticket.id, + name: ticket.name, + description: ticket.description, + teamId: ticket.team_id, + priority: ticket.priority, + stageId: ticket.stage_id, + })) as Array<{ + customerId: number + id: number + name: string + description: string + teamId: number + priority: string + stageId: number + }>, } } -export const updateTicket: bp.Integration['actions']['updateTicket'] = async (params) => { - print(`Updating ticket: ${JSON.stringify(params)}`) +export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicketsByCustomerEmail'] = async ({ + ctx, + input, + logger, +}) => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const { customerEmail } = input + const filters: any[] = [['partner_id.email', '=', customerEmail]] + const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] + const rawTickets = await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.ticket', + method: 'search_read', + args: [filters, fields], + logger, + }) return { - success: false, - error: 'Not implemented', + tickets: rawTickets.map((ticket: any) => ({ + customerId: ticket.partner_id, + id: ticket.id, + name: ticket.name, + description: ticket.description, + teamId: ticket.team_id, + priority: ticket.priority, + stageId: ticket.stage_id, + })) as Array<{ + customerId: number + id: number + name: string + description: string + teamId: number + priority: string + stageId: number + }>, } } -export const closeTicket: bp.Integration['actions']['closeTicket'] = async (params) => { - print(`Closing ticket: ${JSON.stringify(params)}`) +export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ ctx, input, logger }) => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const { ticket } = input + + const newTicketPayload: Record = { + name: input.name ?? ticket.name, + description: input.description ?? ticket.description, + team_id: input.teamId ?? ticket.teamId, + priority: input.priority ?? ticket.priority, + stage_id: input.stageId ?? ticket.stageId ?? null, + } + + const success = (await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.ticket', + method: 'write', + args: [[ticket.id], newTicketPayload], + logger, + })) as boolean + return { - success: false, - error: 'Not implemented', + success, } } diff --git a/integrations/odoo-helpdesk/src/handlers/index.ts b/integrations/odoo-helpdesk/src/handlers/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts new file mode 100644 index 00000000..3ee3c8a9 --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -0,0 +1,102 @@ +import axios, { AxiosInstance } from 'axios' +import * as bp from '.botpress' + +// Cache clients per configuration to avoid re-authenticating +const clientCache = new Map() + +export const getAuthenticatedOdooClient = 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}` + + // Return cached client if it exists + if (clientCache.has(cacheKey)) { + return clientCache.get(cacheKey)! + } + + // Create new axios instance for this configuration + const odooClient = axios.create({ + baseURL: odooApiUrl, + withCredentials: true, // Automatically handles cookies + }) + + // Authenticate + const response = await odooClient.post('/web/session/authenticate', { + jsonrpc: '2.0', + params: { + db: odooDb, + login: odooEmail, + password: odooPassword, + }, + }) + + if (!response.data.result?.uid) { + throw new Error('Authentication failed') + } + + logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) + + // Cache the authenticated client + clientCache.set(cacheKey, odooClient) + + return odooClient +} + +export const executeOdooMethod = async ({ + client, + model, + method, + args, + kwargs, + logger, +}: { + client: AxiosInstance + model: 'helpdesk.ticket' | 'helpdesk.stage' | 'helpdesk.team' | 'res.partner' + method: 'create' | 'read' | 'write' | 'search' | 'search_read' + args?: any[] + kwargs?: Record + logger: bp.Logger +}): Promise => { + logger + .forBot() + .info( + `Executing Odoo method: ${method} on model: ${model} with args: ${JSON.stringify(args)} and kwargs: ${JSON.stringify(kwargs)}` + ) + + const response = await client.post('/web/dataset/call_kw', { + jsonrpc: '2.0', + method: 'call', + params: { + model, + method, + args: args ?? [], + kwargs: kwargs ?? {}, + }, + id: Math.floor(Date.now() / 1000), + }) + + logger.forBot().info(`Odoo method: ${method} on model: ${model} executed successfully`) + + if (response.data.error) { + throw new Error(`Odoo API error: ${JSON.stringify(response.data.error)}`) + } + + logger + .forBot() + .info( + `Odoo method: ${method} on model: ${model} executed successfully with result: ${JSON.stringify(response.data.result)}` + ) + + return response.data.result +} diff --git a/integrations/odoo-helpdesk/src/setup/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts new file mode 100644 index 00000000..8d1eb774 --- /dev/null +++ b/integrations/odoo-helpdesk/src/setup/helpdesk.ts @@ -0,0 +1,72 @@ +import * as bp from '.botpress' +import { z } from '@botpress/sdk' +import { executeOdooMethod, getAuthenticatedOdooClient } from 'src/services/odoo' +import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' + +// Botpress action handlers +export const getHelpdeskTeams = async ({ + ctx, + logger, +}: { + ctx: bp.Context + logger: bp.Logger +}): Promise<{ helpdeskTeams: Array> }> => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + + const filters: any[] = [['active', '=', true]] + const fields: string[] = ['name', 'id'] + + const rawOdooHelpdeskTeams = await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.team', + method: 'search_read', + args: [filters, fields], + logger, + }) + + const helpdeskTeams = rawOdooHelpdeskTeams.map((team: any) => ({ + name: team.name as string, + id: team.id as number, + })) as Array> + + return { + helpdeskTeams, + } +} + +export const getStages = async ({ + ctx, + input, + logger, +}: { + ctx: bp.Context + input: { teamIds: number[] } + logger: bp.Logger +}): Promise<{ stages: Array> }> => { + const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + + const teamIds = input.teamIds + const filters: any[] = [['active', '=', true]] + if (teamIds) { + filters.push(['team_id', 'in', teamIds]) + } + + const fields: string[] = ['name', 'id', 'team_ids'] + + const rawOdooStages = await executeOdooMethod({ + client: odooClient, + model: 'helpdesk.stage', + method: 'search_read', + args: [filters, fields], + logger, + }) + const stages = rawOdooStages.map((stage: any) => ({ + name: stage.name as string, + id: stage.id as number, + teamIds: stage.team_ids as number[], + })) as Array> + + return { + stages, + } +} diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index f1045763..e5b8cc39 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -1,7 +1,20 @@ import * as bp from '.botpress' -import { print } from 'src/utils' +import { getHelpdeskTeams, getStages } from 'src/setup/helpdesk' -export const register: bp.IntegrationProps['register'] = async (params) => { - print(`Registering Odoo Helpdesk Integration...`) - print(`Params: ${JSON.stringify(params)}`) -} \ No newline at end of file +export const register: bp.IntegrationProps['register'] = async ({ ctx, client, logger }) => { + logger.forBot().info(`Registering Odoo Helpdesk Integration...`) + + // Get the Odoo helpdesk teams and ticket stages + const { helpdeskTeams } = await getHelpdeskTeams({ ctx, logger }) + const { stages } = await getStages({ ctx, input: { teamIds: helpdeskTeams.map((team) => team.id) }, logger }) + + // Store ticket stages in integration state + await client.getOrSetState({ + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, + }) + + logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) +} diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index 820db014..f84c6623 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -1,7 +1,6 @@ import * as bp from '.botpress' -import { print } from 'src/utils' -export const unregister: bp.IntegrationProps['unregister'] = async (params) => { - print(`Unregistering Odoo Helpdesk Integration...`) - print(`Params: ${JSON.stringify(params)}`) -} \ No newline at end of file +export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => { + logger.forBot().info(`Unregistering Odoo Helpdesk Integration...`) + logger.forBot().info(`Odoo Helpdesk Integration unregistered successfully`) +} diff --git a/integrations/odoo-helpdesk/src/utils/debug.ts b/integrations/odoo-helpdesk/src/utils/debug.ts deleted file mode 100644 index 2d8d9a77..00000000 --- a/integrations/odoo-helpdesk/src/utils/debug.ts +++ /dev/null @@ -1,7 +0,0 @@ -import axios from 'axios' - -const debugUrl = process.env.DEBUG_URL -export const print = async (message: string) => { - if (!debugUrl) {throw new Error('DEBUG_URL is not set')} - return axios.post(debugUrl, { message }) -} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/utils/index.ts b/integrations/odoo-helpdesk/src/utils/index.ts deleted file mode 100644 index e555ba7a..00000000 --- a/integrations/odoo-helpdesk/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { print } from './debug' \ No newline at end of file From 7e42becf017c47699bbad8257f5a8d07e7fb68d1 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Thu, 15 Jan 2026 14:35:53 -0500 Subject: [PATCH 03/29] Bug: Force to requthenticate --- .../odoo-helpdesk/integration.definition.ts | 2 +- integrations/odoo-helpdesk/package.json | 3 +- .../odoo-helpdesk/src/services/odoo.ts | 16 +++++-- .../odoo-helpdesk/src/setup/register.ts | 8 +++- .../odoo-helpdesk/version-bump-and-deploy.js | 44 +++++++++++++++++++ 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100755 integrations/odoo-helpdesk/version-bump-and-deploy.js diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index f740829b..6f1477e0 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.4', + version: '0.1.17', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/package.json b/integrations/odoo-helpdesk/package.json index 050ed6c0..ec899abc 100644 --- a/integrations/odoo-helpdesk/package.json +++ b/integrations/odoo-helpdesk/package.json @@ -4,7 +4,8 @@ "scripts": { "check:type": "tsc --noEmit", "build": "bp add -y && bp build", - "deploy": "bp deploy -y" + "deploy": "bp deploy -y", + "version-bump-and-deploy": "node version-bump-and-deploy.js" }, "private": true, "dependencies": { diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 3ee3c8a9..52a4d439 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance } from 'axios' +import axios, { AxiosInstance, AxiosResponse } from 'axios' import * as bp from '.botpress' // Cache clients per configuration to avoid re-authenticating @@ -39,10 +39,20 @@ export const getAuthenticatedOdooClient = async ({ login: odooEmail, password: odooPassword, }, - }) + id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance + }) as AxiosResponse<{ result: { uid: number }, error?: { message: string } }> + logger.forBot().info(`Authentication response: ${JSON.stringify(response.data)}`) + + // Check for errors first + if (response.data?.error) { + logger.forBot().error(`Authentication error: ${JSON.stringify(response.data.error)}`) + throw new Error(`Authentication failed: ${JSON.stringify(response.data.error)}`) + } + // Then check for uid if (!response.data.result?.uid) { - throw new Error('Authentication failed') + logger.forBot().error(`Authentication failed - no uid in response: ${JSON.stringify(response.data)}`) + throw new Error('Authentication failed - no uid in response') } logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index e5b8cc39..057e1b00 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -1,7 +1,9 @@ import * as bp from '.botpress' import { getHelpdeskTeams, getStages } from 'src/setup/helpdesk' +import { RuntimeError } from '@botpress/sdk' export const register: bp.IntegrationProps['register'] = async ({ ctx, client, logger }) => { + try { logger.forBot().info(`Registering Odoo Helpdesk Integration...`) // Get the Odoo helpdesk teams and ticket stages @@ -16,5 +18,9 @@ export const register: bp.IntegrationProps['register'] = async ({ ctx, client, l payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, }) - logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) + 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/version-bump-and-deploy.js b/integrations/odoo-helpdesk/version-bump-and-deploy.js new file mode 100755 index 00000000..4939d703 --- /dev/null +++ b/integrations/odoo-helpdesk/version-bump-and-deploy.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const definitionFile = path.join(__dirname, 'integration.definition.ts'); + +// Read the file +let content = fs.readFileSync(definitionFile, 'utf8'); + +// Find and increment the version +// Matches version: 'x.x.x' or version: "x.x.x" +const versionRegex = /version:\s*['"]([\d]+)\.([\d]+)\.([\d]+)['"]/; +const match = content.match(versionRegex); + +if (!match) { + console.error('Could not find version in integration.definition.ts'); + process.exit(1); +} + +const major = parseInt(match[1]); +const minor = parseInt(match[2]); +const patch = parseInt(match[3]); + +const newVersion = `${major}.${minor}.${patch + 1}`; + +// Replace the version +content = content.replace(versionRegex, `version: '${newVersion}'`); + +// Write the file back +fs.writeFileSync(definitionFile, content, 'utf8'); + +console.log(`Version updated from ${match[1]}.${match[2]}.${match[3]} to ${newVersion}`); + +// Run bp deploy -y +console.log('Running bp deploy -y...'); +try { + execSync('bp deploy -y', { stdio: 'inherit', cwd: __dirname }); + console.log('Deployment completed successfully!'); +} catch (error) { + console.error('Deployment failed:', error.message); + process.exit(1); +} From bd436c47998572afe328beadbc120625b961dcd3 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Thu, 15 Jan 2026 15:07:51 -0500 Subject: [PATCH 04/29] Managed to get the helpdesk teams and keep session alive --- .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/actions/tickets.ts | 27 ++-- .../odoo-helpdesk/src/services/odoo.ts | 126 ++++++++++++------ .../odoo-helpdesk/src/setup/helpdesk.ts | 13 +- .../odoo-helpdesk/src/setup/register.ts | 28 ++-- 5 files changed, 129 insertions(+), 67 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 6f1477e0..17f64fdb 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.17', + version: '0.1.22', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 845139f5..7844eee3 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,9 +1,9 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' -import { executeOdooMethod, getAuthenticatedOdooClient } from 'src/services/odoo' +import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, input, logger }) => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const { name, description, teamId, priority, stageId, customerId } = input const ticketPayload: Record = { @@ -16,7 +16,8 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ } const ticketId = (await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.ticket', method: 'create', args: [ticketPayload], @@ -37,14 +38,15 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ } export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input, logger }) => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const { id } = input const filters = [id] const fields = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] const rawTicket = (await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.ticket', method: 'read', args: [filters, fields], @@ -82,13 +84,14 @@ export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsBy input, logger, }) => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const { customerId } = input const filters: any[] = [['partner_id', '=', customerId]] const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] const rawTickets = await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.ticket', method: 'search_read', args: [filters, fields], @@ -120,12 +123,13 @@ export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicket input, logger, }) => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const { customerEmail } = input const filters: any[] = [['partner_id.email', '=', customerEmail]] const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] const rawTickets = await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.ticket', method: 'search_read', args: [filters, fields], @@ -153,7 +157,7 @@ export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicket } export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ ctx, input, logger }) => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const { ticket } = input const newTicketPayload: Record = { @@ -165,7 +169,8 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ } const success = (await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.ticket', method: 'write', args: [[ticket.id], newTicketPayload], diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 52a4d439..2c96eb76 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -1,10 +1,36 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios' +import axios, { AxiosResponse } from 'axios' import * as bp from '.botpress' -// Cache clients per configuration to avoid re-authenticating -const clientCache = new Map() +// Cache cookies per configuration to avoid re-authenticating +const cookieCache = new Map() + +/** + * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string + */ +const extractCookies = (headers: Record): string => { + // Axios normalizes headers to lowercase + const setCookieHeaders = headers['set-cookie'] || 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 getAuthenticatedOdooClient = async ({ +export const getAuthenticatedCookie = async ({ odooApiUrl, odooDb, odooEmail, @@ -16,31 +42,36 @@ export const getAuthenticatedOdooClient = async ({ odooEmail: string odooPassword: string logger: bp.Logger -}): Promise => { +}): Promise => { // Create a cache key from configuration const cacheKey = `${odooApiUrl}-${odooDb}-${odooEmail}` - // Return cached client if it exists - if (clientCache.has(cacheKey)) { - return clientCache.get(cacheKey)! + // Return cached cookie if it exists + if (cookieCache.has(cacheKey)) { + logger.forBot().info(`Returning cached Odoo authentication cookie for: ${cacheKey}`) + return cookieCache.get(cacheKey)! } - // Create new axios instance for this configuration - const odooClient = axios.create({ - baseURL: odooApiUrl, - withCredentials: true, // Automatically handles cookies - }) - - // Authenticate - const response = await odooClient.post('/web/session/authenticate', { - jsonrpc: '2.0', - params: { - db: odooDb, - login: odooEmail, - password: odooPassword, + // Authenticate using axios.post directly + logger.forBot().info(`Authenticating with Odoo: ${odooApiUrl}`) + const response = await axios.post( + `${odooApiUrl}/web/session/authenticate`, + { + jsonrpc: '2.0', + params: { + db: odooDb, + login: odooEmail, + password: odooPassword, + }, + id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance }, - id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance - }) as AxiosResponse<{ result: { uid: number }, error?: { message: string } }> + { + headers: { + 'Content-Type': 'application/json', + }, + } + ) as AxiosResponse<{ result: { uid: number }, error?: { message: string } }> + logger.forBot().info(`Authentication response: ${JSON.stringify(response.data)}`) // Check for errors first @@ -55,23 +86,33 @@ export const getAuthenticatedOdooClient = async ({ throw new Error('Authentication failed - no uid in response') } + // Extract cookies from response headers + const cookie = extractCookies(response.headers) + if (!cookie) { + logger.forBot().warn('No cookies found in authentication response') + } + logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) - // Cache the authenticated client - clientCache.set(cacheKey, odooClient) + // Cache the cookie + cookieCache.set(cacheKey, cookie) + + logger.forBot().info(`Odoo authentication cookie cached for: ${cacheKey}`) - return odooClient + return cookie } export const executeOdooMethod = async ({ - client, + odooApiUrl, + cookie, model, method, args, kwargs, logger, }: { - client: AxiosInstance + odooApiUrl: string + cookie: string model: 'helpdesk.ticket' | 'helpdesk.stage' | 'helpdesk.team' | 'res.partner' method: 'create' | 'read' | 'write' | 'search' | 'search_read' args?: any[] @@ -84,17 +125,26 @@ export const executeOdooMethod = async ({ `Executing Odoo method: ${method} on model: ${model} with args: ${JSON.stringify(args)} and kwargs: ${JSON.stringify(kwargs)}` ) - const response = await client.post('/web/dataset/call_kw', { - jsonrpc: '2.0', - method: 'call', - params: { - model, - method, - args: args ?? [], - kwargs: kwargs ?? {}, + const response = await axios.post( + `${odooApiUrl}/web/dataset/call_kw`, + { + jsonrpc: '2.0', + method: 'call', + params: { + model, + method, + args: args ?? [], + kwargs: kwargs ?? {}, + }, + id: Math.floor(Date.now() / 1000), }, - id: Math.floor(Date.now() / 1000), - }) + { + headers: { + 'Content-Type': 'application/json', + Cookie: cookie, + }, + } + ) logger.forBot().info(`Odoo method: ${method} on model: ${model} executed successfully`) diff --git a/integrations/odoo-helpdesk/src/setup/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts index 8d1eb774..91df9e9b 100644 --- a/integrations/odoo-helpdesk/src/setup/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/setup/helpdesk.ts @@ -1,6 +1,6 @@ import * as bp from '.botpress' import { z } from '@botpress/sdk' -import { executeOdooMethod, getAuthenticatedOdooClient } from 'src/services/odoo' +import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' // Botpress action handlers @@ -11,13 +11,15 @@ export const getHelpdeskTeams = async ({ ctx: bp.Context logger: bp.Logger }): Promise<{ helpdeskTeams: Array> }> => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + logger.forBot().info(`Odoo authentication cookie obtained successfully`) const filters: any[] = [['active', '=', true]] const fields: string[] = ['name', 'id'] const rawOdooHelpdeskTeams = await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.team', method: 'search_read', args: [filters, fields], @@ -43,7 +45,7 @@ export const getStages = async ({ input: { teamIds: number[] } logger: bp.Logger }): Promise<{ stages: Array> }> => { - const odooClient = await getAuthenticatedOdooClient({ ...ctx.configuration, logger }) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const teamIds = input.teamIds const filters: any[] = [['active', '=', true]] @@ -54,7 +56,8 @@ export const getStages = async ({ const fields: string[] = ['name', 'id', 'team_ids'] const rawOdooStages = await executeOdooMethod({ - client: odooClient, + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, model: 'helpdesk.stage', method: 'search_read', args: [filters, fields], diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index 057e1b00..e5457394 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -4,23 +4,27 @@ import { RuntimeError } from '@botpress/sdk' export const register: bp.IntegrationProps['register'] = async ({ ctx, client, logger }) => { try { - logger.forBot().info(`Registering Odoo Helpdesk Integration...`) + logger.forBot().info(`Registering Odoo Helpdesk Integration...`) - // Get the Odoo helpdesk teams and ticket stages - const { helpdeskTeams } = await getHelpdeskTeams({ ctx, logger }) - const { stages } = await getStages({ ctx, input: { teamIds: helpdeskTeams.map((team) => team.id) }, logger }) + // Get the Odoo helpdesk teams and ticket stages + const { helpdeskTeams } = await getHelpdeskTeams({ ctx, logger }) + logger.forBot().info(`Odoo helpdesk teams: ${JSON.stringify(helpdeskTeams)}`) + const { stages } = await getStages({ ctx, input: { teamIds: helpdeskTeams.map((team) => team.id) }, logger }) + logger.forBot().info(`Odoo ticket stages: ${JSON.stringify(stages)}`) - // Store ticket stages in integration state - await client.getOrSetState({ - type: 'integration', - name: 'helpdeskIntegrationInfo', - id: ctx.integrationId, - payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, - }) + // Store ticket stages in integration state + await client.getOrSetState({ + 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'}`) + throw new RuntimeError( + `Failed to register Odoo Helpdesk Integration: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } } From be824156053322c8272b3c3c2ced2fcb1afd4e1e Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Thu, 15 Jan 2026 15:15:54 -0500 Subject: [PATCH 05/29] Feat: Odoo Registration and Helpdesk Teams and Stages --- .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/services/odoo.ts | 43 +++++++++---------- .../odoo-helpdesk/src/setup/helpdesk.ts | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 17f64fdb..a7e30b48 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.22', + version: '0.1.24', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 2c96eb76..1fc76095 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -10,14 +10,14 @@ const cookieCache = new Map() const extractCookies = (headers: Record): string => { // Axios normalizes headers to lowercase const setCookieHeaders = headers['set-cookie'] || 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" @@ -125,37 +125,34 @@ export const executeOdooMethod = async ({ `Executing Odoo method: ${method} on model: ${model} with args: ${JSON.stringify(args)} and kwargs: ${JSON.stringify(kwargs)}` ) - const response = await axios.post( - `${odooApiUrl}/web/dataset/call_kw`, - { - jsonrpc: '2.0', - method: 'call', - params: { - model, - method, - args: args ?? [], - kwargs: kwargs ?? {}, - }, - id: Math.floor(Date.now() / 1000), + const url = `${odooApiUrl}/web/dataset/call_kw` + const body = { + jsonrpc: '2.0', + params: { + model, + method, + args: args ?? [], + kwargs: kwargs ?? {}, }, - { - headers: { - 'Content-Type': 'application/json', - Cookie: cookie, - }, - } - ) + } + const headers = { + 'Content-Type': 'application/json', + Cookie: cookie, + } + logger.forBot().info(`Odoo method: ${method} on model: ${model} executing with URL: ${url} and body: ${JSON.stringify(body)} and headers: ${JSON.stringify(headers)}`) + const response = await axios.post(url, body, { headers }) - logger.forBot().info(`Odoo method: ${method} on model: ${model} executed successfully`) + logger.forBot().info(`Odoo Request response data: ${JSON.stringify(response.data)}`) if (response.data.error) { + logger.forBot().error(`Odoo API error: ${JSON.stringify(response.data.error)}`) throw new Error(`Odoo API error: ${JSON.stringify(response.data.error)}`) } logger .forBot() .info( - `Odoo method: ${method} on model: ${model} executed successfully with result: ${JSON.stringify(response.data.result)}` + `Odoo Request response data result: ${JSON.stringify(response.data.result)}` ) return response.data.result diff --git a/integrations/odoo-helpdesk/src/setup/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts index 91df9e9b..9b38b83b 100644 --- a/integrations/odoo-helpdesk/src/setup/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/setup/helpdesk.ts @@ -50,7 +50,7 @@ export const getStages = async ({ const teamIds = input.teamIds const filters: any[] = [['active', '=', true]] if (teamIds) { - filters.push(['team_id', 'in', teamIds]) + filters.push(['team_ids', 'in', teamIds]) } const fields: string[] = ['name', 'id', 'team_ids'] From 80e2f343f5348cdbbbb0199a71f2d446790e2233 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Thu, 15 Jan 2026 17:31:57 -0500 Subject: [PATCH 06/29] Creating customers --- .../definitions/actions/customers.ts | 30 ++-- .../definitions/schemas/customer.ts | 7 +- .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/actions/customers.ts | 128 ++++++++++++++++-- 4 files changed, 146 insertions(+), 21 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 86ec19c3..ccc1d2ff 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -6,11 +6,9 @@ export const createCustomer: ActionDefinition = { description: 'Create a new customer', input: { schema: z.object({ - bpId: z.number().title('BP ID').describe('The id of the customer'), - odooId: z.number().title('Odoo ID').describe('The Odoo ID of the customer').optional(), + id: z.string().title('ID').describe('The id of the customer'), email: z.string().title('Email').describe('The email of the customer'), - firstName: z.string().title('First Name').describe('The first name of the customer').optional(), - lastName: z.string().title('Last Name').describe('The last name of the customer').optional(), + name: z.string().title('Name').describe('The name of the customer').optional(), phone: z.string().title('Phone').describe('The phone of the customer').optional(), }), }, @@ -21,12 +19,27 @@ export const createCustomer: ActionDefinition = { }, } -export const fetchCustomer: ActionDefinition = { - title: 'Fetch Customer', +export const fetchCustomerById: ActionDefinition = { + title: 'Fetch Customer By ID', description: 'Fetch a customer by id', input: { schema: z.object({ - customerId: z.number().title('Customer ID').describe('The id of the customer to fetch'), + 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 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'), }), }, output: { @@ -54,6 +67,7 @@ export const updateCustomer: ActionDefinition = { export const actions = { createCustomer, - fetchCustomer, + fetchCustomerById, + fetchCustomerByEmail, updateCustomer, } as const \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index 943b6f03..d91244f7 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -1,10 +1,9 @@ import { z } from '@botpress/sdk' export const customerSchema = z.object({ - bpId: z.number().describe('The botpress ID of the customer'), + id: z.string().describe('The id of the customer'), + odooId: z.string().describe('The Odoo ID of the customer').optional(), email: z.string().describe('The email of the customer'), - odooId: z.number().describe('The Odoo ID of the customer').optional(), - firstName: z.string().describe('The first name of the customer').optional(), - lastName: z.string().describe('The last name of the customer').optional(), + name: z.string().describe('The name of the customer').optional(), phone: z.string().describe('The phone of the customer').optional(), }) \ No newline at end of file diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index a7e30b48..5e9c5035 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.24', + version: '0.1.28', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 8dd35628..41340b2b 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,27 +1,139 @@ import * as bp from '.botpress' -import axios from 'axios' import { RuntimeError } from '@botpress/client' +import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' +import { customerSchema } from 'definitions/schemas/customer' +import { z } from '@botpress/sdk' export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ ctx, input, logger }) => { - logger.forBot().info(`Creating customer: ${JSON.stringify(input)}`) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + const { id, email, name, phone } = input + + const customerPayload: Record = { + email, + phone, + name, + } + + const odooId = (await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'create', + args: [customerPayload], + logger, + })) as number + return { - customer: undefined, + customer: { + id, + odooId: odooId.toString(), + email, + name, + phone, + }, } } -export const fetchCustomer: bp.Integration['actions']['fetchCustomer'] = async ({ ctx, input, logger }) => { - logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) +const fetchCustomer = async ({ + ctx, + input, + logger, +}: { + ctx: bp.Context + input: { id?: string; email?: string } + logger: bp.Logger +}): Promise<{ customer: z.infer }> => { + const { id, email } = input + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + const fields = ['id', 'email', 'name', 'phone'] + + let rawCustomer: Array> + if (email) { + rawCustomer = (await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'search_read', + args: [[['email', '=', email]], fields], + logger, + })) as Array> + } else if (id) { + rawCustomer = (await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'read', + args: [[id], fields], + logger, + })) as Array> + } else { + throw new RuntimeError('Must provide an id or email to fetch a customer') + } + + if (rawCustomer.length === 0 || !rawCustomer[0]) { + throw new RuntimeError('Customer not found') + } + if (rawCustomer.length > 1) { + throw new RuntimeError('Multiple customers found for the same id') + } + + const customer = rawCustomer[0] + return { - customer: undefined, + customer: { + id, + odooId: customer.id.toString(), + email: customer.email, + name: customer.name, + phone: customer.phone, + } as z.infer, } } +export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = async ({ + ctx, + input, + logger, +}) => { + logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) + return fetchCustomer({ ctx, input, logger }) +} +export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEmail'] = async ({ + ctx, + input, + logger, +}) => { + logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) + return fetchCustomer({ ctx, input, logger }) +} export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async ({ ctx, input, logger }) => { logger.forBot().info(`Updating customer: ${JSON.stringify(input)}`) + const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + const { customer } = input + const { id, email, name, phone } = customer + + const customerPayload: Record = { + email, + phone, + name, + } + + // Convert string id to number for Odoo write operation + const odooIdNumber = parseInt(id, 10) + if (isNaN(odooIdNumber)) { + throw new RuntimeError(`Invalid customer ID: ${id}`) + } + return { - success: false, - error: 'Not implemented', + success: (await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'write', + args: [[odooIdNumber], customerPayload], + logger, + })) as boolean, } } From 9e78264bc3e7c228a7613c8f3481f743c466e98f Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Fri, 16 Jan 2026 15:33:50 -0500 Subject: [PATCH 07/29] Update Odoo Helpdesk integration: bump package manager version, enhance customer and ticket actions with new schemas, and improve state management for customer ID mapping. --- .../definitions/actions/customers.ts | 46 +++- .../definitions/actions/tickets.ts | 13 +- .../definitions/schemas/customer.ts | 12 +- .../definitions/schemas/helpdesk-team.ts | 10 +- .../definitions/schemas/index.ts | 28 ++- .../definitions/schemas/priority.ts | 14 ++ .../definitions/schemas/stage.ts | 11 +- .../definitions/schemas/ticket.ts | 22 +- .../odoo-helpdesk/definitions/states.ts | 11 + .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/actions/customers.ts | 196 ++++++++++++----- .../odoo-helpdesk/src/actions/tickets.ts | 205 +++++++++--------- package.json | 2 +- 13 files changed, 395 insertions(+), 177 deletions(-) create mode 100644 integrations/odoo-helpdesk/definitions/schemas/priority.ts diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index ccc1d2ff..610c3872 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -34,11 +34,28 @@ export const fetchCustomerById: ActionDefinition = { }, } +export const fetchCustomerByOdooId: ActionDefinition = { + title: 'Fetch Customer By Odoo ID', + description: 'Fetch a customer by odoo id', + input: { + schema: z.object({ + id: z.string().title('ID').describe('The id of the customer to fetch'), + odooId: z.string().title('Odoo ID').describe('The odoo id of the customer to fetch'), + }), + }, + 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({ + id: z.string().title('ID').describe('The id of the customer to fetch'), email: z.string().title('Email').describe('The email of the customer to fetch'), }), }, @@ -49,12 +66,33 @@ export const fetchCustomerByEmail: ActionDefinition = { }, } -export const updateCustomer: ActionDefinition = { +export const updateCustomerById: ActionDefinition = { title: 'Update Customer', description: 'Update a customer by id', input: { schema: z.object({ - customer: customerSchema.title('Customer').describe('The customer to update'), + 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'), + error: z.string().title('Error').describe('The error message if the update failed').optional(), + }), + }, +} + +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: { @@ -69,5 +107,7 @@ export const actions = { createCustomer, fetchCustomerById, fetchCustomerByEmail, - updateCustomer, + fetchCustomerByOdooId, + updateCustomerById, + updateCustomerByEmail, } as const \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index e3d40bf5..3abbabe3 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -9,9 +9,9 @@ export const createTicket: ActionDefinition = { 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().title('Team ID').describe('The helpdesk team ID associated with the ticket'), - priority: z.string().title('Priority').describe('The priority of the ticket'), - customerId: z.number().title('Customer ID').describe('The customer ID associated with the ticket'), - stageId: z.number().title('Stage ID').describe('The stage ID associated with the ticket').optional(), + priority: z.number().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().title('Stage ID').describe('The stage ID associated with the ticket'), }), }, output: { @@ -41,7 +41,7 @@ export const fetchTicketsByCustomerId: ActionDefinition = { description: 'Fetch all tickets by customer', input: { schema: z.object({ - customerId: z.number().title('Customer ID').describe('The id of the customer'), + customerOdooId: z.number().title('Customer Odoo ID').describe('The Odoo customer ID associated with the ticket'), }), }, output: { @@ -71,12 +71,13 @@ export const updateTicket: ActionDefinition = { description: 'Update a ticket by id', input: { schema: z.object({ - ticket: ticketSchema.title('Ticket').describe('The ticket to update'), + 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().title('Team ID').describe('The helpdesk team ID associated with the ticket').optional(), - priority: z.string().title('Priority').describe('The priority of the ticket').optional(), + priority: z.number().title('Priority').describe('The priority of the ticket (0 is the lowest priority)').optional(), stageId: z.number().title('Stage ID').describe('The stage ID associated with the ticket').optional(), + customerOdooId: z.number().title('Customer Odoo ID').describe('The Odoo customer ID associated with the ticket').optional(), }), }, output: { diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index d91244f7..9a2aa65c 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -6,4 +6,14 @@ export const customerSchema = z.object({ email: z.string().describe('The email of the customer'), name: z.string().describe('The name of the customer').optional(), phone: z.string().describe('The phone of the customer').optional(), -}) \ No newline at end of file +}) + +export type Customer = z.infer + +const customerPayloadSchema = z.object({ + email: z.string().describe('The email of the customer'), + phone: z.string().describe('The phone of the customer').optional(), + name: z.string().describe('The name of the customer').optional(), +}) + +export type CustomerPayload = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts index 50bc8ffa..7b6bae57 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts @@ -3,4 +3,12 @@ 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'), -}) \ No newline at end of file +}) + +export type HelpdeskTeam = z.infer + +const helpdeskTeamPayloadSchema = z.object({ + name: z.string().describe('The name of the helpdesk team'), +}) + +export type HelpdeskTeamPayload = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts index 03258fea..f7dcfbc6 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/index.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -1,6 +1,24 @@ -import { customerSchema } from './customer' -import { ticketSchema } from './ticket' -import { helpdeskTeamSchema } from './helpdesk-team' -import { stageSchema } from './stage' +import { customerSchema, Customer, CustomerPayload } from './customer' +import { helpdeskTeamSchema, HelpdeskTeam, HelpdeskTeamPayload } from './helpdesk-team' +import { prioritySchema, Priority, PriorityPayload } from './priority' +import { stageSchema, Stage, StagePayload } from './stage' +import { ticketSchema, Ticket, TicketPayload, TicketResponse } from './ticket' -export { customerSchema, ticketSchema, helpdeskTeamSchema, stageSchema } \ No newline at end of file +export { + customerSchema, + helpdeskTeamSchema, + prioritySchema, + stageSchema, + ticketSchema, + Customer, + CustomerPayload, + HelpdeskTeam, + HelpdeskTeamPayload, + Priority, + PriorityPayload, + Stage, + StagePayload, + Ticket, + TicketPayload, + TicketResponse, +} diff --git a/integrations/odoo-helpdesk/definitions/schemas/priority.ts b/integrations/odoo-helpdesk/definitions/schemas/priority.ts new file mode 100644 index 00000000..ce2bf642 --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/priority.ts @@ -0,0 +1,14 @@ +import { z } from '@botpress/sdk' + +export const prioritySchema = z.object({ + name: z.string().describe('The name of the priority'), + id: z.number().describe('The id of the priority'), +}) + +export type Priority = z.infer + +const priorityPayloadSchema = z.object({ + id: z.number().describe('The id of the priority'), +}) + +export type PriorityPayload = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/stage.ts b/integrations/odoo-helpdesk/definitions/schemas/stage.ts index bd11d57a..c61fd545 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/stage.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/stage.ts @@ -4,4 +4,13 @@ export const stageSchema = z.object({ name: z.string().describe('The name of the stage'), id: z.number().describe('The id of the stage'), teamIds: z.array(z.number()).describe('The ids of the helpdesk teams that the stage belongs to'), -}) \ No newline at end of file +}) + +export type Stage = z.infer + +const stagePayloadSchema = z.object({ + 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 StagePayload = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts index 9c614a8f..10f3c786 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -1,11 +1,27 @@ import { z } from '@botpress/sdk' export const ticketSchema = z.object({ - customerId: z.number().describe('The customer ID associated with the ticket'), + customerOdooId: z.number().describe('The Odoo customer ID associated with the ticket'), 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').optional(), + priority: z.number().describe('The priority of the ticket').optional(), stageId: z.number().describe('The stage ID associated with the ticket').optional(), -}) \ No newline at end of file +}) +export type Ticket = z.infer + +const ticketPayloadSchema = 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'), + priority: z.number().describe('The priority of the ticket').optional(), + partner_id: z.number().describe('The customer ID associated with the ticket'), + stage_id: z.number().describe('The stage ID associated with the ticket').optional(), +}) +export type TicketPayload = z.infer + +export const ticketResponseSchema = ticketPayloadSchema.extend({ + id: z.number().describe('The ticket ID'), +}) +export type TicketResponse = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/definitions/states.ts b/integrations/odoo-helpdesk/definitions/states.ts index 3215e443..42caef09 100644 --- a/integrations/odoo-helpdesk/definitions/states.ts +++ b/integrations/odoo-helpdesk/definitions/states.ts @@ -14,6 +14,17 @@ const helpdeskIntegrationInfo = { }), } +const customerIdMapping = { + type: 'integration' as const, + schema: z.object({ + customerIdMapping: z + .record(z.string(), z.string()) + .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/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 5e9c5035..4d6acd17 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.28', + version: '0.1.30', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 41340b2b..77acc620 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,10 +1,12 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { customerSchema } from 'definitions/schemas/customer' +import { customerSchema, Customer } from 'definitions/schemas' import { z } from '@botpress/sdk' -export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ ctx, input, logger }) => { +export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ + ctx, client, input, logger +}) => { logger.forBot().info(`Creating customer: ${JSON.stringify(input)}`) const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) @@ -23,68 +25,75 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async method: 'create', args: [customerPayload], logger, - })) as number + })) as Customer['odooId'] + + // Store the mapping of bp id to odoo id + const { state } = (await client.getOrSetState({ + type: 'integration', + name: 'customerIdMapping', + id: ctx.integrationId, + payload: { customerIdMapping: {} }, + } as any)) as unknown as { + state: { payload: { customerIdMapping: Record } } + } + + const mapping = state.payload?.customerIdMapping || {} + if (odooId) { + mapping[id] = odooId.toString() + } + + await client.setState({ + type: 'integration', + name: 'customerIdMapping', + id: ctx.integrationId, + payload: { customerIdMapping: mapping }, + } as any) return { customer: { id, - odooId: odooId.toString(), + odooId, email, name, phone, - }, + } as Customer } } const fetchCustomer = async ({ ctx, - input, + input: { id, odooId, email }, logger, }: { ctx: bp.Context - input: { id?: string; email?: string } + input: { id: string; odooId?: string; email?: string } logger: bp.Logger }): Promise<{ customer: z.infer }> => { - const { id, email } = input const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const fields = ['id', 'email', 'name', 'phone'] + const filters: any[] = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] - let rawCustomer: Array> - if (email) { - rawCustomer = (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'search_read', - args: [[['email', '=', email]], fields], - logger, - })) as Array> - } else if (id) { - rawCustomer = (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'read', - args: [[id], fields], - logger, - })) as Array> - } else { - throw new RuntimeError('Must provide an id or email to fetch a customer') - } + let rawCustomer: Array> = await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'search_read', + args: [filters, fields], + logger, + }) as Array - if (rawCustomer.length === 0 || !rawCustomer[0]) { + if (rawCustomer.length === 0 || !rawCustomer[0]) throw new RuntimeError('Customer not found') - } - if (rawCustomer.length > 1) { + + if (rawCustomer.length > 1) throw new RuntimeError('Multiple customers found for the same id') - } - const customer = rawCustomer[0] + const customer = rawCustomer[0] as Customer return { customer: { id, - odooId: customer.id.toString(), + odooId: customer.id, email: customer.email, name: customer.name, phone: customer.phone, @@ -92,40 +101,116 @@ const fetchCustomer = async ({ } } export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = async ({ + ctx, + client, + input, + logger, +}) => { + logger.forBot().info(`Fetching customer by id: ${JSON.stringify(input)}`) + + // Look up the odoo id from the bp id mapping + const { state } = (await client.getOrSetState({ + type: 'integration', + name: 'customerIdMapping', + id: ctx.integrationId, + payload: { customerIdMapping: {} }, + } as any)) as unknown as { + state: { payload: { customerIdMapping: Record } } + } + + const mapping = state.payload?.customerIdMapping || {} + const odooId = mapping[input.id] + + if (!odooId) { + throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) + } + + return fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) +} +export const fetchCustomerByOdooId: bp.Integration['actions']['fetchCustomerByOdooId'] = async ({ ctx, input, logger, }) => { - logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input, logger }) + logger.forBot().info(`Fetching customer by odoo id: ${JSON.stringify(input)}`) + return fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) } export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEmail'] = async ({ ctx, input, logger, }) => { - logger.forBot().info(`Fetching customer: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input, logger }) + logger.forBot().info(`Fetching customer by email: ${JSON.stringify(input)}`) + return fetchCustomer({ ctx, input: { id: input.id, email: input.email }, logger }) } -export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async ({ ctx, input, logger }) => { +const updateCustomer = async ({ + ctx, + client, + input, + logger, +}: { + ctx: bp.Context + client: bp.Client + input: { id?: string; email?: string; name?: string; phone?: string } + logger: bp.Logger +}): Promise<{ success: boolean }> => { logger.forBot().info(`Updating customer: ${JSON.stringify(input)}`) const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { customer } = input - const { id, email, name, phone } = customer - const customerPayload: Record = { - email, - phone, - name, + let odooId: string + let currentCustomer: { customer: Customer } + + // Determine odoo id based on input + if (input.id) { + // Look up the odoo id from the bp id mapping + const { state } = (await client.getOrSetState({ + type: 'integration', + name: 'customerIdMapping', + id: ctx.integrationId, + payload: { customerIdMapping: {} }, + } as any)) as unknown as { + state: { payload: { customerIdMapping: Record } } + } + + const mapping = state.payload?.customerIdMapping || {} + const mappedOdooId = mapping[input.id] + + if (!mappedOdooId) { + throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) + } + + odooId = mappedOdooId + currentCustomer = await fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) + } else if (input.email) { + currentCustomer = await fetchCustomer({ ctx, input: { id: '', email: input.email }, logger }) + if (!currentCustomer.customer?.odooId) { + throw new RuntimeError('Customer not found or missing Odoo ID') + } + odooId = currentCustomer.customer.odooId + } else { + throw new RuntimeError('Must provide an id or email to update a customer') + } + + // Build the update payload with only the fields that are provided + const customerPayload: Record = {} + if (input.email !== undefined) { + customerPayload.email = input.email + } + if (input.name !== undefined) { + customerPayload.name = input.name + } + if (input.phone !== undefined) { + customerPayload.phone = input.phone } - // Convert string id to number for Odoo write operation - const odooIdNumber = parseInt(id, 10) - if (isNaN(odooIdNumber)) { - throw new RuntimeError(`Invalid customer ID: ${id}`) + // If no fields to update, return early + if (Object.keys(customerPayload).length === 0) { + throw new RuntimeError('No fields provided to update') } + const odooIdNumber = parseInt(odooId, 10) + return { success: (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, @@ -137,3 +222,12 @@ export const updateCustomer: bp.Integration['actions']['updateCustomer'] = async })) as boolean, } } + +export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] = async ({ ctx, client, input, logger }) => { + logger.forBot().info(`Updating customer by id: ${JSON.stringify(input)}`) + return updateCustomer({ ctx, client, input, logger }) +} +export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByEmail'] = async ({ ctx, client, input, logger }) => { + logger.forBot().info(`Updating customer by email: ${JSON.stringify(input)}`) + return updateCustomer({ ctx, client, input, logger }) +} diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 7844eee3..602cb1a0 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,17 +1,46 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' - -export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, input, logger }) => { +import { Priority, Ticket, TicketPayload, TicketResponse } from 'definitions/schemas' + +// Common fields to fetch from Odoo (id is automatically included by Odoo's read method) +const TICKET_FIELDS = [ + "id", + "name", + "description", + "team_id", + "priority", + "stage_id", + "partner_id", +] as const + +/** + * Maps Odoo TicketResponse to our Ticket schema + */ +const mapTicketResponseToTicket = (response: TicketResponse): Ticket => ({ + id: response.id, + customerOdooId: response.partner_id, + name: response.name, + description: response.description, + teamId: response.team_id, + priority: response.priority, + stageId: response.stage_id, +}) + + +export const createTicket: bp.Integration['actions']['createTicket'] = async ({ + ctx, + input: { name, description, teamId, priority, stageId, customerOdooId }, + logger, +}) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { name, description, teamId, priority, stageId, customerId } = input - const ticketPayload: Record = { + const ticketPayload: TicketPayload = { name, description, team_id: teamId, priority, - partner_id: customerId, + partner_id: customerOdooId, ...(stageId ? { stage_id: stageId } : {}), } @@ -22,162 +51,130 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ method: 'create', args: [ticketPayload], logger, - })) as number + })) as TicketResponse['id'] + + // Fetch the created ticket to return complete data + const ticketResponses = (await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'helpdesk.ticket', + method: 'read', + args: [[ticketId], [...TICKET_FIELDS]], + logger, + })) as Array + + const ticketResponse = ticketResponses[0] + if (!ticketResponse) { + throw new RuntimeError('Failed to fetch created ticket') + } return { - ticket: { - customerId, - name, - description, - teamId, - priority, - stageId, - id: ticketId, - }, + ticket: mapTicketResponseToTicket(ticketResponse), } } -export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input, logger }) => { +export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ + ctx, + input: { id }, + logger, +}) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { id } = input - - const filters = [id] - const fields = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] - const rawTicket = (await executeOdooMethod({ + const ticketResponses = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'read', - args: [filters, fields], + args: [[id], [...TICKET_FIELDS]], logger, - })) as Array> + })) as Array - if (rawTicket.length === 0) { - throw new RuntimeError('Ticket not found') - } - if (rawTicket.length > 1) { - throw new RuntimeError('Multiple tickets found for the same id') - } - - // At this point, we've verified rawTicket has exactly one element - const ticket = rawTicket[0] - if (!ticket) { - throw new RuntimeError('Ticket not found') - } + if (ticketResponses.length === 0) + throw new RuntimeError(`Ticket with id ${id} not found`) + if (ticketResponses.length > 1) + throw new RuntimeError(`Multiple tickets found for id ${id}`) + // We've already validated length > 0, so this is safe + const ticketResponse = ticketResponses[0]! return { - ticket: { - customerId: ticket.partner_id, - id: ticket.id, - name: ticket.name, - description: ticket.description, - teamId: ticket.team_id, - priority: ticket.priority, - stageId: ticket.stage_id, - }, + ticket: mapTicketResponseToTicket(ticketResponse), } } export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsByCustomerId'] = async ({ ctx, - input, + input: { customerOdooId }, logger, }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { customerId } = input - const filters: any[] = [['partner_id', '=', customerId]] - const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] + const filters: any[] = [['partner_id', '=', customerOdooId]] - const rawTickets = await executeOdooMethod({ + const ticketResponses = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'search_read', - args: [filters, fields], + args: [filters, [...TICKET_FIELDS]], logger, - }) + })) as Array + return { - tickets: rawTickets.map((ticket: any) => ({ - customerId: ticket.partner_id, - id: ticket.id, - name: ticket.name, - description: ticket.description, - teamId: ticket.team_id, - priority: ticket.priority, - stageId: ticket.stage_id, - })) as Array<{ - customerId: number - id: number - name: string - description: string - teamId: number - priority: string - stageId: number - }>, + tickets: ticketResponses.map(mapTicketResponseToTicket), } } export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicketsByCustomerEmail'] = async ({ ctx, - input, + input: { customerEmail }, logger, }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { customerEmail } = input const filters: any[] = [['partner_id.email', '=', customerEmail]] - const fields: string[] = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id'] - const rawTickets = await executeOdooMethod({ + + const ticketResponses = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'search_read', - args: [filters, fields], + args: [filters, [...TICKET_FIELDS]], logger, - }) + })) as Array + return { - tickets: rawTickets.map((ticket: any) => ({ - customerId: ticket.partner_id, - id: ticket.id, - name: ticket.name, - description: ticket.description, - teamId: ticket.team_id, - priority: ticket.priority, - stageId: ticket.stage_id, - })) as Array<{ - customerId: number - id: number - name: string - description: string - teamId: number - priority: string - stageId: number - }>, + tickets: ticketResponses.map(mapTicketResponseToTicket), } } -export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ ctx, input, logger }) => { +export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ + ctx, + input: { ticketId, name, description, teamId, priority, stageId, customerOdooId }, + logger, +}) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { ticket } = input - - const newTicketPayload: Record = { - name: input.name ?? ticket.name, - description: input.description ?? ticket.description, - team_id: input.teamId ?? ticket.teamId, - priority: input.priority ?? ticket.priority, - stage_id: input.stageId ?? ticket.stageId ?? null, + + // Build update payload with only provided fields (Odoo's write only updates provided fields) + const updatePayload: Partial = {} + + if (name !== undefined) updatePayload.name = name + if (description !== undefined) updatePayload.description = description + if (teamId !== undefined) updatePayload.team_id = teamId + if (customerOdooId !== undefined) updatePayload.partner_id = customerOdooId + if (stageId !== undefined) updatePayload.stage_id = stageId + if (priority !== undefined) { + updatePayload.priority = priority } + // Only update if there are fields to update + if (Object.keys(updatePayload).length === 0) return { success: true } + const success = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'write', - args: [[ticket.id], newTicketPayload], + args: [[ticketId], updatePayload], logger, })) as boolean - return { - success, - } + return { success } } diff --git a/package.json b/package.json index d3acf60d..d6585a6b 100644 --- a/package.json +++ b/package.json @@ -70,5 +70,5 @@ "typescript-eslint": "^8.35.1", "vitest": "^3.2.4" }, - "packageManager": "pnpm@10.12.4" + "packageManager": "pnpm@10.28.0" } From 85f84c722e93e76e445c4cfbe4407ac01a7bb493 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Fri, 16 Jan 2026 16:05:44 -0500 Subject: [PATCH 08/29] Fixed customer creation --- .../definitions/actions/customers.ts | 7 +- .../definitions/schemas/customer.ts | 4 +- .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/actions/customers.ts | 96 +++++++++---------- 4 files changed, 52 insertions(+), 57 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 610c3872..7bdac6b7 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -8,13 +8,13 @@ export const createCustomer: ActionDefinition = { schema: z.object({ id: z.string().title('ID').describe('The id of the customer'), email: z.string().title('Email').describe('The email of the customer'), - name: z.string().title('Name').describe('The name of the customer').optional(), - phone: z.string().title('Phone').describe('The phone of the customer').optional(), + name: z.string().title('Name').describe('The name of the customer'), + phone: z.string().title('Phone').describe('The phone of the customer'), }), }, output: { schema: z.object({ - customer: customerSchema.title('Customer').describe('The created customer').optional(), + odooId: z.number().title('Odoo ID').describe('The odoo id of the created customer'), }), }, } @@ -55,7 +55,6 @@ export const fetchCustomerByEmail: ActionDefinition = { description: 'Fetch a customer by email', input: { schema: z.object({ - id: z.string().title('ID').describe('The id of the customer to fetch'), email: z.string().title('Email').describe('The email of the customer to fetch'), }), }, diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index 9a2aa65c..f2e823ad 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -1,8 +1,8 @@ import { z } from '@botpress/sdk' export const customerSchema = z.object({ - id: z.string().describe('The id of the customer'), - odooId: z.string().describe('The Odoo ID of the customer').optional(), + id: z.string().describe('The id of the customer').optional(), + odooId: z.number().describe('The Odoo ID of the customer').optional(), email: z.string().describe('The email of the customer'), name: z.string().describe('The name of the customer').optional(), phone: z.string().describe('The phone of the customer').optional(), diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 4d6acd17..9127f0c8 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.30', + version: '0.1.31', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 77acc620..a82a9266 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,16 +1,17 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { customerSchema, Customer } from 'definitions/schemas' -import { z } from '@botpress/sdk' +import { Customer } from 'definitions/schemas' export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ - ctx, client, input, logger + ctx, + client, + input: { id, email, name, phone }, + logger, }) => { - logger.forBot().info(`Creating customer: ${JSON.stringify(input)}`) + logger.forBot().info(`Creating customer: ${JSON.stringify({ id, email, name, phone })}`) const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const { id, email, name, phone } = input const customerPayload: Record = { email, @@ -18,45 +19,37 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async name, } - const odooId = (await executeOdooMethod({ + const odooIdResult = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'create', args: [customerPayload], logger, - })) as Customer['odooId'] + }) as string | number - // Store the mapping of bp id to odoo id - const { state } = (await client.getOrSetState({ + const odooId : number = typeof odooIdResult === 'number' ? odooIdResult : parseInt(odooIdResult as string, 10) + + // Store the mapping of bp id to odoo id (as string for storage) + const { state } = await client.getOrSetState({ type: 'integration', name: 'customerIdMapping', id: ctx.integrationId, payload: { customerIdMapping: {} }, - } as any)) as unknown as { - state: { payload: { customerIdMapping: Record } } - } + }) const mapping = state.payload?.customerIdMapping || {} - if (odooId) { - mapping[id] = odooId.toString() - } + mapping[id] = odooId.toString() await client.setState({ type: 'integration', name: 'customerIdMapping', id: ctx.integrationId, payload: { customerIdMapping: mapping }, - } as any) + }) return { - customer: { - id, - odooId, - email, - name, - phone, - } as Customer + odooId, } } @@ -66,9 +59,9 @@ const fetchCustomer = async ({ logger, }: { ctx: bp.Context - input: { id: string; odooId?: string; email?: string } + input: { id?: string; odooId?: string; email?: string } logger: bp.Logger -}): Promise<{ customer: z.infer }> => { +}): Promise => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const fields = ['id', 'email', 'name', 'phone'] const filters: any[] = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] @@ -80,7 +73,7 @@ const fetchCustomer = async ({ method: 'search_read', args: [filters, fields], logger, - }) as Array + }) if (rawCustomer.length === 0 || !rawCustomer[0]) throw new RuntimeError('Customer not found') @@ -88,17 +81,20 @@ const fetchCustomer = async ({ if (rawCustomer.length > 1) throw new RuntimeError('Multiple customers found for the same id') - const customer = rawCustomer[0] as Customer + const customerData = rawCustomer[0] - return { - customer: { - id, - odooId: customer.id, - email: customer.email, - name: customer.name, - phone: customer.phone, - } as z.infer, + const customer: Customer = { + odooId: customerData.id as number, + email: customerData.email as string, + name: customerData.name as string, + phone: customerData.phone as string, + } + + if (id) { + customer.id = id } + + return customer } export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = async ({ ctx, @@ -109,14 +105,12 @@ export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = logger.forBot().info(`Fetching customer by id: ${JSON.stringify(input)}`) // Look up the odoo id from the bp id mapping - const { state } = (await client.getOrSetState({ + const { state } = await client.getOrSetState({ type: 'integration', name: 'customerIdMapping', id: ctx.integrationId, payload: { customerIdMapping: {} }, - } as any)) as unknown as { - state: { payload: { customerIdMapping: Record } } - } + }) const mapping = state.payload?.customerIdMapping || {} const odooId = mapping[input.id] @@ -125,7 +119,8 @@ export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) } - return fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) + const customer = await fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) + return { customer } } export const fetchCustomerByOdooId: bp.Integration['actions']['fetchCustomerByOdooId'] = async ({ ctx, @@ -133,7 +128,8 @@ export const fetchCustomerByOdooId: bp.Integration['actions']['fetchCustomerByOd logger, }) => { logger.forBot().info(`Fetching customer by odoo id: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) + const customer = await fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) + return { customer } } export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEmail'] = async ({ ctx, @@ -141,7 +137,8 @@ export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEma logger, }) => { logger.forBot().info(`Fetching customer by email: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input: { id: input.id, email: input.email }, logger }) + const customer = await fetchCustomer({ ctx, input: { email: input.email }, logger }) + return { customer } } const updateCustomer = async ({ @@ -159,19 +156,17 @@ const updateCustomer = async ({ const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) let odooId: string - let currentCustomer: { customer: Customer } + let currentCustomer: Customer // Determine odoo id based on input if (input.id) { // Look up the odoo id from the bp id mapping - const { state } = (await client.getOrSetState({ + const { state } = await client.getOrSetState({ type: 'integration', name: 'customerIdMapping', id: ctx.integrationId, payload: { customerIdMapping: {} }, - } as any)) as unknown as { - state: { payload: { customerIdMapping: Record } } - } + }) const mapping = state.payload?.customerIdMapping || {} const mappedOdooId = mapping[input.id] @@ -183,11 +178,12 @@ const updateCustomer = async ({ odooId = mappedOdooId currentCustomer = await fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) } else if (input.email) { - currentCustomer = await fetchCustomer({ ctx, input: { id: '', email: input.email }, logger }) - if (!currentCustomer.customer?.odooId) { + currentCustomer = await fetchCustomer({ ctx, input: { email: input.email }, logger }) + const customerOdooId = currentCustomer.odooId + if (customerOdooId === undefined) { throw new RuntimeError('Customer not found or missing Odoo ID') } - odooId = currentCustomer.customer.odooId + odooId = customerOdooId.toString() } else { throw new RuntimeError('Must provide an id or email to update a customer') } From 1271a7e2123efcfe16a36e208068b440c3172698 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Sun, 18 Jan 2026 16:52:07 -0500 Subject: [PATCH 09/29] Completed Create ticket to a final working state --- .../definitions/actions/tickets.ts | 7 +++--- .../definitions/schemas/ticket.ts | 12 +++++++--- .../odoo-helpdesk/integration.definition.ts | 2 +- .../odoo-helpdesk/src/actions/tickets.ts | 22 +++++++++---------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index 3abbabe3..5fd61ece 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -9,14 +9,14 @@ export const createTicket: ActionDefinition = { 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().title('Team ID').describe('The helpdesk team ID associated with the ticket'), - priority: z.number().title('Priority').describe('The priority of the ticket (0 is the lowest priority)').optional(), + 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().title('Stage ID').describe('The stage ID associated with the ticket'), }), }, output: { schema: z.object({ - ticket: ticketSchema.title('Ticket').describe('The created ticket').optional(), + ticketId: z.number().title('Ticket ID').describe('The ID of the created ticket'), }), }, } @@ -75,9 +75,8 @@ export const updateTicket: ActionDefinition = { 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().title('Team ID').describe('The helpdesk team ID associated with the ticket').optional(), - priority: z.number().title('Priority').describe('The priority of the ticket (0 is the lowest priority)').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(), - customerOdooId: z.number().title('Customer Odoo ID').describe('The Odoo customer ID associated with the ticket').optional(), }), }, output: { diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts index 10f3c786..3e80c37f 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -6,7 +6,7 @@ export const ticketSchema = z.object({ 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.number().describe('The priority of the ticket').optional(), + priority: z.string().describe('The priority of the ticket').optional(), stageId: z.number().describe('The stage ID associated with the ticket').optional(), }) export type Ticket = z.infer @@ -15,13 +15,19 @@ const ticketPayloadSchema = 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'), - priority: z.number().describe('The priority of 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'), stage_id: z.number().describe('The stage ID associated with the ticket').optional(), }) export type TicketPayload = z.infer -export const ticketResponseSchema = ticketPayloadSchema.extend({ +export const ticketResponseSchema = 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: z.tuple([z.number(), z.string()]).describe('The helpdesk team ID and name as a tuple [id, name]'), + priority: z.string().describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")').optional().nullable(), + partner_id: z.tuple([z.number(), z.string()]).describe('The customer ID and name as a tuple [id, name]'), + stage_id: z.tuple([z.number(), z.string()]).describe('The stage ID and name as a tuple [id, name]').optional(), }) export type TicketResponse = z.infer \ No newline at end of file diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 9127f0c8..5b6900e8 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.31', + version: '0.1.41', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 602cb1a0..394770af 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -16,15 +16,16 @@ const TICKET_FIELDS = [ /** * Maps Odoo TicketResponse to our Ticket schema + * Odoo returns relational fields as tuples [id, name], so we extract just the ID */ const mapTicketResponseToTicket = (response: TicketResponse): Ticket => ({ id: response.id, - customerOdooId: response.partner_id, + customerOdooId: Array.isArray(response.partner_id) ? response.partner_id[0] : response.partner_id, name: response.name, description: response.description, - teamId: response.team_id, - priority: response.priority, - stageId: response.stage_id, + teamId: Array.isArray(response.team_id) ? response.team_id[0] : response.team_id, + priority: response.priority !== undefined && response.priority !== null ? String(response.priority) : undefined, + stageId: response.stage_id ? (Array.isArray(response.stage_id) ? response.stage_id[0] : response.stage_id) : undefined, }) @@ -39,7 +40,7 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ name, description, team_id: teamId, - priority, + ...(priority !== undefined ? { priority: String(priority) } : {}), partner_id: customerOdooId, ...(stageId ? { stage_id: stageId } : {}), } @@ -69,7 +70,7 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ } return { - ticket: mapTicketResponseToTicket(ticketResponse), + ticketId: mapTicketResponseToTicket(ticketResponse).id, } } @@ -147,7 +148,7 @@ export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicket export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ ctx, - input: { ticketId, name, description, teamId, priority, stageId, customerOdooId }, + input: { ticketId, name, description, teamId, priority, stageId }, logger, }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) @@ -158,15 +159,14 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ if (name !== undefined) updatePayload.name = name if (description !== undefined) updatePayload.description = description if (teamId !== undefined) updatePayload.team_id = teamId - if (customerOdooId !== undefined) updatePayload.partner_id = customerOdooId if (stageId !== undefined) updatePayload.stage_id = stageId - if (priority !== undefined) { - updatePayload.priority = priority - } + if (priority !== undefined) updatePayload.priority = priority // Only update if there are fields to update if (Object.keys(updatePayload).length === 0) return { success: true } + logger.forBot().info(`Updating ticket ${ticketId} with payload: ${JSON.stringify(updatePayload)}`) + const success = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, From 866e692ef25c363c81fb1855da3993f795039045 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Sun, 18 Jan 2026 17:14:26 -0500 Subject: [PATCH 10/29] Got Update to work --- integrations/odoo-helpdesk/definitions/actions/tickets.ts | 4 ++-- integrations/odoo-helpdesk/integration.definition.ts | 2 +- integrations/odoo-helpdesk/src/actions/tickets.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index 5fd61ece..f517bd29 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -8,7 +8,7 @@ export const createTicket: ActionDefinition = { 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().title('Team ID').describe('The helpdesk team ID associated with 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().title('Stage ID').describe('The stage ID associated with the ticket'), @@ -74,7 +74,7 @@ export const updateTicket: ActionDefinition = { 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().title('Team ID').describe('The helpdesk team ID associated with 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(), }), diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 5b6900e8..83cfe773 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -4,7 +4,7 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.41', + version: '0.1.43', name: integrationName, title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 394770af..7346f35d 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -154,6 +154,7 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) // Build update payload with only provided fields (Odoo's write only updates provided fields) + // Fields with .optional() will be undefined when not provided by the user const updatePayload: Partial = {} if (name !== undefined) updatePayload.name = name From d62bd32735df03ad70fb1add320cbec62b2e2455 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 11:26:48 -0500 Subject: [PATCH 11/29] Added Readme for Odoo Helpdesk --- integrations/odoo-helpdesk/hub.md | 234 +++++++++++++++++++++++++++--- 1 file changed, 214 insertions(+), 20 deletions(-) diff --git a/integrations/odoo-helpdesk/hub.md b/integrations/odoo-helpdesk/hub.md index 3874c6d6..b7824bf0 100644 --- a/integrations/odoo-helpdesk/hub.md +++ b/integrations/odoo-helpdesk/hub.md @@ -1,33 +1,227 @@ -# Integration Title +# Odoo Helpdesk Integration -> Describe the integration's purpose. +Connect your Botpress chatbot with Odoo Helpdesk to manage tickets and customers directly from your bot. This integration enables you to create, fetch, and update helpdesk tickets and customer records, making it easy to provide customer support through your chatbot. ## Configuration -> Explain how to configure your integration and list prerequisites `ex: accounts, etc.`. -> You might also want to add configuration details for specific use cases. +### Prerequisites + +- An Odoo instance with the Helpdesk module installed +- Odoo user credentials with appropriate permissions to manage tickets and customers +- Access to your Odoo API URL + +### Setup + +1. **Enable the integration** in your Botpress workspace +2. **Configure the integration** with the following required fields: + - **Odoo API URL**: The base URL of your Odoo instance (e.g., `https://your-odoo-instance.com`) + - **Odoo Database**: The name of your Odoo database (case-sensitive) + - **Odoo Email**: The email address of your Odoo user account + - **Odoo Password**: The password for your Odoo user account (stored securely as a secret) +3. **Save the configuration** - The integration will automatically register and cache helpdesk teams and stages ## Usage -> Explain how to use your integration. -> You might also want to include an example if there is a specific use case. +### Customer Management -## Limitations +#### Create Customer -> List the known bugs. -> List known limits `ex: rate-limiting, payload sizes, etc.` -> List unsupported use cases. +Create a new customer in Odoo with their contact information. -## Changelog +**Input:** +- `id` (required): A unique identifier for the customer in your Botpress system +- `email` (required): The customer's email address +- `name` (required): The customer's name +- `phone` (required): The customer's phone number + +**Output:** +- `odooId`: The Odoo ID of the created customer + +**Example:** +```json +{ + "id": "customer-123", + "email": "john.doe@example.com", + "name": "John Doe", + "phone": "+1234567890" +} +``` + +#### Fetch Customer By ID + +Retrieve a customer using their Botpress ID. The integration automatically maps Botpress IDs to Odoo IDs. + +**Input:** +- `id` (required): The Botpress customer ID + +**Output:** +- `customer`: The customer object with Odoo ID, email, name, and phone + +#### Fetch Customer By Odoo ID + +Retrieve a customer using their Odoo ID directly. + +**Input:** +- `id` (required): The Botpress customer ID (optional, for mapping) +- `odooId` (required): The Odoo customer ID + +**Output:** +- `customer`: The customer object + +#### Fetch Customer By Email + +Retrieve a customer by their email address. + +**Input:** +- `email` (required): The customer's email address + +**Output:** +- `customer`: The customer object + +#### Update Customer By ID + +Update an existing customer's information using their Botpress ID. + +**Input:** +- `id` (required): The Botpress customer ID +- `email` (optional): New email address +- `name` (optional): New name +- `phone` (optional): New phone number + +**Output:** +- `success`: Boolean indicating if the update was successful +- `error`: Error message if the update failed + +#### Update Customer By Email + +Update an existing customer's information using their email address. + +**Input:** +- `email` (required): The customer's email address +- `name` (optional): New name +- `phone` (optional): New phone number + +**Output:** +- `success`: Boolean indicating if the update was successful +- `error`: Error message if the update failed + +### Ticket Management + +#### Create Ticket + +Create a new helpdesk ticket in Odoo. + +**Input:** +- `name` (required): The ticket title/subject +- `description` (required): The ticket description/details +- `teamId` (required): The ID of the helpdesk team to assign the ticket to (minimum: 1) +- `customerOdooId` (required): The Odoo ID of the customer associated with the ticket +- `priority` (optional): Priority level - `"0"` (lowest), `"1"`, `"2"`, or `"3"` (highest) +- `stageId` (required): The ID of the initial ticket stage + +**Output:** +- `ticketId`: The ID of the created ticket -> If some versions of your integration introduce changes worth mentionning (breaking changes, bug fixes), describe them here. This will help users to know what to expect when updating the integration. +**Example:** +```json +{ + "name": "Unable to access account", + "description": "Customer reports login issues", + "teamId": 1, + "customerOdooId": 42, + "priority": "2", + "stageId": 1 +} +``` -### Integration publication checklist +#### Fetch Ticket By ID + +Retrieve a ticket by its ID. + +**Input:** +- `id` (required): The ticket ID + +**Output:** +- `ticket`: The ticket object with all details including customer, team, priority, and stage + +#### Fetch Tickets By Customer ID + +Retrieve all tickets associated with a customer using their Odoo ID. + +**Input:** +- `customerOdooId` (required): The Odoo customer ID + +**Output:** +- `tickets`: Array of ticket objects + +#### Fetch Tickets By Customer Email + +Retrieve all tickets associated with a customer using their email address. + +**Input:** +- `customerEmail` (required): The customer's email address + +**Output:** +- `tickets`: Array of ticket objects + +#### Update Ticket + +Update an existing ticket's properties. + +**Input:** +- `ticketId` (required): The ID of the ticket to update +- `name` (optional): New ticket title +- `description` (optional): New ticket description +- `teamId` (optional): New helpdesk team ID +- `priority` (optional): New priority level (`"0"`, `"1"`, `"2"`, or `"3"`) +- `stageId` (optional): New stage ID + +**Output:** +- `success`: Boolean indicating if the update was successful + +### Helpdesk Configuration + +#### Get Helpdesk Teams + +Retrieve all active helpdesk teams from your Odoo instance. Teams are cached during integration registration for improved performance. + +**Input:** None + +**Output:** +- `helpdeskTeams`: Array of helpdesk team objects with `id` and `name` + +#### Get Stages + +Retrieve all ticket stages. Optionally filter by team ID to get stages for a specific team. + +**Input:** +- `teamId` (optional): Filter stages by helpdesk team ID + +**Output:** +- `stages`: Array of stage objects with `id`, `name`, and `teamIds` + +## Use Cases + +- **Customer Support Automation**: Automatically create tickets when customers report issues through your chatbot +- **Ticket Status Updates**: Update ticket stages as issues are resolved +- **Customer Information Management**: Keep customer records synchronized between Botpress and Odoo +- **Ticket Lookup**: Allow customers to check their ticket status by email +- **Support Team Workflows**: Integrate chatbot interactions with your helpdesk team's workflow + +## Limitations + +- The integration requires an active Odoo instance with the Helpdesk module installed +- Customer ID mapping is stored per integration instance and is not shared across workspaces +- Authentication cookies are cached per configuration to reduce API calls, but may need to be refreshed if credentials change +- The integration does not support webhook events from Odoo (ticket updates must be polled or triggered manually) +- Rate limiting depends on your Odoo instance configuration + +## Changelog -- [ ] The register handler is implemented and validates the configuration. -- [ ] Title and descriptions for all schemas are present in `integration.definition.ts`. -- [ ] Events store `conversationId`, `userId` and `messageId` when available. -- [ ] Implement events & actions that are related to `channels`, `entities`, `user`, `conversations` and `messages`. -- [ ] Events related to messages are implemented as messages. -- [ ] When an action is required by the bot developer, a `RuntimeError` is thrown with instructions to fix the problem. -- [ ] Bot name and bot avatar URL fields are available in the integration configuration. +### Version 0.1.43 +- Initial release of Odoo Helpdesk integration +- Customer management actions (create, fetch, update) +- Ticket management actions (create, fetch, update) +- Helpdesk configuration actions (teams, stages) +- Automatic ID mapping between Botpress and Odoo +- Cookie-based authentication with caching From 9a101ba7ef22d4d23a5a0922eeafb774effafe7b Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 13:21:33 -0500 Subject: [PATCH 12/29] Fix: Formatting, Integration Version and Name, More Strict Types --- .../definitions/actions/customers.ts | 2 +- .../definitions/actions/index.ts | 6 +- .../definitions/actions/tickets.ts | 16 +- .../odoo-helpdesk/definitions/index.ts | 2 +- .../definitions/schemas/customer.ts | 2 +- .../definitions/schemas/helpdesk-team.ts | 2 +- .../definitions/schemas/priority.ts | 2 +- .../definitions/schemas/stage.ts | 2 +- .../definitions/schemas/ticket.ts | 8 +- integrations/odoo-helpdesk/hub.md | 28 ++ .../odoo-helpdesk/integration.definition.ts | 5 +- integrations/odoo-helpdesk/package.json | 3 +- integrations/odoo-helpdesk/pnpm-lock.yaml | 369 ++++++++++++++++++ .../odoo-helpdesk/src/actions/customers.ts | 42 +- .../odoo-helpdesk/src/actions/tickets.ts | 41 +- .../odoo-helpdesk/src/services/odoo.ts | 31 +- .../odoo-helpdesk/src/setup/helpdesk.ts | 28 +- integrations/odoo-helpdesk/src/setup/index.ts | 2 +- integrations/odoo-helpdesk/tsconfig.json | 2 +- .../odoo-helpdesk/version-bump-and-deploy.js | 44 --- 20 files changed, 501 insertions(+), 136 deletions(-) create mode 100644 integrations/odoo-helpdesk/pnpm-lock.yaml delete mode 100755 integrations/odoo-helpdesk/version-bump-and-deploy.js diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 7bdac6b7..898724a3 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -109,4 +109,4 @@ export const actions = { fetchCustomerByOdooId, updateCustomerById, updateCustomerByEmail, -} as const \ No newline at end of file +} as const diff --git a/integrations/odoo-helpdesk/definitions/actions/index.ts b/integrations/odoo-helpdesk/definitions/actions/index.ts index a69ccffa..462568d0 100644 --- a/integrations/odoo-helpdesk/definitions/actions/index.ts +++ b/integrations/odoo-helpdesk/definitions/actions/index.ts @@ -1,7 +1,7 @@ import * as sdk from '@botpress/sdk' -import {actions as customerActions} from './customers' -import {actions as ticketsActions} from './tickets' -import {actions as helpdeskActions} from './helpdesk' +import { actions as customerActions } from './customers' +import { actions as ticketsActions } from './tickets' +import { actions as helpdeskActions } from './helpdesk' export const actions = { ...customerActions, diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index f517bd29..7ca449d8 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -9,7 +9,11 @@ export const createTicket: ActionDefinition = { 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(), + 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().title('Stage ID').describe('The stage ID associated with the ticket'), }), @@ -75,13 +79,17 @@ export const updateTicket: ActionDefinition = { 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(), + 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') + success: z.boolean().title('Success').describe('The success of the update'), }), }, } @@ -92,4 +100,4 @@ export const actions = { fetchTicketsByCustomerId, fetchTicketsByCustomerEmail, updateTicket, -} as const \ No newline at end of file +} as const diff --git a/integrations/odoo-helpdesk/definitions/index.ts b/integrations/odoo-helpdesk/definitions/index.ts index e2180ab2..e945a089 100644 --- a/integrations/odoo-helpdesk/definitions/index.ts +++ b/integrations/odoo-helpdesk/definitions/index.ts @@ -1,4 +1,4 @@ import { actions } from './actions' import { states } from './states' -export { actions, states } \ No newline at end of file +export { actions, states } diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index f2e823ad..00dc09fd 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -16,4 +16,4 @@ const customerPayloadSchema = z.object({ name: z.string().describe('The name of the customer').optional(), }) -export type CustomerPayload = z.infer \ No newline at end of file +export type CustomerPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts index 7b6bae57..a656ad1e 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts @@ -11,4 +11,4 @@ const helpdeskTeamPayloadSchema = z.object({ name: z.string().describe('The name of the helpdesk team'), }) -export type HelpdeskTeamPayload = z.infer \ No newline at end of file +export type HelpdeskTeamPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/priority.ts b/integrations/odoo-helpdesk/definitions/schemas/priority.ts index ce2bf642..d78adfc4 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/priority.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/priority.ts @@ -11,4 +11,4 @@ const priorityPayloadSchema = z.object({ id: z.number().describe('The id of the priority'), }) -export type PriorityPayload = z.infer \ No newline at end of file +export type PriorityPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/stage.ts b/integrations/odoo-helpdesk/definitions/schemas/stage.ts index c61fd545..133b4200 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/stage.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/stage.ts @@ -13,4 +13,4 @@ const stagePayloadSchema = z.object({ team_ids: z.array(z.number()).describe('The ids of the helpdesk teams that the stage belongs to'), }) -export type StagePayload = z.infer \ No newline at end of file +export type StagePayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts index 3e80c37f..c8651b72 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -26,8 +26,12 @@ export const ticketResponseSchema = z.object({ name: z.string().describe('The name of the ticket'), description: z.string().describe('The description of the ticket'), team_id: z.tuple([z.number(), z.string()]).describe('The helpdesk team ID and name as a tuple [id, name]'), - priority: z.string().describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")').optional().nullable(), + priority: z + .string() + .describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")') + .optional() + .nullable(), partner_id: z.tuple([z.number(), z.string()]).describe('The customer ID and name as a tuple [id, name]'), stage_id: z.tuple([z.number(), z.string()]).describe('The stage ID and name as a tuple [id, name]').optional(), }) -export type TicketResponse = z.infer \ No newline at end of file +export type TicketResponse = z.infer diff --git a/integrations/odoo-helpdesk/hub.md b/integrations/odoo-helpdesk/hub.md index b7824bf0..e8ac3af6 100644 --- a/integrations/odoo-helpdesk/hub.md +++ b/integrations/odoo-helpdesk/hub.md @@ -29,15 +29,18 @@ Connect your Botpress chatbot with Odoo Helpdesk to manage tickets and customers Create a new customer in Odoo with their contact information. **Input:** + - `id` (required): A unique identifier for the customer in your Botpress system - `email` (required): The customer's email address - `name` (required): The customer's name - `phone` (required): The customer's phone number **Output:** + - `odooId`: The Odoo ID of the created customer **Example:** + ```json { "id": "customer-123", @@ -52,9 +55,11 @@ Create a new customer in Odoo with their contact information. Retrieve a customer using their Botpress ID. The integration automatically maps Botpress IDs to Odoo IDs. **Input:** + - `id` (required): The Botpress customer ID **Output:** + - `customer`: The customer object with Odoo ID, email, name, and phone #### Fetch Customer By Odoo ID @@ -62,10 +67,12 @@ Retrieve a customer using their Botpress ID. The integration automatically maps Retrieve a customer using their Odoo ID directly. **Input:** + - `id` (required): The Botpress customer ID (optional, for mapping) - `odooId` (required): The Odoo customer ID **Output:** + - `customer`: The customer object #### Fetch Customer By Email @@ -73,9 +80,11 @@ Retrieve a customer using their Odoo ID directly. Retrieve a customer by their email address. **Input:** + - `email` (required): The customer's email address **Output:** + - `customer`: The customer object #### Update Customer By ID @@ -83,12 +92,14 @@ Retrieve a customer by their email address. Update an existing customer's information using their Botpress ID. **Input:** + - `id` (required): The Botpress customer ID - `email` (optional): New email address - `name` (optional): New name - `phone` (optional): New phone number **Output:** + - `success`: Boolean indicating if the update was successful - `error`: Error message if the update failed @@ -97,11 +108,13 @@ Update an existing customer's information using their Botpress ID. Update an existing customer's information using their email address. **Input:** + - `email` (required): The customer's email address - `name` (optional): New name - `phone` (optional): New phone number **Output:** + - `success`: Boolean indicating if the update was successful - `error`: Error message if the update failed @@ -112,6 +125,7 @@ Update an existing customer's information using their email address. Create a new helpdesk ticket in Odoo. **Input:** + - `name` (required): The ticket title/subject - `description` (required): The ticket description/details - `teamId` (required): The ID of the helpdesk team to assign the ticket to (minimum: 1) @@ -120,9 +134,11 @@ Create a new helpdesk ticket in Odoo. - `stageId` (required): The ID of the initial ticket stage **Output:** + - `ticketId`: The ID of the created ticket **Example:** + ```json { "name": "Unable to access account", @@ -139,9 +155,11 @@ Create a new helpdesk ticket in Odoo. Retrieve a ticket by its ID. **Input:** + - `id` (required): The ticket ID **Output:** + - `ticket`: The ticket object with all details including customer, team, priority, and stage #### Fetch Tickets By Customer ID @@ -149,9 +167,11 @@ Retrieve a ticket by its ID. Retrieve all tickets associated with a customer using their Odoo ID. **Input:** + - `customerOdooId` (required): The Odoo customer ID **Output:** + - `tickets`: Array of ticket objects #### Fetch Tickets By Customer Email @@ -159,9 +179,11 @@ Retrieve all tickets associated with a customer using their Odoo ID. Retrieve all tickets associated with a customer using their email address. **Input:** + - `customerEmail` (required): The customer's email address **Output:** + - `tickets`: Array of ticket objects #### Update Ticket @@ -169,6 +191,7 @@ Retrieve all tickets associated with a customer using their email address. Update an existing ticket's properties. **Input:** + - `ticketId` (required): The ID of the ticket to update - `name` (optional): New ticket title - `description` (optional): New ticket description @@ -177,6 +200,7 @@ Update an existing ticket's properties. - `stageId` (optional): New stage ID **Output:** + - `success`: Boolean indicating if the update was successful ### Helpdesk Configuration @@ -188,6 +212,7 @@ Retrieve all active helpdesk teams from your Odoo instance. Teams are cached dur **Input:** None **Output:** + - `helpdeskTeams`: Array of helpdesk team objects with `id` and `name` #### Get Stages @@ -195,9 +220,11 @@ Retrieve all active helpdesk teams from your Odoo instance. Teams are cached dur Retrieve all ticket stages. Optionally filter by team ID to get stages for a specific team. **Input:** + - `teamId` (optional): Filter stages by helpdesk team ID **Output:** + - `stages`: Array of stage objects with `id`, `name`, and `teamIds` ## Use Cases @@ -219,6 +246,7 @@ Retrieve all ticket stages. Optionally filter by team ID to get stages for a spe ## Changelog ### Version 0.1.43 + - Initial release of Odoo Helpdesk integration - Customer management actions (create, fetch, update) - Ticket management actions (create, fetch, update) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 83cfe773..2d3efc1f 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -1,11 +1,10 @@ import { z, IntegrationDefinition } from '@botpress/sdk' -import { integrationName } from './package.json' import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.43', - name: integrationName, + version: '0.1.0', + name: 'plus/odoo-helpdesk-integration', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', diff --git a/integrations/odoo-helpdesk/package.json b/integrations/odoo-helpdesk/package.json index ec899abc..17ef8f9f 100644 --- a/integrations/odoo-helpdesk/package.json +++ b/integrations/odoo-helpdesk/package.json @@ -1,6 +1,5 @@ { "name": "@bp-templates/empty-integration", - "integrationName": "erichuang/odoo-helpdesk-integration", "scripts": { "check:type": "tsc --noEmit", "build": "bp add -y && bp build", @@ -17,4 +16,4 @@ "@types/node": "^22.16.4", "typescript": "^5.6.3" } -} \ No newline at end of file +} 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 index a82a9266..bd2fb4d5 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -13,22 +13,22 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const customerPayload: Record = { + const customerPayload: Record = { email, phone, name, } - const odooIdResult = await executeOdooMethod({ + const odooIdResult = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'create', args: [customerPayload], logger, - }) as string | number + })) as string | number - const odooId : number = typeof odooIdResult === 'number' ? odooIdResult : parseInt(odooIdResult as string, 10) + const odooId: number = typeof odooIdResult === 'number' ? odooIdResult : parseInt(odooIdResult as string, 10) // Store the mapping of bp id to odoo id (as string for storage) const { state } = await client.getOrSetState({ @@ -64,27 +64,25 @@ const fetchCustomer = async ({ }): Promise => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const fields = ['id', 'email', 'name', 'phone'] - const filters: any[] = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] + const filters: (string | number)[][] = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] - let rawCustomer: Array> = await executeOdooMethod({ + let rawCustomer: Array> = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'search_read', - args: [filters, fields], + args: [filters, fields] as (string | number)[][], logger, - }) + })) as Array> - if (rawCustomer.length === 0 || !rawCustomer[0]) - throw new RuntimeError('Customer not found') + if (rawCustomer.length === 0 || !rawCustomer[0]) throw new RuntimeError('Customer not found') - if (rawCustomer.length > 1) - throw new RuntimeError('Multiple customers found for the same id') + if (rawCustomer.length > 1) throw new RuntimeError('Multiple customers found for the same id') const customerData = rawCustomer[0] const customer: Customer = { - odooId: customerData.id as number, + odooId: customerData.id as unknown as number, email: customerData.email as string, name: customerData.name as string, phone: customerData.phone as string, @@ -189,7 +187,7 @@ const updateCustomer = async ({ } // Build the update payload with only the fields that are provided - const customerPayload: Record = {} + const customerPayload: Record = {} if (input.email !== undefined) { customerPayload.email = input.email } @@ -213,17 +211,27 @@ const updateCustomer = async ({ cookie, model: 'res.partner', method: 'write', - args: [[odooIdNumber], customerPayload], + args: [[odooIdNumber], customerPayload] as unknown as (number | Record)[], logger, })) as boolean, } } -export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] = async ({ ctx, client, input, logger }) => { +export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] = async ({ + ctx, + client, + input, + logger, +}) => { logger.forBot().info(`Updating customer by id: ${JSON.stringify(input)}`) return updateCustomer({ ctx, client, input, logger }) } -export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByEmail'] = async ({ ctx, client, input, logger }) => { +export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByEmail'] = async ({ + ctx, + client, + input, + logger, +}) => { logger.forBot().info(`Updating customer by email: ${JSON.stringify(input)}`) return updateCustomer({ ctx, client, input, logger }) } diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 7346f35d..89c94630 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -4,15 +4,7 @@ import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' import { Priority, Ticket, TicketPayload, TicketResponse } from 'definitions/schemas' // Common fields to fetch from Odoo (id is automatically included by Odoo's read method) -const TICKET_FIELDS = [ - "id", - "name", - "description", - "team_id", - "priority", - "stage_id", - "partner_id", -] as const +const TICKET_FIELDS = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] as const /** * Maps Odoo TicketResponse to our Ticket schema @@ -25,10 +17,13 @@ const mapTicketResponseToTicket = (response: TicketResponse): Ticket => ({ description: response.description, teamId: Array.isArray(response.team_id) ? response.team_id[0] : response.team_id, priority: response.priority !== undefined && response.priority !== null ? String(response.priority) : undefined, - stageId: response.stage_id ? (Array.isArray(response.stage_id) ? response.stage_id[0] : response.stage_id) : undefined, + stageId: response.stage_id + ? Array.isArray(response.stage_id) + ? response.stage_id[0] + : response.stage_id + : undefined, }) - export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, input: { name, description, teamId, priority, stageId, customerOdooId }, @@ -50,7 +45,7 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ cookie, model: 'helpdesk.ticket', method: 'create', - args: [ticketPayload], + args: [ticketPayload] as unknown as Record[], logger, })) as TicketResponse['id'] @@ -74,11 +69,7 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ } } -export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ - ctx, - input: { id }, - logger, -}) => { +export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input: { id }, logger }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const ticketResponses = (await executeOdooMethod({ @@ -90,10 +81,8 @@ export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = asy logger, })) as Array - if (ticketResponses.length === 0) - throw new RuntimeError(`Ticket with id ${id} not found`) - if (ticketResponses.length > 1) - throw new RuntimeError(`Multiple tickets found for id ${id}`) + if (ticketResponses.length === 0) throw new RuntimeError(`Ticket with id ${id} not found`) + if (ticketResponses.length > 1) throw new RuntimeError(`Multiple tickets found for id ${id}`) // We've already validated length > 0, so this is safe const ticketResponse = ticketResponses[0]! @@ -108,14 +97,14 @@ export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsBy logger, }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const filters: any[] = [['partner_id', '=', customerOdooId]] + const filters: (string | number)[][] = [['partner_id', '=', customerOdooId]] const ticketResponses = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'search_read', - args: [filters, [...TICKET_FIELDS]], + args: [filters, [...TICKET_FIELDS]] as (string | number)[][], logger, })) as Array @@ -130,14 +119,14 @@ export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicket logger, }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const filters: any[] = [['partner_id.email', '=', customerEmail]] + const filters: (string | number | boolean)[][] = [['partner_id.email', '=', customerEmail]] const ticketResponses = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'search_read', - args: [filters, [...TICKET_FIELDS]], + args: [filters, [...TICKET_FIELDS]] as (string | number)[][], logger, })) as Array @@ -173,7 +162,7 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ cookie, model: 'helpdesk.ticket', method: 'write', - args: [[ticketId], updatePayload], + args: [[ticketId], updatePayload] as unknown as (number | Record)[], logger, })) as boolean diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 1fc76095..db43d0e9 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -7,7 +7,7 @@ const cookieCache = new Map() /** * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string */ -const extractCookies = (headers: Record): string => { +const extractCookies = (headers: Record): string => { // Axios normalizes headers to lowercase const setCookieHeaders = headers['set-cookie'] || headers['Set-Cookie'] @@ -54,7 +54,7 @@ export const getAuthenticatedCookie = async ({ // Authenticate using axios.post directly logger.forBot().info(`Authenticating with Odoo: ${odooApiUrl}`) - const response = await axios.post( + const response = (await axios.post( `${odooApiUrl}/web/session/authenticate`, { jsonrpc: '2.0', @@ -70,7 +70,7 @@ export const getAuthenticatedCookie = async ({ 'Content-Type': 'application/json', }, } - ) as AxiosResponse<{ result: { uid: number }, error?: { message: string } }> + )) as AxiosResponse<{ result: { uid: number }; error?: { message: string } }> logger.forBot().info(`Authentication response: ${JSON.stringify(response.data)}`) @@ -87,7 +87,7 @@ export const getAuthenticatedCookie = async ({ } // Extract cookies from response headers - const cookie = extractCookies(response.headers) + const cookie = extractCookies(response.headers as Record) if (!cookie) { logger.forBot().warn('No cookies found in authentication response') } @@ -115,10 +115,15 @@ export const executeOdooMethod = async ({ cookie: string model: 'helpdesk.ticket' | 'helpdesk.stage' | 'helpdesk.team' | 'res.partner' method: 'create' | 'read' | 'write' | 'search' | 'search_read' - args?: any[] - kwargs?: Record + args?: + | (string | number)[][] + | (string | boolean)[][] + | Record[] + | number[] + | (number | Record)[] + kwargs?: Record logger: bp.Logger -}): Promise => { +}): Promise> | number | boolean | string> => { logger .forBot() .info( @@ -139,7 +144,11 @@ export const executeOdooMethod = async ({ 'Content-Type': 'application/json', Cookie: cookie, } - logger.forBot().info(`Odoo method: ${method} on model: ${model} executing with URL: ${url} and body: ${JSON.stringify(body)} and headers: ${JSON.stringify(headers)}`) + logger + .forBot() + .info( + `Odoo method: ${method} on model: ${model} executing with URL: ${url} and body: ${JSON.stringify(body)} and headers: ${JSON.stringify(headers)}` + ) const response = await axios.post(url, body, { headers }) logger.forBot().info(`Odoo Request response data: ${JSON.stringify(response.data)}`) @@ -149,11 +158,7 @@ export const executeOdooMethod = async ({ throw new Error(`Odoo API error: ${JSON.stringify(response.data.error)}`) } - logger - .forBot() - .info( - `Odoo Request response data result: ${JSON.stringify(response.data.result)}` - ) + logger.forBot().info(`Odoo Request response data result: ${JSON.stringify(response.data.result)}`) return response.data.result } diff --git a/integrations/odoo-helpdesk/src/setup/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts index 9b38b83b..ddd5b42c 100644 --- a/integrations/odoo-helpdesk/src/setup/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/setup/helpdesk.ts @@ -14,21 +14,21 @@ export const getHelpdeskTeams = async ({ const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) logger.forBot().info(`Odoo authentication cookie obtained successfully`) - const filters: any[] = [['active', '=', true]] + const filters: (string | boolean)[][] = [['active', '=', true]] const fields: string[] = ['name', 'id'] - const rawOdooHelpdeskTeams = await executeOdooMethod({ + const rawOdooHelpdeskTeams = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.team', method: 'search_read', - args: [filters, fields], + args: [filters, fields] as (string | number)[][], logger, - }) + })) as Array> - const helpdeskTeams = rawOdooHelpdeskTeams.map((team: any) => ({ + const helpdeskTeams = rawOdooHelpdeskTeams.map((team: Record) => ({ name: team.name as string, - id: team.id as number, + id: team.id as unknown as number, })) as Array> return { @@ -48,25 +48,25 @@ export const getStages = async ({ const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const teamIds = input.teamIds - const filters: any[] = [['active', '=', true]] + const filters: (string | number | boolean)[][] = [['active', '=', true]] if (teamIds) { - filters.push(['team_ids', 'in', teamIds]) + filters.push(['team_ids', 'in', teamIds] as (string | number)[]) } const fields: string[] = ['name', 'id', 'team_ids'] - const rawOdooStages = await executeOdooMethod({ + const rawOdooStages = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.stage', method: 'search_read', - args: [filters, fields], + args: [filters, fields] as (string | number)[][], logger, - }) - const stages = rawOdooStages.map((stage: any) => ({ + })) as Array> + const stages = rawOdooStages.map((stage: Record) => ({ name: stage.name as string, - id: stage.id as number, - teamIds: stage.team_ids as number[], + id: stage.id as unknown as number, + teamIds: stage.team_ids as unknown as number[], })) as Array> return { diff --git a/integrations/odoo-helpdesk/src/setup/index.ts b/integrations/odoo-helpdesk/src/setup/index.ts index 65590a7a..d38de813 100644 --- a/integrations/odoo-helpdesk/src/setup/index.ts +++ b/integrations/odoo-helpdesk/src/setup/index.ts @@ -1,4 +1,4 @@ import { register } from './register' import { unregister } from './unregister' -export { register, unregister } \ No newline at end of file +export { register, unregister } diff --git a/integrations/odoo-helpdesk/tsconfig.json b/integrations/odoo-helpdesk/tsconfig.json index c18bb01d..86f886db 100644 --- a/integrations/odoo-helpdesk/tsconfig.json +++ b/integrations/odoo-helpdesk/tsconfig.json @@ -23,5 +23,5 @@ "noUnusedLocals": false, "baseUrl": "." }, - "include": [".botpress/**/*", "src/**/*", "definitions/**/*", "*.ts", "*.json"], + "include": [".botpress/**/*", "src/**/*", "definitions/**/*", "*.ts", "*.json"] } diff --git a/integrations/odoo-helpdesk/version-bump-and-deploy.js b/integrations/odoo-helpdesk/version-bump-and-deploy.js deleted file mode 100755 index 4939d703..00000000 --- a/integrations/odoo-helpdesk/version-bump-and-deploy.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const definitionFile = path.join(__dirname, 'integration.definition.ts'); - -// Read the file -let content = fs.readFileSync(definitionFile, 'utf8'); - -// Find and increment the version -// Matches version: 'x.x.x' or version: "x.x.x" -const versionRegex = /version:\s*['"]([\d]+)\.([\d]+)\.([\d]+)['"]/; -const match = content.match(versionRegex); - -if (!match) { - console.error('Could not find version in integration.definition.ts'); - process.exit(1); -} - -const major = parseInt(match[1]); -const minor = parseInt(match[2]); -const patch = parseInt(match[3]); - -const newVersion = `${major}.${minor}.${patch + 1}`; - -// Replace the version -content = content.replace(versionRegex, `version: '${newVersion}'`); - -// Write the file back -fs.writeFileSync(definitionFile, content, 'utf8'); - -console.log(`Version updated from ${match[1]}.${match[2]}.${match[3]} to ${newVersion}`); - -// Run bp deploy -y -console.log('Running bp deploy -y...'); -try { - execSync('bp deploy -y', { stdio: 'inherit', cwd: __dirname }); - console.log('Deployment completed successfully!'); -} catch (error) { - console.error('Deployment failed:', error.message); - process.exit(1); -} From 409f70bec067a6d6c5cc3c876de3feb8ac8e7a06 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 13:27:58 -0500 Subject: [PATCH 13/29] Fix: Reverting root package.json updates --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d6585a6b..d3acf60d 100644 --- a/package.json +++ b/package.json @@ -70,5 +70,5 @@ "typescript-eslint": "^8.35.1", "vitest": "^3.2.4" }, - "packageManager": "pnpm@10.28.0" + "packageManager": "pnpm@10.12.4" } From f30f9fe900ac988db684f7b735289a98ce051e01 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 13:31:43 -0500 Subject: [PATCH 14/29] Fix: Made version 1.0.0 and renamed integration name --- integrations/odoo-helpdesk/integration.definition.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 2d3efc1f..3ff2d55a 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -3,8 +3,8 @@ import { actions, states } from './definitions' // import { states } from './definitions/states' export default new IntegrationDefinition({ - version: '0.1.0', - name: 'plus/odoo-helpdesk-integration', + version: '1.0.0', + name: 'plus/odoo-helpdesk', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', From caa22f21a6c07315c9a24867e20a24676e6b9e33 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 14:31:48 -0500 Subject: [PATCH 15/29] Fix: Removed unused Priority Class. Forced OdooId to be a number. Made Logging less verbose --- .../definitions/actions/customers.ts | 26 +++++++++++--- .../definitions/actions/helpdesk.ts | 2 +- .../definitions/schemas/index.ts | 4 --- .../definitions/schemas/priority.ts | 14 -------- .../odoo-helpdesk/definitions/states.ts | 2 +- .../odoo-helpdesk/src/actions/customers.ts | 36 +++++++++++-------- .../odoo-helpdesk/src/actions/helpdesk.ts | 2 +- .../odoo-helpdesk/src/actions/tickets.ts | 4 +-- .../odoo-helpdesk/src/services/odoo.ts | 24 +++---------- 9 files changed, 52 insertions(+), 62 deletions(-) delete mode 100644 integrations/odoo-helpdesk/definitions/schemas/priority.ts diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 898724a3..45f2fd36 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -39,8 +39,8 @@ export const fetchCustomerByOdooId: ActionDefinition = { description: 'Fetch a customer by odoo id', input: { schema: z.object({ - id: z.string().title('ID').describe('The id of the customer to fetch'), - odooId: z.string().title('Odoo ID').describe('The odoo id of the customer to fetch'), + 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: { @@ -56,6 +56,7 @@ export const fetchCustomerByEmail: ActionDefinition = { 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: { @@ -79,7 +80,24 @@ export const updateCustomerById: ActionDefinition = { output: { schema: z.object({ success: z.boolean().title('Success').describe('The success of the update'), - error: z.string().title('Error').describe('The error message if the update failed').optional(), + }), + }, +} + +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'), }), }, } @@ -97,7 +115,6 @@ export const updateCustomerByEmail: ActionDefinition = { output: { schema: z.object({ success: z.boolean().title('Success').describe('The success of the update'), - error: z.string().title('Error').describe('The error message if the update failed').optional(), }), }, } @@ -108,5 +125,6 @@ export const actions = { 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 index ca9f6a19..808a0d7e 100644 --- a/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/definitions/actions/helpdesk.ts @@ -19,7 +19,7 @@ export const getStages: ActionDefinition = { description: 'Get all stages', input: { schema: z.object({ - teamId: z.number().title('Team ID').describe('The id of the team to get the stages for'), + teamId: z.number().optional().title('Team ID').describe('The id of the team to get the stages for'), }), }, output: { diff --git a/integrations/odoo-helpdesk/definitions/schemas/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts index f7dcfbc6..5fe10745 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/index.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -1,21 +1,17 @@ import { customerSchema, Customer, CustomerPayload } from './customer' import { helpdeskTeamSchema, HelpdeskTeam, HelpdeskTeamPayload } from './helpdesk-team' -import { prioritySchema, Priority, PriorityPayload } from './priority' import { stageSchema, Stage, StagePayload } from './stage' import { ticketSchema, Ticket, TicketPayload, TicketResponse } from './ticket' export { customerSchema, helpdeskTeamSchema, - prioritySchema, stageSchema, ticketSchema, Customer, CustomerPayload, HelpdeskTeam, HelpdeskTeamPayload, - Priority, - PriorityPayload, Stage, StagePayload, Ticket, diff --git a/integrations/odoo-helpdesk/definitions/schemas/priority.ts b/integrations/odoo-helpdesk/definitions/schemas/priority.ts deleted file mode 100644 index d78adfc4..00000000 --- a/integrations/odoo-helpdesk/definitions/schemas/priority.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from '@botpress/sdk' - -export const prioritySchema = z.object({ - name: z.string().describe('The name of the priority'), - id: z.number().describe('The id of the priority'), -}) - -export type Priority = z.infer - -const priorityPayloadSchema = z.object({ - id: z.number().describe('The id of the priority'), -}) - -export type PriorityPayload = z.infer diff --git a/integrations/odoo-helpdesk/definitions/states.ts b/integrations/odoo-helpdesk/definitions/states.ts index 42caef09..ef5807dd 100644 --- a/integrations/odoo-helpdesk/definitions/states.ts +++ b/integrations/odoo-helpdesk/definitions/states.ts @@ -18,7 +18,7 @@ const customerIdMapping = { type: 'integration' as const, schema: z.object({ customerIdMapping: z - .record(z.string(), z.string()) + .record(z.string(), z.number()) .title('Customer ID Mapping') .describe('Maps Botpress customer IDs to Odoo customer IDs'), }), diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index bd2fb4d5..3e8332f2 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -19,16 +19,14 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async name, } - const odooIdResult = (await executeOdooMethod({ + const odooId = (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'create', args: [customerPayload], logger, - })) as string | number - - const odooId: number = typeof odooIdResult === 'number' ? odooIdResult : parseInt(odooIdResult as string, 10) + })) as number // Store the mapping of bp id to odoo id (as string for storage) const { state } = await client.getOrSetState({ @@ -39,7 +37,7 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async }) const mapping = state.payload?.customerIdMapping || {} - mapping[id] = odooId.toString() + mapping[id] = odooId await client.setState({ type: 'integration', @@ -59,7 +57,7 @@ const fetchCustomer = async ({ logger, }: { ctx: bp.Context - input: { id?: string; odooId?: string; email?: string } + input: { id?: string; odooId?: number; email?: string } logger: bp.Logger }): Promise => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) @@ -135,7 +133,7 @@ export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEma logger, }) => { logger.forBot().info(`Fetching customer by email: ${JSON.stringify(input)}`) - const customer = await fetchCustomer({ ctx, input: { email: input.email }, logger }) + const customer = await fetchCustomer({ ctx, input: { email: input.email, id: input.id }, logger }) return { customer } } @@ -147,13 +145,13 @@ const updateCustomer = async ({ }: { ctx: bp.Context client: bp.Client - input: { id?: string; email?: string; name?: string; phone?: string } + input: { id?: string; email?: string; name?: string; phone?: string; odooId?: number } logger: bp.Logger }): Promise<{ success: boolean }> => { logger.forBot().info(`Updating customer: ${JSON.stringify(input)}`) const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - let odooId: string + let odooId: number = input.odooId || 0 let currentCustomer: Customer // Determine odoo id based on input @@ -174,14 +172,15 @@ const updateCustomer = async ({ } odooId = mappedOdooId - currentCustomer = await fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) + } else if (input.odooId) { + odooId = input.odooId } else if (input.email) { currentCustomer = await fetchCustomer({ ctx, input: { email: input.email }, logger }) const customerOdooId = currentCustomer.odooId if (customerOdooId === undefined) { throw new RuntimeError('Customer not found or missing Odoo ID') } - odooId = customerOdooId.toString() + odooId = customerOdooId } else { throw new RuntimeError('Must provide an id or email to update a customer') } @@ -203,15 +202,13 @@ const updateCustomer = async ({ throw new RuntimeError('No fields provided to update') } - const odooIdNumber = parseInt(odooId, 10) - return { success: (await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'write', - args: [[odooIdNumber], customerPayload] as unknown as (number | Record)[], + args: [[odooId], customerPayload] as unknown as (number | Record)[], logger, })) as boolean, } @@ -226,6 +223,15 @@ export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] logger.forBot().info(`Updating customer by id: ${JSON.stringify(input)}`) return updateCustomer({ ctx, client, input, logger }) } +export const updateCustomerByOdooId: bp.Integration['actions']['updateCustomerByOdooId'] = async ({ + ctx, + client, + input, + logger, +}) => { + logger.forBot().info(`Updating customer by odoo id: ${JSON.stringify(input)}`) + return updateCustomer({ ctx, client, input, logger }) +} export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByEmail'] = async ({ ctx, client, @@ -234,4 +240,4 @@ export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByE }) => { logger.forBot().info(`Updating customer by email: ${JSON.stringify(input)}`) return updateCustomer({ ctx, client, input, logger }) -} +} \ No newline at end of file diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index 8adc18c4..46583042 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -33,7 +33,7 @@ export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, c if (input && input.teamId) { stages = state.payload.helpdeskIntegrationInfo.stages.filter((stage) => - stage.teamIds.includes(input.teamId) + stage.teamIds.includes(input.teamId as number) ) as Array> logger.forBot().info(`Filtered stages: ${JSON.stringify(stages)}`) } diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 89c94630..50f7a3f4 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,7 +1,7 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { Priority, Ticket, TicketPayload, TicketResponse } from 'definitions/schemas' +import { Ticket, TicketPayload, TicketResponse } from 'definitions/schemas' // Common fields to fetch from Odoo (id is automatically included by Odoo's read method) const TICKET_FIELDS = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] as const @@ -153,7 +153,7 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ if (priority !== undefined) updatePayload.priority = priority // Only update if there are fields to update - if (Object.keys(updatePayload).length === 0) return { success: true } + if (Object.keys(updatePayload).length === 0) throw new RuntimeError('No fields provided to update a ticket.') logger.forBot().info(`Updating ticket ${ticketId} with payload: ${JSON.stringify(updatePayload)}`) diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index db43d0e9..06a341bd 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -48,7 +48,7 @@ export const getAuthenticatedCookie = async ({ // Return cached cookie if it exists if (cookieCache.has(cacheKey)) { - logger.forBot().info(`Returning cached Odoo authentication cookie for: ${cacheKey}`) + logger.forBot().debug(`Returning cached Odoo authentication cookie for: ${cacheKey}`) return cookieCache.get(cacheKey)! } @@ -72,8 +72,6 @@ export const getAuthenticatedCookie = async ({ } )) as AxiosResponse<{ result: { uid: number }; error?: { message: string } }> - logger.forBot().info(`Authentication response: ${JSON.stringify(response.data)}`) - // Check for errors first if (response.data?.error) { logger.forBot().error(`Authentication error: ${JSON.stringify(response.data.error)}`) @@ -97,8 +95,6 @@ export const getAuthenticatedCookie = async ({ // Cache the cookie cookieCache.set(cacheKey, cookie) - logger.forBot().info(`Odoo authentication cookie cached for: ${cacheKey}`) - return cookie } @@ -124,11 +120,7 @@ export const executeOdooMethod = async ({ kwargs?: Record logger: bp.Logger }): Promise> | number | boolean | string> => { - logger - .forBot() - .info( - `Executing Odoo method: ${method} on model: ${model} with args: ${JSON.stringify(args)} and kwargs: ${JSON.stringify(kwargs)}` - ) + logger.forBot().info(`Executing Odoo method: ${method} on model: ${model}`) const url = `${odooApiUrl}/web/dataset/call_kw` const body = { @@ -144,21 +136,13 @@ export const executeOdooMethod = async ({ 'Content-Type': 'application/json', Cookie: cookie, } - logger - .forBot() - .info( - `Odoo method: ${method} on model: ${model} executing with URL: ${url} and body: ${JSON.stringify(body)} and headers: ${JSON.stringify(headers)}` - ) - const response = await axios.post(url, body, { headers }) - logger.forBot().info(`Odoo Request response data: ${JSON.stringify(response.data)}`) + const response = await axios.post(url, body, { headers }) if (response.data.error) { logger.forBot().error(`Odoo API error: ${JSON.stringify(response.data.error)}`) throw new Error(`Odoo API error: ${JSON.stringify(response.data.error)}`) } - logger.forBot().info(`Odoo Request response data result: ${JSON.stringify(response.data.result)}`) - return response.data.result -} +} \ No newline at end of file From 5c96ce1e741e96bdfb8bc12e38eecb91fd9b4fa8 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 14:35:41 -0500 Subject: [PATCH 16/29] Fix: Formatted with Prettier --- .../odoo-helpdesk/definitions/actions/customers.ts | 12 ++++++++++-- integrations/odoo-helpdesk/src/actions/customers.ts | 2 +- integrations/odoo-helpdesk/src/services/odoo.ts | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 45f2fd36..28463878 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -40,7 +40,11 @@ export const fetchCustomerByOdooId: ActionDefinition = { 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(), + id: z + .string() + .title('ID') + .describe('The id of the customer to fetch. If provided, the returned customer will have this id.') + .optional(), }), }, output: { @@ -56,7 +60,11 @@ export const fetchCustomerByEmail: ActionDefinition = { 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(), + id: z + .string() + .title('ID') + .describe('The id of the customer to fetch. If provided, the returned customer will have this id.') + .optional(), }), }, output: { diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 3e8332f2..9e1b135f 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -240,4 +240,4 @@ export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByE }) => { logger.forBot().info(`Updating customer by email: ${JSON.stringify(input)}`) return updateCustomer({ ctx, client, input, logger }) -} \ No newline at end of file +} diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 06a341bd..421e4f36 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -145,4 +145,4 @@ export const executeOdooMethod = async ({ } return response.data.result -} \ No newline at end of file +} From 366b0ac5e7febf9e9015433e115edfe2f8369e83 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 14:54:12 -0500 Subject: [PATCH 17/29] Chore: Added TTYL to cookie cache --- .../odoo-helpdesk/src/services/odoo.ts | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 421e4f36..befe288a 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -2,7 +2,15 @@ import axios, { AxiosResponse } from 'axios' import * as bp from '.botpress' // Cache cookies per configuration to avoid re-authenticating -const cookieCache = new Map() +// 30 minutes TTL +const COOKIE_TTL_MS = 30 * 60 * 1000 + +interface CachedCookie { + cookie: string + timestamp: number +} + +const cookieCache = new Map() /** * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string @@ -46,10 +54,18 @@ export const getAuthenticatedCookie = async ({ // Create a cache key from configuration const cacheKey = `${odooApiUrl}-${odooDb}-${odooEmail}` - // Return cached cookie if it exists - if (cookieCache.has(cacheKey)) { - logger.forBot().debug(`Returning cached Odoo authentication cookie for: ${cacheKey}`) - return cookieCache.get(cacheKey)! + // 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 for: ${cacheKey}`) + return cached.cookie + } else { + // Cookie expired, remove from cache + cookieCache.delete(cacheKey) + logger.forBot().debug(`Cached Odoo authentication cookie expired for: ${cacheKey}`) + } } // Authenticate using axios.post directly @@ -92,8 +108,11 @@ export const getAuthenticatedCookie = async ({ logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) - // Cache the cookie - cookieCache.set(cacheKey, cookie) + // Cache the cookie with timestamp + cookieCache.set(cacheKey, { + cookie, + timestamp: Date.now(), + }) return cookie } From 3d3390450a30bcdb18a74646f3ef95af9a4ba9ff Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 15:07:19 -0500 Subject: [PATCH 18/29] Fix: Added copilot revisions --- integrations/odoo-helpdesk/definitions/actions/tickets.ts | 2 +- integrations/odoo-helpdesk/integration.definition.ts | 1 - integrations/odoo-helpdesk/src/actions/helpdesk.ts | 2 +- integrations/odoo-helpdesk/src/setup/register.ts | 4 ++-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index 7ca449d8..f5b31ef9 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -15,7 +15,7 @@ export const createTicket: ActionDefinition = { .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().title('Stage ID').describe('The stage ID associated with the ticket'), + stageId: z.number().optional().title('Stage ID').describe('The stage ID associated with the ticket'), }), }, output: { diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 3ff2d55a..e4e71bdb 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -1,6 +1,5 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { actions, states } from './definitions' -// import { states } from './definitions/states' export default new IntegrationDefinition({ version: '1.0.0', diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index 46583042..bb2766a8 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -31,7 +31,7 @@ export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, c let stages = state.payload.helpdeskIntegrationInfo.stages logger.forBot().info(`Cached stages: ${JSON.stringify(stages)}`) - if (input && input.teamId) { + if (input.teamId) { stages = state.payload.helpdeskIntegrationInfo.stages.filter((stage) => stage.teamIds.includes(input.teamId as number) ) as Array> diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index e5457394..669e256d 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -8,9 +8,9 @@ export const register: bp.IntegrationProps['register'] = async ({ ctx, client, l // Get the Odoo helpdesk teams and ticket stages const { helpdeskTeams } = await getHelpdeskTeams({ ctx, logger }) - logger.forBot().info(`Odoo helpdesk teams: ${JSON.stringify(helpdeskTeams)}`) + logger.forBot().info(`Odoo helpdesk teams retrieved: count=${helpdeskTeams.length}`) const { stages } = await getStages({ ctx, input: { teamIds: helpdeskTeams.map((team) => team.id) }, logger }) - logger.forBot().info(`Odoo ticket stages: ${JSON.stringify(stages)}`) + logger.forBot().info(`Odoo ticket stages retrieved: count=${stages.length}`) // Store ticket stages in integration state await client.getOrSetState({ From 122cd382f17a1b8c8e6e78eb46f65e0e2dfb79b5 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Mon, 19 Jan 2026 16:15:39 -0500 Subject: [PATCH 19/29] Updated Logo --- integrations/odoo-helpdesk/icon.svg | 8 -------- integrations/odoo-helpdesk/integration.definition.ts | 2 +- integrations/odoo-helpdesk/odoo-logo.svg | 6 ++++++ 3 files changed, 7 insertions(+), 9 deletions(-) delete mode 100644 integrations/odoo-helpdesk/icon.svg create mode 100644 integrations/odoo-helpdesk/odoo-logo.svg diff --git a/integrations/odoo-helpdesk/icon.svg b/integrations/odoo-helpdesk/icon.svg deleted file mode 100644 index 91ad303b..00000000 --- a/integrations/odoo-helpdesk/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index e4e71bdb..f8f0b71f 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -7,7 +7,7 @@ export default new IntegrationDefinition({ title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', - icon: 'icon.svg', + icon: 'odoo-logo.svg', configuration: { schema: z.object({ 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 @@ + + + + + + From 4ed650bdfab77af72e3f6e8d550407049d451d60 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Tue, 20 Jan 2026 15:47:18 -0500 Subject: [PATCH 20/29] Fix: Removed `as unknown`, `as` casts when possible, Non-null assertions. Created Zod Response Schemas for all Odoo API calls. Feat: Added Cookie expiration logic. Added pagination support for list opperations. Implemented Cleanup in unregister hook. --- .../definitions/actions/customers.ts | 2 +- .../definitions/actions/tickets.ts | 16 ++ .../definitions/schemas/authentication.ts | 47 +++++ .../definitions/schemas/customer.ts | 30 ++- .../definitions/schemas/helpdesk-team.ts | 9 +- .../definitions/schemas/index.ts | 113 +++++++++- .../odoo-helpdesk/definitions/schemas/odoo.ts | 55 +++++ .../definitions/schemas/stage.ts | 18 +- .../definitions/schemas/ticket.ts | 54 +++-- integrations/odoo-helpdesk/hub.md | 36 +++- .../odoo-helpdesk/integration.definition.ts | 4 +- integrations/odoo-helpdesk/package.json | 2 +- .../odoo-helpdesk/src/actions/customers.ts | 133 ++++++------ .../odoo-helpdesk/src/actions/helpdesk.ts | 22 +- .../odoo-helpdesk/src/actions/tickets.ts | 197 +++++++++++------- .../odoo-helpdesk/src/services/odoo.ts | 115 +++++----- .../odoo-helpdesk/src/setup/helpdesk.ts | 53 ++--- .../odoo-helpdesk/src/setup/register.ts | 2 +- .../odoo-helpdesk/src/setup/unregister.ts | 21 +- 19 files changed, 640 insertions(+), 289 deletions(-) create mode 100644 integrations/odoo-helpdesk/definitions/schemas/authentication.ts create mode 100644 integrations/odoo-helpdesk/definitions/schemas/odoo.ts diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 28463878..35ce739d 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -9,7 +9,7 @@ export const createCustomer: ActionDefinition = { id: z.string().title('ID').describe('The id of the customer'), email: z.string().title('Email').describe('The email of the customer'), name: z.string().title('Name').describe('The name of the customer'), - phone: z.string().title('Phone').describe('The phone of the customer'), + phone: z.string().title('Phone').describe('The phone of the customer').optional(), }), }, output: { diff --git a/integrations/odoo-helpdesk/definitions/actions/tickets.ts b/integrations/odoo-helpdesk/definitions/actions/tickets.ts index f5b31ef9..a40ee019 100644 --- a/integrations/odoo-helpdesk/definitions/actions/tickets.ts +++ b/integrations/odoo-helpdesk/definitions/actions/tickets.ts @@ -46,6 +46,14 @@ export const fetchTicketsByCustomerId: ActionDefinition = { 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: { @@ -61,6 +69,14 @@ export const fetchTicketsByCustomerEmail: ActionDefinition = { 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: { diff --git a/integrations/odoo-helpdesk/definitions/schemas/authentication.ts b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts new file mode 100644 index 00000000..ee2e939e --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts @@ -0,0 +1,47 @@ +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'), +}) + +const authResponseSchema = z.object({ + data: z.object({ + result: z + .object({ + uid: z.number().describe('The UID of the user'), + }) + .optional(), + error: z + .object({ + code: z.number().describe('The error code'), + message: z.string().describe('The error message'), + }) + .optional(), + }), + headers: 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 type AuthPayloadBody = z.infer +export type AuthHeaders = z.infer +export type AuthResponse = z.infer +export type Cookie = z.infer diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index 00dc09fd..183c05d0 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -8,12 +8,34 @@ export const customerSchema = z.object({ phone: z.string().describe('The phone of the customer').optional(), }) -export type Customer = z.infer - -const customerPayloadSchema = z.object({ +export const createCustomerPayloadSchema = z.object({ email: z.string().describe('The email of the customer'), phone: z.string().describe('The phone of the customer').optional(), name: z.string().describe('The name 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 CustomerPayload = z.infer +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 index a656ad1e..b90020f0 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/helpdesk-team.ts @@ -5,10 +5,7 @@ export const helpdeskTeamSchema = z.object({ id: z.number().describe('The id of the helpdesk team'), }) -export type HelpdeskTeam = z.infer +export const fetchHelpdeskTeamResultsSchema = z.array(helpdeskTeamSchema) -const helpdeskTeamPayloadSchema = z.object({ - name: z.string().describe('The name of the helpdesk team'), -}) - -export type HelpdeskTeamPayload = z.infer +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 index 5fe10745..932b1042 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/index.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -1,20 +1,115 @@ -import { customerSchema, Customer, CustomerPayload } from './customer' -import { helpdeskTeamSchema, HelpdeskTeam, HelpdeskTeamPayload } from './helpdesk-team' -import { stageSchema, Stage, StagePayload } from './stage' -import { ticketSchema, Ticket, TicketPayload, TicketResponse } from './ticket' +import { z } from '@botpress/sdk' +import { AuthPayloadBody, AuthHeaders, AuthResponse, Cookie } 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, + OdooRequestModel, + OdooRequestMethod, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooResponseFruitfulObject, + OdooResponseObject, +} 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, stageSchema, + fetchStagesResultsSchema, ticketSchema, + createTicketPayloadSchema, + createTicketResultSchema, + fetchTicketResultSchema, + fetchTicketResultsSchema, + AuthPayloadBody, + AuthHeaders, + AuthResponse, + Cookie, Customer, - CustomerPayload, + CreateCustomerPayload, + CreateCustomerResult, + FetchCustomerResult, + UpdateCustomerPayload, HelpdeskTeam, - HelpdeskTeamPayload, + FetchHelpdeskTeamResults, + OdooRequestModel, + OdooRequestMethod, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooRequestArgs, + OdooResponseFruitfulObject, + OdooResponseObject, Stage, - StagePayload, + FetchStagesResults, Ticket, - TicketPayload, - TicketResponse, + 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..78a12f0c --- /dev/null +++ b/integrations/odoo-helpdesk/definitions/schemas/odoo.ts @@ -0,0 +1,55 @@ +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()]) + +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 diff --git a/integrations/odoo-helpdesk/definitions/schemas/stage.ts b/integrations/odoo-helpdesk/definitions/schemas/stage.ts index 133b4200..a0f40f7d 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/stage.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/stage.ts @@ -1,16 +1,18 @@ import { z } from '@botpress/sdk' export const stageSchema = z.object({ - name: z.string().describe('The name of the stage'), 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 type Stage = z.infer +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'), + }) +) -const stagePayloadSchema = z.object({ - 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 StagePayload = z.infer +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 index c8651b72..e3d34d1b 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/ticket.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/ticket.ts @@ -1,37 +1,61 @@ import { z } from '@botpress/sdk' +import { odooResponseFruitfulObjectSchema, odooResponseObjectSchema } from './odoo' export const ticketSchema = z.object({ - customerOdooId: z.number().describe('The Odoo customer ID associated with the ticket'), 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').optional(), + 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 type Ticket = z.infer -const ticketPayloadSchema = z.object({ +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'), - 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'), + 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 type TicketPayload = z.infer -export const ticketResponseSchema = z.object({ +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: z.tuple([z.number(), z.string()]).describe('The helpdesk team ID and name as a tuple [id, name]'), + team_id: odooResponseFruitfulObjectSchema.describe('The helpdesk team ID and name as a tuple [id, name]'), priority: z - .string() - .describe('The priority of the ticket as a string (e.g., "0", "1", "2", "3")') - .optional() - .nullable(), - partner_id: z.tuple([z.number(), z.string()]).describe('The customer ID and name as a tuple [id, name]'), - stage_id: z.tuple([z.number(), z.string()]).describe('The stage ID and name as a tuple [id, name]').optional(), + .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 TicketResponse = z.infer + +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/hub.md b/integrations/odoo-helpdesk/hub.md index e8ac3af6..8b1c8bfd 100644 --- a/integrations/odoo-helpdesk/hub.md +++ b/integrations/odoo-helpdesk/hub.md @@ -33,7 +33,7 @@ Create a new customer in Odoo with their contact information. - `id` (required): A unique identifier for the customer in your Botpress system - `email` (required): The customer's email address - `name` (required): The customer's name -- `phone` (required): The customer's phone number +- `phone` (optional): The customer's phone number **Output:** @@ -101,7 +101,21 @@ Update an existing customer's information using their Botpress ID. **Output:** - `success`: Boolean indicating if the update was successful -- `error`: Error message if the update failed + +#### Update Customer By Odoo ID + +Update an existing customer's information using their Odoo ID directly. + +**Input:** + +- `odooId` (required): The Odoo customer ID +- `email` (optional): New email address +- `name` (optional): New name +- `phone` (optional): New phone number + +**Output:** + +- `success`: Boolean indicating if the update was successful #### Update Customer By Email @@ -116,7 +130,6 @@ Update an existing customer's information using their email address. **Output:** - `success`: Boolean indicating if the update was successful -- `error`: Error message if the update failed ### Ticket Management @@ -131,7 +144,7 @@ Create a new helpdesk ticket in Odoo. - `teamId` (required): The ID of the helpdesk team to assign the ticket to (minimum: 1) - `customerOdooId` (required): The Odoo ID of the customer associated with the ticket - `priority` (optional): Priority level - `"0"` (lowest), `"1"`, `"2"`, or `"3"` (highest) -- `stageId` (required): The ID of the initial ticket stage +- `stageId` (optional): The ID of the initial ticket stage **Output:** @@ -150,6 +163,8 @@ Create a new helpdesk ticket in Odoo. } ``` +Note: `stageId` is optional. If not provided, Odoo will use the default stage for the team. + #### Fetch Ticket By ID Retrieve a ticket by its ID. @@ -169,6 +184,8 @@ Retrieve all tickets associated with a customer using their Odoo ID. **Input:** - `customerOdooId` (required): The Odoo customer ID +- `page` (optional): The page number to fetch (default: 1) +- `pageSize` (optional): The number of tickets per page (default: 100) **Output:** @@ -181,6 +198,8 @@ Retrieve all tickets associated with a customer using their email address. **Input:** - `customerEmail` (required): The customer's email address +- `page` (optional): The page number to fetch (default: 1) +- `pageSize` (optional): The number of tickets per page (default: 100) **Output:** @@ -245,11 +264,12 @@ Retrieve all ticket stages. Optionally filter by team ID to get stages for a spe ## Changelog -### Version 0.1.43 +### Version 1.0.0 - Initial release of Odoo Helpdesk integration -- Customer management actions (create, fetch, update) -- Ticket management actions (create, fetch, update) -- Helpdesk configuration actions (teams, stages) +- Customer management actions (create, fetch by ID/Odoo ID/email, update by ID/Odoo ID/email) +- Ticket management actions (create, fetch by ID/customer ID/customer email, update) +- Helpdesk configuration actions (get teams, get stages) - Automatic ID mapping between Botpress and Odoo - Cookie-based authentication with caching +- Pagination support for fetching tickets by customer diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index f8f0b71f..49d2aca0 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -2,8 +2,8 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { actions, states } from './definitions' export default new IntegrationDefinition({ - version: '1.0.0', - name: 'plus/odoo-helpdesk', + version: '0.1.57', + name: 'odoo-helpdesk-integration', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', diff --git a/integrations/odoo-helpdesk/package.json b/integrations/odoo-helpdesk/package.json index 17ef8f9f..0f800256 100644 --- a/integrations/odoo-helpdesk/package.json +++ b/integrations/odoo-helpdesk/package.json @@ -1,5 +1,5 @@ { - "name": "@bp-templates/empty-integration", + "name": "@bp-templates/odoo-helpdesk", "scripts": { "check:type": "tsc --noEmit", "build": "bp add -y && bp build", diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 9e1b135f..f16c74f0 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,7 +1,21 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { Customer } from 'definitions/schemas' +import { + customerSchema, + createCustomerResultSchema, + fetchCustomerResultSchema, + updateCustomerPayloadSchema, + Customer, + CreateCustomerPayload, + CreateCustomerResult, + FetchCustomerResult, + UpdateCustomerPayload, + OdooRequestFilters, + OdooRequestFields, + OdooRequestArgs, +} from 'definitions/schemas' +import { z } from '@botpress/sdk' export const createCustomer: bp.Integration['actions']['createCustomer'] = async ({ ctx, @@ -9,24 +23,25 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async input: { id, email, name, phone }, logger, }) => { - logger.forBot().info(`Creating customer: ${JSON.stringify({ id, email, name, phone })}`) + logger.forBot().debug(`Creating customer: ${JSON.stringify({ id, email, name, phone })}`) const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const customerPayload: Record = { + const customerPayload: CreateCustomerPayload = { email, phone, name, } - const odooId = (await executeOdooMethod({ + const odooId: CreateCustomerResult = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'create', args: [customerPayload], + schema: createCustomerResultSchema, logger, - })) as number + }) // Store the mapping of bp id to odoo id (as string for storage) const { state } = await client.getOrSetState({ @@ -46,9 +61,7 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async payload: { customerIdMapping: mapping }, }) - return { - odooId, - } + return { odooId } } const fetchCustomer = async ({ @@ -59,45 +72,41 @@ const fetchCustomer = async ({ ctx: bp.Context input: { id?: string; odooId?: number; email?: string } logger: bp.Logger -}): Promise => { +}): Promise<{ customer: Customer }> => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const fields = ['id', 'email', 'name', 'phone'] - const filters: (string | number)[][] = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] + const filters: OdooRequestFilters = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] + const fields: OdooRequestFields = ['id', 'email', 'name', 'phone'] + const args: OdooRequestArgs = [filters, fields] - let rawCustomer: Array> = (await executeOdooMethod({ + let rawCustomer: FetchCustomerResult = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'res.partner', method: 'search_read', - args: [filters, fields] as (string | number)[][], + args, logger, - })) as Array> - - if (rawCustomer.length === 0 || !rawCustomer[0]) throw new RuntimeError('Customer not found') + schema: fetchCustomerResultSchema, + }) + if (rawCustomer.length === 0 || rawCustomer[0]?.id === undefined) throw new RuntimeError('Customer not found') if (rawCustomer.length > 1) throw new RuntimeError('Multiple customers found for the same id') - const customerData = rawCustomer[0] - - const customer: Customer = { - odooId: customerData.id as unknown as number, - email: customerData.email as string, - name: customerData.name as string, - phone: customerData.phone as string, - } - - if (id) { - customer.id = id - } + const customer: Customer = customerSchema.parse({ + id, + odooId: rawCustomer[0].id, + email: rawCustomer[0].email, + name: rawCustomer[0].name, + phone: rawCustomer[0].phone, + }) - return customer + return { customer } } export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = async ({ ctx, client, input, logger, -}) => { +}): Promise<{ customer: Customer }> => { logger.forBot().info(`Fetching customer by id: ${JSON.stringify(input)}`) // Look up the odoo id from the bp id mapping @@ -111,30 +120,24 @@ export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = const mapping = state.payload?.customerIdMapping || {} const odooId = mapping[input.id] - if (!odooId) { - throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) - } - - const customer = await fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) - return { customer } + if (!odooId) throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) + return fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) } export const fetchCustomerByOdooId: bp.Integration['actions']['fetchCustomerByOdooId'] = async ({ ctx, input, logger, -}) => { +}): Promise<{ customer: Customer }> => { logger.forBot().info(`Fetching customer by odoo id: ${JSON.stringify(input)}`) - const customer = await fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) - return { customer } + return fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) } export const fetchCustomerByEmail: bp.Integration['actions']['fetchCustomerByEmail'] = async ({ ctx, input, logger, -}) => { +}): Promise<{ customer: Customer }> => { logger.forBot().info(`Fetching customer by email: ${JSON.stringify(input)}`) - const customer = await fetchCustomer({ ctx, input: { email: input.email, id: input.id }, logger }) - return { customer } + return fetchCustomer({ ctx, input: { email: input.email, id: input.id }, logger }) } const updateCustomer = async ({ @@ -167,53 +170,39 @@ const updateCustomer = async ({ const mapping = state.payload?.customerIdMapping || {} const mappedOdooId = mapping[input.id] - if (!mappedOdooId) { - throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) - } + if (!mappedOdooId) throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) odooId = mappedOdooId } else if (input.odooId) { odooId = input.odooId } else if (input.email) { - currentCustomer = await fetchCustomer({ ctx, input: { email: input.email }, logger }) - const customerOdooId = currentCustomer.odooId - if (customerOdooId === undefined) { - throw new RuntimeError('Customer not found or missing Odoo ID') - } - odooId = customerOdooId + currentCustomer = (await fetchCustomer({ ctx, input: { email: input.email }, logger })).customer + if (currentCustomer.odooId === undefined) throw new RuntimeError('Customer not found or missing Odoo ID') + odooId = currentCustomer.odooId } else { throw new RuntimeError('Must provide an id or email to update a customer') } // Build the update payload with only the fields that are provided - const customerPayload: Record = {} - if (input.email !== undefined) { - customerPayload.email = input.email - } - if (input.name !== undefined) { - customerPayload.name = input.name - } - if (input.phone !== undefined) { - customerPayload.phone = input.phone - } + const customerPayload: UpdateCustomerPayload = updateCustomerPayloadSchema.parse(input) // If no fields to update, return early if (Object.keys(customerPayload).length === 0) { throw new RuntimeError('No fields provided to update') } - return { - success: (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'write', - args: [[odooId], customerPayload] as unknown as (number | Record)[], - logger, - })) as boolean, - } -} + const success: boolean = await executeOdooMethod({ + odooApiUrl: ctx.configuration.odooApiUrl, + cookie, + model: 'res.partner', + method: 'write', + args: [[odooId], customerPayload], + logger, + schema: z.boolean(), + }) + return { success } +} export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] = async ({ ctx, client, diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index bb2766a8..84363a41 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -1,40 +1,34 @@ import * as bp from '.botpress' -import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' -import { z } from '@botpress/sdk' export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = async ({ ctx, client, logger }) => { logger.forBot().info(`Getting cached helpdesk teams...`) - const { state } = (await client.getState({ + const { state } = await client.getState({ type: 'integration', name: 'helpdeskIntegrationInfo', id: ctx.integrationId, - })) as { - state: { payload: { helpdeskIntegrationInfo: { helpdeskTeams: Array> } } } - } + }) const helpdeskTeams = state.payload.helpdeskIntegrationInfo.helpdeskTeams logger.forBot().info(`Cached helpdesk teams: ${JSON.stringify(helpdeskTeams)}`) - return { - helpdeskTeams, - } + return { helpdeskTeams } } export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, client, input, logger }) => { - const { state } = (await client.getState({ + const { state } = await client.getState({ type: 'integration', name: 'helpdeskIntegrationInfo', id: ctx.integrationId, - })) as { state: { payload: { helpdeskIntegrationInfo: { stages: Array> } } } } + }) let stages = state.payload.helpdeskIntegrationInfo.stages logger.forBot().info(`Cached stages: ${JSON.stringify(stages)}`) if (input.teamId) { - stages = state.payload.helpdeskIntegrationInfo.stages.filter((stage) => - stage.teamIds.includes(input.teamId as number) - ) as Array> + stages = state.payload.helpdeskIntegrationInfo.stages.filter( + (stage) => input.teamId !== undefined && stage.teamIds.includes(input.teamId) + ) logger.forBot().info(`Filtered stages: ${JSON.stringify(stages)}`) } diff --git a/integrations/odoo-helpdesk/src/actions/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 50f7a3f4..061ac73a 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,28 +1,47 @@ +import { z } from '@botpress/sdk' import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { Ticket, TicketPayload, TicketResponse } from 'definitions/schemas' - -// Common fields to fetch from Odoo (id is automatically included by Odoo's read method) -const TICKET_FIELDS = ['id', 'name', 'description', 'team_id', 'priority', 'stage_id', 'partner_id'] as const +import { + createTicketResultSchema, + createTicketPayloadSchema, + fetchTicketResultsSchema, + Ticket, + CreateTicketPayload, + FetchTicketResult, + FetchTicketResults, + UpdateTicketPayload, + CreateTicketResult, + OdooRequestArgs, + OdooRequestFilters, + OdooRequestFields, + OdooRequestKwargs, + OdooRequestMethod, + OdooResponseObject, +} from 'definitions/schemas' /** * 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) */ -const mapTicketResponseToTicket = (response: TicketResponse): Ticket => ({ - id: response.id, - customerOdooId: Array.isArray(response.partner_id) ? response.partner_id[0] : response.partner_id, - name: response.name, - description: response.description, - teamId: Array.isArray(response.team_id) ? response.team_id[0] : response.team_id, - priority: response.priority !== undefined && response.priority !== null ? String(response.priority) : undefined, - stageId: response.stage_id - ? Array.isArray(response.stage_id) - ? response.stage_id[0] - : response.stage_id - : undefined, -}) +const mapTicketResponseToTicket = (response: FetchTicketResult): Ticket => { + const extractId = (field: OdooResponseObject) => { + return Array.isArray(field) && field.length > 0 ? field[0] : undefined + } + + const customerOdooId = extractId(response.partner_id) + const stageId = extractId(response.stage_id) + return { + id: response.id, + customerOdooId, + name: response.name, + description: response.description, + teamId: response.team_id[0], + priority: response.priority === false ? '0' : String(response.priority), + stageId, + } +} export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, @@ -31,107 +50,142 @@ export const createTicket: bp.Integration['actions']['createTicket'] = async ({ }) => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const ticketPayload: TicketPayload = { + const ticketPayload: CreateTicketPayload = createTicketPayloadSchema.parse({ name, description, team_id: teamId, - ...(priority !== undefined ? { priority: String(priority) } : {}), partner_id: customerOdooId, + ...(priority !== undefined ? { priority: String(priority) } : {}), ...(stageId ? { stage_id: stageId } : {}), - } + }) - const ticketId = (await executeOdooMethod({ + const ticketId: CreateTicketResult = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'create', - args: [ticketPayload] as unknown as Record[], + args: [ticketPayload], + schema: createTicketResultSchema, logger, - })) as TicketResponse['id'] + }) // Fetch the created ticket to return complete data - const ticketResponses = (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', + const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ + ctx, method: 'read', - args: [[ticketId], [...TICKET_FIELDS]], + filters: [ticketId], logger, - })) as Array + }) - const ticketResponse = ticketResponses[0] - if (!ticketResponse) { - throw new RuntimeError('Failed to fetch created ticket') - } + if (tickets.length !== 1) throw new RuntimeError('Failed to fetch created ticket') + + const ticketResult = tickets[0] + if (ticketResult === undefined || ticketResult === null || ticketResult.id === undefined || isNaN(ticketResult.id)) + throw new RuntimeError('Invalid ticket result') return { - ticketId: mapTicketResponseToTicket(ticketResponse).id, + ticketId: ticketResult.id, } } -export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input: { id }, logger }) => { +const fetchRawTickets = async ({ + ctx, + method, + filters, + pageSize = 100, + page = 1, + logger, +}: { + ctx: bp.Context + method: OdooRequestMethod + filters: OdooRequestFilters + pageSize?: number + page?: number + logger: bp.Logger +}): Promise<{ tickets: FetchTicketResults }> => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - - const ticketResponses = (await executeOdooMethod({ + 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', + } + : {} + const schema = fetchTicketResultsSchema + logger.forBot().info(`Fetching tickets ${JSON.stringify({ method, args, kwargs, pageSize, page })}`) + const tickets: FetchTicketResults = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', + method, + args, + kwargs, + schema, + logger, + }) + logger.forBot().info(`Fetched tickets: ${JSON.stringify(tickets)}`) + return { tickets } +} + +export const fetchTicketById: bp.Integration['actions']['fetchTicketById'] = async ({ ctx, input: { id }, logger }) => { + const { tickets } = await fetchRawTickets({ + ctx, method: 'read', - args: [[id], [...TICKET_FIELDS]], + filters: [id], logger, - })) as Array + }) + + logger.forBot().info(`Fetched tickets: ${JSON.stringify(tickets)}`) - if (ticketResponses.length === 0) throw new RuntimeError(`Ticket with id ${id} not found`) - if (ticketResponses.length > 1) throw new RuntimeError(`Multiple tickets found for id ${id}`) + if (tickets.length === 0) throw new RuntimeError(`Ticket with id ${id} not found`) + if (tickets.length > 1) throw new RuntimeError(`Multiple tickets found for id ${id}`) // We've already validated length > 0, so this is safe - const ticketResponse = ticketResponses[0]! + const ticketResult = tickets[0] + if (ticketResult === undefined || ticketResult === null || ticketResult.id === undefined || ticketResult.id === null) + throw new RuntimeError('Invalid ticket result') + return { - ticket: mapTicketResponseToTicket(ticketResponse), + ticket: mapTicketResponseToTicket(ticketResult), } } export const fetchTicketsByCustomerId: bp.Integration['actions']['fetchTicketsByCustomerId'] = async ({ ctx, - input: { customerOdooId }, + input: { customerOdooId, page, pageSize }, logger, }) => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const filters: (string | number)[][] = [['partner_id', '=', customerOdooId]] - - const ticketResponses = (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', + const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ + ctx, method: 'search_read', - args: [filters, [...TICKET_FIELDS]] as (string | number)[][], + filters: [['partner_id', '=', customerOdooId]], + pageSize, + page, logger, - })) as Array - + }) return { - tickets: ticketResponses.map(mapTicketResponseToTicket), + tickets: tickets.map(mapTicketResponseToTicket), } } export const fetchTicketsByCustomerEmail: bp.Integration['actions']['fetchTicketsByCustomerEmail'] = async ({ ctx, - input: { customerEmail }, + input: { customerEmail, page, pageSize }, logger, }) => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const filters: (string | number | boolean)[][] = [['partner_id.email', '=', customerEmail]] - - const ticketResponses = (await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', + const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ + ctx, method: 'search_read', - args: [filters, [...TICKET_FIELDS]] as (string | number)[][], + filters: [['partner_id.email', '=', customerEmail]], + pageSize, + page, logger, - })) as Array - + }) return { - tickets: ticketResponses.map(mapTicketResponseToTicket), + tickets: tickets.map(mapTicketResponseToTicket), } } @@ -144,7 +198,7 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ // Build update payload with only provided fields (Odoo's write only updates provided fields) // Fields with .optional() will be undefined when not provided by the user - const updatePayload: Partial = {} + const updatePayload: Partial = {} if (name !== undefined) updatePayload.name = name if (description !== undefined) updatePayload.description = description @@ -157,14 +211,15 @@ export const updateTicket: bp.Integration['actions']['updateTicket'] = async ({ logger.forBot().info(`Updating ticket ${ticketId} with payload: ${JSON.stringify(updatePayload)}`) - const success = (await executeOdooMethod({ + const success: boolean = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.ticket', method: 'write', - args: [[ticketId], updatePayload] as unknown as (number | Record)[], + args: [[ticketId], updatePayload], logger, - })) as boolean + schema: z.boolean(), + }) return { success } } diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index befe288a..61638ee4 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -1,27 +1,32 @@ -import axios, { AxiosResponse } from 'axios' +import { z } from '@botpress/sdk' +import axios from 'axios' import * as bp from '.botpress' +import { + AuthResponse, + AuthPayloadBody, + AuthHeaders, + Cookie, + OdooRequestArgs, + OdooRequestKwargs, + OdooRequestModel, + OdooRequestMethod, +} from 'definitions/schemas' +import { RuntimeError } from '@botpress/sdk' // Cache cookies per configuration to avoid re-authenticating // 30 minutes TTL const COOKIE_TTL_MS = 30 * 60 * 1000 -interface CachedCookie { - cookie: string - timestamp: number -} - -const cookieCache = new Map() +const cookieCache = new Map() /** * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string */ -const extractCookies = (headers: Record): string => { +const extractCookiesFromHeaders = (headers: AuthResponse['headers']): Cookie['cookie'] => { // Axios normalizes headers to lowercase - const setCookieHeaders = headers['set-cookie'] || headers['Set-Cookie'] + const setCookieHeaders = headers['set-cookie'] - if (!setCookieHeaders) { - return '' - } + if (!setCookieHeaders) return '' // Handle both array and string formats const cookies = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders] @@ -50,7 +55,7 @@ export const getAuthenticatedCookie = async ({ odooEmail: string odooPassword: string logger: bp.Logger -}): Promise => { +}): Promise => { // Create a cache key from configuration const cacheKey = `${odooApiUrl}-${odooDb}-${odooEmail}` @@ -70,40 +75,37 @@ export const getAuthenticatedCookie = async ({ // Authenticate using axios.post directly logger.forBot().info(`Authenticating with Odoo: ${odooApiUrl}`) - const response = (await axios.post( - `${odooApiUrl}/web/session/authenticate`, - { - jsonrpc: '2.0', - params: { - db: odooDb, - login: odooEmail, - password: odooPassword, - }, - id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance + const payloadBody: AuthPayloadBody = { + jsonrpc: '2.0', + params: { + db: odooDb, + login: odooEmail, + password: odooPassword, }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - )) as AxiosResponse<{ result: { uid: number }; error?: { message: string } }> + id: Math.floor(Date.now() / 1000), // id field for JSON-RPC compliance + } + const headers: AuthHeaders = { + 'Content-Type': 'application/json', + } + const response: AuthResponse = await axios.post(`${odooApiUrl}/web/session/authenticate`, payloadBody, { headers }) // Check for errors first - if (response.data?.error) { + if (response.data.error) { logger.forBot().error(`Authentication error: ${JSON.stringify(response.data.error)}`) - throw new Error(`Authentication failed: ${JSON.stringify(response.data.error)}`) + throw new RuntimeError(`Authentication failed: ${JSON.stringify(response.data.error)}`) } // Then check for uid - if (!response.data.result?.uid) { - logger.forBot().error(`Authentication failed - no uid in response: ${JSON.stringify(response.data)}`) - throw new Error('Authentication failed - no uid in response') + if (response.data.result === undefined || response.data.result.uid === undefined) { + logger.forBot().error(`Authentication failed - no uid in response: ${JSON.stringify(response.data.result)}`) + throw new RuntimeError('Authentication failed - no uid in response') } // Extract cookies from response headers - const cookie = extractCookies(response.headers as Record) - if (!cookie) { + const cookie = extractCookiesFromHeaders(response.headers) + if (cookie === '') { logger.forBot().warn('No cookies found in authentication response') + throw new RuntimeError('No cookies found in authentication response') } logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) @@ -123,23 +125,20 @@ export const executeOdooMethod = async ({ model, method, args, + schema, kwargs, logger, }: { odooApiUrl: string cookie: string - model: 'helpdesk.ticket' | 'helpdesk.stage' | 'helpdesk.team' | 'res.partner' - method: 'create' | 'read' | 'write' | 'search' | 'search_read' - args?: - | (string | number)[][] - | (string | boolean)[][] - | Record[] - | number[] - | (number | Record)[] - kwargs?: Record - logger: bp.Logger -}): Promise> | number | boolean | string> => { - logger.forBot().info(`Executing Odoo method: ${method} on model: ${model}`) + model: OdooRequestModel + method: OdooRequestMethod + args: OdooRequestArgs + schema: z.ZodSchema + kwargs?: OdooRequestKwargs + logger?: bp.Logger +}): Promise> => { + logger?.forBot().info(`Executing Odoo method: ${method} on model: ${model}`) const url = `${odooApiUrl}/web/dataset/call_kw` const body = { @@ -159,9 +158,23 @@ export const executeOdooMethod = async ({ const response = await axios.post(url, body, { headers }) if (response.data.error) { - logger.forBot().error(`Odoo API error: ${JSON.stringify(response.data.error)}`) - throw new Error(`Odoo API error: ${JSON.stringify(response.data.error)}`) + throw new RuntimeError(`Odoo API error: ${JSON.stringify(response.data.error)}`) } - return response.data.result + return schema.parse(response.data.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/setup/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts index ddd5b42c..281184e1 100644 --- a/integrations/odoo-helpdesk/src/setup/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/setup/helpdesk.ts @@ -1,7 +1,14 @@ import * as bp from '.botpress' -import { z } from '@botpress/sdk' import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { helpdeskTeamSchema, stageSchema } from 'definitions/schemas' +import { + fetchHelpdeskTeamResultsSchema, + fetchStagesResultsSchema, + HelpdeskTeam, + FetchHelpdeskTeamResults, + FetchStagesResults, + Stage, + OdooRequestFilters, +} from 'definitions/schemas' // Botpress action handlers export const getHelpdeskTeams = async ({ @@ -10,29 +17,25 @@ export const getHelpdeskTeams = async ({ }: { ctx: bp.Context logger: bp.Logger -}): Promise<{ helpdeskTeams: Array> }> => { +}): Promise<{ helpdeskTeams: HelpdeskTeam[] }> => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) logger.forBot().info(`Odoo authentication cookie obtained successfully`) - const filters: (string | boolean)[][] = [['active', '=', true]] + const filters: OdooRequestFilters = [['active', '=', true]] const fields: string[] = ['name', 'id'] - const rawOdooHelpdeskTeams = (await executeOdooMethod({ + const rawOdooHelpdeskTeams: FetchHelpdeskTeamResults = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.team', method: 'search_read', - args: [filters, fields] as (string | number)[][], + args: [filters, fields], logger, - })) as Array> - - const helpdeskTeams = rawOdooHelpdeskTeams.map((team: Record) => ({ - name: team.name as string, - id: team.id as unknown as number, - })) as Array> + schema: fetchHelpdeskTeamResultsSchema, + }) return { - helpdeskTeams, + helpdeskTeams: rawOdooHelpdeskTeams, } } @@ -44,32 +47,32 @@ export const getStages = async ({ ctx: bp.Context input: { teamIds: number[] } logger: bp.Logger -}): Promise<{ stages: Array> }> => { +}): Promise<{ stages: Stage[] }> => { const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) const teamIds = input.teamIds - const filters: (string | number | boolean)[][] = [['active', '=', true]] + const filters: OdooRequestFilters = [['active', '=', true]] if (teamIds) { - filters.push(['team_ids', 'in', teamIds] as (string | number)[]) + filters.push(['team_ids', 'in', teamIds]) } const fields: string[] = ['name', 'id', 'team_ids'] - const rawOdooStages = (await executeOdooMethod({ + const rawOdooStages: FetchStagesResults = await executeOdooMethod({ odooApiUrl: ctx.configuration.odooApiUrl, cookie, model: 'helpdesk.stage', method: 'search_read', - args: [filters, fields] as (string | number)[][], + args: [filters, fields], logger, - })) as Array> - const stages = rawOdooStages.map((stage: Record) => ({ - name: stage.name as string, - id: stage.id as unknown as number, - teamIds: stage.team_ids as unknown as number[], - })) as Array> + schema: fetchStagesResultsSchema, + }) return { - stages, + stages: rawOdooStages.map((stage) => ({ + name: stage.name, + id: stage.id, + teamIds: stage.team_ids, + })), } } diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index 669e256d..1b5ff3a9 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -13,7 +13,7 @@ export const register: bp.IntegrationProps['register'] = async ({ ctx, client, l logger.forBot().info(`Odoo ticket stages retrieved: count=${stages.length}`) // Store ticket stages in integration state - await client.getOrSetState({ + await client.setState({ type: 'integration', name: 'helpdeskIntegrationInfo', id: ctx.integrationId, diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index f84c6623..48da4f75 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -1,6 +1,25 @@ import * as bp from '.botpress' +import { clearCookieCache } from 'src/services/odoo' -export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => { +export const unregister: bp.IntegrationProps['unregister'] = async ({ logger, client, ctx }) => { logger.forBot().info(`Unregistering Odoo Helpdesk Integration...`) + await client.setState({ + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { + helpdeskIntegrationInfo: { + helpdeskTeams: [], + stages: [], + }, + }, + }) + logger.forBot().info(`Cleared Integration State`) + await clearCookieCache({ + odooApiUrl: ctx.configuration.odooApiUrl, + odooDb: ctx.configuration.odooDb, + odooEmail: ctx.configuration.odooEmail, + logger, + }) logger.forBot().info(`Odoo Helpdesk Integration unregistered successfully`) } From 5943d20dac3230647244147328a7b4692e161e43 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Tue, 20 Jan 2026 15:48:13 -0500 Subject: [PATCH 21/29] fix: Reverted integration definition for production --- integrations/odoo-helpdesk/integration.definition.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 49d2aca0..f8f0b71f 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -2,8 +2,8 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { actions, states } from './definitions' export default new IntegrationDefinition({ - version: '0.1.57', - name: 'odoo-helpdesk-integration', + version: '1.0.0', + name: 'plus/odoo-helpdesk', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', From 570b103356882d7aac69b76517a16d90ce197b5d Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Tue, 20 Jan 2026 16:07:34 -0500 Subject: [PATCH 22/29] Fix: Aligned customer schema with create customer function and schemas --- .../odoo-helpdesk/definitions/actions/customers.ts | 9 +++------ .../odoo-helpdesk/definitions/schemas/customer.ts | 6 +++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 35ce739d..46928bca 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -1,20 +1,17 @@ import { z, ActionDefinition } from '@botpress/sdk' -import { customerSchema } from 'definitions/schemas' +import { customerSchema, createCustomerPayloadSchema, createCustomerResultSchema } from 'definitions/schemas' export const createCustomer: ActionDefinition = { title: 'Create Customer', description: 'Create a new customer', input: { - schema: z.object({ + schema: createCustomerPayloadSchema.extend({ id: z.string().title('ID').describe('The id of the customer'), - email: z.string().title('Email').describe('The email of the customer'), - name: z.string().title('Name').describe('The name of the customer'), - phone: z.string().title('Phone').describe('The phone of the customer').optional(), }), }, output: { schema: z.object({ - odooId: z.number().title('Odoo ID').describe('The odoo id of the created customer'), + odooId: createCustomerResultSchema }), }, } diff --git a/integrations/odoo-helpdesk/definitions/schemas/customer.ts b/integrations/odoo-helpdesk/definitions/schemas/customer.ts index 183c05d0..22a96dd2 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/customer.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/customer.ts @@ -1,17 +1,17 @@ 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(), - email: z.string().describe('The email of the customer'), 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'), - phone: z.string().describe('The phone of the customer').optional(), - name: z.string().describe('The name of the customer').optional(), + 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') From ab5e09310f7132d6b353f8ccb0e5b57a36e04edc Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 11:46:33 -0500 Subject: [PATCH 23/29] Refactor: Update Odoo Helpdesk integration with new repository structure and added axios retry logic - Introduced CustomerIdMappingService and TicketRepository for better separation of concerns. - Enhanced error handling in state management and Odoo API interactions. - Added axios-retry for improved API call resilience. - Updated action handlers to utilize new repository methods for customer and ticket operations. - Removed deprecated setup files and streamlined integration registration and unregistration processes. --- .../definitions/actions/customers.ts | 2 +- .../definitions/schemas/authentication.ts | 49 ++- .../definitions/schemas/index.ts | 21 +- .../odoo-helpdesk/definitions/schemas/odoo.ts | 22 +- integrations/odoo-helpdesk/package.json | 3 +- .../odoo-helpdesk/src/actions/customers.ts | 359 ++++++++++-------- .../odoo-helpdesk/src/actions/helpdesk.ts | 86 +++-- .../odoo-helpdesk/src/actions/tickets.ts | 253 +++++------- .../src/services/customerIdMapping.ts | 97 +++++ .../src/services/customerRepository.ts | 167 ++++++++ .../src/services/helpdeskRepository.ts | 112 ++++++ .../odoo-helpdesk/src/services/odoo.ts | 119 ++++-- .../src/services/ticketRepository.ts | 254 +++++++++++++ .../odoo-helpdesk/src/setup/helpdesk.ts | 78 ---- .../odoo-helpdesk/src/setup/register.ts | 44 ++- .../odoo-helpdesk/src/setup/unregister.ts | 32 +- .../odoo-helpdesk/src/utils/attempt.ts | 116 ++++++ integrations/odoo-helpdesk/src/utils/index.ts | 2 + integrations/odoo-helpdesk/src/utils/state.ts | 73 ++++ 19 files changed, 1374 insertions(+), 515 deletions(-) create mode 100644 integrations/odoo-helpdesk/src/services/customerIdMapping.ts create mode 100644 integrations/odoo-helpdesk/src/services/customerRepository.ts create mode 100644 integrations/odoo-helpdesk/src/services/helpdeskRepository.ts create mode 100644 integrations/odoo-helpdesk/src/services/ticketRepository.ts delete mode 100644 integrations/odoo-helpdesk/src/setup/helpdesk.ts create mode 100644 integrations/odoo-helpdesk/src/utils/attempt.ts create mode 100644 integrations/odoo-helpdesk/src/utils/index.ts create mode 100644 integrations/odoo-helpdesk/src/utils/state.ts diff --git a/integrations/odoo-helpdesk/definitions/actions/customers.ts b/integrations/odoo-helpdesk/definitions/actions/customers.ts index 46928bca..74fa9156 100644 --- a/integrations/odoo-helpdesk/definitions/actions/customers.ts +++ b/integrations/odoo-helpdesk/definitions/actions/customers.ts @@ -11,7 +11,7 @@ export const createCustomer: ActionDefinition = { }, output: { schema: z.object({ - odooId: createCustomerResultSchema + odooId: createCustomerResultSchema, }), }, } diff --git a/integrations/odoo-helpdesk/definitions/schemas/authentication.ts b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts index ee2e939e..e4a25202 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/authentication.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/authentication.ts @@ -14,26 +14,15 @@ const authHeadersSchema = z.object({ 'Content-Type': z.literal('application/json'), }) -const authResponseSchema = z.object({ - data: z.object({ - result: z - .object({ - uid: z.number().describe('The UID of the user'), - }) - .optional(), - error: z - .object({ - code: z.number().describe('The error code'), - message: z.string().describe('The error message'), - }) - .optional(), - }), - headers: z.object({ - 'set-cookie': z - .union([z.string(), z.array(z.string())]) - .optional() - .describe('The set-cookie header from the Odoo API response.'), - }), +/** + * 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({ @@ -41,7 +30,25 @@ const cookieSchema = z.object({ 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 AuthResponse = 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/index.ts b/integrations/odoo-helpdesk/definitions/schemas/index.ts index 932b1042..0fd9cb60 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/index.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/index.ts @@ -1,5 +1,12 @@ import { z } from '@botpress/sdk' -import { AuthPayloadBody, AuthHeaders, AuthResponse, Cookie } from './authentication' +import { + AuthPayloadBody, + AuthHeaders, + AuthResponseHeaders, + Cookie, + AuthResponseData, + authResponseDataSchema, +} from './authentication' import { customerSchema, createCustomerPayloadSchema, @@ -38,6 +45,7 @@ import { odooRequestFiltersSchema, odooRequestFieldsSchema, odooRequestKwargsSchema, + odooApiResponseSchema, OdooRequestModel, OdooRequestMethod, OdooRequestFilters, @@ -45,9 +53,12 @@ import { OdooRequestKwargs, OdooResponseFruitfulObject, OdooResponseObject, + OdooApiResponse, } from './odoo' -// Args schema accepts filter elements plus all payload schemas +/** + * Args schema accepts filter elements plus all payload schemas. + */ const odooRequestArgsSchema = z .union([ z @@ -78,6 +89,7 @@ export { odooRequestFiltersSchema, odooRequestFieldsSchema, odooRequestKwargsSchema, + odooApiResponseSchema, stageSchema, fetchStagesResultsSchema, ticketSchema, @@ -85,9 +97,11 @@ export { createTicketResultSchema, fetchTicketResultSchema, fetchTicketResultsSchema, + authResponseDataSchema, AuthPayloadBody, AuthHeaders, - AuthResponse, + AuthResponseHeaders, + AuthResponseData, Cookie, Customer, CreateCustomerPayload, @@ -104,6 +118,7 @@ export { OdooRequestArgs, OdooResponseFruitfulObject, OdooResponseObject, + OdooApiResponse, Stage, FetchStagesResults, Ticket, diff --git a/integrations/odoo-helpdesk/definitions/schemas/odoo.ts b/integrations/odoo-helpdesk/definitions/schemas/odoo.ts index 78a12f0c..5428f753 100644 --- a/integrations/odoo-helpdesk/definitions/schemas/odoo.ts +++ b/integrations/odoo-helpdesk/definitions/schemas/odoo.ts @@ -1,6 +1,8 @@ import { z } from '@botpress/sdk' -// Base filter element schema - can be extended by other schemas +/** + * Base filter element schema - can be extended by other schemas. + */ export const odooRequestFilterSchema = z.union([ z .tuple([ @@ -46,6 +48,23 @@ export const odooRequestKwargsSchema = z 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 @@ -53,3 +72,4 @@ 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/package.json b/integrations/odoo-helpdesk/package.json index 0f800256..eab659f1 100644 --- a/integrations/odoo-helpdesk/package.json +++ b/integrations/odoo-helpdesk/package.json @@ -10,7 +10,8 @@ "dependencies": { "@botpress/client": "1.28.0", "@botpress/sdk": "5.1.1", - "axios": "^1.6.8" + "axios": "^1.6.8", + "axios-retry": "^4.0.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index f16c74f0..5825e6b2 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -1,31 +1,28 @@ import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' -import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { - customerSchema, - createCustomerResultSchema, - fetchCustomerResultSchema, - updateCustomerPayloadSchema, - Customer, - CreateCustomerPayload, - CreateCustomerResult, - FetchCustomerResult, - UpdateCustomerPayload, - OdooRequestFilters, - OdooRequestFields, - OdooRequestArgs, -} from 'definitions/schemas' -import { z } from '@botpress/sdk' +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, -}) => { - logger.forBot().debug(`Creating customer: ${JSON.stringify({ id, email, name, phone })}`) +}): Promise<{ odooId: number }> => { + logger.forBot().debug(`Creating customer: id=${id}, email=${email}`) - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) const customerPayload: CreateCustomerPayload = { email, @@ -33,200 +30,248 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async name, } - const odooId: CreateCustomerResult = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'create', - args: [customerPayload], - schema: createCustomerResultSchema, - logger, - }) - - // Store the mapping of bp id to odoo id (as string for storage) - const { state } = await client.getOrSetState({ - type: 'integration', - name: 'customerIdMapping', - id: ctx.integrationId, - payload: { customerIdMapping: {} }, - }) - - const mapping = state.payload?.customerIdMapping || {} - mapping[id] = odooId - - await client.setState({ - type: 'integration', - name: 'customerIdMapping', - id: ctx.integrationId, - payload: { customerIdMapping: mapping }, - }) + const odooId = await repository.create(customerPayload) + await idMappingService.setMapping(id, odooId) + logger.forBot().info(`Customer created successfully: id=${id}, odooId=${odooId}`) return { odooId } } -const fetchCustomer = async ({ - ctx, - input: { id, odooId, email }, - logger, -}: { - ctx: bp.Context - input: { id?: string; odooId?: number; email?: string } - logger: bp.Logger -}): Promise<{ customer: Customer }> => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - const filters: OdooRequestFilters = odooId ? [['id', '=', odooId]] : email ? [['email', '=', email]] : [] - const fields: OdooRequestFields = ['id', 'email', 'name', 'phone'] - const args: OdooRequestArgs = [filters, fields] - - let rawCustomer: FetchCustomerResult = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'search_read', - args, - logger, - schema: fetchCustomerResultSchema, - }) - - if (rawCustomer.length === 0 || rawCustomer[0]?.id === undefined) throw new RuntimeError('Customer not found') - if (rawCustomer.length > 1) throw new RuntimeError('Multiple customers found for the same id') - - const customer: Customer = customerSchema.parse({ - id, - odooId: rawCustomer[0].id, - email: rawCustomer[0].email, - name: rawCustomer[0].name, - phone: rawCustomer[0].phone, - }) +/** + * 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') + } + + // Add the Botpress ID if provided. + 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().info(`Fetching customer by id: ${JSON.stringify(input)}`) + logger.forBot().debug(`Fetching customer by id: ${input.id}`) - // Look up the odoo id from the bp id mapping - const { state } = await client.getOrSetState({ - type: 'integration', - name: 'customerIdMapping', - id: ctx.integrationId, - payload: { customerIdMapping: {} }, - }) + const repository = createCustomerRepository(ctx, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) - const mapping = state.payload?.customerIdMapping || {} - const odooId = mapping[input.id] - - if (!odooId) throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) - return fetchCustomer({ ctx, input: { id: input.id, odooId }, logger }) + 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().info(`Fetching customer by odoo id: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input: { id: input.id, odooId: input.odooId }, logger }) + 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().info(`Fetching customer by email: ${JSON.stringify(input)}`) - return fetchCustomer({ ctx, input: { email: input.email, id: input.id }, logger }) -} - -const updateCustomer = async ({ - ctx, - client, - input, - logger, -}: { - ctx: bp.Context - client: bp.Client - input: { id?: string; email?: string; name?: string; phone?: string; odooId?: number } - logger: bp.Logger -}): Promise<{ success: boolean }> => { - logger.forBot().info(`Updating customer: ${JSON.stringify(input)}`) - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + logger.forBot().debug(`Fetching customer by email: ${input.email}`) - let odooId: number = input.odooId || 0 - let currentCustomer: Customer + const repository = createCustomerRepository(ctx, logger) + return fetchCustomerWithId(repository, input.id, undefined, input.email) +} - // Determine odoo id based on input +/** + * 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) { - // Look up the odoo id from the bp id mapping - const { state } = await client.getOrSetState({ - type: 'integration', - name: 'customerIdMapping', - id: ctx.integrationId, - payload: { customerIdMapping: {} }, - }) - - const mapping = state.payload?.customerIdMapping || {} - const mappedOdooId = mapping[input.id] - - if (!mappedOdooId) throw new RuntimeError(`No Odoo ID found for customer ID: ${input.id}`) - - odooId = mappedOdooId - } else if (input.odooId) { - odooId = input.odooId - } else if (input.email) { - currentCustomer = (await fetchCustomer({ ctx, input: { email: input.email }, logger })).customer - if (currentCustomer.odooId === undefined) throw new RuntimeError('Customer not found or missing Odoo ID') - odooId = currentCustomer.odooId - } else { - throw new RuntimeError('Must provide an id or email to update a customer') + return idMappingService.getOdooId(input.id) } - // Build the update payload with only the fields that are provided - const customerPayload: UpdateCustomerPayload = updateCustomerPayloadSchema.parse(input) + if (input.odooId) { + return input.odooId + } - // If no fields to update, return early - if (Object.keys(customerPayload).length === 0) { - throw new RuntimeError('No fields provided to update') + 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 } - const success: boolean = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'res.partner', - method: 'write', - args: [[odooId], customerPayload], - logger, - schema: z.boolean(), - }) + 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().info(`Updating customer by id: ${JSON.stringify(input)}`) - return updateCustomer({ 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, logger) + + 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().info(`Updating customer by odoo id: ${JSON.stringify(input)}`) - return updateCustomer({ 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, logger) + + 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().info(`Updating customer by email: ${JSON.stringify(input)}`) - return updateCustomer({ 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, logger) + + 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 index 84363a41..db0d6253 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -1,36 +1,78 @@ 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().info(`Getting cached helpdesk teams...`) - const { state } = await client.getState({ - type: 'integration', - name: 'helpdeskIntegrationInfo', - id: ctx.integrationId, - }) + logger.forBot().debug(`Getting cached helpdesk teams`) + + const { state } = await safeGetState( + client, + { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }, + logger + ) + + // Validate payload structure. + if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') + } + + // Type guard to safely access helpdeskIntegrationInfo. + if (!('helpdeskIntegrationInfo' in state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo property missing') + } - const helpdeskTeams = state.payload.helpdeskIntegrationInfo.helpdeskTeams + const payload = state.payload as HelpdeskIntegrationInfo + const helpdeskTeams = payload.helpdeskIntegrationInfo.helpdeskTeams - logger.forBot().info(`Cached helpdesk teams: ${JSON.stringify(helpdeskTeams)}`) + logger.forBot().info(`Retrieved ${helpdeskTeams.length} helpdesk teams`) return { helpdeskTeams } } export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, client, input, logger }) => { - const { state } = await client.getState({ - type: 'integration', - name: 'helpdeskIntegrationInfo', - id: ctx.integrationId, - }) - - let stages = state.payload.helpdeskIntegrationInfo.stages - logger.forBot().info(`Cached stages: ${JSON.stringify(stages)}`) - - if (input.teamId) { - stages = state.payload.helpdeskIntegrationInfo.stages.filter( - (stage) => input.teamId !== undefined && stage.teamIds.includes(input.teamId) - ) - logger.forBot().info(`Filtered stages: ${JSON.stringify(stages)}`) + logger.forBot().debug(`Getting cached stages${input.teamId ? ` for teamId=${input.teamId}` : ''}`) + + const { state } = await safeGetState( + client, + { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }, + logger + ) + + // Validate payload structure. + if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') + } + + // Type guard to safely access helpdeskIntegrationInfo. + if (!('helpdeskIntegrationInfo' in state.payload)) { + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo property missing') + } + + const payload = state.payload as HelpdeskIntegrationInfo + 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/tickets.ts b/integrations/odoo-helpdesk/src/actions/tickets.ts index 061ac73a..b75443ef 100644 --- a/integrations/odoo-helpdesk/src/actions/tickets.ts +++ b/integrations/odoo-helpdesk/src/actions/tickets.ts @@ -1,225 +1,142 @@ -import { z } from '@botpress/sdk' import * as bp from '.botpress' import { RuntimeError } from '@botpress/client' -import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { - createTicketResultSchema, - createTicketPayloadSchema, - fetchTicketResultsSchema, - Ticket, - CreateTicketPayload, - FetchTicketResult, - FetchTicketResults, - UpdateTicketPayload, - CreateTicketResult, - OdooRequestArgs, - OdooRequestFilters, - OdooRequestFields, - OdooRequestKwargs, - OdooRequestMethod, - OdooResponseObject, -} from 'definitions/schemas' +import { createTicketRepository, TicketRepository } from 'src/services/ticketRepository' +import { CreateTicketPayload, UpdateTicketPayload } from 'definitions/schemas' /** - * 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) + * 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 */ -const mapTicketResponseToTicket = (response: FetchTicketResult): Ticket => { - const extractId = (field: OdooResponseObject) => { - return Array.isArray(field) && field.length > 0 ? field[0] : undefined - } - - const customerOdooId = extractId(response.partner_id) - const stageId = extractId(response.stage_id) - return { - id: response.id, - customerOdooId, - name: response.name, - description: response.description, - teamId: response.team_id[0], - priority: response.priority === false ? '0' : String(response.priority), - stageId, - } -} - export const createTicket: bp.Integration['actions']['createTicket'] = async ({ ctx, input: { name, description, teamId, priority, stageId, customerOdooId }, logger, }) => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + logger.forBot().debug(`Creating ticket: name=${name}, teamId=${teamId}`) - const ticketPayload: CreateTicketPayload = createTicketPayloadSchema.parse({ + const repository = createTicketRepository(ctx, logger) + + const ticketPayload: CreateTicketPayload = { name, description, team_id: teamId, partner_id: customerOdooId, - ...(priority !== undefined ? { priority: String(priority) } : {}), + priority: priority !== undefined ? String(priority) : '0', ...(stageId ? { stage_id: stageId } : {}), - }) - - const ticketId: CreateTicketResult = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', - method: 'create', - args: [ticketPayload], - schema: createTicketResultSchema, - logger, - }) - - // Fetch the created ticket to return complete data - const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ - ctx, - method: 'read', - filters: [ticketId], - logger, - }) - - if (tickets.length !== 1) throw new RuntimeError('Failed to fetch created ticket') - - const ticketResult = tickets[0] - if (ticketResult === undefined || ticketResult === null || ticketResult.id === undefined || isNaN(ticketResult.id)) - throw new RuntimeError('Invalid ticket result') + } + 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: ticketResult.id, + ticketId: ticket.id, } } -const fetchRawTickets = async ({ - ctx, - method, - filters, - pageSize = 100, - page = 1, - logger, -}: { - ctx: bp.Context - method: OdooRequestMethod - filters: OdooRequestFilters - pageSize?: number - page?: number - logger: bp.Logger -}): Promise<{ tickets: FetchTicketResults }> => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - 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', - } - : {} - const schema = fetchTicketResultsSchema - logger.forBot().info(`Fetching tickets ${JSON.stringify({ method, args, kwargs, pageSize, page })}`) - const tickets: FetchTicketResults = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', - method, - args, - kwargs, - schema, - logger, - }) - logger.forBot().info(`Fetched tickets: ${JSON.stringify(tickets)}`) - return { tickets } -} - +/** + * 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 }) => { - const { tickets } = await fetchRawTickets({ - ctx, - method: 'read', - filters: [id], - logger, - }) + logger.forBot().debug(`Fetching ticket by id: ${id}`) - logger.forBot().info(`Fetched tickets: ${JSON.stringify(tickets)}`) - - if (tickets.length === 0) throw new RuntimeError(`Ticket with id ${id} not found`) - if (tickets.length > 1) throw new RuntimeError(`Multiple tickets found for id ${id}`) - - // We've already validated length > 0, so this is safe - const ticketResult = tickets[0] - if (ticketResult === undefined || ticketResult === null || ticketResult.id === undefined || ticketResult.id === null) - throw new RuntimeError('Invalid ticket result') + const repository = createTicketRepository(ctx, logger) + const ticket = await repository.findById(id) + logger.forBot().info(`Ticket fetched successfully: ticketId=${ticket.id}`) return { - ticket: mapTicketResponseToTicket(ticketResult), + 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, }) => { - const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ - ctx, - method: 'search_read', - filters: [['partner_id', '=', customerOdooId]], - pageSize, - page, - 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: tickets.map(mapTicketResponseToTicket), + 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, }) => { - const { tickets }: { tickets: FetchTicketResults } = await fetchRawTickets({ - ctx, - method: 'search_read', - filters: [['partner_id.email', '=', customerEmail]], - pageSize, - page, - 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: tickets.map(mapTicketResponseToTicket), + 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, }) => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) + logger.forBot().debug(`Updating ticket: ticketId=${ticketId}`) - // Build update payload with only provided fields (Odoo's write only updates provided fields) - // Fields with .optional() will be undefined when not provided by the user - const updatePayload: Partial = {} + const repository = createTicketRepository(ctx, logger) - if (name !== undefined) updatePayload.name = name - if (description !== undefined) updatePayload.description = description - if (teamId !== undefined) updatePayload.team_id = teamId - if (stageId !== undefined) updatePayload.stage_id = stageId - if (priority !== undefined) updatePayload.priority = priority - - // Only update if there are fields to update - if (Object.keys(updatePayload).length === 0) throw new RuntimeError('No fields provided to update a ticket.') - - logger.forBot().info(`Updating ticket ${ticketId} with payload: ${JSON.stringify(updatePayload)}`) + const updatePayload: Partial = { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(teamId !== undefined && { team_id: teamId }), + ...(stageId !== undefined && { stage_id: stageId }), + ...(priority !== undefined && { priority }), + } - const success: boolean = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.ticket', - method: 'write', - args: [[ticketId], updatePayload], - logger, - schema: z.boolean(), - }) + const success = await repository.update(ticketId, updatePayload) + logger.forBot().info(`Ticket updated successfully: ticketId=${ticketId}`) return { success } } diff --git a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts new file mode 100644 index 00000000..48dce127 --- /dev/null +++ b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts @@ -0,0 +1,97 @@ +import * as bp from '.botpress' +import { RuntimeError } from '@botpress/client' +import { safeGetOrSetState, safeSetState } from 'src/utils' + +/** + * Service responsible for managing the mapping between Botpress customer IDs and Odoo customer IDs. + * Follows Single Responsibility Principle - only handles ID mapping operations. + */ +export class CustomerIdMappingService { + private readonly client: bp.Client + private readonly integrationId: string + private readonly logger: bp.Logger + + constructor(client: bp.Client, integrationId: string, logger: bp.Logger) { + this.client = client + this.integrationId = integrationId + this.logger = logger + } + + /** + * 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 }, + }, + this.logger + ) + } + + /** + * 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: {} }, + }, + this.logger + ) + + // Validate payload structure. + if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { + return {} + } + + // Type guard to safely access customerIdMapping. + if ('customerIdMapping' in state.payload) { + const mapping = state.payload.customerIdMapping + if ( + mapping && + typeof mapping === 'object' && + !Array.isArray(mapping) && + Object.values(mapping).every((v) => typeof v === 'number') + ) { + return mapping as Record + } + } + + return {} + } +} 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 index 61638ee4..2589198c 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -1,39 +1,68 @@ import { z } from '@botpress/sdk' -import axios from 'axios' +import axios, { AxiosInstance } from 'axios' +import axiosRetry from 'axios-retry' import * as bp from '.botpress' +import { RuntimeError } from '@botpress/sdk' import { - AuthResponse, AuthPayloadBody, AuthHeaders, + AuthResponseHeaders, Cookie, + authResponseDataSchema, OdooRequestArgs, OdooRequestKwargs, OdooRequestModel, OdooRequestMethod, + odooApiResponseSchema, } from 'definitions/schemas' -import { RuntimeError } from '@botpress/sdk' -// Cache cookies per configuration to avoid re-authenticating -// 30 minutes TTL +// Cache cookies per configuration to avoid re-authenticating. +// 30 minutes TTL. const COOKIE_TTL_MS = 30 * 60 * 1000 const cookieCache = new Map() /** - * Extracts cookies from Set-Cookie headers and returns them as a Cookie header string + * 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: AuthResponse['headers']): Cookie['cookie'] => { - // Axios normalizes headers to lowercase +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 + // Handle both array and string formats. const cookies = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders] - // Extract cookie name=value pairs from Set-Cookie headers + // Extract cookie name=value pairs from Set-Cookie headers. // Set-Cookie format: "name=value; Path=/; HttpOnly" - // We only need "name=value" + // We only need "name=value". return cookies .map((cookie: string) => { const match = cookie.match(/^([^=]+=[^;]+)/) @@ -56,25 +85,25 @@ export const getAuthenticatedCookie = async ({ odooPassword: string logger: bp.Logger }): Promise => { - // Create a cache key from configuration + // Create a cache key from configuration. const cacheKey = `${odooApiUrl}-${odooDb}-${odooEmail}` - // Check if cached cookie exists and is still valid + // 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 for: ${cacheKey}`) + logger.forBot().debug(`Returning cached Odoo authentication cookie`) return cached.cookie } else { - // Cookie expired, remove from cache + // Cookie expired, remove from cache. cookieCache.delete(cacheKey) - logger.forBot().debug(`Cached Odoo authentication cookie expired for: ${cacheKey}`) + logger.forBot().debug(`Cached Odoo authentication cookie expired`) } } - // Authenticate using axios.post directly - logger.forBot().info(`Authenticating with Odoo: ${odooApiUrl}`) + // Authenticate using axios.post directly. + logger.forBot().debug(`Authenticating with Odoo`) const payloadBody: AuthPayloadBody = { jsonrpc: '2.0', params: { @@ -87,30 +116,31 @@ export const getAuthenticatedCookie = async ({ const headers: AuthHeaders = { 'Content-Type': 'application/json', } - const response: AuthResponse = await axios.post(`${odooApiUrl}/web/session/authenticate`, payloadBody, { headers }) - // Check for errors first - if (response.data.error) { - logger.forBot().error(`Authentication error: ${JSON.stringify(response.data.error)}`) - throw new RuntimeError(`Authentication failed: ${JSON.stringify(response.data.error)}`) + const response = await axiosInstance.post(`${odooApiUrl}/web/session/authenticate`, payloadBody, { headers }) + + // Validate response structure. + const validatedResponse = authResponseDataSchema.parse(response.data) + + // Check for errors first. + if (validatedResponse.error) { + throw new RuntimeError(`Authentication failed: ${validatedResponse.error.message}`) } - // Then check for uid - if (response.data.result === undefined || response.data.result.uid === undefined) { - logger.forBot().error(`Authentication failed - no uid in response: ${JSON.stringify(response.data.result)}`) + // Then check for uid. + if (validatedResponse.result === undefined || validatedResponse.result.uid === undefined) { throw new RuntimeError('Authentication failed - no uid in response') } - // Extract cookies from response headers + // Extract cookies from response headers. const cookie = extractCookiesFromHeaders(response.headers) if (cookie === '') { - logger.forBot().warn('No cookies found in authentication response') throw new RuntimeError('No cookies found in authentication response') } - logger.forBot().info(`Authentication successful. UID: ${response.data.result.uid}`) + logger.forBot().info(`Authentication successful. UID: ${validatedResponse.result.uid}`) - // Cache the cookie with timestamp + // Cache the cookie with timestamp. cookieCache.set(cacheKey, { cookie, timestamp: Date.now(), @@ -119,7 +149,7 @@ export const getAuthenticatedCookie = async ({ return cookie } -export const executeOdooMethod = async ({ +export const executeOdooMethod = async ({ odooApiUrl, cookie, model, @@ -134,11 +164,11 @@ export const executeOdooMethod = async ({ model: OdooRequestModel method: OdooRequestMethod args: OdooRequestArgs - schema: z.ZodSchema + schema: T kwargs?: OdooRequestKwargs logger?: bp.Logger -}): Promise> => { - logger?.forBot().info(`Executing Odoo method: ${method} on model: ${model}`) +}): Promise> => { + logger?.forBot().debug(`Executing Odoo method: ${method} on model: ${model}`) const url = `${odooApiUrl}/web/dataset/call_kw` const body = { @@ -155,13 +185,26 @@ export const executeOdooMethod = async ({ Cookie: cookie, } - const response = await axios.post(url, body, { headers }) + const response = await axiosInstance.post(url, body, { headers }) - if (response.data.error) { - throw new RuntimeError(`Odoo API error: ${JSON.stringify(response.data.error)}`) + logger?.forBot().debug(`Odoo API response: ${JSON.stringify(response.data)}`) + + // Validate response structure. + const validatedResponse = odooApiResponseSchema.parse(response.data) + + logger?.forBot().debug(`Odoo API validated response: ${JSON.stringify(validatedResponse)}`) + + 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(response.data.result) + // Log the actual result for debugging + logger?.forBot().debug(`Odoo API result: ${JSON.stringify(validatedResponse.result)}`) + + // Validate and parse the result using the provided schema. + return schema.parse(validatedResponse.result) } export const clearCookieCache = async ({ 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/helpdesk.ts b/integrations/odoo-helpdesk/src/setup/helpdesk.ts deleted file mode 100644 index 281184e1..00000000 --- a/integrations/odoo-helpdesk/src/setup/helpdesk.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as bp from '.botpress' -import { executeOdooMethod, getAuthenticatedCookie } from 'src/services/odoo' -import { - fetchHelpdeskTeamResultsSchema, - fetchStagesResultsSchema, - HelpdeskTeam, - FetchHelpdeskTeamResults, - FetchStagesResults, - Stage, - OdooRequestFilters, -} from 'definitions/schemas' - -// Botpress action handlers -export const getHelpdeskTeams = async ({ - ctx, - logger, -}: { - ctx: bp.Context - logger: bp.Logger -}): Promise<{ helpdeskTeams: HelpdeskTeam[] }> => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - logger.forBot().info(`Odoo authentication cookie obtained successfully`) - - const filters: OdooRequestFilters = [['active', '=', true]] - const fields: string[] = ['name', 'id'] - - const rawOdooHelpdeskTeams: FetchHelpdeskTeamResults = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.team', - method: 'search_read', - args: [filters, fields], - logger, - schema: fetchHelpdeskTeamResultsSchema, - }) - - return { - helpdeskTeams: rawOdooHelpdeskTeams, - } -} - -export const getStages = async ({ - ctx, - input, - logger, -}: { - ctx: bp.Context - input: { teamIds: number[] } - logger: bp.Logger -}): Promise<{ stages: Stage[] }> => { - const cookie = await getAuthenticatedCookie({ ...ctx.configuration, logger }) - - const teamIds = input.teamIds - const filters: OdooRequestFilters = [['active', '=', true]] - if (teamIds) { - filters.push(['team_ids', 'in', teamIds]) - } - - const fields: string[] = ['name', 'id', 'team_ids'] - - const rawOdooStages: FetchStagesResults = await executeOdooMethod({ - odooApiUrl: ctx.configuration.odooApiUrl, - cookie, - model: 'helpdesk.stage', - method: 'search_read', - args: [filters, fields], - logger, - schema: fetchStagesResultsSchema, - }) - - return { - stages: rawOdooStages.map((stage) => ({ - name: stage.name, - id: stage.id, - teamIds: stage.team_ids, - })), - } -} diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index 1b5ff3a9..f535e54b 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -1,25 +1,41 @@ import * as bp from '.botpress' -import { getHelpdeskTeams, getStages } from 'src/setup/helpdesk' 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...`) + logger.forBot().info(`Registering Odoo Helpdesk Integration`) - // Get the Odoo helpdesk teams and ticket stages - const { helpdeskTeams } = await getHelpdeskTeams({ ctx, logger }) - logger.forBot().info(`Odoo helpdesk teams retrieved: count=${helpdeskTeams.length}`) - const { stages } = await getStages({ ctx, input: { teamIds: helpdeskTeams.map((team) => team.id) }, logger }) - logger.forBot().info(`Odoo ticket stages retrieved: count=${stages.length}`) - - // Store ticket stages in integration state - await client.setState({ - type: 'integration', - name: 'helpdeskIntegrationInfo', - id: ctx.integrationId, - payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, + // 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 + ) + logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) } catch (error) { logger.forBot().error(`Failed to register Odoo Helpdesk Integration`, error) diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index 48da4f75..f3e242bb 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -1,25 +1,35 @@ 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 client.setState({ - type: 'integration', - name: 'helpdeskIntegrationInfo', - id: ctx.integrationId, - payload: { - helpdeskIntegrationInfo: { - helpdeskTeams: [], - stages: [], + 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`) + logger + ) + + logger.forBot().info(`Cleared integration 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..f7452f0d --- /dev/null +++ b/integrations/odoo-helpdesk/src/utils/state.ts @@ -0,0 +1,73 @@ +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], + logger: bp.Logger +): 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], + logger: bp.Logger +): 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], + logger: bp.Logger +): 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'}` + ) + } +} From c72e19eafab9f5ce377f7976a0916c8c3de7a05b Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 11:49:06 -0500 Subject: [PATCH 24/29] Chore: Removed unused logger parameter --- integrations/odoo-helpdesk/src/actions/helpdesk.ts | 6 ++---- .../odoo-helpdesk/src/services/customerIdMapping.ts | 6 ++---- integrations/odoo-helpdesk/src/setup/register.ts | 1 - integrations/odoo-helpdesk/src/setup/unregister.ts | 3 +-- integrations/odoo-helpdesk/src/utils/state.ts | 3 --- 5 files changed, 5 insertions(+), 14 deletions(-) diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index db0d6253..20715893 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -19,8 +19,7 @@ export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = a type: 'integration', name: 'helpdeskIntegrationInfo', id: ctx.integrationId, - }, - logger + } ) // Validate payload structure. @@ -50,8 +49,7 @@ export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, c type: 'integration', name: 'helpdeskIntegrationInfo', id: ctx.integrationId, - }, - logger + } ) // Validate payload structure. diff --git a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts index 48dce127..fdd039e7 100644 --- a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts +++ b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts @@ -52,8 +52,7 @@ export class CustomerIdMappingService { name: 'customerIdMapping', id: this.integrationId, payload: { customerIdMapping: mapping }, - }, - this.logger + } ) } @@ -70,8 +69,7 @@ export class CustomerIdMappingService { name: 'customerIdMapping', id: this.integrationId, payload: { customerIdMapping: {} }, - }, - this.logger + } ) // Validate payload structure. diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index f535e54b..0f6adabe 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -33,7 +33,6 @@ export const register: bp.IntegrationProps['register'] = async ({ ctx, client, l id: ctx.integrationId, payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, }, - logger ) logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index f3e242bb..6a0fc1ef 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -18,8 +18,7 @@ export const unregister: bp.IntegrationProps['unregister'] = async ({ logger, cl stages: [], }, }, - }, - logger + } ) logger.forBot().info(`Cleared integration state`) diff --git a/integrations/odoo-helpdesk/src/utils/state.ts b/integrations/odoo-helpdesk/src/utils/state.ts index f7452f0d..b93c6d48 100644 --- a/integrations/odoo-helpdesk/src/utils/state.ts +++ b/integrations/odoo-helpdesk/src/utils/state.ts @@ -14,7 +14,6 @@ import { RuntimeError } from '@botpress/client' export async function safeGetOrSetState( client: bp.Client, params: Parameters[0], - logger: bp.Logger ): Promise>> { try { return await client.getOrSetState(params) @@ -38,7 +37,6 @@ export async function safeGetOrSetState( export async function safeGetState( client: bp.Client, params: Parameters[0], - logger: bp.Logger ): Promise>> { try { return await client.getState(params) @@ -61,7 +59,6 @@ export async function safeGetState( export async function safeSetState( client: bp.Client, params: Parameters[0], - logger: bp.Logger ): Promise { try { await client.setState(params) From 5d11fb354dff240117917acedf73189f9af5a043 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 11:54:08 -0500 Subject: [PATCH 25/29] Fix: Shortened hub.md --- integrations/odoo-helpdesk/hub.md | 275 +----------------------------- 1 file changed, 5 insertions(+), 270 deletions(-) diff --git a/integrations/odoo-helpdesk/hub.md b/integrations/odoo-helpdesk/hub.md index 8b1c8bfd..bda00a8c 100644 --- a/integrations/odoo-helpdesk/hub.md +++ b/integrations/odoo-helpdesk/hub.md @@ -1,275 +1,10 @@ -# Odoo Helpdesk Integration +# Odoo Helpdesk integration -Connect your Botpress chatbot with Odoo Helpdesk to manage tickets and customers directly from your bot. This integration enables you to create, fetch, and update helpdesk tickets and customer records, making it easy to provide customer support through your chatbot. +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. -## Configuration - -### Prerequisites +## Requirements - An Odoo instance with the Helpdesk module installed - Odoo user credentials with appropriate permissions to manage tickets and customers -- Access to your Odoo API URL - -### Setup - -1. **Enable the integration** in your Botpress workspace -2. **Configure the integration** with the following required fields: - - **Odoo API URL**: The base URL of your Odoo instance (e.g., `https://your-odoo-instance.com`) - - **Odoo Database**: The name of your Odoo database (case-sensitive) - - **Odoo Email**: The email address of your Odoo user account - - **Odoo Password**: The password for your Odoo user account (stored securely as a secret) -3. **Save the configuration** - The integration will automatically register and cache helpdesk teams and stages - -## Usage - -### Customer Management - -#### Create Customer - -Create a new customer in Odoo with their contact information. - -**Input:** - -- `id` (required): A unique identifier for the customer in your Botpress system -- `email` (required): The customer's email address -- `name` (required): The customer's name -- `phone` (optional): The customer's phone number - -**Output:** - -- `odooId`: The Odoo ID of the created customer - -**Example:** - -```json -{ - "id": "customer-123", - "email": "john.doe@example.com", - "name": "John Doe", - "phone": "+1234567890" -} -``` - -#### Fetch Customer By ID - -Retrieve a customer using their Botpress ID. The integration automatically maps Botpress IDs to Odoo IDs. - -**Input:** - -- `id` (required): The Botpress customer ID - -**Output:** - -- `customer`: The customer object with Odoo ID, email, name, and phone - -#### Fetch Customer By Odoo ID - -Retrieve a customer using their Odoo ID directly. - -**Input:** - -- `id` (required): The Botpress customer ID (optional, for mapping) -- `odooId` (required): The Odoo customer ID - -**Output:** - -- `customer`: The customer object - -#### Fetch Customer By Email - -Retrieve a customer by their email address. - -**Input:** - -- `email` (required): The customer's email address - -**Output:** - -- `customer`: The customer object - -#### Update Customer By ID - -Update an existing customer's information using their Botpress ID. - -**Input:** - -- `id` (required): The Botpress customer ID -- `email` (optional): New email address -- `name` (optional): New name -- `phone` (optional): New phone number - -**Output:** - -- `success`: Boolean indicating if the update was successful - -#### Update Customer By Odoo ID - -Update an existing customer's information using their Odoo ID directly. - -**Input:** - -- `odooId` (required): The Odoo customer ID -- `email` (optional): New email address -- `name` (optional): New name -- `phone` (optional): New phone number - -**Output:** - -- `success`: Boolean indicating if the update was successful - -#### Update Customer By Email - -Update an existing customer's information using their email address. - -**Input:** - -- `email` (required): The customer's email address -- `name` (optional): New name -- `phone` (optional): New phone number - -**Output:** - -- `success`: Boolean indicating if the update was successful - -### Ticket Management - -#### Create Ticket - -Create a new helpdesk ticket in Odoo. - -**Input:** - -- `name` (required): The ticket title/subject -- `description` (required): The ticket description/details -- `teamId` (required): The ID of the helpdesk team to assign the ticket to (minimum: 1) -- `customerOdooId` (required): The Odoo ID of the customer associated with the ticket -- `priority` (optional): Priority level - `"0"` (lowest), `"1"`, `"2"`, or `"3"` (highest) -- `stageId` (optional): The ID of the initial ticket stage - -**Output:** - -- `ticketId`: The ID of the created ticket - -**Example:** - -```json -{ - "name": "Unable to access account", - "description": "Customer reports login issues", - "teamId": 1, - "customerOdooId": 42, - "priority": "2", - "stageId": 1 -} -``` - -Note: `stageId` is optional. If not provided, Odoo will use the default stage for the team. - -#### Fetch Ticket By ID - -Retrieve a ticket by its ID. - -**Input:** - -- `id` (required): The ticket ID - -**Output:** - -- `ticket`: The ticket object with all details including customer, team, priority, and stage - -#### Fetch Tickets By Customer ID - -Retrieve all tickets associated with a customer using their Odoo ID. - -**Input:** - -- `customerOdooId` (required): The Odoo customer ID -- `page` (optional): The page number to fetch (default: 1) -- `pageSize` (optional): The number of tickets per page (default: 100) - -**Output:** - -- `tickets`: Array of ticket objects - -#### Fetch Tickets By Customer Email - -Retrieve all tickets associated with a customer using their email address. - -**Input:** - -- `customerEmail` (required): The customer's email address -- `page` (optional): The page number to fetch (default: 1) -- `pageSize` (optional): The number of tickets per page (default: 100) - -**Output:** - -- `tickets`: Array of ticket objects - -#### Update Ticket - -Update an existing ticket's properties. - -**Input:** - -- `ticketId` (required): The ID of the ticket to update -- `name` (optional): New ticket title -- `description` (optional): New ticket description -- `teamId` (optional): New helpdesk team ID -- `priority` (optional): New priority level (`"0"`, `"1"`, `"2"`, or `"3"`) -- `stageId` (optional): New stage ID - -**Output:** - -- `success`: Boolean indicating if the update was successful - -### Helpdesk Configuration - -#### Get Helpdesk Teams - -Retrieve all active helpdesk teams from your Odoo instance. Teams are cached during integration registration for improved performance. - -**Input:** None - -**Output:** - -- `helpdeskTeams`: Array of helpdesk team objects with `id` and `name` - -#### Get Stages - -Retrieve all ticket stages. Optionally filter by team ID to get stages for a specific team. - -**Input:** - -- `teamId` (optional): Filter stages by helpdesk team ID - -**Output:** - -- `stages`: Array of stage objects with `id`, `name`, and `teamIds` - -## Use Cases - -- **Customer Support Automation**: Automatically create tickets when customers report issues through your chatbot -- **Ticket Status Updates**: Update ticket stages as issues are resolved -- **Customer Information Management**: Keep customer records synchronized between Botpress and Odoo -- **Ticket Lookup**: Allow customers to check their ticket status by email -- **Support Team Workflows**: Integrate chatbot interactions with your helpdesk team's workflow - -## Limitations - -- The integration requires an active Odoo instance with the Helpdesk module installed -- Customer ID mapping is stored per integration instance and is not shared across workspaces -- Authentication cookies are cached per configuration to reduce API calls, but may need to be refreshed if credentials change -- The integration does not support webhook events from Odoo (ticket updates must be polled or triggered manually) -- Rate limiting depends on your Odoo instance configuration - -## Changelog - -### Version 1.0.0 - -- Initial release of Odoo Helpdesk integration -- Customer management actions (create, fetch by ID/Odoo ID/email, update by ID/Odoo ID/email) -- Ticket management actions (create, fetch by ID/customer ID/customer email, update) -- Helpdesk configuration actions (get teams, get stages) -- Automatic ID mapping between Botpress and Odoo -- Cookie-based authentication with caching -- Pagination support for fetching tickets by customer From f220021d64ebb32a68ffc77dd54c676de0bff3bf Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 11:57:38 -0500 Subject: [PATCH 26/29] Fix: Applied prettier formatting --- .../odoo-helpdesk/src/actions/helpdesk.ts | 26 +++++++--------- .../src/services/customerIdMapping.ts | 30 ++++++++----------- .../odoo-helpdesk/src/setup/register.ts | 15 ++++------ .../odoo-helpdesk/src/setup/unregister.ts | 23 +++++++------- integrations/odoo-helpdesk/src/utils/state.ts | 9 ++---- 5 files changed, 41 insertions(+), 62 deletions(-) diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index 20715893..3425d057 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -13,14 +13,11 @@ type HelpdeskIntegrationInfo = { 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, - } - ) + const { state } = await safeGetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }) // Validate payload structure. if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { @@ -43,14 +40,11 @@ export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = a 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, - } - ) + const { state } = await safeGetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + }) // Validate payload structure. if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { diff --git a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts index fdd039e7..4475cba9 100644 --- a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts +++ b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts @@ -45,15 +45,12 @@ export class CustomerIdMappingService { const mapping = await this.getMapping() mapping[bpId] = odooId - await safeSetState( - this.client, - { - type: 'integration', - name: 'customerIdMapping', - id: this.integrationId, - payload: { customerIdMapping: mapping }, - } - ) + await safeSetState(this.client, { + type: 'integration', + name: 'customerIdMapping', + id: this.integrationId, + payload: { customerIdMapping: mapping }, + }) } /** @@ -62,15 +59,12 @@ export class CustomerIdMappingService { * @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: {} }, - } - ) + const { state } = await safeGetOrSetState(this.client, { + type: 'integration', + name: 'customerIdMapping', + id: this.integrationId, + payload: { customerIdMapping: {} }, + }) // Validate payload structure. if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { diff --git a/integrations/odoo-helpdesk/src/setup/register.ts b/integrations/odoo-helpdesk/src/setup/register.ts index 0f6adabe..dcbf2009 100644 --- a/integrations/odoo-helpdesk/src/setup/register.ts +++ b/integrations/odoo-helpdesk/src/setup/register.ts @@ -25,15 +25,12 @@ export const register: bp.IntegrationProps['register'] = async ({ ctx, client, l 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 } }, - }, - ) + await safeSetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { helpdeskIntegrationInfo: { helpdeskTeams, stages } }, + }) logger.forBot().info(`Odoo Helpdesk Integration registered successfully`) } catch (error) { diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index 6a0fc1ef..b86da70b 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -6,20 +6,17 @@ 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: [], - }, + await safeSetState(client, { + type: 'integration', + name: 'helpdeskIntegrationInfo', + id: ctx.integrationId, + payload: { + helpdeskIntegrationInfo: { + helpdeskTeams: [], + stages: [], }, - } - ) + }, + }) logger.forBot().info(`Cleared integration state`) diff --git a/integrations/odoo-helpdesk/src/utils/state.ts b/integrations/odoo-helpdesk/src/utils/state.ts index b93c6d48..64e8c7a7 100644 --- a/integrations/odoo-helpdesk/src/utils/state.ts +++ b/integrations/odoo-helpdesk/src/utils/state.ts @@ -13,7 +13,7 @@ import { RuntimeError } from '@botpress/client' */ export async function safeGetOrSetState( client: bp.Client, - params: Parameters[0], + params: Parameters[0] ): Promise>> { try { return await client.getOrSetState(params) @@ -36,7 +36,7 @@ export async function safeGetOrSetState( */ export async function safeGetState( client: bp.Client, - params: Parameters[0], + params: Parameters[0] ): Promise>> { try { return await client.getState(params) @@ -56,10 +56,7 @@ export async function safeGetState( * @param logger - The logger instance * @throws RuntimeError if state operation fails */ -export async function safeSetState( - client: bp.Client, - params: Parameters[0], -): Promise { +export async function safeSetState(client: bp.Client, params: Parameters[0]): Promise { try { await client.setState(params) } catch (error) { From b5bdd685484c5fadee1c95cbc557dfd04efbd9a1 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 13:36:35 -0500 Subject: [PATCH 27/29] Fix: Unregister the customerIdMapping in unregistration --- integrations/odoo-helpdesk/src/services/odoo.ts | 15 --------------- .../odoo-helpdesk/src/setup/unregister.ts | 11 ++++++++++- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/integrations/odoo-helpdesk/src/services/odoo.ts b/integrations/odoo-helpdesk/src/services/odoo.ts index 2589198c..454165c0 100644 --- a/integrations/odoo-helpdesk/src/services/odoo.ts +++ b/integrations/odoo-helpdesk/src/services/odoo.ts @@ -119,20 +119,16 @@ export const getAuthenticatedCookie = async ({ const response = await axiosInstance.post(`${odooApiUrl}/web/session/authenticate`, payloadBody, { headers }) - // Validate response structure. const validatedResponse = authResponseDataSchema.parse(response.data) - // Check for errors first. if (validatedResponse.error) { throw new RuntimeError(`Authentication failed: ${validatedResponse.error.message}`) } - // Then check for uid. if (validatedResponse.result === undefined || validatedResponse.result.uid === undefined) { throw new RuntimeError('Authentication failed - no uid in response') } - // Extract cookies from response headers. const cookie = extractCookiesFromHeaders(response.headers) if (cookie === '') { throw new RuntimeError('No cookies found in authentication response') @@ -140,7 +136,6 @@ export const getAuthenticatedCookie = async ({ logger.forBot().info(`Authentication successful. UID: ${validatedResponse.result.uid}`) - // Cache the cookie with timestamp. cookieCache.set(cacheKey, { cookie, timestamp: Date.now(), @@ -187,23 +182,13 @@ export const executeOdooMethod = async ({ const response = await axiosInstance.post(url, body, { headers }) - logger?.forBot().debug(`Odoo API response: ${JSON.stringify(response.data)}`) - - // Validate response structure. const validatedResponse = odooApiResponseSchema.parse(response.data) - - logger?.forBot().debug(`Odoo API validated response: ${JSON.stringify(validatedResponse)}`) - if (validatedResponse.error) { const errorMessage = validatedResponse.error.message logger?.forBot().error(`Odoo API error: ${errorMessage}`) throw new RuntimeError(`Odoo API error: ${errorMessage}`) } - // Log the actual result for debugging - logger?.forBot().debug(`Odoo API result: ${JSON.stringify(validatedResponse.result)}`) - - // Validate and parse the result using the provided schema. return schema.parse(validatedResponse.result) } diff --git a/integrations/odoo-helpdesk/src/setup/unregister.ts b/integrations/odoo-helpdesk/src/setup/unregister.ts index b86da70b..3ae20481 100644 --- a/integrations/odoo-helpdesk/src/setup/unregister.ts +++ b/integrations/odoo-helpdesk/src/setup/unregister.ts @@ -17,9 +17,18 @@ export const unregister: bp.IntegrationProps['unregister'] = async ({ logger, cl }, }, }) - 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, From 8947eee16f760ef20f52b34beb5a6e2ae8ea74f5 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 13:53:20 -0500 Subject: [PATCH 28/29] Fix: removed unecessary comments. Removed `as` in helpdesk files and unused logger in odoo services. - Updated integration version and name for clarity. - Removed unnecessary logger parameter from CustomerIdMappingService. - Enhanced state payload validation in action handlers for better error handling. - Simplified customer ID mapping retrieval logic. --- .../odoo-helpdesk/integration.definition.ts | 4 +-- .../odoo-helpdesk/src/actions/customers.ts | 11 ++++--- .../odoo-helpdesk/src/actions/helpdesk.ts | 26 +++++++++------- .../src/services/customerIdMapping.ts | 30 ++++++++----------- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index f8f0b71f..4ea14394 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -2,8 +2,8 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { actions, states } from './definitions' export default new IntegrationDefinition({ - version: '1.0.0', - name: 'plus/odoo-helpdesk', + version: '0.1.63', + name: 'odoo-helpdesk-integration', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md', diff --git a/integrations/odoo-helpdesk/src/actions/customers.ts b/integrations/odoo-helpdesk/src/actions/customers.ts index 5825e6b2..38fbb185 100644 --- a/integrations/odoo-helpdesk/src/actions/customers.ts +++ b/integrations/odoo-helpdesk/src/actions/customers.ts @@ -22,7 +22,7 @@ export const createCustomer: bp.Integration['actions']['createCustomer'] = async logger.forBot().debug(`Creating customer: id=${id}, email=${email}`) const repository = createCustomerRepository(ctx, logger) - const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) const customerPayload: CreateCustomerPayload = { email, @@ -64,7 +64,6 @@ async function fetchCustomerWithId( throw new RuntimeError('Must provide either odooId or email to fetch customer') } - // Add the Botpress ID if provided. if (id) { customer = { ...customer, id } } @@ -91,7 +90,7 @@ export const fetchCustomerById: bp.Integration['actions']['fetchCustomerById'] = logger.forBot().debug(`Fetching customer by id: ${input.id}`) const repository = createCustomerRepository(ctx, logger) - const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) const odooId = await idMappingService.getOdooId(input.id) return fetchCustomerWithId(repository, input.id, odooId, undefined) @@ -217,7 +216,7 @@ export const updateCustomerById: bp.Integration['actions']['updateCustomerById'] logger.forBot().debug(`Updating customer by id: ${input.id}`) const repository = createCustomerRepository(ctx, logger) - const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) const result = await updateCustomer(repository, idMappingService, input) logger.forBot().info(`Customer updated successfully: id=${input.id}`) @@ -243,7 +242,7 @@ export const updateCustomerByOdooId: bp.Integration['actions']['updateCustomerBy logger.forBot().debug(`Updating customer by odoo id: ${input.odooId}`) const repository = createCustomerRepository(ctx, logger) - const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) const result = await updateCustomer(repository, idMappingService, input) logger.forBot().info(`Customer updated successfully: odooId=${input.odooId}`) @@ -269,7 +268,7 @@ export const updateCustomerByEmail: bp.Integration['actions']['updateCustomerByE logger.forBot().debug(`Updating customer by email: ${input.email}`) const repository = createCustomerRepository(ctx, logger) - const idMappingService = new CustomerIdMappingService(client, ctx.integrationId, logger) + const idMappingService = new CustomerIdMappingService(client, ctx.integrationId) const result = await updateCustomer(repository, idMappingService, input) logger.forBot().info(`Customer updated successfully: email=${input.email}`) diff --git a/integrations/odoo-helpdesk/src/actions/helpdesk.ts b/integrations/odoo-helpdesk/src/actions/helpdesk.ts index 3425d057..e1a673eb 100644 --- a/integrations/odoo-helpdesk/src/actions/helpdesk.ts +++ b/integrations/odoo-helpdesk/src/actions/helpdesk.ts @@ -19,17 +19,19 @@ export const getHelpdeskTeams: bp.Integration['actions']['getHelpdeskTeams'] = a id: ctx.integrationId, }) - // Validate payload structure. - if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') } - - // Type guard to safely access helpdeskIntegrationInfo. if (!('helpdeskIntegrationInfo' in state.payload)) { throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo property missing') } - const payload = state.payload as HelpdeskIntegrationInfo + const payload: HelpdeskIntegrationInfo = state.payload const helpdeskTeams = payload.helpdeskIntegrationInfo.helpdeskTeams logger.forBot().info(`Retrieved ${helpdeskTeams.length} helpdesk teams`) @@ -46,17 +48,19 @@ export const getStages: bp.Integration['actions']['getStages'] = async ({ ctx, c id: ctx.integrationId, }) - // Validate payload structure. - if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') } - - // Type guard to safely access helpdeskIntegrationInfo. if (!('helpdeskIntegrationInfo' in state.payload)) { - throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo property missing') + throw new RuntimeError('Invalid state payload: helpdeskIntegrationInfo not found') } - const payload = state.payload as HelpdeskIntegrationInfo + const payload: HelpdeskIntegrationInfo = state.payload let stages = payload.helpdeskIntegrationInfo.stages if (input.teamId !== undefined) { diff --git a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts index 4475cba9..233f7d19 100644 --- a/integrations/odoo-helpdesk/src/services/customerIdMapping.ts +++ b/integrations/odoo-helpdesk/src/services/customerIdMapping.ts @@ -1,20 +1,18 @@ 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. - * Follows Single Responsibility Principle - only handles ID mapping operations. */ export class CustomerIdMappingService { private readonly client: bp.Client private readonly integrationId: string - private readonly logger: bp.Logger - constructor(client: bp.Client, integrationId: string, logger: bp.Logger) { + constructor(client: bp.Client, integrationId: string) { this.client = client this.integrationId = integrationId - this.logger = logger } /** @@ -66,24 +64,20 @@ export class CustomerIdMappingService { payload: { customerIdMapping: {} }, }) - // Validate payload structure. - if (!state.payload || typeof state.payload !== 'object' || Array.isArray(state.payload)) { - return {} + if ( + state.payload === undefined || + state.payload === null || + typeof state.payload !== 'object' || + Array.isArray(state.payload) + ) { + throw new RuntimeError('Invalid state payload: customerIdMapping not found') } - // Type guard to safely access customerIdMapping. if ('customerIdMapping' in state.payload) { - const mapping = state.payload.customerIdMapping - if ( - mapping && - typeof mapping === 'object' && - !Array.isArray(mapping) && - Object.values(mapping).every((v) => typeof v === 'number') - ) { - return mapping as Record - } + const mapping: Record = z.record(z.string(), z.number()).parse(state.payload.customerIdMapping) + return mapping } - return {} + throw new RuntimeError('Invalid state payload: customerIdMapping not found') } } From c13905806a0780d18f70641c9630b3a9a650eee7 Mon Sep 17 00:00:00 2001 From: Eric Huang Date: Wed, 21 Jan 2026 13:54:07 -0500 Subject: [PATCH 29/29] fix: Reverted integration definiition version and name for prodution deployment --- integrations/odoo-helpdesk/integration.definition.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/odoo-helpdesk/integration.definition.ts b/integrations/odoo-helpdesk/integration.definition.ts index 4ea14394..f8f0b71f 100644 --- a/integrations/odoo-helpdesk/integration.definition.ts +++ b/integrations/odoo-helpdesk/integration.definition.ts @@ -2,8 +2,8 @@ import { z, IntegrationDefinition } from '@botpress/sdk' import { actions, states } from './definitions' export default new IntegrationDefinition({ - version: '0.1.63', - name: 'odoo-helpdesk-integration', + version: '1.0.0', + name: 'plus/odoo-helpdesk', title: 'Odoo Helpdesk', description: 'Connect with Odoo Helpdesk to manage tickets and customers', readme: 'hub.md',