chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -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'
+7
View File
@@ -0,0 +1,7 @@
# BambooHR Integration
The BambooHR integration connects your Botpress agent with your companys 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',
},
})
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+23
View File
@@ -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']
+176
View File
@@ -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
}
}
+37
View File
@@ -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 }
}
+53
View File
@@ -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}`)
}
}
+54
View File
@@ -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)),
},
})
}
+74
View File
@@ -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))
}
}
+24
View File
@@ -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
)
+115
View File
@@ -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,
})
}
+3
View File
@@ -0,0 +1,3 @@
import * as bp from '.botpress'
export type CommonHandlerProps = Pick<bp.HandlerProps, 'ctx' | 'client' | 'logger'>
+189
View File
@@ -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,
})
}
+12
View File
@@ -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"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config