From ef0140c976762a9ae69d3b5f20c275c15ef24a9c Mon Sep 17 00:00:00 2001 From: Mak <98408710+makhlouf1102@users.noreply.github.com> Date: Wed, 27 May 2026 20:41:16 -0400 Subject: [PATCH 1/2] feat(monday): add OAuth connection flow (#15202) Co-authored-by: Makhlouf Hennine Co-authored-by: Makhlouf Hennine --- integrations/monday/hub.md | 17 ++- integrations/monday/integration.definition.ts | 31 ++++- integrations/monday/linkTemplate.vrl | 4 + integrations/monday/package.json | 1 + integrations/monday/src/actions/index.ts | 14 +- integrations/monday/src/index.ts | 28 +++- integrations/monday/src/misc/auth.ts | 57 ++++++++ .../monday/src/misc/custom-schemas.ts | 9 +- .../monday/src/misc/graphql-queries.ts | 14 ++ integrations/monday/src/misc/monday-client.ts | 48 ++++++- integrations/monday/src/oauth-wizard/index.ts | 15 ++ .../monday/src/oauth-wizard/wizard.ts | 131 ++++++++++++++++++ integrations/monday/tsconfig.json | 2 + pnpm-lock.yaml | 3 + 14 files changed, 349 insertions(+), 25 deletions(-) create mode 100644 integrations/monday/linkTemplate.vrl create mode 100644 integrations/monday/src/misc/auth.ts create mode 100644 integrations/monday/src/oauth-wizard/index.ts create mode 100644 integrations/monday/src/oauth-wizard/wizard.ts diff --git a/integrations/monday/hub.md b/integrations/monday/hub.md index 2620c77aadd..775fb86c7f7 100644 --- a/integrations/monday/hub.md +++ b/integrations/monday/hub.md @@ -4,15 +4,19 @@ ## Configuration -This integration makes use of a personal access token from Monday.com. You need to acquire your personal access token and provide it to the integration when you install it with your bot. +This integration connects to Monday.com with OAuth by default. You can also select manual configuration and provide a personal access token if you prefer to configure access manually. ### Monday -Along with your personal access token, you will need to identify the Board IDs of the Monday.com Boards you would like your bot to interact with. +You will need to identify the Board IDs of the Monday.com boards you would like your bot to interact with. -#### Access token +#### OAuth -Please refer to the [Authentication Guide](https://developer.monday.com/api-reference/docs/authentication#get-your-token) in the Monday documentation to learn how to acquire your personal access token. +Use the authorization button in Botpress to connect your Monday.com account. During the OAuth flow, Monday will ask you to approve access for this integration. + +#### Personal access token + +If you prefer manual configuration, refer to the [Authentication Guide](https://developer.monday.com/api-reference/docs/authentication#get-your-token) in the Monday documentation to learn how to acquire your token. #### Board ID @@ -27,5 +31,6 @@ In the URL provided above, the Board ID would be `9012345678`. Keep this (and an ### Botpress 1. Install the Monday integration in your Botpress bot. -2. Paste the personal access token in the configuration field. -3. Save configuration. +2. Use the default OAuth configuration and click the authorization button to complete the OAuth flow. +3. To configure manually, select **Manual Configuration** and paste your Monday.com personal access token. +4. Save configuration. diff --git a/integrations/monday/integration.definition.ts b/integrations/monday/integration.definition.ts index fda1f152fae..2b9b28deb25 100644 --- a/integrations/monday/integration.definition.ts +++ b/integrations/monday/integration.definition.ts @@ -1,14 +1,21 @@ import { IntegrationDefinition, z } from '@botpress/sdk' -import { configurationSchema, createItemSchema } from 'src/misc/custom-schemas' +import { configurationSchema, createItemSchema, manualConfigurationSchema } from 'src/misc/custom-schemas' export default new IntegrationDefinition({ name: 'monday', title: 'Monday', description: 'Manage items in Monday boards.', - version: '1.0.2', + version: '1.1.2', readme: 'hub.md', icon: 'icon.svg', - states: {}, + states: { + oAuthCredentials: { + type: 'integration', + schema: z.object({ + accessToken: z.string().secret().title('Access Token').describe('The Monday OAuth access token.'), + }), + }, + }, actions: { createItem: { title: 'Create Item', @@ -21,6 +28,24 @@ export default new IntegrationDefinition({ }, configuration: { schema: configurationSchema, + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, + }, + configurations: { + manual: { + title: 'Manual Configuration', + description: 'Configure with your Personal Access Token', + schema: manualConfigurationSchema, + }, + }, + secrets: { + CLIENT_ID: { + description: 'The client ID of the OAuth app.', + }, + CLIENT_SECRET: { + description: 'The client secret of the OAuth app.', + }, }, attributes: { category: 'Project Management', diff --git a/integrations/monday/linkTemplate.vrl b/integrations/monday/linkTemplate.vrl new file mode 100644 index 00000000000..06be15fb414 --- /dev/null +++ b/integrations/monday/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/oauth-redirect?state={{ webhookId }}" diff --git a/integrations/monday/package.json b/integrations/monday/package.json index aff1e8fdf62..0cebbc53e70 100644 --- a/integrations/monday/package.json +++ b/integrations/monday/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@botpress/client": "workspace:*", + "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", "axios": "^1.4.0" } diff --git a/integrations/monday/src/actions/index.ts b/integrations/monday/src/actions/index.ts index b0b63dc23a8..082e1d76bd7 100644 --- a/integrations/monday/src/actions/index.ts +++ b/integrations/monday/src/actions/index.ts @@ -1,14 +1,12 @@ -import { MondayClient } from 'src/misc/monday-client' -import { IntegrationProps } from '.botpress' +import { getMondayClient } from 'src/misc/auth' +import * as bp from '.botpress' -type CreateItem = IntegrationProps['actions']['createItem'] +type CreateItem = bp.IntegrationProps['actions']['createItem'] -export const createItem: CreateItem = async ({ input, ctx }) => { - const client = MondayClient.create({ - personalAccessToken: ctx.configuration.personalAccessToken, - }) +export const createItem: CreateItem = async ({ input, ctx, client }) => { + const mondayClient = await getMondayClient({ client, ctx }) - await client.createItem(input.boardId, { + await mondayClient.createItem(input.boardId, { name: input.itemName, }) diff --git a/integrations/monday/src/index.ts b/integrations/monday/src/index.ts index 3d2ee762858..b406409c5b1 100644 --- a/integrations/monday/src/index.ts +++ b/integrations/monday/src/index.ts @@ -1,10 +1,34 @@ +import { RuntimeError } from '@botpress/sdk' import * as actions from 'src/actions' +import { getMondayClient } from 'src/misc/auth' +import { isOAuthWizardUrl, oauthWizardHandler } from './oauth-wizard' import * as bp from '.botpress' export default new bp.Integration({ - register: async () => {}, + register: async ({ client, ctx }) => { + try { + const mondayClient = await getMondayClient({ client, ctx }) + await mondayClient.validateAccessToken() + + await client.configureIntegration({ + identifier: ctx.webhookId, + }) + } catch (thrown) { + if (thrown instanceof RuntimeError) { + throw thrown + } + + const message = thrown instanceof Error ? thrown.message : String(thrown) + throw new RuntimeError(`Failed to configure Monday integration. Please reconnect your account. (${message})`) + } + }, unregister: async () => {}, actions, channels: {}, - handler: async () => {}, + handler: async (props) => { + if (isOAuthWizardUrl(props.req.path)) { + return await oauthWizardHandler(props) + } + return { status: 404, body: 'Invalid endpoint' } + }, }) diff --git a/integrations/monday/src/misc/auth.ts b/integrations/monday/src/misc/auth.ts new file mode 100644 index 00000000000..877b82d067a --- /dev/null +++ b/integrations/monday/src/misc/auth.ts @@ -0,0 +1,57 @@ +import { RuntimeError, isApiError } from '@botpress/sdk' +import { MondayClient } from './monday-client' +import * as bp from '.botpress' + +type AuthProps = { + client: bp.Client + ctx: bp.Context +} + +export const getOAuthAccessToken = async ({ client, ctx }: AuthProps) => { + try { + const { state } = await client.getState({ + type: 'integration', + name: 'oAuthCredentials', + id: ctx.integrationId, + }) + + return state?.payload.accessToken || undefined + } catch (thrown) { + if (isApiError(thrown) && thrown.code === 404) { + return undefined + } + + throw thrown + } +} + +export const getMondayClient = async (props: AuthProps) => { + if (props.ctx.configurationType === 'manual') { + const { personalAccessToken } = props.ctx.configuration + + if (!personalAccessToken) { + throw new RuntimeError('Monday credentials are missing. Please provide a personal access token.') + } + + return MondayClient.create({ authorization: personalAccessToken }) + } + + let oAuthAccessToken: string | undefined + try { + oAuthAccessToken = await getOAuthAccessToken(props) + } catch (thrown) { + const message = thrown instanceof Error ? thrown.message : String(thrown) + throw new RuntimeError(`Failed to load Monday OAuth credentials. Please reconnect your account. (${message})`) + } + + if (!oAuthAccessToken) { + throw new RuntimeError( + 'Monday credentials are missing. Please connect your Monday account or provide a personal access token.' + ) + } + + return createOAuthMondayClient(oAuthAccessToken) +} + +export const createOAuthMondayClient = (accessToken: string) => + MondayClient.create({ authorization: `Bearer ${accessToken}` }) diff --git a/integrations/monday/src/misc/custom-schemas.ts b/integrations/monday/src/misc/custom-schemas.ts index 84d02dd474b..355fa9288ad 100644 --- a/integrations/monday/src/misc/custom-schemas.ts +++ b/integrations/monday/src/misc/custom-schemas.ts @@ -1,13 +1,14 @@ import { z } from '@botpress/sdk' -export const configurationSchema = z.object({ +export const configurationSchema = z.object({}) + +export const manualConfigurationSchema = z.object({ personalAccessToken: z .string() .min(1) + .secret() .title('Personal Access Token') - .describe( - 'The personal access token for your Monday.com account with sufficient access to manage items on your Monday.com boards.' - ), + .describe('A Monday.com personal access token with sufficient access to manage items on your Monday.com boards.'), }) export const createItemSchema = z.object({ diff --git a/integrations/monday/src/misc/graphql-queries.ts b/integrations/monday/src/misc/graphql-queries.ts index 2508f796d3c..19bd5ee0cad 100644 --- a/integrations/monday/src/misc/graphql-queries.ts +++ b/integrations/monday/src/misc/graphql-queries.ts @@ -8,6 +8,20 @@ type GraphQLQuery = { } export const GRAPHQL_QUERIES = { + validateAccessToken: { + query: ` + query ValidateAccessToken { + boards(limit: 1) { + id + } + }`, + [QUERY_INPUT]: {} as Record, + [QUERY_RESPONSE]: {} as { + boards: { + id: string + }[] + }, + }, createItem: { query: ` mutation CreateNewItem($boardId: ID!, $itemName: String!) { diff --git a/integrations/monday/src/misc/monday-client.ts b/integrations/monday/src/misc/monday-client.ts index 5cadeef6866..f18690762af 100644 --- a/integrations/monday/src/misc/monday-client.ts +++ b/integrations/monday/src/misc/monday-client.ts @@ -2,7 +2,22 @@ import axios, { Axios } from 'axios' import { GRAPHQL_QUERIES, QUERY_INPUT, QUERY_RESPONSE } from './graphql-queries' export type MondayClientConfiguration = { - personalAccessToken: string + authorization: string +} + +type MondayOAuthTokenResponse = { + access_token: string +} + +export type MondayOAuthCredentials = { + accessToken: string +} + +export type ExchangeCodeForTokensInput = { + clientId: string + clientSecret: string + redirectUri: string + code: string } export type CreateItemOptions = { @@ -19,6 +34,24 @@ export type ItemsPageResponse = { nextToken: string | undefined } +export const exchangeCodeForTokens = async ({ + clientId, + clientSecret, + redirectUri, + code, +}: ExchangeCodeForTokensInput): Promise => { + const response = await axios.post('https://auth.monday.com/oauth2/token', { + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + code, + }) + + return { + accessToken: response.data.access_token, + } +} + export class MondayClient { private constructor(private readonly _client: Axios) {} @@ -27,7 +60,7 @@ export class MondayClient { baseURL: 'https://api.monday.com/v2', timeout: 10_000, headers: { - Authorization: config.personalAccessToken, + Authorization: config.authorization, 'API-Version': '2023-07', 'Content-Type': 'application/json', }, @@ -48,6 +81,17 @@ export class MondayClient { return response.data.data } + public async validateAccessToken(): Promise { + try { + const response = await this._executeGraphqlQuery('validateAccessToken', {}) + if (!Array.isArray(response.boards)) { + throw new Error('Monday credentials validation returned an unexpected response.') + } + } catch (thrown) { + throw thrown instanceof Error ? thrown : new Error('Monday credentials validation failed.') + } + } + public async createItem( boardId: string, item: CreateItemOptions diff --git a/integrations/monday/src/oauth-wizard/index.ts b/integrations/monday/src/oauth-wizard/index.ts new file mode 100644 index 00000000000..a805cd41e21 --- /dev/null +++ b/integrations/monday/src/oauth-wizard/index.ts @@ -0,0 +1,15 @@ +import * as wizard from './wizard' +import * as bp from '.botpress' + +export const oauthWizardHandler: bp.IntegrationProps['handler'] = async (props) => { + const { logger } = props + try { + return await wizard.handler(props) + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)) + logger.forBot().error(`OAuth wizard error: ${error.message}`) + return wizard.redirectToInterstitial(false, error.message) + } +} + +export const isOAuthWizardUrl = wizard.isOAuthWizardUrl diff --git a/integrations/monday/src/oauth-wizard/wizard.ts b/integrations/monday/src/oauth-wizard/wizard.ts new file mode 100644 index 00000000000..70a4697af26 --- /dev/null +++ b/integrations/monday/src/oauth-wizard/wizard.ts @@ -0,0 +1,131 @@ +import { OAUTH_IDENTIFIER_HEADER, RuntimeError, type Response } from '@botpress/sdk' +import { createOAuthMondayClient } from 'src/misc/auth' +import { exchangeCodeForTokens } from 'src/misc/monday-client' +import * as bp from '.botpress' + +const OAUTH_CONFIGURATION_ERROR_MESSAGE = 'Unable to complete the Monday OAuth setup. Please try again.' +const BASE_WIZARD_PATH = '/oauth/wizard/' +const DISABLE_INTERSTITIAL_HEADER = { 'x-bp-disable-interstitial': 'true' } as const +const SCOPES = 'boards:read boards:write' + +export const handler = async (props: bp.HandlerProps) => { + if (!isOAuthWizardUrl(props.req.path)) { + throw new RuntimeError('Invalid OAuth wizard URL') + } + + const stepId = props.req.path.slice(BASE_WIZARD_PATH.length) + const query = new URLSearchParams(props.req.query) + + if (stepId === 'oauth-redirect') { + return await _oauthRedirectHandler(props) + } + + if (stepId === 'oauth-callback') { + return await _oauthCallbackHandler(props, query) + } + + throw new RuntimeError(`Unknown OAuth wizard step: ${stepId}`) +} + +const _oauthRedirectHandler = async ({ ctx }: bp.HandlerProps) => { + try { + const url = new URL('https://auth.monday.com/oauth2/authorize') + const params = new URLSearchParams({ + client_id: bp.secrets.CLIENT_ID, + redirect_uri: getOAuthRedirectUri(), + response_type: 'code', + scope: SCOPES, + state: ctx.webhookId, + force_install_if_needed: String(true), + }) + url.search = params.toString() + + return redirectToUrl(url) + } catch (thrown) { + return redirectToInterstitial(false, _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE)) + } +} + +const _oauthCallbackHandler = async ({ ctx, client }: bp.HandlerProps, query: URLSearchParams) => { + try { + const code = query.get('code') + const state = query.get('state') + + if (!code) { + return redirectToInterstitial(false, 'Missing OAuth code') + } + + if (state !== ctx.webhookId) { + return redirectToInterstitial(false, 'Invalid OAuth state') + } + + const credentials = await _exchangeCodeForTokens({ code, redirectUri: getOAuthRedirectUri() }) + const mondayClient = createOAuthMondayClient(credentials.accessToken) + await mondayClient.validateAccessToken() + + await client.setState({ + type: 'integration', + name: 'oAuthCredentials', + id: ctx.integrationId, + payload: { + accessToken: credentials.accessToken, + }, + }) + + await client.configureIntegration({ identifier: ctx.webhookId }) + + const response = redirectToInterstitial(true) + return { + ...response, + headers: { + ...response.headers, + [OAUTH_IDENTIFIER_HEADER]: ctx.webhookId, + }, + } + } catch (thrown) { + return redirectToInterstitial(false, _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE)) + } +} + +const _formatWizardError = (thrown: unknown, fallbackMessage: string) => { + const message = thrown instanceof Error ? thrown.message : String(thrown) + return message ? `${fallbackMessage} (${message})` : fallbackMessage +} + +const _exchangeCodeForTokens = async ({ code, redirectUri }: { code: string; redirectUri: string }) => { + try { + return await exchangeCodeForTokens({ + clientId: bp.secrets.CLIENT_ID, + clientSecret: bp.secrets.CLIENT_SECRET, + redirectUri, + code, + }) + } catch (thrown) { + const message = thrown instanceof Error ? thrown.message : String(thrown) + throw new RuntimeError(`Failed to exchange Monday OAuth code for tokens. ${message}`) + } +} + +const getWizardStepUrl = (stepId: string) => new URL(`${BASE_WIZARD_PATH}${stepId}`, process.env.BP_WEBHOOK_URL) + +const getOAuthRedirectUri = () => getWizardStepUrl('oauth-callback').toString() + +export const isOAuthWizardUrl = (path: string) => path.startsWith(BASE_WIZARD_PATH) + +const redirectToUrl = (url: URL): Response => ({ + status: 303, + headers: { + ...DISABLE_INTERSTITIAL_HEADER, + location: url.toString(), + }, +}) + +export const redirectToInterstitial = (success: boolean, message?: string): Response => { + const url = new URL( + `${process.env.BP_WEBHOOK_URL?.replace('webhook', 'app')}/oauth/interstitial?success=${success}${ + message ? `&errorMessage=${message}` : '' + }` + ) + + return redirectToUrl(url) +} diff --git a/integrations/monday/tsconfig.json b/integrations/monday/tsconfig.json index d46abc5b88f..b94d8463ee4 100644 --- a/integrations/monday/tsconfig.json +++ b/integrations/monday/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", "paths": { "*": ["./*"] }, "outDir": "dist" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18a600c7348..682b93ddc1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1660,6 +1660,9 @@ importers: '@botpress/client': specifier: workspace:* version: link:../../packages/client + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk From 0269aaf2477716c32d7fae4106f595e2c31a0020 Mon Sep 17 00:00:00 2001 From: Mak <98408710+makhlouf1102@users.noreply.github.com> Date: Wed, 27 May 2026 21:23:06 -0400 Subject: [PATCH 2/2] feat(odoo): add helpdesk ticket actions (#15209) Co-authored-by: Makhlouf Hennine Co-authored-by: Makhlouf Hennine --- integrations/odoo/definitions/actions.ts | 176 +++++++++++++++++- integrations/odoo/integration.definition.ts | 2 +- .../odoo/src/odoo-client/OdooClient.ts | 133 +++++++------ .../odoo/src/odoo-client/actions/errors.ts | 2 +- .../contacts/deleteContacts.ts | 12 +- .../actions/implementations/index.ts | 7 + .../implementations/leads/deleteLeads.ts | 12 +- .../implementations/tickets/createTicket.ts | 10 + .../implementations/tickets/deleteTickets.ts | 19 ++ .../actions/implementations/tickets/index.ts | 6 + .../tickets/listTicketFields.ts | 10 + .../implementations/tickets/listTickets.ts | 10 + .../implementations/tickets/searchTickets.ts | 10 + .../implementations/tickets/updateTickets.ts | 19 ++ .../odoo-client/{ => types}/contact.types.ts | 2 - .../odoo/src/odoo-client/types/index.ts | 4 + .../src/odoo-client/{ => types}/lead.types.ts | 2 - .../src/odoo-client/{ => types}/odoo.types.ts | 4 +- .../src/odoo-client/types/ticket.types.ts | 85 +++++++++ 19 files changed, 445 insertions(+), 80 deletions(-) create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/createTicket.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/deleteTickets.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/index.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/listTicketFields.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/listTickets.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/searchTickets.ts create mode 100644 integrations/odoo/src/odoo-client/actions/implementations/tickets/updateTickets.ts rename integrations/odoo/src/odoo-client/{ => types}/contact.types.ts (95%) create mode 100644 integrations/odoo/src/odoo-client/types/index.ts rename integrations/odoo/src/odoo-client/{ => types}/lead.types.ts (95%) rename integrations/odoo/src/odoo-client/{ => types}/odoo.types.ts (80%) create mode 100644 integrations/odoo/src/odoo-client/types/ticket.types.ts diff --git a/integrations/odoo/definitions/actions.ts b/integrations/odoo/definitions/actions.ts index 7b381ea8cca..2ee810b598c 100644 --- a/integrations/odoo/definitions/actions.ts +++ b/integrations/odoo/definitions/actions.ts @@ -29,8 +29,38 @@ const contactFieldsSchema = fieldsSchema.describe( const leadFieldsSchema = fieldsSchema.describe( 'Odoo crm.lead field names to include in the response. Call listLeadFields before choosing fields unless the exact field names were already retrieved in this conversation. To find which contacts are also leads, first retrieve the available contact and lead fields, then read comparable fields.' ) +const ticketFieldsSchema = fieldsSchema.describe( + 'Odoo helpdesk.ticket field names to include in the response. Call listTicketFields before choosing fields unless the exact field names were already retrieved in this conversation.' +) const contactIdsSchema = z.array(z.number()).title('Contact IDs').describe('Odoo contact record IDs.') const leadIdsSchema = z.array(z.number()).title('Lead IDs').describe('Odoo CRM lead record IDs.') +const ticketIdsSchema = z.array(z.number()).title('Ticket IDs').describe('Odoo helpdesk ticket record IDs.') + +const contactValuesSchema = z + .object({ + name: z.string().title('Name').describe('Contact or company display name.'), + email: z.string().title('Email').describe('Contact email address.').optional(), + phone: z.string().title('Phone').describe('Contact phone number.').optional(), + parent_id: z.number().title('Company ID').describe('Related company/contact ID for this contact.').optional(), + is_company: z.boolean().title('Is Company').describe('Whether this contact represents a company.').optional(), + street: z.string().title('Street').describe('Street address.').optional(), + city: z.string().title('City').describe('City.').optional(), + zip: z.string().title('ZIP').describe('Postal or ZIP code.').optional(), + country_id: z.number().title('Country ID').describe('Related Odoo country ID.').optional(), + state_id: z.number().title('State ID').describe('Related Odoo state ID.').optional(), + vat: z.string().title('Tax ID').describe('Tax identification number.').optional(), + website: z.string().title('Website').describe('Contact or company website.').optional(), + }) + .passthrough() + .title('Contact Values') + .describe( + 'Odoo contact field values keyed by field name. Use listContactFields before using custom fields; company names are usually represented by name or parent_id, not company_name.' + ) + +const contactUpdateValuesSchema = contactValuesSchema + .partial() + .title('Contact Values') + .describe('Odoo contact field values to update, keyed by field name.') const leadValuesSchema = z .object({ @@ -66,6 +96,35 @@ const leadUpdateValuesSchema = leadValuesSchema .title('Lead Values') .describe('Odoo CRM lead field values to update, keyed by field name.') +const ticketValuesSchema = z + .object({ + name: z.string().title('Name').describe('Helpdesk ticket title.'), + description: z.string().title('Description').describe('Ticket description or customer request details.').optional(), + partner_id: z.number().title('Customer ID').describe('Related Odoo customer/contact ID.').optional(), + partner_name: z.string().title('Customer Name').describe('Customer name for the ticket.').optional(), + partner_email: z.string().title('Customer Email').describe('Customer email address for the ticket.').optional(), + email_cc: z.string().title('Email CC').describe('Additional email recipients copied on the ticket.').optional(), + team_id: z.number().title('Helpdesk Team ID').describe('Assigned Odoo helpdesk team ID.').optional(), + user_id: z.number().title('Assigned User ID').describe('Assigned Odoo user ID.').optional(), + stage_id: z.number().title('Stage ID').describe('Odoo helpdesk ticket stage ID.').optional(), + ticket_type_id: z.number().title('Ticket Type ID').describe('Odoo helpdesk ticket type ID.').optional(), + priority: z + .enum(['0', '1', '2', '3']) + .title('Priority') + .describe('Odoo ticket priority, from 0 (lowest) to 3 (highest).') + .optional(), + tag_ids: z.array(z.number()).title('Tag IDs').describe('Odoo helpdesk ticket tag IDs.').optional(), + company_id: z.number().title('Company ID').describe('Related Odoo company ID.').optional(), + }) + .passthrough() + .title('Ticket Values') + .describe('Odoo helpdesk ticket field values keyed by field name.') + +const ticketUpdateValuesSchema = ticketValuesSchema + .partial() + .title('Ticket Values') + .describe('Odoo helpdesk ticket field values to update, keyed by field name.') + const notDeletedContactSchema = z .object({ id: z.number().title('Contact ID').describe('Odoo contact record ID.'), @@ -142,6 +201,29 @@ export const actions = { }), }, }, + listTicketFields: { + title: 'List Odoo Ticket Fields', + description: 'List available fields for Odoo helpdesk tickets.', + input: { + schema: z.object({ + allfields: ticketFieldsSchema.optional(), + attributes: z + .array(z.string()) + .title('Attributes') + .describe('Field metadata attributes to return, such as string, type, and required.') + .optional(), + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + fields: z + .record(z.string(), z.unknown()) + .title('Fields') + .describe('Field metadata keyed by Odoo ticket field name.'), + }), + }, + }, searchContacts: { title: 'Search Contacts', description: @@ -199,6 +281,43 @@ export const actions = { }), }, }, + searchTickets: { + title: 'Search Tickets', + description: + 'Search Odoo helpdesk tickets using an Odoo domain and optional read parameters. Call listTicketFields first unless the needed helpdesk.ticket field names were already retrieved in this conversation.', + input: { + schema: z.object({ + domain: odooDomainSchema.optional(), + fields: ticketFieldsSchema.optional(), + offset: z.number().title('Offset').describe('Number of matching tickets to skip.').optional(), + limit: z.number().title('Limit').describe('Maximum number of tickets to return.').optional(), + order: z.string().title('Order').describe('Odoo order expression, such as "name asc".').optional(), + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + records: z.array(odooRecordSchema).title('Records').describe('Matching Odoo helpdesk ticket records.'), + }), + }, + }, + listTickets: { + title: 'List Tickets', + description: + 'List Odoo helpdesk tickets by ID. Call listTicketFields first unless the needed helpdesk.ticket field names were already retrieved in this conversation.', + input: { + schema: z.object({ + ids: ticketIdsSchema, + fields: ticketFieldsSchema.optional(), + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + records: z.array(odooRecordSchema).title('Records').describe('Requested Odoo helpdesk ticket records.'), + }), + }, + }, createLead: { title: 'Create Lead', description: @@ -215,6 +334,22 @@ export const actions = { }), }, }, + createTicket: { + title: 'Create Ticket', + description: + 'Create an Odoo helpdesk ticket. Call listTicketFields first unless the needed helpdesk.ticket field names were already retrieved in this conversation.', + input: { + schema: z.object({ + values: ticketValuesSchema, + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + id: z.number().title('Ticket ID').describe('ID of the created Odoo helpdesk ticket.'), + }), + }, + }, updateLeads: { title: 'Update Leads', description: @@ -232,6 +367,23 @@ export const actions = { }), }, }, + updateTickets: { + title: 'Update Tickets', + description: + 'Update one or more Odoo helpdesk tickets. Call listTicketFields first unless the needed helpdesk.ticket field names were already retrieved in this conversation.', + input: { + schema: z.object({ + ids: ticketIdsSchema, + values: ticketUpdateValuesSchema, + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + updatedIds: ticketIdsSchema.describe('Odoo helpdesk ticket record IDs that were updated.'), + }), + }, + }, deleteLeads: { title: 'Delete Leads', description: 'Delete one or more Odoo CRM leads owned by the specified Odoo user.', @@ -247,7 +399,6 @@ export const actions = { }, output: { schema: z.object({ - message: z.string().title('Message').describe('Summary of which requested leads were deleted or not deleted.'), deletedIds: leadIdsSchema.describe('Odoo CRM lead record IDs that were deleted.'), notDeletedLeads: z .array(notDeletedLeadSchema) @@ -256,6 +407,21 @@ export const actions = { }), }, }, + deleteTickets: { + title: 'Delete Tickets', + description: 'Delete one or more Odoo helpdesk tickets.', + input: { + schema: z.object({ + ids: ticketIdsSchema, + context: odooContextSchema.optional(), + }), + }, + output: { + schema: z.object({ + deletedIds: ticketIdsSchema.describe('Odoo helpdesk ticket record IDs that were deleted.'), + }), + }, + }, listContacts: { title: 'List Contacts', description: @@ -279,7 +445,7 @@ export const actions = { 'Create an Odoo contact. Call listContactFields first unless the needed res.partner field names were already retrieved in this conversation.', input: { schema: z.object({ - values: odooRecordSchema, + values: contactValuesSchema, context: odooContextSchema.optional(), }), }, @@ -296,7 +462,7 @@ export const actions = { input: { schema: z.object({ ids: contactIdsSchema, - values: odooRecordSchema, + values: contactUpdateValuesSchema, context: odooContextSchema.optional(), }), }, @@ -321,10 +487,6 @@ export const actions = { }, output: { schema: z.object({ - message: z - .string() - .title('Message') - .describe('Summary of which requested contacts were deleted or not deleted.'), deletedIds: contactIdsSchema.describe('Odoo contact record IDs that were deleted.'), notDeletedContacts: z .array(notDeletedContactSchema) diff --git a/integrations/odoo/integration.definition.ts b/integrations/odoo/integration.definition.ts index d1bc00ed857..4e55dc1de16 100644 --- a/integrations/odoo/integration.definition.ts +++ b/integrations/odoo/integration.definition.ts @@ -5,7 +5,7 @@ export default new IntegrationDefinition({ name: 'odoo', title: 'Odoo', description: 'Connect Botpress to Odoo records such as leads, contacts, and tickets.', - version: '1.0.0', + version: '2.0.0', readme: 'hub.md', icon: 'icon.svg', attributes: { diff --git a/integrations/odoo/src/odoo-client/OdooClient.ts b/integrations/odoo/src/odoo-client/OdooClient.ts index c79a6a77e50..bb2dd856359 100644 --- a/integrations/odoo/src/odoo-client/OdooClient.ts +++ b/integrations/odoo/src/odoo-client/OdooClient.ts @@ -1,8 +1,8 @@ +import { z } from '@botpress/sdk' import type { GetFieldsOutput, GetFieldsRequest, Model, - OdooRecord, ResPartnerCreateInput, ResPartnerCreateOutput, ResPartnerReadInput, @@ -14,8 +14,6 @@ import type { ResPartnerWriteInput, ResPartnerWriteOutput, ResUsersContextGetOutput, -} from './contact.types' -import type { CrmLeadCreateInput, CrmLeadCreateOutput, CrmLeadFieldsGetInput, @@ -28,43 +26,57 @@ import type { CrmLeadUnlinkOutput, CrmLeadWriteInput, CrmLeadWriteOutput, -} from './lead.types' + HelpdeskTicketCreateInput, + HelpdeskTicketCreateOutput, + HelpdeskTicketFieldsGetInput, + HelpdeskTicketFieldsGetOutput, + HelpdeskTicketReadInput, + HelpdeskTicketReadOutput, + HelpdeskTicketSearchReadInput, + HelpdeskTicketSearchReadOutput, + HelpdeskTicketUnlinkInput, + HelpdeskTicketUnlinkOutput, + HelpdeskTicketWriteInput, + HelpdeskTicketWriteOutput, +} from './types' const modelMap: Record = { Lead: 'crm.lead', Contact: 'res.partner', Ticket: 'helpdesk.ticket', } +const recordSchema = z.record(z.string(), z.unknown()).and(z.object({ id: z.number().optional() })) + +const odooRecordArraySchema = z.array(recordSchema) -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const helpdeskTicketRecordSchema = recordSchema.and(z.object({ id: z.number() })) +const helpdeskTicketRecordArraySchema = z.array(helpdeskTicketRecordSchema) -const isOdooRecordArray = (value: unknown): value is OdooRecord[] => Array.isArray(value) && value.every(isRecord) +const recordMapSchema = z.record(z.string(), recordSchema) -const isRecordMap = (value: unknown): value is Record> => - isRecord(value) && Object.values(value).every(isRecord) +const createdIdSchema = z.tuple([z.number()]) -const isNumberArray = (value: unknown): value is number[] => - Array.isArray(value) && value.every((item) => typeof item === 'number') +const booleanSchema = z.boolean() +const resUsersContextGetOutputSchema = recordSchema.and(z.object({ uid: z.number() })) -const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean' +const odooErrorSchema = recordSchema.and(z.object({ name: z.string().optional(), message: z.string().optional() })) const readOdooError = (errorMessage: string): string => { try { const errorBody = JSON.parse(errorMessage) as unknown + const result = odooErrorSchema.safeParse(errorBody) - if (isRecord(errorBody)) { - const name = typeof errorBody.name === 'string' ? errorBody.name : undefined - const message = typeof errorBody.message === 'string' ? errorBody.message : undefined + if (!result.success) { + return errorMessage + } - if (name && message) { - return `${name}: ${message}` - } + const { name, message } = result.data - return message ?? name ?? errorMessage + if (name && message) { + return `${name}: ${message}` } - return errorMessage + return message ?? name ?? errorMessage } catch { return errorMessage } @@ -92,8 +104,7 @@ export class OdooClient { private async _postJson( endpoint: string, body: object, - isExpectedResponse: (data: unknown) => data is TResponse, - expectedResponseDescription: string + responseSchema: z.ZodType ): Promise { const headers = this._getHeaders() @@ -111,98 +122,112 @@ export class OdooClient { } const data = (await response.json()) as unknown - if (!isExpectedResponse(data)) { - throw new Error(`Odoo API request failed: expected ${expectedResponseDescription} response`) + const parse = responseSchema.safeParse(data) + if (!parse.success) { + throw new Error(`Odoo API request failed: expected ${parse.error.message} response`) } - return data + return parse.data } public async listFields(model: Model, request: GetFieldsRequest): Promise { - return this._postJson(`/json/2/${modelMap[model]}/fields_get`, request, isRecord, 'JSON object') + return this._postJson(`/json/2/${modelMap[model]}/fields_get`, request, recordSchema) } public async listLeadFields(input: CrmLeadFieldsGetInput): Promise { - return this._postJson('/json/2/crm.lead/fields_get', input, isRecordMap, 'JSON object') + return this._postJson('/json/2/crm.lead/fields_get', input, recordMapSchema) + } + + public async listTicketFields(input: HelpdeskTicketFieldsGetInput): Promise { + return this._postJson('/json/2/helpdesk.ticket/fields_get', input, recordMapSchema) } public async getCurrentUserId(): Promise { const context = await this._postJson( '/json/2/res.users/context_get', {}, - (data): data is ResUsersContextGetOutput => isRecord(data) && typeof data.uid === 'number', - 'JSON object with uid' + resUsersContextGetOutputSchema ) return context.uid } public async searchContacts(input: ResPartnerSearchReadInput): Promise { - return this._postJson('/json/2/res.partner/search_read', input, isOdooRecordArray, 'JSON array') + return this._postJson('/json/2/res.partner/search_read', input, odooRecordArraySchema) } public async searchLeads(input: CrmLeadSearchReadInput): Promise { - return this._postJson('/json/2/crm.lead/search_read', input, isOdooRecordArray, 'JSON array') + return this._postJson('/json/2/crm.lead/search_read', input, odooRecordArraySchema) + } + + public async searchTickets(input: HelpdeskTicketSearchReadInput): Promise { + return this._postJson('/json/2/helpdesk.ticket/search_read', input, helpdeskTicketRecordArraySchema) } public async listContacts(input: ResPartnerReadInput): Promise { - return this._postJson('/json/2/res.partner/read', input, isOdooRecordArray, 'JSON array') + return this._postJson('/json/2/res.partner/read', input, odooRecordArraySchema) } public async listLeads(input: CrmLeadReadInput): Promise { - return this._postJson('/json/2/crm.lead/read', input, isOdooRecordArray, 'JSON array') + return this._postJson('/json/2/crm.lead/read', input, odooRecordArraySchema) + } + + public async listTickets(input: HelpdeskTicketReadInput): Promise { + return this._postJson('/json/2/helpdesk.ticket/read', input, helpdeskTicketRecordArraySchema) } public async createContact(input: ResPartnerCreateInput): Promise { const { values, ...rest } = input - const ids = await this._postJson( - '/json/2/res.partner/create', - { ...rest, vals_list: [values] }, - isNumberArray, - 'number array' - ) + const ids = await this._postJson('/json/2/res.partner/create', { ...rest, vals_list: [values] }, createdIdSchema) + return ids[0] + } - if (ids.length !== 1 || ids[0] === undefined) { - throw new Error('Odoo API request failed: expected one created contact id') - } + public async createLead(input: CrmLeadCreateInput): Promise { + const { values, ...rest } = input + const ids = await this._postJson('/json/2/crm.lead/create', { ...rest, vals_list: [values] }, createdIdSchema) return ids[0] } - public async createLead(input: CrmLeadCreateInput): Promise { + public async createTicket(input: HelpdeskTicketCreateInput): Promise { const { values, ...rest } = input const ids = await this._postJson( - '/json/2/crm.lead/create', + '/json/2/helpdesk.ticket/create', { ...rest, vals_list: [values] }, - isNumberArray, - 'number array' + createdIdSchema ) - if (ids.length !== 1 || ids[0] === undefined) { - throw new Error('Odoo API request failed: expected one created lead id') - } - return ids[0] } public async updateContacts(input: ResPartnerWriteInput): Promise { const { values, ...rest } = input - return this._postJson('/json/2/res.partner/write', { ...rest, vals: values }, isBoolean, 'boolean') + return this._postJson('/json/2/res.partner/write', { ...rest, vals: values }, booleanSchema) } public async updateLeads(input: CrmLeadWriteInput): Promise { const { values, ...rest } = input - return this._postJson('/json/2/crm.lead/write', { ...rest, vals: values }, isBoolean, 'boolean') + return this._postJson('/json/2/crm.lead/write', { ...rest, vals: values }, booleanSchema) + } + + public async updateTickets(input: HelpdeskTicketWriteInput): Promise { + const { values, ...rest } = input + + return this._postJson('/json/2/helpdesk.ticket/write', { ...rest, vals: values }, booleanSchema) } public async deleteContacts(input: ResPartnerUnlinkInput): Promise { - return this._postJson('/json/2/res.partner/unlink', input, isBoolean, 'boolean') + return this._postJson('/json/2/res.partner/unlink', input, booleanSchema) } public async deleteLeads(input: CrmLeadUnlinkInput): Promise { - return this._postJson('/json/2/crm.lead/unlink', input, isBoolean, 'boolean') + return this._postJson('/json/2/crm.lead/unlink', input, booleanSchema) + } + + public async deleteTickets(input: HelpdeskTicketUnlinkInput): Promise { + return this._postJson('/json/2/helpdesk.ticket/unlink', input, booleanSchema) } } diff --git a/integrations/odoo/src/odoo-client/actions/errors.ts b/integrations/odoo/src/odoo-client/actions/errors.ts index e480fd4b6d9..ebdbc73ec17 100644 --- a/integrations/odoo/src/odoo-client/actions/errors.ts +++ b/integrations/odoo/src/odoo-client/actions/errors.ts @@ -9,7 +9,7 @@ const fieldLookupActionByModel: Record = { export const createOdooRuntimeError = (thrown: unknown): sdk.RuntimeError => { const message = getErrorMessage(thrown) - const invalidFieldMatch = /Invalid field '([^']+)' on '([^']+)'/.exec(message) + const invalidFieldMatch = /Invalid field '([^']+)' (?:on|in) '([^']+)'/.exec(message) if (invalidFieldMatch) { const [, field, model] = invalidFieldMatch diff --git a/integrations/odoo/src/odoo-client/actions/implementations/contacts/deleteContacts.ts b/integrations/odoo/src/odoo-client/actions/implementations/contacts/deleteContacts.ts index e5e2f2c0c07..88a06c17c49 100644 --- a/integrations/odoo/src/odoo-client/actions/implementations/contacts/deleteContacts.ts +++ b/integrations/odoo/src/odoo-client/actions/implementations/contacts/deleteContacts.ts @@ -1,4 +1,5 @@ import * as sdk from '@botpress/sdk' +import { z } from '@botpress/sdk' import { wrapAction } from '../../action-wrapper' import { getErrorMessage, isActiveUserLinkedContactError } from '../../errors' @@ -16,12 +17,14 @@ type DeletableContact = { const getContactOwnerId = (contact: Record): number | undefined => { const owner = contact.user_id - if (Array.isArray(owner) && typeof owner[0] === 'number') { - return owner[0] + const isNumberArray = z.array(z.number()).safeParse(owner) + if (isNumberArray.success) { + return isNumberArray.data[0] } - if (typeof owner === 'number') { - return owner + const isNumber = z.number().safeParse(owner) + if (isNumber.success) { + return isNumber.data } return undefined @@ -161,7 +164,6 @@ export const deleteContacts = wrapAction( } return { - message: getDeleteContactsMessage(deletedIds, notDeletedContacts), deletedIds, notDeletedContacts, } diff --git a/integrations/odoo/src/odoo-client/actions/implementations/index.ts b/integrations/odoo/src/odoo-client/actions/implementations/index.ts index 6821f7491a4..d18bc2a12d4 100644 --- a/integrations/odoo/src/odoo-client/actions/implementations/index.ts +++ b/integrations/odoo/src/odoo-client/actions/implementations/index.ts @@ -8,17 +8,24 @@ import { } from './contacts' import { getCurrentUser } from './current-user' import { createLead, deleteLeads, listLeadFields, listLeads, searchLeads, updateLeads } from './leads' +import { createTicket, deleteTickets, listTicketFields, listTickets, searchTickets, updateTickets } from './tickets' import * as bp from '.botpress' export default { getCurrentUser, listContactFields, listLeadFields, + listTicketFields, searchLeads, + searchTickets, listLeads, + listTickets, createLead, + createTicket, updateLeads, + updateTickets, deleteLeads, + deleteTickets, searchContacts, listContacts, createContact, diff --git a/integrations/odoo/src/odoo-client/actions/implementations/leads/deleteLeads.ts b/integrations/odoo/src/odoo-client/actions/implementations/leads/deleteLeads.ts index 2f6f8616f2b..a7582c11669 100644 --- a/integrations/odoo/src/odoo-client/actions/implementations/leads/deleteLeads.ts +++ b/integrations/odoo/src/odoo-client/actions/implementations/leads/deleteLeads.ts @@ -1,4 +1,5 @@ import * as sdk from '@botpress/sdk' +import { z } from '@botpress/sdk' import { wrapAction } from '../../action-wrapper' import { getErrorMessage } from '../../errors' @@ -16,12 +17,14 @@ type DeletableLead = { const getLeadOwnerId = (lead: Record): number | undefined => { const owner = lead.user_id - if (Array.isArray(owner) && typeof owner[0] === 'number') { - return owner[0] + const isNumberArray = z.array(z.number()).safeParse(owner) + if (isNumberArray.success) { + return isNumberArray.data[0] } - if (typeof owner === 'number') { - return owner + const isNumber = z.number().safeParse(owner) + if (isNumber.success) { + return isNumber.data } return undefined @@ -158,7 +161,6 @@ export const deleteLeads = wrapAction( } return { - message: getDeleteLeadsMessage(deletedIds, notDeletedLeads), deletedIds, notDeletedLeads, } diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/createTicket.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/createTicket.ts new file mode 100644 index 00000000000..9b02c734481 --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/createTicket.ts @@ -0,0 +1,10 @@ +import { wrapAction } from '../../action-wrapper' + +export const createTicket = wrapAction( + { actionName: 'createTicket', errorMessage: 'Failed to create Odoo ticket' }, + async ({ odooClient }, input) => { + const id = await odooClient.createTicket(input) + + return { id } + } +) diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/deleteTickets.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/deleteTickets.ts new file mode 100644 index 00000000000..889286ce7da --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/deleteTickets.ts @@ -0,0 +1,19 @@ +import * as sdk from '@botpress/sdk' +import { wrapAction } from '../../action-wrapper' + +export const deleteTickets = wrapAction( + { actionName: 'deleteTickets', errorMessage: 'Failed to delete Odoo tickets' }, + async ({ odooClient }, input) => { + const success = await odooClient.deleteTickets(input) + + if (!success) { + throw new sdk.RuntimeError( + `Odoo returned false while deleting ticket IDs ${input.ids.join( + ', ' + )}. Verify the ticket IDs and user permissions.` + ) + } + + return { deletedIds: input.ids } + } +) diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/index.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/index.ts new file mode 100644 index 00000000000..573e49d00e5 --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/index.ts @@ -0,0 +1,6 @@ +export { createTicket } from './createTicket' +export { deleteTickets } from './deleteTickets' +export { listTicketFields } from './listTicketFields' +export { listTickets } from './listTickets' +export { searchTickets } from './searchTickets' +export { updateTickets } from './updateTickets' diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTicketFields.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTicketFields.ts new file mode 100644 index 00000000000..054f48b2dbb --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTicketFields.ts @@ -0,0 +1,10 @@ +import { wrapAction } from '../../action-wrapper' + +export const listTicketFields = wrapAction( + { actionName: 'listTicketFields', errorMessage: 'Failed to list Odoo ticket fields' }, + async ({ odooClient }, input) => { + const fields = await odooClient.listTicketFields(input) + + return { fields } + } +) diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTickets.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTickets.ts new file mode 100644 index 00000000000..cefa99fc882 --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/listTickets.ts @@ -0,0 +1,10 @@ +import { wrapAction } from '../../action-wrapper' + +export const listTickets = wrapAction( + { actionName: 'listTickets', errorMessage: 'Failed to list Odoo tickets' }, + async ({ odooClient }, input) => { + const records = await odooClient.listTickets(input) + + return { records } + } +) diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/searchTickets.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/searchTickets.ts new file mode 100644 index 00000000000..7921a330317 --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/searchTickets.ts @@ -0,0 +1,10 @@ +import { wrapAction } from '../../action-wrapper' + +export const searchTickets = wrapAction( + { actionName: 'searchTickets', errorMessage: 'Failed to search Odoo tickets' }, + async ({ odooClient }, input) => { + const records = await odooClient.searchTickets(input) + + return { records } + } +) diff --git a/integrations/odoo/src/odoo-client/actions/implementations/tickets/updateTickets.ts b/integrations/odoo/src/odoo-client/actions/implementations/tickets/updateTickets.ts new file mode 100644 index 00000000000..3f04b6e810b --- /dev/null +++ b/integrations/odoo/src/odoo-client/actions/implementations/tickets/updateTickets.ts @@ -0,0 +1,19 @@ +import * as sdk from '@botpress/sdk' +import { wrapAction } from '../../action-wrapper' + +export const updateTickets = wrapAction( + { actionName: 'updateTickets', errorMessage: 'Failed to update Odoo tickets' }, + async ({ odooClient }, input) => { + const success = await odooClient.updateTickets(input) + + if (!success) { + throw new sdk.RuntimeError( + `Odoo returned false while updating ticket IDs ${input.ids.join( + ', ' + )}. The update was not applied; verify the ticket IDs, field names, values, and user permissions.` + ) + } + + return { updatedIds: input.ids } + } +) diff --git a/integrations/odoo/src/odoo-client/contact.types.ts b/integrations/odoo/src/odoo-client/types/contact.types.ts similarity index 95% rename from integrations/odoo/src/odoo-client/contact.types.ts rename to integrations/odoo/src/odoo-client/types/contact.types.ts index 0b3cc5d6ba1..2998fdd6223 100644 --- a/integrations/odoo/src/odoo-client/contact.types.ts +++ b/integrations/odoo/src/odoo-client/types/contact.types.ts @@ -1,7 +1,5 @@ import type { OdooContext, OdooDomain, OdooRecord } from './odoo.types' -export type { OdooContext, OdooDomain, OdooRecord } from './odoo.types' - export type Model = 'Lead' | 'Contact' | 'Ticket' export type GetFieldsRequest = { diff --git a/integrations/odoo/src/odoo-client/types/index.ts b/integrations/odoo/src/odoo-client/types/index.ts new file mode 100644 index 00000000000..1c69f727ac4 --- /dev/null +++ b/integrations/odoo/src/odoo-client/types/index.ts @@ -0,0 +1,4 @@ +export * from './contact.types' +export * from './lead.types' +export * from './ticket.types' +export * from './odoo.types' diff --git a/integrations/odoo/src/odoo-client/lead.types.ts b/integrations/odoo/src/odoo-client/types/lead.types.ts similarity index 95% rename from integrations/odoo/src/odoo-client/lead.types.ts rename to integrations/odoo/src/odoo-client/types/lead.types.ts index 59e976dc46e..4216acc048b 100644 --- a/integrations/odoo/src/odoo-client/lead.types.ts +++ b/integrations/odoo/src/odoo-client/types/lead.types.ts @@ -1,7 +1,5 @@ import type { OdooContext, OdooDomain, OdooRecord } from './odoo.types' -export type { OdooContext, OdooDomain, OdooMany2One, OdooRecord } from './odoo.types' - /** * POST /json/2/crm.lead/fields_get */ diff --git a/integrations/odoo/src/odoo-client/odoo.types.ts b/integrations/odoo/src/odoo-client/types/odoo.types.ts similarity index 80% rename from integrations/odoo/src/odoo-client/odoo.types.ts rename to integrations/odoo/src/odoo-client/types/odoo.types.ts index dba3e922fb9..c94667fa301 100644 --- a/integrations/odoo/src/odoo-client/odoo.types.ts +++ b/integrations/odoo/src/odoo-client/types/odoo.types.ts @@ -4,8 +4,6 @@ export type OdooDomainCondition = unknown[] export type OdooDomainOperator = '&' | '|' | '!' export type OdooDomain = Array -export type OdooRecord = Record & { - id?: number -} +export type OdooRecord = Record & { id?: number } export type OdooMany2One = [id: number, displayName: string] diff --git a/integrations/odoo/src/odoo-client/types/ticket.types.ts b/integrations/odoo/src/odoo-client/types/ticket.types.ts new file mode 100644 index 00000000000..9a627945be2 --- /dev/null +++ b/integrations/odoo/src/odoo-client/types/ticket.types.ts @@ -0,0 +1,85 @@ +import type { OdooContext, OdooDomain, OdooRecord } from './odoo.types' + +export type HelpdeskTicketRecord = OdooRecord & { id: number } + +/** + * POST /json/2/helpdesk.ticket/fields_get + */ +export type HelpdeskTicketFieldsGetInput = { + allfields?: string[] + attributes?: string[] + context?: OdooContext +} + +export type HelpdeskTicketFieldsGetOutput = Record> + +/** + * POST /json/2/helpdesk.ticket/search_read + */ +export type HelpdeskTicketSearchReadInput = { + domain?: OdooDomain + fields?: string[] + offset?: number + limit?: number + order?: string + context?: OdooContext +} + +export type HelpdeskTicketSearchReadOutput = HelpdeskTicketRecord[] + +/** + * POST /json/2/helpdesk.ticket/read + */ +export type HelpdeskTicketReadInput = { + ids: number[] + fields?: string[] + context?: OdooContext +} + +export type HelpdeskTicketReadOutput = HelpdeskTicketRecord[] + +/** + * POST /json/2/helpdesk.ticket/create + */ +export type HelpdeskTicketCreateInput = { + values: { + name: string + description?: string + partner_id?: number + partner_name?: string + partner_email?: string + email_cc?: string + team_id?: number + user_id?: number + stage_id?: number + ticket_type_id?: number + priority?: '0' | '1' | '2' | '3' + tag_ids?: number[] + company_id?: number + [field: string]: unknown + } + context?: OdooContext +} + +export type HelpdeskTicketCreateOutput = number + +/** + * POST /json/2/helpdesk.ticket/write + */ +export type HelpdeskTicketWriteInput = { + ids: number[] + values: Record + context?: OdooContext +} + +export type HelpdeskTicketWriteOutput = boolean + +/** + * POST /json/2/helpdesk.ticket/unlink + */ +export type HelpdeskTicketUnlinkInput = { + ids: number[] + context?: OdooContext +} + +export type HelpdeskTicketUnlinkOutput = boolean