diff --git a/integrations/shopify/.gitignore b/integrations/shopify/.gitignore new file mode 100644 index 00000000000..87e6b2c246d --- /dev/null +++ b/integrations/shopify/.gitignore @@ -0,0 +1 @@ +.botpress diff --git a/integrations/shopify/.sentryclirc b/integrations/shopify/.sentryclirc new file mode 100644 index 00000000000..6bedab2ff28 --- /dev/null +++ b/integrations/shopify/.sentryclirc @@ -0,0 +1,5 @@ +[defaults] +project=integration-shopify + +[auth] +dsn=https://58077d91efaa7f20094ecb7e872bc23b@o363631.ingest.sentry.io/4505755730968576 diff --git a/integrations/shopify/hub.md b/integrations/shopify/hub.md new file mode 100644 index 00000000000..0837ecea19b --- /dev/null +++ b/integrations/shopify/hub.md @@ -0,0 +1,45 @@ +This integration enables you to connect your Botpress chatbot with Shopify, a leading e-commerce platform. With this integration, you can facilitate seamless interactions related to store operations directly from your chatbot. + +To effectively use the integration, you'll need to provide your Shopify store name (as seen in the browser/URL) and the access token generated after adding our App to your shop. + +For comprehensive instructions on setting up and utilizing the Botpress Shopify integration, please consult our main documentation. + +## Prerequisites + +Before enabling the Botpress Shopify Integration, ensure you have: + +1. A Botpress cloud account. +2. Access to a Shopify store admin panel. +3. An access token, generated after adding our App to your Shopify store. + +## Configuration Setup + +To set up the Shopify integration in Botpress, the following configurations are required: + +1. **Shop/Store Name**: Extracted from the browser/URL when accessing your Shopify store. For instance, if the URL to your store admin is \`https://admin.shopify.com/store/botpress-test-store\`, then the shop name you'll enter is \`botpress-test-store\`. +2. **Admin API access token**: This token is essential for allowing Botpress to communicate with your Shopify store. It can be located within the app settings under the 'API credentials' section in your Shopify admin panel. + +## Enable Integration + +To activate the Shopify integration in Botpress: + +1. Access the Botpress admin dashboard. +2. Go to the "Integrations" tab. +3. Search for the Shopify integration and select "Enable" or "Configure." +4. Input the required Shop/Store Name and Admin API access token. +5. Save your configurations. + +## Usage + +After enabling the integration, your Botpress chatbot can seamlessly communicate with Shopify, aiding in tasks like product queries, order updates, and more. Specific built-in actions and more detailed usage instructions will be found in our core documentation. + +## Limitations + +1. Ensure you always have the correct and latest Admin API access token for accurate integration functionality. +2. There may be rate limits applied by Shopify's API. + +## Contributing + +Your contributions are valued! Feel free to submit any issues or pull requests. + +Relish the smooth e-commerce operations integration between Botpress and Shopify! diff --git a/integrations/shopify/icon.svg b/integrations/shopify/icon.svg new file mode 100644 index 00000000000..dd41cb324ce --- /dev/null +++ b/integrations/shopify/icon.svg @@ -0,0 +1,12 @@ + + Shopify_logo_2018-svg + + + + + \ No newline at end of file diff --git a/integrations/shopify/integration.definition.ts b/integrations/shopify/integration.definition.ts new file mode 100644 index 00000000000..3ee0c1cae1d --- /dev/null +++ b/integrations/shopify/integration.definition.ts @@ -0,0 +1,21 @@ +import { IntegrationDefinition } from '@botpress/sdk' +import { sentry as sentryHelpers } from '@botpress/sdk-addons' +import { INTEGRATION_NAME } from './src/const' +import { actions } from './src/definitions/actions' +import { configuration } from './src/definitions/configuration' +import { events } from './src/definitions/events' +import { states } from './src/definitions/states' + +export default new IntegrationDefinition({ + name: INTEGRATION_NAME, + title: 'Shopify', + description: 'This integration allows your bot to interact with Shopify.', + version: '0.2.0', + icon: 'icon.svg', + readme: 'hub.md', + configuration, + actions, + states, + events, + secrets: [...sentryHelpers.COMMON_SECRET_NAMES], +}) diff --git a/integrations/shopify/package.json b/integrations/shopify/package.json new file mode 100644 index 00000000000..df8f2142695 --- /dev/null +++ b/integrations/shopify/package.json @@ -0,0 +1,30 @@ +{ + "name": "@botpresshub/shopify", + "scripts": { + "start": "bp serve", + "build": "bp build", + "deploy": "bp deploy", + "type:check": "tsc --noEmit", + "test": "echo \"Tests not implemented yet.\"" + }, + "keywords": [], + "private": true, + "author": "", + "license": "MIT", + "dependencies": { + "@botpress/client": "workspace:*", + "@botpress/sdk": "workspace:*", + "@botpress/sdk-addons": "workspace:*", + "axios": "^1.4.0", + "zod": "^3.21.4" + }, + "devDependencies": { + "@botpress/cli": "workspace:*", + "@sentry/cli": "^2.18.1", + "@types/node": "^18.11.17", + "esbuild": "^0.15.18", + "nodemon": "^2.0.20", + "ts-node": "^10.9.1", + "typescript": "^4.9.4" + } +} diff --git a/integrations/shopify/src/actions/get-customerOrders.ts b/integrations/shopify/src/actions/get-customerOrders.ts new file mode 100644 index 00000000000..11ca1e6c824 --- /dev/null +++ b/integrations/shopify/src/actions/get-customerOrders.ts @@ -0,0 +1,37 @@ +import { RuntimeError } from '@botpress/client' +import axios from 'axios' +import qs from 'querystring' +import { SHOPIFY_API_VERSION } from '../const' +import { IntegrationProps } from '.botpress' + +type GetCustomerOrders = IntegrationProps['actions']['getCustomerOrders'] + +export const getCustomerOrders: GetCustomerOrders = async ({ ctx, input, logger }) => { + const { customer_id, status } = input + const filters = qs.stringify({ customer_id, status }) + + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + }, + } + + try { + const response = await axios.get(`/admin/api/${SHOPIFY_API_VERSION}/orders.json?${filters}`, axiosConfig) + + const customerOrdersList = response.data.orders + + logger + .forBot() + .info( + `Ran 'Get Customer Orders List' and found ${customerOrdersList.length} customer orders matching criteria ${filters}` + ) + + return { customerOrdersList } + } catch (e) { + const errorMsg = `'Get Customer Orders List' exception ${JSON.stringify(e)}` + logger.forBot().error(errorMsg) + throw new RuntimeError(errorMsg) + } +} diff --git a/integrations/shopify/src/actions/get-customers.ts b/integrations/shopify/src/actions/get-customers.ts new file mode 100644 index 00000000000..c11c4cade70 --- /dev/null +++ b/integrations/shopify/src/actions/get-customers.ts @@ -0,0 +1,35 @@ +import { RuntimeError } from '@botpress/client' +import axios from 'axios' +import qs from 'querystring' +import { SHOPIFY_API_VERSION } from '../const' +import { IntegrationProps } from '.botpress' + +type GetCustomers = IntegrationProps['actions']['getCustomers'] + +export const getCustomers: GetCustomers = async ({ ctx, input, logger }) => { + const { ids, limit } = input + const filters = qs.stringify({ ids, limit }) + + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + }, + } + + try { + const response = await axios.get(`/admin/api/${SHOPIFY_API_VERSION}/customers.json?${filters}`, axiosConfig) + + const customersList = response.data.customers + + logger + .forBot() + .info(`Ran 'Get Customers List' and found ${customersList.length} customers matching criteria ${filters}`) + + return { customersList } + } catch (e) { + const errorMsg = `'Get Customers List' exception ${JSON.stringify(e)}` + logger.forBot().error(errorMsg) + throw new RuntimeError(errorMsg) + } +} diff --git a/integrations/shopify/src/actions/get-productVariant.ts b/integrations/shopify/src/actions/get-productVariant.ts new file mode 100644 index 00000000000..97acb233b85 --- /dev/null +++ b/integrations/shopify/src/actions/get-productVariant.ts @@ -0,0 +1,37 @@ +import { RuntimeError } from '@botpress/client' +import axios from 'axios' +import qs from 'querystring' +import { SHOPIFY_API_VERSION } from '../const' +import { IntegrationProps } from '.botpress' + +type GetProductVariants = IntegrationProps['actions']['getProductVariants'] + +export const getProductVariants: GetProductVariants = async ({ ctx, input, logger }) => { + const { product_id, limit } = input + const filters = qs.stringify({ product_id, limit }) + + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + }, + } + + try { + const response = await axios.get(`/admin/api/${SHOPIFY_API_VERSION}/variants.json?${filters}`, axiosConfig) + + const productVariantsList = response.data.variants + + logger + .forBot() + .info( + `Ran 'Get Product Variants List' and found ${productVariantsList.length} product variants matching criteria ${filters}` + ) + + return { productVariantsList } + } catch (e) { + const errorMsg = `'Get Product Variants List' exception ${JSON.stringify(e)}` + logger.forBot().error(errorMsg) + throw new RuntimeError(errorMsg) + } +} diff --git a/integrations/shopify/src/actions/get-products.ts b/integrations/shopify/src/actions/get-products.ts new file mode 100644 index 00000000000..c5422217f6d --- /dev/null +++ b/integrations/shopify/src/actions/get-products.ts @@ -0,0 +1,35 @@ +import { RuntimeError } from '@botpress/client' +import axios from 'axios' +import qs from 'querystring' +import { SHOPIFY_API_VERSION } from '../const' +import { IntegrationProps } from '.botpress' + +type GetProductVariants = IntegrationProps['actions']['getProducts'] + +export const getProducts: GetProductVariants = async ({ ctx, input, logger }) => { + const { ids, limit, product_type, title } = input + const filters = qs.stringify({ ids, limit, product_type, title }) + + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + }, + } + + try { + const response = await axios.get(`/admin/api/${SHOPIFY_API_VERSION}/products.json?${filters}`, axiosConfig) + + const productsList = response.data.products + + logger + .forBot() + .info(`Ran 'Get Products List' and found ${productsList.length} products matching criteria ${filters}`) + + return { productsList } + } catch (e) { + const errorMsg = `'Get Products List' exception ${JSON.stringify(e)}` + logger.forBot().error(errorMsg) + throw new RuntimeError(errorMsg) + } +} diff --git a/integrations/shopify/src/actions/index.ts b/integrations/shopify/src/actions/index.ts new file mode 100644 index 00000000000..f3fe268aa9f --- /dev/null +++ b/integrations/shopify/src/actions/index.ts @@ -0,0 +1,7 @@ +import '@botpress/client' +import { getCustomerOrders } from './get-customerOrders' +import { getCustomers } from './get-customers' +import { getProducts } from './get-products' +import { getProductVariants } from './get-productVariant' + +export default { getProducts, getProductVariants, getCustomers, getCustomerOrders } diff --git a/integrations/shopify/src/const.ts b/integrations/shopify/src/const.ts new file mode 100644 index 00000000000..2be98939f37 --- /dev/null +++ b/integrations/shopify/src/const.ts @@ -0,0 +1,3 @@ +export const INTEGRATION_NAME = 'shopify' +export const SHOPIFY_API_VERSION = '2023-07' +export const ARR_OF_EVENTS = ['orders/create', 'orders/cancelled', 'customers/create'] diff --git a/integrations/shopify/src/definitions/actions.ts b/integrations/shopify/src/definitions/actions.ts new file mode 100644 index 00000000000..2fea6285866 --- /dev/null +++ b/integrations/shopify/src/definitions/actions.ts @@ -0,0 +1,139 @@ +import { IntegrationDefinitionProps } from '@botpress/sdk' +import { z } from 'zod' + +type ActionDefinition = NonNullable[string] + +const getProducts = { + title: 'Get Products List', + description: 'Gets a list of all products based on the parameters', + input: { + schema: z.object({ + ids: z.string().optional().describe('Comma-separated list of product IDs.'), + limit: z + .number() + .min(0) + .max(250) + .default(50) + .optional() + .describe('Return up to this many results per page. Default is 50, Maximum is 250'), + title: z.string().optional().describe('The exact product title.'), + product_type: z.string().optional().describe('Exact product type.'), + }), + ui: { + ids: { + title: 'Product ID(s)', + examples: ['632910392', '632910392,632910391'], + }, + limit: { + title: 'Limit', + }, + title: { + title: 'Product Title', + }, + product_type: { + title: 'Product Type', + }, + }, + }, + output: { + schema: z.object({ + productsList: z.array(z.object({}).passthrough()), + }), + }, +} satisfies ActionDefinition + +const getProductVariants = { + title: 'Get Product Variants List', + description: 'Gets a list of all product variants based on the parameters', + input: { + schema: z.object({ + product_id: z.string().optional().describe('The product ID to retrieve its variants'), + limit: z + .number() + .min(0) + .max(250) + .default(50) + .optional() + .describe('Return up to this many results per page. Default is 50, Maximum is 250'), + }), + ui: { + product_id: { + title: 'Product ID(s)', + examples: ['632910392', '632910392,632910391'], + }, + limit: { + title: 'Limit', + }, + }, + }, + output: { + schema: z.object({ + productVariantsList: z.array(z.object({}).passthrough()), + }), + }, +} satisfies ActionDefinition + +const getCustomers = { + title: 'Get Customers List', + description: 'Gets a list of all customers based on the parameters', + input: { + schema: z.object({ + ids: z.string().optional().describe('Comma-separated list of customers IDs.'), + limit: z + .number() + .min(0) + .max(250) + .default(50) + .optional() + .describe('Return up to this many results per page. Default is 50, Maximum is 250'), + }), + ui: { + ids: { + title: 'Customer ID(s)', + examples: ['207119551', '207119551,1073339478'], + }, + limit: { + title: 'Limit', + }, + }, + }, + output: { + schema: z.object({ + customersList: z.array(z.object({}).passthrough()), + }), + }, +} satisfies ActionDefinition + +const getCustomerOrders = { + title: 'Get Customer Orders List', + description: 'Gets a list of all customer orders based on the parameters', + input: { + schema: z.object({ + customer_id: z.string().optional().describe('The exact customer ID.'), + status: z + .enum(['open', 'closed', 'cancelled', 'any']) + .optional() + .default('open') + .describe( + 'The status of the order. It could be any of the following variables: "open","closed","cancelled","any"' + ), + }), + ui: { + customer_id: { + title: 'Customer ID', + examples: ['207119551'], + }, + status: { + title: 'Status', + examples: ['open', 'closed', 'cancelled', 'any'], + }, + }, + }, + output: { + schema: z.object({ + customerOrdersList: z.array(z.object({}).passthrough()), + }), + }, +} satisfies ActionDefinition + +export const actions = { getProducts, getProductVariants, getCustomers, getCustomerOrders } diff --git a/integrations/shopify/src/definitions/configuration.ts b/integrations/shopify/src/definitions/configuration.ts new file mode 100644 index 00000000000..84d161d4477 --- /dev/null +++ b/integrations/shopify/src/definitions/configuration.ts @@ -0,0 +1,21 @@ +import { IntegrationDefinitionProps } from '@botpress/sdk' +import { z } from 'zod' + +export const configuration = { + schema: z.object({ + shopName: z.string().describe('The shop name from the browser/URL.'), + access_token: z.string().describe('The access token generated after adding our App to your shop.'), + }), + ui: { + shopName: { + title: 'Shop/Store Name', + examples: [ + 'If the url to your store admin is https://admin.shopify.com/store/botpress-test-store, then the shop name is botpress-test-store', + ], + }, + access_token: { + title: 'Admin API access token', + examples: ['It is found in the app settings, in the API credentials'], + }, + }, +} satisfies IntegrationDefinitionProps['configuration'] diff --git a/integrations/shopify/src/definitions/events.ts b/integrations/shopify/src/definitions/events.ts new file mode 100644 index 00000000000..a668bb0d795 --- /dev/null +++ b/integrations/shopify/src/definitions/events.ts @@ -0,0 +1,30 @@ +import { IntegrationDefinitionProps } from '@botpress/sdk' +import { newCustomerSchema, orderCancelledSchema, orderCreatedSchema } from 'src/schemas' +import z from 'zod' + +export type OrderCreated = z.infer + +const orderCreated = { + schema: orderCreatedSchema, + ui: {}, +} + +export type OrdeCancelled = z.infer + +const orderCancelled = { + schema: orderCancelledSchema, + ui: {}, +} + +export type NewCustomer = z.infer + +const newCustomer = { + schema: newCustomerSchema, + ui: {}, +} + +export const events = { + orderCreated, + orderCancelled, + newCustomer, +} satisfies IntegrationDefinitionProps['events'] diff --git a/integrations/shopify/src/definitions/states.ts b/integrations/shopify/src/definitions/states.ts new file mode 100644 index 00000000000..65540a4743a --- /dev/null +++ b/integrations/shopify/src/definitions/states.ts @@ -0,0 +1,9 @@ +import { IntegrationDefinitionProps } from '@botpress/sdk' +import { z } from 'zod' + +export const states = { + configuration: { + type: 'integration', + schema: z.object({ webhookIds: z.array(z.string()).optional() }), + }, +} satisfies IntegrationDefinitionProps['states'] diff --git a/integrations/shopify/src/events/newCustomer.ts b/integrations/shopify/src/events/newCustomer.ts new file mode 100644 index 00000000000..3d6e03ff442 --- /dev/null +++ b/integrations/shopify/src/events/newCustomer.ts @@ -0,0 +1,44 @@ +import { newCustomerSchema } from 'src/schemas' +import * as botpress from '.botpress' + +type Implementation = ConstructorParameters[0] +type RegisterFunction = Implementation['handler'] +type IntegrationContext = Parameters[0]['ctx'] +type Client = Parameters[0]['client'] + +export const fireNewCustomer = async ({ + req, + client, + ctx, + logger, +}: { + req: any + client: Client + ctx: IntegrationContext + logger: any +}) => { + const shopifyEvent = JSON.parse(req.body) + + const payload = { + shopName: ctx.configuration.shopName, + id: shopifyEvent.id, + email: shopifyEvent.email, + accepts_marketing: shopifyEvent.accepts_marketing, + first_name: shopifyEvent.first_name, + last_name: shopifyEvent.last_name, + orders_count: shopifyEvent.orders_count, + state: shopifyEvent.state, + total_spent: shopifyEvent.total_spent, + last_order_id: shopifyEvent.last_order_id, + note: shopifyEvent.note, + } + + const parsedObject = newCustomerSchema.parse(payload) + + logger.forBot().info(`Recieved a customer created event for ${shopifyEvent.email}`) + + await client.createEvent({ + type: 'newCustomer', + payload: parsedObject, + }) +} diff --git a/integrations/shopify/src/events/orderCancelled.ts b/integrations/shopify/src/events/orderCancelled.ts new file mode 100644 index 00000000000..aff11a3a55d --- /dev/null +++ b/integrations/shopify/src/events/orderCancelled.ts @@ -0,0 +1,46 @@ +import { orderCancelledSchema } from 'src/schemas' +import * as botpress from '.botpress' + +type Implementation = ConstructorParameters[0] +type RegisterFunction = Implementation['handler'] +type IntegrationContext = Parameters[0]['ctx'] +type Client = Parameters[0]['client'] + +export const fireOrderCancelled = async ({ + req, + client, + ctx, + logger, +}: { + req: any + client: Client + ctx: IntegrationContext + logger: any +}) => { + const shopifyEvent = JSON.parse(req.body) + + const payload = { + order_id: shopifyEvent.id, + shopName: ctx.configuration.shopName, + created_at: shopifyEvent.created_at, + cancel_reason: shopifyEvent.cancel_reason, + closed_at: shopifyEvent.closed_at, + currency: shopifyEvent.currency, + current_subtotal_price: shopifyEvent.current_subtotal_price, + current_total_discounts: shopifyEvent.current_total_discounts, + current_total_price: shopifyEvent.current_total_price, + current_total_tax: shopifyEvent.current_total_tax, + customer_locale: shopifyEvent.customer_locale, + order_status_url: shopifyEvent.order_status_url, + fullBody: req, + } + + const parsedObject = orderCancelledSchema.parse(payload) + + logger.forBot().info(`Recieved an order cancelled event for ${shopifyEvent.id}`) + + await client.createEvent({ + type: 'orderCancelled', + payload: parsedObject, + }) +} diff --git a/integrations/shopify/src/events/orderCreated.ts b/integrations/shopify/src/events/orderCreated.ts new file mode 100644 index 00000000000..0d3f265185d --- /dev/null +++ b/integrations/shopify/src/events/orderCreated.ts @@ -0,0 +1,45 @@ +import { orderCreatedSchema } from 'src/schemas' +import * as botpress from '.botpress' + +type Implementation = ConstructorParameters[0] +type RegisterFunction = Implementation['handler'] +type IntegrationContext = Parameters[0]['ctx'] +type Client = Parameters[0]['client'] + +export const fireOrderCreated = async ({ + req, + client, + ctx, + logger, +}: { + req: any + client: Client + ctx: IntegrationContext + logger: any +}) => { + const shopifyEvent = JSON.parse(req.body) + + const payload = { + order_id: shopifyEvent.id, + shopName: ctx.configuration.shopName, + confirmation_number: shopifyEvent.confirmation_number, + created_at: shopifyEvent.created_at, + currency: shopifyEvent.currency, + current_subtotal_price: shopifyEvent.current_subtotal_price, + current_total_discounts: shopifyEvent.current_total_discounts, + current_total_price: shopifyEvent.current_total_price, + current_total_tax: shopifyEvent.current_total_tax, + customer_locale: shopifyEvent.customer_locale, + order_status_url: shopifyEvent.order_status_url, + fullBody: req, + } + + const parsedObject = orderCreatedSchema.parse(payload) + + logger.forBot().info(`Recieved an order created event for ${shopifyEvent.id}`) + + await client.createEvent({ + type: 'orderCreated', + payload: parsedObject, + }) +} diff --git a/integrations/shopify/src/handler.ts b/integrations/shopify/src/handler.ts new file mode 100644 index 00000000000..7859a9ef91f --- /dev/null +++ b/integrations/shopify/src/handler.ts @@ -0,0 +1,18 @@ +import { fireNewCustomer } from './events/newCustomer' +import { fireOrderCancelled } from './events/orderCancelled' +import { fireOrderCreated } from './events/orderCreated' +import * as botpress from '.botpress' + +export const handler: botpress.IntegrationProps['handler'] = async ({ req, client, ctx, logger }) => { + if (req.headers['x-shopify-topic'] === 'orders/create') { + return fireOrderCreated({ req, client, ctx, logger }) + } + + if (req.headers['x-shopify-topic'] === 'orders/cancelled') { + return fireOrderCancelled({ req, client, ctx, logger }) + } + + if (req.headers['x-shopify-topic'] === 'customers/create') { + return fireNewCustomer({ req, client, ctx, logger }) + } +} diff --git a/integrations/shopify/src/index.ts b/integrations/shopify/src/index.ts new file mode 100644 index 00000000000..6bf0fc4e467 --- /dev/null +++ b/integrations/shopify/src/index.ts @@ -0,0 +1,15 @@ +import actions from './actions' +import { handler } from './handler' +import { register } from './setup/register' +import { unregister } from './setup/unregister' +import * as botpress from '.botpress' + +console.info('Starting Shopify Integration') + +export default new botpress.Integration({ + register, + unregister, + channels: {}, + actions, + handler, +}) diff --git a/integrations/shopify/src/schemas/index.ts b/integrations/shopify/src/schemas/index.ts new file mode 100644 index 00000000000..842b7b57d73 --- /dev/null +++ b/integrations/shopify/src/schemas/index.ts @@ -0,0 +1,55 @@ +import { INTEGRATION_NAME } from 'src/const' +import z from 'zod' + +export const orderCreatedSchema = z.object({ + type: z.literal(`${INTEGRATION_NAME}:orderCreated`).optional(), + order_id: z.number(), + shopName: z.string(), + confirmation_number: z.string(), + created_at: z.string(), + currency: z.string().optional(), + current_subtotal_price: z.string().optional(), + current_total_discounts: z.string().optional(), + current_total_price: z.string().optional(), + current_total_tax: z.string().optional(), + customer_locale: z.string().optional(), + order_status_url: z.string().optional(), + fullBody: z.object({}).passthrough(), +}) + +export type orderCreated = z.infer + +export const orderCancelledSchema = z.object({ + type: z.literal(`${INTEGRATION_NAME}:orderCancelled`).optional(), + order_id: z.number(), + shopName: z.string(), + cancel_reason: z.string(), + closed_at: z.string().optional(), + currency: z.string().optional(), + current_subtotal_price: z.string().optional(), + current_total_discounts: z.string().optional(), + current_total_price: z.string().optional(), + current_total_tax: z.string().optional(), + customer_locale: z.string().optional(), + order_status_url: z.string().optional(), + fullBody: z.object({}).passthrough(), +}) + +export type orderCancelled = z.infer + +export const newCustomerSchema = z.object({ + type: z.literal(`${INTEGRATION_NAME}:newCustomer`).optional(), + shopName: z.string(), + id: z.number(), + email: z.string().nullable().optional(), + accepts_marketing: z.boolean().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + orders_count: z.number().nullable().optional(), + state: z.string().nullable().optional(), + total_spent: z.string().nullable().optional(), + last_order_id: z.string().nullable().optional(), + note: z.string().nullable().optional(), +}) + +export type newCustomer = z.infer diff --git a/integrations/shopify/src/setup/register.ts b/integrations/shopify/src/setup/register.ts new file mode 100644 index 00000000000..134337188bc --- /dev/null +++ b/integrations/shopify/src/setup/register.ts @@ -0,0 +1,87 @@ +import axios from 'axios' +import { ARR_OF_EVENTS, SHOPIFY_API_VERSION } from '../const' +import type * as botpress from '.botpress' + +type IntegrationLogger = Parameters[0]['logger'] +type Implementation = ConstructorParameters[0] +type RegisterFunction = Implementation['register'] +type IntegrationContext = Parameters[0]['ctx'] + +function getValue(obj: string | undefined) { + if (typeof obj === 'string') { + return obj + } else { + return '' + } +} + +export const register: RegisterFunction = async ({ ctx, logger, webhookUrl }) => { + await Promise.all( + ARR_OF_EVENTS.map(async (event) => { + const topic = getValue(event) + await createWebhook({ topic, ctx, logger, webhookUrl }) + }) + ) +} + +async function createWebhook({ + topic, + ctx, + logger, + webhookUrl, +}: { + topic: string + ctx: IntegrationContext + logger: IntegrationLogger + webhookUrl: string +}) { + const topicReadable = topic.replace('/', ' ') + + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + 'Content-Type': 'application/json', + }, + } + + try { + let response = await axios.get( + `/admin/api/${SHOPIFY_API_VERSION}/webhooks.json?topic=${topic}&address=${webhookUrl}`, + axiosConfig + ) + + if (response.data.webhooks.length > 0) { + logger + .forBot() + .info( + `Shopify "${topicReadable}" Webhook was found with id ${response.data.webhooks[0].id.toString()} for Bot ${ + ctx.botId + }. Webhook was not created` + ) + return + } + + response = await axios.post( + `/admin/api/${SHOPIFY_API_VERSION}/webhooks.json`, + { + webhook: { + topic, + address: webhookUrl, + format: 'json', + }, + }, + axiosConfig + ) + + logger + .forBot() + .info( + `Shopify ${topicReadable} Webhook Created ${response.data.webhook.id.toString()} for Bot with Id ${ctx.botId}` + ) + return response.data.webhook.id.toString() + } catch (e) { + logger.forBot().error(`'Shopify ${topicReadable} Webhook Creation' exception ${JSON.stringify(e)}`) + return null + } +} diff --git a/integrations/shopify/src/setup/unregister.ts b/integrations/shopify/src/setup/unregister.ts new file mode 100644 index 00000000000..3bf45a85e96 --- /dev/null +++ b/integrations/shopify/src/setup/unregister.ts @@ -0,0 +1,63 @@ +import axios from 'axios' +import { SHOPIFY_API_VERSION } from '../const' +import type * as botpress from '.botpress' + +type IntegrationLogger = Parameters[0]['logger'] +type Implementation = ConstructorParameters[0] +type UnregisterFunction = Implementation['unregister'] +type IntegrationContext = Parameters[0]['ctx'] + +export const unregister: UnregisterFunction = async ({ ctx, logger, webhookUrl }) => { + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + 'Content-Type': 'application/json', + }, + } + + let response = await axios.get(`/admin/api/${SHOPIFY_API_VERSION}/webhooks.json?address=${webhookUrl}`, axiosConfig) + + if (response.data.webhooks.length > 0) { + logger + .forBot() + .info( + `Shopify "${ + response.data.webhooks.topic + }" Webhook was found with id ${response.data.webhooks[0].id.toString()} for Bot ${ + ctx.botId + }. Webhook was not created` + ) + + for (const webhook of response.data.webhooks) { + const webhookId = webhook.id + await deleteWebhook({ webhookId, ctx, logger }) + } + } +} + +async function deleteWebhook({ + webhookId, + ctx, + logger, +}: { + webhookId: string + ctx: IntegrationContext + logger: IntegrationLogger +}) { + try { + const axiosConfig = { + baseURL: `https://${ctx.configuration.shopName}.myshopify.com`, + headers: { + 'X-Shopify-Access-Token': ctx.configuration.access_token, + 'Content-Type': 'application/json', + }, + } + + const response = await axios.delete(`/admin/api/${SHOPIFY_API_VERSION}/webhooks/${webhookId}.json`, axiosConfig) + + logger.forBot().info(`Shopify ${webhookId} Webhook Deleted ${JSON.stringify(response.data)}`) + } catch (e) { + logger.forBot().error(`'Shopify ${webhookId} Webhook Deletion' exception ${JSON.stringify(e)}`) + } +} diff --git a/integrations/shopify/tsconfig.json b/integrations/shopify/tsconfig.json new file mode 100644 index 00000000000..83e678aa1a7 --- /dev/null +++ b/integrations/shopify/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es2017", + "baseUrl": ".", + "outDir": "dist", + "checkJs": false + }, + "include": [".botpress/**/*", "src/**/*"] +} diff --git a/integrations/trello/hub.md b/integrations/trello/hub.md index 5225330bc1d..2d845ee4eca 100644 --- a/integrations/trello/hub.md +++ b/integrations/trello/hub.md @@ -1,5 +1,3 @@ -# Botpress Trello Integration - This integration allows you to connect your Botpress chatbot with Trello, a popular project management platform. With this integration, you can easily manage your projects and tasks directly from your chatbot. To set up the integration, you will need to provide your **Trello API key** and **Token**. Once the integration is set up, you can use the built-in actions to create and update cards, add comments to cards, and more. diff --git a/integrations/trello/integration.definition.ts b/integrations/trello/integration.definition.ts index 02a1e649741..d9fc1e861e4 100644 --- a/integrations/trello/integration.definition.ts +++ b/integrations/trello/integration.definition.ts @@ -1,4 +1,5 @@ import { IntegrationDefinition } from '@botpress/sdk' + import { sentry as sentryHelpers } from '@botpress/sdk-addons' import { configuration, states, user, actions } from './src/definitions' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de37e3fe794..f9e288737ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,46 @@ importers: specifier: ^0.33.0 version: 0.33.0 + integrations/shopify: + dependencies: + '@botpress/client': + specifier: workspace:* + version: link:../../packages/client + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@botpress/sdk-addons': + specifier: workspace:* + version: link:../../packages/sdk-addons + axios: + specifier: ^1.4.0 + version: 1.4.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + '@sentry/cli': + specifier: ^2.18.1 + version: 2.18.1 + '@types/node': + specifier: ^18.11.17 + version: 18.16.0 + esbuild: + specifier: ^0.15.18 + version: 0.15.18 + nodemon: + specifier: ^2.0.20 + version: 2.0.22 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@types/node@18.16.0)(typescript@4.9.5) + typescript: + specifier: ^4.9.4 + version: 4.9.5 + integrations/slack: dependencies: '@botpress/sdk':