chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { z, type ActionDefinition } from '@botpress/sdk'
|
||||
import {
|
||||
bambooHrCompanyInfo,
|
||||
bambooHrEmployeeBasicInfoResponse,
|
||||
bambooHrEmployeeCustomInfoResponse,
|
||||
bambooHrFields,
|
||||
} from './bamboohr-schemas'
|
||||
import { employeeId, employeeIdObject } from './common'
|
||||
|
||||
const getEmployeeBasicInfo: ActionDefinition = {
|
||||
title: 'Get Basic Info',
|
||||
description: 'Retrieve basic information about an employee by their ID.',
|
||||
input: {
|
||||
schema: employeeIdObject,
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrEmployeeBasicInfoResponse,
|
||||
},
|
||||
}
|
||||
|
||||
const getEmployeeCustomInfo: ActionDefinition = {
|
||||
title: 'Get Custom Info',
|
||||
description: 'Retrieve custom information about an employee by their ID and specified fields.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
id: employeeId,
|
||||
fields: z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.title('Custom Fields')
|
||||
.describe('List of custom field names to retrieve for the employee.'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrEmployeeCustomInfoResponse,
|
||||
},
|
||||
}
|
||||
|
||||
const listEmployees: ActionDefinition = {
|
||||
title: 'List Employees',
|
||||
description:
|
||||
'Retrieve a list of all employees with IDs and names. Warning: this endpoint may not be enabled in your workspace.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
employees: z
|
||||
.array(
|
||||
z.object({
|
||||
id: employeeId,
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
)
|
||||
.title('Employees')
|
||||
.describe('List of employees in the directory. Includes only ID and display name.'),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const getCompanyInfo: ActionDefinition = {
|
||||
title: 'Get Company Info',
|
||||
description: 'Retrieve information about the company.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: bambooHrCompanyInfo,
|
||||
},
|
||||
}
|
||||
|
||||
const getFields: ActionDefinition = {
|
||||
title: 'Get Employee Fields',
|
||||
description: 'Retrieve the list of available employee fields.',
|
||||
input: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
fields: bambooHrFields,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
getEmployeeBasicInfo,
|
||||
getEmployeeCustomInfo,
|
||||
listEmployees,
|
||||
getCompanyInfo,
|
||||
getFields,
|
||||
} as const
|
||||
@@ -0,0 +1,227 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import { employeeId, timestamp } from './common'
|
||||
|
||||
export const bambooHrOauthTokenResponse = z.object({
|
||||
access_token: z.string().title('Access Token').describe('The temporary access token issued by BambooHR.'),
|
||||
refresh_token: z.string().title('Refresh Token').describe('The refresh token issued by BambooHR.'),
|
||||
expires_in: z.number().title('Expires In').describe('The number of seconds until the access token expires.'),
|
||||
token_type: z.literal('Bearer').title('Token Type').describe('The type of token issued.'),
|
||||
scope: z.string().title('Scopes').describe('The scopes granted to the access token (space-separated).'),
|
||||
id_token: z.string().title('ID Token').describe('A unique identifier for the user.'),
|
||||
companyDomain: z.string().title('Company Domain').describe('The company subdomain on BambooHR.'),
|
||||
})
|
||||
|
||||
export const bambooHrWebhookCreateResponse = z.object({
|
||||
id: z.string().title('Webhook ID').describe('The unique identifier for the created webhook.'),
|
||||
privateKey: z.string().title('Private Key').describe('The private key to validate incoming webhooks.'),
|
||||
})
|
||||
|
||||
/** Fields that can be monitored for updates as a webhook event */
|
||||
export const bambooHrEmployeeWebhookFields = z
|
||||
.object({
|
||||
customBenefitIDNumber: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Benefit ID Number')
|
||||
.describe('Custom benefit ID number.'),
|
||||
customCitizenshipCertificate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Citizenship Certificate')
|
||||
.describe('Custom citizenship certificate.'),
|
||||
payChangeReason: z.string().nullable().optional().title('Pay Change Reason').describe('Pay change reason.'),
|
||||
payRateEffectiveDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Pay Rate Effective Date')
|
||||
.describe('Pay rate effective date.'),
|
||||
department: z.string().nullable().optional().title('Department').describe('Department.'),
|
||||
division: z.string().nullable().optional().title('Division').describe('Division.'),
|
||||
employeeNumber: z.string().nullable().optional().title('Employee Number').describe('Employee number.'),
|
||||
employeeTaxType: z.string().nullable().optional().title('Employee Tax Type').describe('Employee tax type.'),
|
||||
employmentHistoryStatus: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Employment History Status')
|
||||
.describe('Employment history status.'),
|
||||
employeeStatusDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Employee Status Date')
|
||||
.describe('Employee status date.'),
|
||||
ethnicity: z.string().nullable().optional().title('Ethnicity').describe('Ethnicity.'),
|
||||
facebook: z.string().nullable().optional().title('Facebook').describe('Facebook.'),
|
||||
firstName: z.string().nullable().optional().title('First Name').describe('First name.'),
|
||||
gender: z.string().nullable().optional().title('Gender').describe('Gender.'),
|
||||
hireDate: z.string().nullable().optional().title('Hire Date').describe('Hire date.'),
|
||||
homeEmail: z.string().nullable().optional().title('Home Email').describe('Home email.'),
|
||||
homePhone: z.string().nullable().optional().title('Home Phone').describe('Home phone.'),
|
||||
jobTitle: z.string().nullable().optional().title('Job Title').describe('Job title.'),
|
||||
lastName: z.string().nullable().optional().title('Last Name').describe('Last name.'),
|
||||
linkedIn: z.string().nullable().optional().title('LinkedIn').describe('LinkedIn.'),
|
||||
location: z.string().nullable().optional().title('Location').describe('Location.'),
|
||||
maritalStatus: z.string().nullable().optional().title('Marital Status').describe('Marital status.'),
|
||||
middleName: z.string().nullable().optional().title('Middle Name').describe('Middle name.'),
|
||||
mobilePhone: z.string().nullable().optional().title('Mobile Phone').describe('Mobile phone.'),
|
||||
customNIN1: z.string().nullable().optional().title('Custom NIN1').describe('Custom NIN1.'),
|
||||
nationality: z.string().nullable().optional().title('Nationality').describe('Nationality.'),
|
||||
originalHireDate: z.string().nullable().optional().title('Original Hire Date').describe('Original hire date.'),
|
||||
overtimeRate: z.string().nullable().optional().title('Overtime Rate').describe('Overtime rate.'),
|
||||
exempt: z.string().nullable().optional().title('Exempt').describe('Exempt.'),
|
||||
payPer: z.string().nullable().optional().title('Pay Per').describe('Pay per.'),
|
||||
paySchedule: z.string().nullable().optional().title('Pay Schedule').describe('Pay schedule.'),
|
||||
payRate: z.string().nullable().optional().title('Pay Rate').describe('Pay rate.'),
|
||||
payType: z.string().nullable().optional().title('Pay Type').describe('Pay type.'),
|
||||
preferredName: z.string().nullable().optional().title('Preferred Name').describe('Preferred name.'),
|
||||
customProbationaryPeriodEndDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Probationary Period End Date')
|
||||
.describe('Custom probationary period end date.'),
|
||||
customProbationaryPeriodStartDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Probationary Period Start Date')
|
||||
.describe('Custom probationary period start date.'),
|
||||
customProjectedTerminationDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Projected Termination Date')
|
||||
.describe('Custom projected termination date.'),
|
||||
customROESATCompleted: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom ROESAT Completed')
|
||||
.describe('Custom ROESAT completed.'),
|
||||
reportsTo: z.string().nullable().optional().title('Reports To').describe('Reports to.'),
|
||||
customShirtsize: z.string().nullable().optional().title('Custom Shirt Size').describe('Custom shirt size.'),
|
||||
status: z.string().nullable().optional().title('Status').describe('Status.'),
|
||||
teams: z.string().nullable().optional().title('Teams').describe('Teams.'),
|
||||
customTerminationCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Termination Code')
|
||||
.describe('Custom termination code.'),
|
||||
customTerminationNoticeGiven: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Custom Termination Notice Given')
|
||||
.describe('Custom termination notice given.'),
|
||||
twitterFeed: z.string().nullable().optional().title('Twitter Feed').describe('Twitter feed.'),
|
||||
workEmail: z.string().nullable().optional().title('Work Email').describe('Work email.'),
|
||||
workPhoneExtension: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.title('Work Phone Extension')
|
||||
.describe('Work phone extension.'),
|
||||
workPhone: z.string().nullable().optional().title('Work Phone').describe('Work phone.'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const bambooHrEmployeeBaseEvent = z.object({
|
||||
id: employeeId,
|
||||
timestamp,
|
||||
})
|
||||
export const bambooHrEmployeeCreatedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Created').title('Action').describe('Creation action.'),
|
||||
})
|
||||
export const bambooHrEmployeeDeletedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Deleted').title('Action').describe('Deletion action.'),
|
||||
})
|
||||
export const bambooHrEmployeeUpdatedEvent = bambooHrEmployeeBaseEvent.extend({
|
||||
action: z.literal('Updated').title('Action').describe('Update action.'),
|
||||
fields: z
|
||||
.object(mapValues(bambooHrEmployeeWebhookFields.shape, (schema) => z.object({ value: schema })))
|
||||
.title('Fields')
|
||||
.describe(
|
||||
'All fields subscribed to for the employee, including unchanged fields. Value is nested as `fieldName: { value }`.'
|
||||
),
|
||||
changedFields: z
|
||||
.array(z.string())
|
||||
.title('Changed Fields')
|
||||
.describe('List of fields that were changed in this event.'),
|
||||
})
|
||||
|
||||
/**Employee events by BambooHR webhook */
|
||||
export const bambooHrEmployeeWebhookEvent = z.object({
|
||||
employees: z
|
||||
.array(z.union([bambooHrEmployeeCreatedEvent, bambooHrEmployeeDeletedEvent, bambooHrEmployeeUpdatedEvent]))
|
||||
.title('Employees')
|
||||
.describe('List of employees included in the event.'),
|
||||
})
|
||||
|
||||
/** All fields that can be queried on an employee as an action */
|
||||
export const bambooHrEmployeeBasicInfoResponse = bambooHrEmployeeWebhookFields.extend({
|
||||
id: employeeId,
|
||||
supervisorEid: z.string().nullable().optional().title('Supervisor ID').describe("Employee's supervisor's ID."),
|
||||
lastChanged: z.string().title('Last Changed').describe('Full timestamp of the last change to the employee record.'),
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
|
||||
export const bambooHrEmployeeCustomInfoResponse = z.object({ id: employeeId }).passthrough()
|
||||
|
||||
export const bambooHrEmployeeDirectoryResponse = z.object({
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().title('Field ID').describe('Unique identifier for the field.'),
|
||||
name: z.string().title('Field Name').describe('Name of the field.'),
|
||||
type: z.string().title('Field Type').describe('Type of the field. See BambooHR API documentation for details.'),
|
||||
})
|
||||
)
|
||||
.title('Fields')
|
||||
.describe('List of fields included in the directory. Does not include all workspace fields.'),
|
||||
employees: z
|
||||
.array(
|
||||
z.object({
|
||||
id: employeeId,
|
||||
displayName: z.string().title('Display Name').describe("Employee's display name."),
|
||||
})
|
||||
)
|
||||
.title('Employees')
|
||||
.describe('List of employees in the directory. Includes more fields.'),
|
||||
})
|
||||
|
||||
export const bambooHrCompanyInfo = z.object({
|
||||
legalName: z.string().title('Legal Name').describe('Legal name of the company.'),
|
||||
displayName: z.string().title('Display Name').describe('Display name of the company.'),
|
||||
address: z
|
||||
.object({
|
||||
line1: z.string().nullable().optional().title('Address Line 1').describe('First line of the street.'),
|
||||
line2: z.string().nullable().optional().title('Address Line 2').describe('Second line of the street.'),
|
||||
city: z.string().nullable().optional().title('City').describe('City.'),
|
||||
state: z.string().nullable().optional().title('State').describe('State or province.'),
|
||||
country: z.string().nullable().optional().title('Country').describe('Country.'),
|
||||
zip: z.string().nullable().optional().title('ZIP Code').describe('ZIP code.'),
|
||||
})
|
||||
.title('Address')
|
||||
.describe('Address of the company.'),
|
||||
phone: z.string().nullable().optional().title('Phone Number').describe('Phone number of the company.'),
|
||||
})
|
||||
|
||||
export const bambooHrFields = z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.union([z.string(), z.number()]).title('ID').describe('Unique identifier for the field.'),
|
||||
name: z.string().title('Name').describe('Name of the field.'),
|
||||
type: z.string().title('Type').describe('Data type of the field.'),
|
||||
alias: z.string().optional().title('Alias').describe('Alias used for the field in API requests.'),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.title('Fields')
|
||||
.describe('List of available employee fields.')
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
/** User-friendly pastable url, to be extracted to subdomain for use in handler */
|
||||
export const subdomain = z
|
||||
.string()
|
||||
.min(1)
|
||||
.title('BambooHR Subdomain')
|
||||
.describe("Your company's BambooHR subdomain (https://[subdomain].bamboohr.com)")
|
||||
|
||||
export const employeeId = z.string().min(1).title('Employee ID').describe('Unique identifier for the employee.')
|
||||
export const employeeIdObject = z.object({ id: employeeId })
|
||||
|
||||
export const timestamp = z.string().title('Timestamp').describe('Timestamp in ISO 8601 format.')
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z, type EventDefinition } from '@botpress/sdk'
|
||||
import { bambooHrEmployeeWebhookFields } from './bamboohr-schemas'
|
||||
import { employeeId, timestamp } from './common'
|
||||
|
||||
const sharedEventFields = z.object({
|
||||
id: employeeId,
|
||||
timestamp,
|
||||
})
|
||||
|
||||
const employeeCreated: EventDefinition = {
|
||||
title: 'Employee Created',
|
||||
description: 'Triggers when a new employee is created in BambooHR.',
|
||||
schema: sharedEventFields,
|
||||
}
|
||||
|
||||
const employeeDeleted: EventDefinition = {
|
||||
title: 'Employee Deleted',
|
||||
description:
|
||||
'Triggers when an employee is deleted from BambooHR. Terminating employees will trigger the "Employee Updated" event instead.',
|
||||
schema: sharedEventFields,
|
||||
}
|
||||
|
||||
const employeeUpdated: EventDefinition = {
|
||||
title: 'Employee Updated',
|
||||
description: 'Triggers when an existing employee is updated in BambooHR.',
|
||||
schema: sharedEventFields.extend({
|
||||
fields: bambooHrEmployeeWebhookFields
|
||||
.title('Fields')
|
||||
.describe('All fields subscribed to for the employee, including unchanged fields.'),
|
||||
changedFields: z
|
||||
.array(z.string())
|
||||
.title('Changed Fields')
|
||||
.describe('List of fields that were changed in this event.'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const events = {
|
||||
employeeCreated,
|
||||
employeeDeleted,
|
||||
employeeUpdated,
|
||||
} as const
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './bamboohr-schemas'
|
||||
export * from './common'
|
||||
export { actions } from './actions'
|
||||
export { events } from './events'
|
||||
@@ -0,0 +1,7 @@
|
||||
# BambooHR Integration
|
||||
|
||||
The BambooHR integration connects your Botpress agent with your company’s HR system, allowing it to access and manage employee data. It enables an agent to read and update employee information, fetch organizational details, and interact with key BambooHR resources like job titles, departments, and time-off balances.
|
||||
|
||||
With this integration, your agent can automate common HR tasks like looking up employee profiles, verifying job information, checking leave requests, or returning structured HR data to other connected systems. It provides endpoints for managing records, performing authenticated API calls, and handling responses in a consistent format.
|
||||
|
||||
By bridging BambooHR and Botpress, your agent can directly participate in HR workflows by helping you find information faster and reducing manual data entry.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,83 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { IntegrationDefinition, z } from '@botpress/sdk'
|
||||
import { actions, events, subdomain } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'bamboohr'
|
||||
export const INTEGRATION_VERSION = '2.1.3'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
title: 'BambooHR',
|
||||
description: 'Retrieve your BambooHR information',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
|
||||
configuration: {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
required: true,
|
||||
},
|
||||
schema: z.object({}),
|
||||
},
|
||||
|
||||
configurations: {
|
||||
manual: {
|
||||
title: 'Manual configuration',
|
||||
description: 'Configure manually with your BambooHR API Key',
|
||||
schema: z.object({
|
||||
apiKey: z.string().min(1).title('API Key').describe('Your BambooHR API Key, from My Account > Api Keys'),
|
||||
subdomain,
|
||||
}),
|
||||
},
|
||||
},
|
||||
actions,
|
||||
events,
|
||||
secrets: {
|
||||
OAUTH_CLIENT_SECRET: {
|
||||
description: 'The OAuth Client Secret provided by BambooHR from the developer portal.',
|
||||
},
|
||||
OAUTH_CLIENT_ID: {
|
||||
description: 'The OAuth Client ID provided by BambooHR from the developer portal.',
|
||||
},
|
||||
...posthogHelper.COMMON_SECRET_NAMES,
|
||||
},
|
||||
states: {
|
||||
oauth: {
|
||||
type: 'integration',
|
||||
schema: z
|
||||
.object({
|
||||
domain: z.string().title('Domain').describe('The domain of the company.'),
|
||||
accessToken: z.string().title('Temporary Access Token').describe('Temporary access token for the API.'),
|
||||
refreshToken: z.string().title('Refresh Token').describe('Token used to refresh the access token.'),
|
||||
expiresAt: z
|
||||
.number()
|
||||
.title('Expiration Timestamp')
|
||||
.describe('Timestamp (in ms from epoch) at which the token expires.'),
|
||||
scopes: z.string().title('Scopes').describe('Scopes for the token (space-separated).'),
|
||||
})
|
||||
.title('OAuth Parameters')
|
||||
.describe('Parameters required to authenticate via OAuth. Not required if using API Key.'),
|
||||
},
|
||||
webhook: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
privateKey: z
|
||||
.string()
|
||||
.nullable()
|
||||
.title('Private Key')
|
||||
.describe('The private key provided by BambooHR to validate the webhook.'),
|
||||
id: z
|
||||
.string()
|
||||
.nullable()
|
||||
.title('Webhook ID')
|
||||
.describe('The ID of the webhook as provided by BambooHR when the webhook was created.'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Business Operations',
|
||||
guideSlug: 'bamboohr',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@botpresshub/bamboohr",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
"@types/lodash": "^4.14.191"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { BambooHRClient } from 'src/api/bamboohr-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getCompanyInfo: bp.IntegrationProps['actions']['getCompanyInfo'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
try {
|
||||
return await bambooHrClient.getCompanyInfo()
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get company info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { BambooHRClient } from 'src/api/bamboohr-client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getEmployeeBasicInfo: bp.IntegrationProps['actions']['getEmployeeBasicInfo'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
input,
|
||||
}) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.getEmployeeBasicInfo(input.id)
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get employee basic info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const getEmployeeCustomInfo: bp.IntegrationProps['actions']['getEmployeeCustomInfo'] = async ({
|
||||
input,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
}) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.getEmployeeCustomInfo(input.id, input.fields)
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get employee custom info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const listEmployees: bp.IntegrationProps['actions']['listEmployees'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return await bambooHrClient.listEmployees()
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to list employees: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const getFields: bp.IntegrationProps['actions']['getFields'] = async ({ client, ctx, logger }) => {
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
|
||||
try {
|
||||
return { fields: await bambooHrClient.getFields() }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`Failed to get fields: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getCompanyInfo } from './company'
|
||||
import { getEmployeeBasicInfo, getEmployeeCustomInfo, listEmployees, getFields } from './employees'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
getEmployeeBasicInfo,
|
||||
getEmployeeCustomInfo,
|
||||
listEmployees,
|
||||
getCompanyInfo,
|
||||
getFields,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,176 @@
|
||||
import { bambooHrOauthTokenResponse } from 'definitions'
|
||||
import * as types from '../types'
|
||||
import { stripSubdomain } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const OAUTH_EXPIRATION_MARGIN = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const _fetchBambooHrOauthToken = async (props: {
|
||||
subdomain: string
|
||||
oAuthInfo: { code: string; redirectUri: string } | { refreshToken: string }
|
||||
}): Promise<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
scopes: string
|
||||
idToken: string
|
||||
}> => {
|
||||
const { subdomain, oAuthInfo } = props
|
||||
const bambooHrOauthUrl = `https://${subdomain}.bamboohr.com/token.php?request=token`
|
||||
|
||||
const { OAUTH_CLIENT_SECRET, OAUTH_CLIENT_ID } = bp.secrets
|
||||
|
||||
// See https://documentation.bamboohr.com/docs/getting-started
|
||||
const requestTimestamp = Date.now()
|
||||
|
||||
const body = JSON.stringify({
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
client_secret: OAUTH_CLIENT_SECRET,
|
||||
...('code' in oAuthInfo
|
||||
? { grant_type: 'authorization_code', code: oAuthInfo.code, redirect_uri: oAuthInfo.redirectUri }
|
||||
: { grant_type: 'refresh_token', refresh_token: oAuthInfo.refreshToken }),
|
||||
})
|
||||
|
||||
const tokenResponse = await fetch(bambooHrOauthUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (tokenResponse.status < 200 || tokenResponse.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed POST request for OAuth token: ${tokenResponse.status} ${tokenResponse.statusText} at ${bambooHrOauthUrl} with ${'code' in oAuthInfo ? oAuthInfo.code : oAuthInfo.refreshToken}`
|
||||
)
|
||||
}
|
||||
|
||||
const tokenData = bambooHrOauthTokenResponse.safeParse(await tokenResponse.json())
|
||||
if (!tokenData.success) {
|
||||
throw new Error(`Failed parse OAuth token response: ${tokenData.error.message}`)
|
||||
}
|
||||
const { access_token, refresh_token, expires_in, scope, id_token } = tokenData.data
|
||||
|
||||
return {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresAt: requestTimestamp + expires_in * 1000 - OAUTH_EXPIRATION_MARGIN,
|
||||
scopes: scope,
|
||||
idToken: id_token,
|
||||
}
|
||||
}
|
||||
|
||||
export type BambooHRAuthorization = { authorization: string; expiresAt: number; domain: string } & (
|
||||
| {
|
||||
type: 'apiKey'
|
||||
}
|
||||
| {
|
||||
type: 'oauth'
|
||||
refreshToken: string
|
||||
}
|
||||
)
|
||||
|
||||
export const getCurrentBambooHrAuthorization = async ({
|
||||
ctx,
|
||||
client,
|
||||
}: types.CommonHandlerProps): Promise<BambooHRAuthorization> => {
|
||||
if (ctx.configurationType === 'manual') {
|
||||
return {
|
||||
type: 'apiKey',
|
||||
authorization: `Basic ${Buffer.from(ctx.configuration.apiKey + ':x').toString('base64')}`,
|
||||
expiresAt: Infinity,
|
||||
domain: stripSubdomain(ctx.configuration.subdomain),
|
||||
}
|
||||
}
|
||||
|
||||
let oauth: bp.states.States['oauth']['payload']
|
||||
try {
|
||||
const { state } = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
oauth = state.payload
|
||||
} catch (err) {
|
||||
throw new Error('OAuth token missing in state for OAuth-linked integration.', { cause: err })
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'oauth',
|
||||
authorization: `Bearer ${oauth.accessToken}`,
|
||||
expiresAt: oauth.expiresAt,
|
||||
refreshToken: oauth.refreshToken,
|
||||
domain: oauth.domain,
|
||||
}
|
||||
}
|
||||
|
||||
export const refreshBambooHrAuthorization = async (
|
||||
{ ctx, client }: types.CommonHandlerProps,
|
||||
previousAuth: BambooHRAuthorization
|
||||
): Promise<BambooHRAuthorization> => {
|
||||
// Return the previous authorization if it is an API key
|
||||
if (previousAuth.type === 'apiKey') {
|
||||
return previousAuth
|
||||
}
|
||||
|
||||
const oauth = previousAuth
|
||||
|
||||
const { accessToken, expiresAt, refreshToken, scopes } = await _fetchBambooHrOauthToken({
|
||||
subdomain: oauth.domain,
|
||||
oAuthInfo: { refreshToken: oauth.refreshToken },
|
||||
})
|
||||
|
||||
await client.patchState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
scopes,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'oauth',
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
expiresAt: oauth.expiresAt,
|
||||
refreshToken,
|
||||
domain: oauth.domain,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles OAuth endpoint on integration authentication.
|
||||
*
|
||||
* Exchanges code for token, saves token in state, and configures integration with identifier and subdomain.
|
||||
*/
|
||||
export const handleOauthRequest = async ({ ctx, client, req, logger }: bp.HandlerProps, subdomain: string) => {
|
||||
const code = new URLSearchParams(req.query).get('code')
|
||||
const redirectUri = new URLSearchParams(req.query).get('redirect_uri')
|
||||
|
||||
if (!code || !redirectUri) throw new Error('Missing authentication code or redirect URI')
|
||||
if (!subdomain) throw new Error('Subdomain is required')
|
||||
|
||||
const { ...oauthState } = await _fetchBambooHrOauthToken({
|
||||
subdomain,
|
||||
oAuthInfo: { code, redirectUri },
|
||||
}).catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new Error('Failed to fetch BambooHR OAuth token: ' + error.message)
|
||||
})
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...oauthState, domain: subdomain },
|
||||
})
|
||||
|
||||
await client.configureIntegration({
|
||||
identifier: subdomain,
|
||||
})
|
||||
|
||||
logger.forBot().info('BambooHR OAuth authentication successfully set up.')
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { type z } from '@botpress/sdk'
|
||||
|
||||
import {
|
||||
bambooHrCompanyInfo,
|
||||
bambooHrEmployeeBasicInfoResponse,
|
||||
bambooHrEmployeeCustomInfoResponse,
|
||||
bambooHrEmployeeDirectoryResponse,
|
||||
bambooHrFields,
|
||||
bambooHrWebhookCreateResponse,
|
||||
} from 'definitions'
|
||||
import * as types from '../types'
|
||||
import { BambooHRAuthorization, getCurrentBambooHrAuthorization, refreshBambooHrAuthorization } from './auth'
|
||||
import { parseResponseWithErrors } from './utils'
|
||||
|
||||
const getHeaders = (authorization: string) => ({
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
export class BambooHRClient {
|
||||
private _baseUrl: string
|
||||
private _currentAuth: BambooHRAuthorization
|
||||
private _props: types.CommonHandlerProps
|
||||
|
||||
public static async create(props: types.CommonHandlerProps): Promise<BambooHRClient> {
|
||||
const currentAuth = await getCurrentBambooHrAuthorization(props)
|
||||
return new BambooHRClient({ subdomain: currentAuth.domain, props, currentAuth })
|
||||
}
|
||||
|
||||
private constructor({
|
||||
subdomain,
|
||||
props,
|
||||
currentAuth,
|
||||
}: {
|
||||
subdomain: string
|
||||
props: types.CommonHandlerProps
|
||||
currentAuth: BambooHRAuthorization
|
||||
}) {
|
||||
this._baseUrl = `https://${subdomain}.bamboohr.com/api/v1`
|
||||
this._props = props
|
||||
this._currentAuth = currentAuth
|
||||
}
|
||||
|
||||
private async _makeRequest({
|
||||
url,
|
||||
...params
|
||||
}: Pick<RequestInit, 'method' | 'body'> & { url: URL }): Promise<Response> {
|
||||
// Refresh token if too close to expiry
|
||||
if (Date.now() >= this._currentAuth.expiresAt) {
|
||||
this._currentAuth = await refreshBambooHrAuthorization(this._props, this._currentAuth)
|
||||
}
|
||||
|
||||
const headers = getHeaders(this._currentAuth.authorization)
|
||||
const res = await fetch(url, { ...params, headers })
|
||||
if (!res.ok) {
|
||||
// Custom error header from BambooHR with more details
|
||||
const additionalInfo = res.headers.get('x-bamboohr-error-message')
|
||||
throw new Error(
|
||||
`BambooHR API request failed with status ${res.status} ${res.statusText}` +
|
||||
(additionalInfo ? `: ${additionalInfo}` : '')
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
public async testAuthorization(): Promise<boolean> {
|
||||
const url = new URL(`${this._baseUrl}/employees/0`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
public async createWebhook(
|
||||
webhookUrl: string,
|
||||
fields: string[]
|
||||
): Promise<z.infer<typeof bambooHrWebhookCreateResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks`)
|
||||
|
||||
const body = JSON.stringify({
|
||||
name: this._props.ctx.integrationId,
|
||||
monitorFields: fields.filter((field) => field !== 'terminationDate'), // terminationDate returns error on monitor
|
||||
postFields: fields.reduce((acc, field) => ({ ...acc, [field]: field }), {} as Record<string, string>),
|
||||
url: webhookUrl,
|
||||
format: 'json',
|
||||
limit: {
|
||||
times: 1000, // Send at most 1000 records per event
|
||||
seconds: 60, // Fire at most once per minute
|
||||
},
|
||||
})
|
||||
|
||||
const res = await this._makeRequest({ method: 'POST', url, body })
|
||||
const result = await parseResponseWithErrors(res, bambooHrWebhookCreateResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async deleteWebhook(webhookId: string): Promise<Response> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks/${webhookId}`)
|
||||
|
||||
return await this._makeRequest({ method: 'DELETE', url })
|
||||
}
|
||||
|
||||
// API Methods
|
||||
|
||||
public async getMonitoredFields(): Promise<string[]> {
|
||||
const url = new URL(`${this._baseUrl}/webhooks/monitor_fields`)
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await res.json()
|
||||
if ('fields' in result) {
|
||||
return result.fields.map((field: { alias: string }) => field.alias).filter((field: string) => field !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public async getCompanyInfo(): Promise<z.infer<typeof bambooHrCompanyInfo>> {
|
||||
const url = new URL(`${this._baseUrl}/company_information`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrCompanyInfo)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getFields(): Promise<z.infer<typeof bambooHrFields>> {
|
||||
const url = new URL(`${this._baseUrl}/meta/fields`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrFields)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeeBasicInfo(employeeId: string): Promise<z.infer<typeof bambooHrEmployeeBasicInfoResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeBasicInfoResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeeCustomInfo(
|
||||
employeeId: string,
|
||||
fields: string[]
|
||||
): Promise<z.infer<typeof bambooHrEmployeeCustomInfoResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}`)
|
||||
url.searchParams.append('fields', fields.join(','))
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeCustomInfoResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getEmployeePhoto(employeeId: string, size: string): Promise<Blob> {
|
||||
const url = new URL(`${this._baseUrl}/employees/${employeeId}/photo/${size}`)
|
||||
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
return await res.blob()
|
||||
}
|
||||
|
||||
public async listEmployees(): Promise<z.infer<typeof bambooHrEmployeeDirectoryResponse>> {
|
||||
const url = new URL(`${this._baseUrl}/employees/directory`)
|
||||
const res = await this._makeRequest({ method: 'GET', url })
|
||||
const result = await parseResponseWithErrors(res, bambooHrEmployeeDirectoryResponse)
|
||||
|
||||
if (!result.success) {
|
||||
this._props.logger.forBot().error(result.error, result.details)
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
export type ValidateBambooHrSignatureResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const validateBambooHrSignature = async (
|
||||
req: sdk.Request,
|
||||
privateKey: string
|
||||
): Promise<ValidateBambooHrSignatureResult> => {
|
||||
const signature = req.headers?.['x-bamboohr-signature']
|
||||
const timestamp = req.headers?.['x-bamboohr-timestamp']
|
||||
if (!signature || !timestamp) {
|
||||
return { success: false, reason: 'Missing signature headers to verify webhook event.' }
|
||||
}
|
||||
if (!req.body) {
|
||||
return { success: false, reason: 'No request body found to verify signature.' }
|
||||
}
|
||||
|
||||
const computedBuffer = createHmac('sha256', privateKey)
|
||||
.update(Buffer.concat([Buffer.from(req.body, 'utf8'), Buffer.from(timestamp, 'utf8')]))
|
||||
.digest()
|
||||
const signatureBuffer = Buffer.from(signature, 'hex')
|
||||
const isValid = computedBuffer.length === signatureBuffer.length && timingSafeEqual(computedBuffer, signatureBuffer)
|
||||
|
||||
if (!isValid) {
|
||||
return { success: false, reason: 'Invalid BambooHR webhook signature.' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type z } from '@botpress/sdk'
|
||||
|
||||
export type ParseResult<T> = { success: true; data: T } | { success: false; error: string; details?: unknown }
|
||||
|
||||
export const parseResponseWithErrors = async <T>(res: Response, schema: z.ZodSchema<T>): Promise<ParseResult<T>> => {
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (thrown) {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'BambooHR API response is not valid JSON',
|
||||
details: err,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = schema.parse(json)
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'BambooHR API response did not match expected format',
|
||||
details: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const safeParseJson = <T>(str: string): ParseResult<T> => {
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return { success: true, data: parsed }
|
||||
} catch (thrown) {
|
||||
const error = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return { success: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
export const stripSubdomain = (input: string): string => {
|
||||
const trimmedInput = input.trim()
|
||||
|
||||
// Remove protocol if present
|
||||
const withoutProtocol = trimmedInput.replace(/^https?:\/\//, '')
|
||||
|
||||
const suffix = '.bamboohr.com'
|
||||
if (withoutProtocol.endsWith(suffix)) {
|
||||
return withoutProtocol.slice(0, -suffix.length)
|
||||
}
|
||||
|
||||
return trimmedInput
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
|
||||
export class BambooHRRuntimeError extends RuntimeError {
|
||||
public static from(thrown: unknown, ctx: string): BambooHRRuntimeError {
|
||||
const errMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return new BambooHRRuntimeError(`${ctx}: ${errMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { z } from '@botpress/sdk'
|
||||
import {
|
||||
bambooHrEmployeeWebhookFields,
|
||||
type bambooHrEmployeeCreatedEvent,
|
||||
type bambooHrEmployeeDeletedEvent,
|
||||
type bambooHrEmployeeUpdatedEvent,
|
||||
} from 'definitions'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEmployeeCreatedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeCreatedEvent>
|
||||
) => {
|
||||
const { id, timestamp } = event
|
||||
await client.createEvent({
|
||||
type: 'employeeCreated',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const handleEmployeeDeletedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeDeletedEvent>
|
||||
) => {
|
||||
const { id, timestamp } = event
|
||||
await client.createEvent({
|
||||
type: 'employeeDeleted',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const handleEmployeeUpdatedEvent = async (
|
||||
{ client }: bp.HandlerProps,
|
||||
event: z.output<typeof bambooHrEmployeeUpdatedEvent>
|
||||
) => {
|
||||
const { id, timestamp, fields, changedFields } = event
|
||||
|
||||
await client.createEvent({
|
||||
type: 'employeeUpdated',
|
||||
payload: {
|
||||
id,
|
||||
timestamp,
|
||||
changedFields,
|
||||
fields: bambooHrEmployeeWebhookFields.parse(mapValues(fields, ({ value }) => value)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { bambooHrEmployeeWebhookEvent } from 'definitions'
|
||||
import { validateBambooHrSignature } from './api/signing'
|
||||
import { safeParseJson } from './api/utils'
|
||||
import { BambooHRRuntimeError } from './error-handling'
|
||||
import { handleEmployeeCreatedEvent, handleEmployeeDeletedEvent, handleEmployeeUpdatedEvent } from './events'
|
||||
import { handler as oauthHandler } from './handlers/oauth'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path.startsWith('/oauth')
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
if (_isOauthRequest(props)) {
|
||||
return await oauthHandler(props).catch((thrown) => {
|
||||
const err = BambooHRRuntimeError.from(thrown, 'Error in OAuth creation flow')
|
||||
logger.forBot().error(err.message)
|
||||
return { status: 500, body: err.message }
|
||||
})
|
||||
}
|
||||
|
||||
const { state } = await props.client.getState({
|
||||
name: 'webhook',
|
||||
type: 'integration',
|
||||
id: props.ctx.integrationId,
|
||||
})
|
||||
const privateKey = state.payload.privateKey
|
||||
if (!privateKey) {
|
||||
logger.forBot().error('No private key found for webhook state.')
|
||||
return
|
||||
}
|
||||
|
||||
const signingResult = await validateBambooHrSignature(props.req, privateKey)
|
||||
if (!signingResult.success) {
|
||||
logger.forBot().error('HMAC signature validation failed: ' + signingResult.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const jsonParseResult = safeParseJson(req.body ?? '')
|
||||
if (!jsonParseResult.success) {
|
||||
logger.forBot().error('Error parsing request body: ' + jsonParseResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
const zodParseResult = bambooHrEmployeeWebhookEvent.safeParse(jsonParseResult.data)
|
||||
if (!zodParseResult.success) {
|
||||
logger.forBot().error('Error parsing request body: ' + zodParseResult.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const event = zodParseResult.data
|
||||
|
||||
await Promise.all(
|
||||
event.employees.map(async (employee) => {
|
||||
const { action, id, timestamp } = employee
|
||||
logger.forBot().info(`Sending employee ${action.toLowerCase()} event for ID ${id} at ${timestamp}`)
|
||||
|
||||
switch (action) {
|
||||
case 'Created':
|
||||
await handleEmployeeCreatedEvent(props, employee)
|
||||
break
|
||||
case 'Deleted':
|
||||
await handleEmployeeDeletedEvent(props, employee)
|
||||
break
|
||||
case 'Updated':
|
||||
await handleEmployeeUpdatedEvent(props, employee)
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn(`Unknown action type: ${action}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { status: 200 }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
|
||||
import * as wizard from '../wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ req, client, ctx, logger }) => {
|
||||
if (!isOAuthWizardUrl(req.path)) {
|
||||
return {
|
||||
status: 404,
|
||||
body: 'Invalid OAuth endpoint',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await wizard.handler({ req, client, ctx, logger })
|
||||
} catch (error) {
|
||||
const errorMessage = 'OAuth registration error: ' + (error as Error).message
|
||||
logger.forBot().error(errorMessage)
|
||||
return generateRedirection(getInterstitialUrl(false, errorMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import { actions } from './actions'
|
||||
import { handler } from './handler'
|
||||
import { register, unregister } from './setup'
|
||||
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integrationConfig: bp.IntegrationProps = {
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels: {},
|
||||
handler,
|
||||
}
|
||||
|
||||
export default posthogHelper.wrapIntegration(
|
||||
{
|
||||
integrationName: INTEGRATION_NAME,
|
||||
key: bp.secrets.POSTHOG_KEY,
|
||||
integrationVersion: INTEGRATION_VERSION,
|
||||
},
|
||||
integrationConfig
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
import { BambooHRClient } from './api/bamboohr-client'
|
||||
|
||||
import { BambooHRRuntimeError } from './error-handling'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.Integration['register'] = async (props) => {
|
||||
const { client, ctx, logger, webhookUrl } = props
|
||||
// For OAuth mode, verify OAuth state exists
|
||||
if (ctx.configurationType !== 'manual') {
|
||||
const { state } = await client
|
||||
.getState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
.catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new BambooHRRuntimeError('OAuth state not properly configured: ' + error.message)
|
||||
})
|
||||
|
||||
if (!state.payload.accessToken || !state.payload.refreshToken) {
|
||||
const error = new Error('OAuth tokens not found. Please complete OAuth flow.')
|
||||
throw BambooHRRuntimeError.from(error, 'Error registering BambooHR integration')
|
||||
}
|
||||
}
|
||||
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger })
|
||||
await bambooHrClient.testAuthorization().catch((thrown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw BambooHRRuntimeError.from(error, 'Error authorizing BambooHR integration')
|
||||
})
|
||||
logger.forBot().info('Integration is authorized.')
|
||||
|
||||
const fields = await bambooHrClient.getMonitoredFields().catch((thrown) => {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error getting monitored fields')
|
||||
})
|
||||
|
||||
logger.forBot().info('Setting up webhook with BambooHR...')
|
||||
try {
|
||||
const { state } = await client.getOrSetState({
|
||||
name: 'webhook',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: { id: null, privateKey: null },
|
||||
})
|
||||
|
||||
if (!state.payload.id) {
|
||||
const { id, privateKey } = await bambooHrClient.createWebhook(webhookUrl, fields)
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'webhook',
|
||||
id: ctx.integrationId,
|
||||
payload: { id, privateKey },
|
||||
})
|
||||
}
|
||||
} catch (thrown) {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error registering BambooHR webhook')
|
||||
}
|
||||
logger.forBot().info('Registered webhook.')
|
||||
}
|
||||
|
||||
export const unregister: bp.Integration['unregister'] = async (props) => {
|
||||
const { client, ctx, logger } = props
|
||||
|
||||
if (ctx.configurationType === 'manual') {
|
||||
logger.forBot().info('Unregistering BambooHR webhook is not supported for manual configuration.')
|
||||
return
|
||||
}
|
||||
|
||||
const { state } = await client
|
||||
.getState({
|
||||
name: 'webhook',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
.catch((thrown) => {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error getting webhook state.')
|
||||
})
|
||||
|
||||
if (!state.payload.id) {
|
||||
// Not critical but shouldn't happen normally
|
||||
logger.forBot().warn('No webhook to unregister for BambooHR integration.')
|
||||
return
|
||||
}
|
||||
|
||||
const bambooHrClient = await BambooHRClient.create({ client, ctx, logger }).catch((thrown) => {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error creating BambooHR client for unregisterstration')
|
||||
})
|
||||
|
||||
const res = await bambooHrClient.deleteWebhook(state.payload.id).catch((thrown) => {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error deleting BambooHR webhook')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new BambooHRRuntimeError(`Webhook delete failed with status ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
await client
|
||||
.setState({
|
||||
type: 'integration',
|
||||
name: 'webhook',
|
||||
id: ctx.integrationId,
|
||||
payload: { id: null, privateKey: null },
|
||||
})
|
||||
.catch((thrown) => {
|
||||
throw BambooHRRuntimeError.from(thrown, 'Error clearing BambooHR webhook state')
|
||||
})
|
||||
|
||||
logger.forBot().info('Unregistered webhook.')
|
||||
|
||||
await client.configureIntegration({
|
||||
identifier: null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type CommonHandlerProps = Pick<bp.HandlerProps, 'ctx' | 'client' | 'logger'>
|
||||
@@ -0,0 +1,189 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { handleOauthRequest } from './api/auth'
|
||||
import { stripSubdomain } from './api/utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
export const handler = async (props: bp.HandlerProps) => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({
|
||||
id: 'start',
|
||||
handler: _startHandler,
|
||||
})
|
||||
.addStep({
|
||||
id: 'oauth-redirect',
|
||||
handler: _oauthRedirectHandler,
|
||||
})
|
||||
.addStep({
|
||||
id: 'oauth-callback',
|
||||
handler: _oauthCallbackHandler,
|
||||
})
|
||||
.addStep({
|
||||
id: 'end',
|
||||
handler: _endHandler,
|
||||
})
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = ({ responses }) => {
|
||||
return responses.displayInput({
|
||||
pageTitle: 'BambooHR Integration',
|
||||
htmlOrMarkdownPageContents: `Please enter your BambooHR subdomain to continue.
|
||||
|
||||
**Important:** BambooHR has a known OAuth bug that may cause the first connection attempt from Google to fail. If this happens, please try connecting again.`,
|
||||
input: {
|
||||
type: 'text',
|
||||
label: 'Subdomain',
|
||||
},
|
||||
nextStepId: 'oauth-redirect',
|
||||
})
|
||||
}
|
||||
|
||||
const _oauthRedirectHandler: WizardHandler = async ({ inputValue, responses, ctx, client }) => {
|
||||
if (!inputValue) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Subdomain is required',
|
||||
})
|
||||
}
|
||||
|
||||
const subdomain = stripSubdomain(inputValue)
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
domain: subdomain,
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
expiresAt: 0,
|
||||
scopes: '',
|
||||
},
|
||||
})
|
||||
|
||||
// Define the OAuth scopes required by BambooHR
|
||||
const scopes = [
|
||||
'email',
|
||||
'openid',
|
||||
'webhooks',
|
||||
'webhooks.write',
|
||||
'offline_access',
|
||||
'field',
|
||||
'employee',
|
||||
'employee:assets',
|
||||
'employee:compensation',
|
||||
'employee:contact',
|
||||
'employee:custom_fields',
|
||||
'employee:custom_fields_encrypted',
|
||||
'employee:demographic',
|
||||
'employee:dependent',
|
||||
'employee:dependent:ssn',
|
||||
'employee:education',
|
||||
'employee:emergency_contacts',
|
||||
'employee:file',
|
||||
'employee:identification',
|
||||
'employee:job',
|
||||
'employee:management',
|
||||
'employee:name',
|
||||
'employee:payroll',
|
||||
'employee:photo',
|
||||
'employee_directory',
|
||||
'company:info',
|
||||
'company_file',
|
||||
]
|
||||
|
||||
// Generate BambooHR OAuth URL with subdomain encoded in state
|
||||
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback')
|
||||
|
||||
const oauthUrl =
|
||||
`https://${inputValue}.bamboohr.com/authorize.php?` +
|
||||
'request=authorize' +
|
||||
`&state=${ctx.webhookId}` +
|
||||
'&response_type=code' +
|
||||
`&scope=${scopes.join('+')}` +
|
||||
`&client_id=${bp.secrets.OAUTH_CLIENT_ID}` +
|
||||
`&redirect_uri=${redirectUri.toString()}`
|
||||
|
||||
return responses.redirectToExternalUrl(oauthUrl)
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ query, responses, client, ctx, logger }) => {
|
||||
const code = query.get('code')
|
||||
const state = query.get('state')
|
||||
|
||||
if (!code) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Authorization code is required',
|
||||
})
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'State parameter is missing',
|
||||
})
|
||||
}
|
||||
|
||||
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback')
|
||||
|
||||
const { state: oauthState } = await client
|
||||
.getState({
|
||||
type: 'integration',
|
||||
name: 'oauth',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
.catch((thrown) => {
|
||||
throw new Error('OAuth state not found', { cause: thrown })
|
||||
})
|
||||
|
||||
const subdomain = oauthState.payload.domain
|
||||
|
||||
try {
|
||||
// Complete OAuth flow with the subdomain
|
||||
await handleOauthRequest(
|
||||
{
|
||||
req: {
|
||||
query: `code=${code}&redirect_uri=${redirectUri.toString()}`,
|
||||
path: '/oauth',
|
||||
body: '',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
},
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
},
|
||||
subdomain
|
||||
)
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Setup Complete',
|
||||
htmlOrMarkdownPageContents: 'Your BambooHR integration has been successfully configured!',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Done',
|
||||
buttonType: 'primary',
|
||||
action: 'navigate',
|
||||
navigateToStep: 'end',
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('error', error)
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Failed to complete OAuth setup: ' + (error as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => {
|
||||
return responses.endWizard({
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user