diff --git a/bots/bugbuster/src/bootstrap.ts b/bots/bugbuster/src/bootstrap.ts index a3e30a6691b..01944514669 100644 --- a/bots/bugbuster/src/bootstrap.ts +++ b/bots/bugbuster/src/bootstrap.ts @@ -1,3 +1,4 @@ +import { CommandProcessor } from './services/command-processor' import { IssueProcessor } from './services/issue-processor' import { IssueStateChecker } from './services/issue-state-checker' import { RecentlyLintedManager } from './services/recently-linted-manager' @@ -14,6 +15,7 @@ export const bootstrap = (props: types.CommonHandlerProps) => { const recentlyLintedManager = new RecentlyLintedManager(linear) const issueProcessor = new IssueProcessor(logger, linear, teamsManager) const issueStateChecker = new IssueStateChecker(linear, logger) + const commandProcessor = new CommandProcessor(client, teamsManager, ctx.botId) return { botpress, @@ -22,5 +24,6 @@ export const bootstrap = (props: types.CommonHandlerProps) => { recentlyLintedManager, issueProcessor, issueStateChecker, + commandProcessor, } } diff --git a/bots/bugbuster/src/handlers/message-created.ts b/bots/bugbuster/src/handlers/message-created.ts index e2335450be6..dbf67cf4035 100644 --- a/bots/bugbuster/src/handlers/message-created.ts +++ b/bots/bugbuster/src/handlers/message-created.ts @@ -1,19 +1,11 @@ +import { CommandDefinition } from 'src/types' import * as boot from '../bootstrap' import * as bp from '.botpress' const MESSAGING_INTEGRATIONS = ['telegram', 'slack'] -const COMMAND_LIST_MESSAGE = `Unknown command. Here's a list of possible commands: -#health -#addTeam [teamName] -#removeTeam [teamName] -#listTeams -#lintAll -#getNotifChannel -#setNotifChannel [channelName]` -const ARGUMENT_REQUIRED_MESSAGE = 'Error: an argument is required with this command.' export const handleMessageCreated: bp.MessageHandlers['*'] = async (props) => { - const { conversation, message, client, ctx } = props + const { conversation, message } = props if (!MESSAGING_INTEGRATIONS.includes(conversation.integration)) { props.logger.info(`Ignoring message from ${conversation.integration}`) return @@ -25,107 +17,58 @@ export const handleMessageCreated: bp.MessageHandlers['*'] = async (props) => { return } - const { botpress, teamsManager } = boot.bootstrap(props) + const { botpress, commandProcessor } = boot.bootstrap(props) + const commandListMessage = _buildListCommandsMessage(commandProcessor.commandDefinitions) if (message.type !== 'text') { - await botpress.respondText(conversation.id, COMMAND_LIST_MESSAGE) + await botpress.respondText(conversation.id, commandListMessage) return } if (!message.payload.text) { - await botpress.respondText(conversation.id, COMMAND_LIST_MESSAGE) + await botpress.respondText(conversation.id, commandListMessage) return } - const [command, arg1] = message.payload.text.trim().split(' ') + const [command, ...args] = message.payload.text.trim().split(' ') if (!command) { - await botpress.respondText(conversation.id, COMMAND_LIST_MESSAGE) + await botpress.respondText(conversation.id, commandListMessage) return } - const _handleError = (context: string) => (thrown: unknown) => - botpress.handleError({ context, conversationId: conversation.id }, thrown) + const commandDefinition = commandProcessor.commandDefinitions.find((commandImpl) => commandImpl.name === command) + if (!commandDefinition) { + await botpress.respondText(conversation.id, commandListMessage) + return + } - switch (command) { - case '#addTeam': { - if (!arg1) { - await botpress.respondText(conversation.id, ARGUMENT_REQUIRED_MESSAGE) - return - } + if (commandDefinition.requiredArgs && args.length < commandDefinition.requiredArgs?.length) { + await botpress.respondText( + conversation.id, + `Error: a minimum of ${commandDefinition.requiredArgs.length} argument(s) is required.` + ) + return + } - await teamsManager.addWatchedTeam(arg1).catch(_handleError('trying to add a team')) + const _handleError = (context: string, thrown: unknown) => + botpress.handleError({ context, conversationId: conversation.id }, thrown) - await botpress.respondText( - conversation.id, - `Success: the team with the key '${arg1}' has been added to the watched team list.` - ) - break - } - case '#removeTeam': { - if (!arg1) { - await botpress.respondText(conversation.id, ARGUMENT_REQUIRED_MESSAGE) - return - } + try { + const result = await commandDefinition.implementation(args, conversation.id) + await botpress.respondText(conversation.id, `${result.success ? '' : 'Error: '}${result.message}`) + } catch (thrown) { + await _handleError(`trying to run ${commandDefinition.name}`, thrown) + } +} - await teamsManager.removeWatchedTeam(arg1).catch(_handleError('trying to remove a team')) - await botpress.respondText( - conversation.id, - `Success: the team with the key '${arg1}' has been removed from the watched team list.` - ) - break - } - case '#listTeams': { - const teams = await teamsManager.listWatchedTeams().catch(_handleError('trying to list teams')) - await botpress.respondText(conversation.id, teams.join(', ')) - break - } - case '#lintAll': { - await client.getOrCreateWorkflow({ - name: 'lintAll', - input: {}, - discriminateByStatusGroup: 'active', - conversationId: conversation.id, - status: 'pending', - }) +const _buildListCommandsMessage = (definitions: CommandDefinition[]) => { + const commands = definitions.map(_buildCommandMessage).join('\n') + return `Unknown command. Here's a list of possible commands:\n${commands}` +} - await botpress.respondText(conversation.id, "Launched 'lintAll' workflow.") - break - } - case '#setNotifChannel': { - if (!arg1) { - await botpress.respondText(conversation.id, ARGUMENT_REQUIRED_MESSAGE) - return - } - await client.setState({ - id: ctx.botId, - name: 'notificationChannelName', - type: 'bot', - payload: { name: arg1 }, - }) - await botpress.respondText(conversation.id, `Success. Notification channel is now set to ${arg1}.`) - break - } - case '#getNotifChannel': { - const { - state: { - payload: { name }, - }, - } = await client.getOrSetState({ - id: ctx.botId, - name: 'notificationChannelName', - type: 'bot', - payload: {}, - }) - let message = 'There is no set Slack notification channel.' - if (name) { - message = `The Slack notification channel is ${name}.` - } - await botpress.respondText(conversation.id, message) - break - } - default: { - await botpress.respondText(conversation.id, COMMAND_LIST_MESSAGE) - break - } - } +const _buildCommandMessage = (definition: CommandDefinition) => { + const requiredArgs = definition.requiredArgs?.map((arg) => `<${arg}>`).join(' ') + const optionalArgs = definition.optionalArgs?.map((arg) => `[${arg}]`).join(' ') + + return `${definition.name} ${requiredArgs ?? ''} ${optionalArgs ?? ''}` } diff --git a/bots/bugbuster/src/services/command-processor.ts b/bots/bugbuster/src/services/command-processor.ts new file mode 100644 index 00000000000..5b01565fc74 --- /dev/null +++ b/bots/bugbuster/src/services/command-processor.ts @@ -0,0 +1,129 @@ +import * as types from '../types' +import { TeamsManager } from './teams-manager' +import { Client } from '.botpress' + +const MISSING_ARGS_ERROR = 'More arguments are required with this command.' + +export class CommandProcessor { + public constructor( + private _client: Client, + private _teamsManager: TeamsManager, + private _botId: string + ) {} + + private _listTeams: types.CommandImplementation = async () => { + const teams = await this._teamsManager.listWatchedTeams() + return { success: true, message: teams.join(', ') } + } + + private _addTeam: types.CommandImplementation = async ([team]: string[]) => { + if (!team) { + return { success: false, message: MISSING_ARGS_ERROR } + } + + await this._teamsManager.addWatchedTeam(team) + + return { + success: true, + message: `Success: the team with the key '${team}' has been added to the watched team list.`, + } + } + + private _removeTeam: types.CommandImplementation = async ([team]: string[]) => { + if (!team) { + return { success: false, message: MISSING_ARGS_ERROR } + } + + await this._teamsManager.removeWatchedTeam(team) + + return { + success: true, + message: `Success: the team with the key '${team}' has been removed from the watched team list.`, + } + } + + private _lintAll: types.CommandImplementation = async (_: string[], conversationId: string) => { + await this._client.getOrCreateWorkflow({ + name: 'lintAll', + input: {}, + discriminateByStatusGroup: 'active', + conversationId, + status: 'pending', + }) + + return { + success: true, + message: "Launched 'lintAll' workflow.", + } + } + + private _setNotifChannel: types.CommandImplementation = async ([channel]: string[]) => { + if (!channel) { + return { success: false, message: MISSING_ARGS_ERROR } + } + await this._client.setState({ + id: this._botId, + name: 'notificationChannelName', + type: 'bot', + payload: { name: channel }, + }) + + return { + success: true, + message: `Success. Notification channel is now set to ${channel}.`, + } + } + + private _getNotifChannel: types.CommandImplementation = async () => { + const { + state: { + payload: { name }, + }, + } = await this._client.getOrSetState({ + id: this._botId, + name: 'notificationChannelName', + type: 'bot', + payload: {}, + }) + + let message = 'There is no set Slack notification channel.' + if (name) { + message = `The Slack notification channel is ${name}.` + } + + return { + success: true, + message, + } + } + + public commandDefinitions: types.CommandDefinition[] = [ + { + name: '#listTeams', + implementation: this._listTeams, + }, + { + name: '#addTeam', + implementation: this._addTeam, + requiredArgs: ['teamName'], + }, + { + name: '#removeTeam', + implementation: this._removeTeam, + requiredArgs: ['teamName'], + }, + { + name: '#lintAll', + implementation: this._lintAll, + }, + { + name: '#setNotifChannel', + implementation: this._setNotifChannel, + requiredArgs: ['channelName'], + }, + { + name: '#getNotifChannel', + implementation: this._getNotifChannel, + }, + ] +} diff --git a/bots/bugbuster/src/types.ts b/bots/bugbuster/src/types.ts index 608e581614e..e06d45581b3 100644 --- a/bots/bugbuster/src/types.ts +++ b/bots/bugbuster/src/types.ts @@ -34,3 +34,12 @@ export type StateAttributes = { } export type ISO8601Duration = string + +type CommandResult = { success: boolean; message: string } +export type CommandImplementation = (args: string[], conversationId: string) => CommandResult | Promise +export type CommandDefinition = { + name: string + requiredArgs?: string[] + optionalArgs?: string[] + implementation: CommandImplementation +} diff --git a/integrations/bamboohr/definitions/bamboohr-schemas.ts b/integrations/bamboohr/definitions/bamboohr-schemas.ts index 3b1d0498fea..d024cc6db9f 100644 --- a/integrations/bamboohr/definitions/bamboohr-schemas.ts +++ b/integrations/bamboohr/definitions/bamboohr-schemas.ts @@ -14,39 +14,122 @@ export const bambooHrOauthTokenResponse = z.object({ export const bambooHrWebhookCreateResponse = z.object({ id: z.string().title('Webhook ID').describe('The unique identifier for the created webhook.'), - created: z.string().title('Created At').describe('The timestamp at which the webhook was created.'), 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({ - firstName: z.string().title('First Name').describe("Employee's first name."), - lastName: z.string().title('Last Name').describe("Employee's last name."), - preferredName: z.string().nullable().optional().title('Preferred Name').describe("Employee's preferred name."), - jobTitle: z.string().nullable().optional().title('Job Title').describe("Employee's job title."), - department: z.string().nullable().optional().title('Department').describe("Employee's department."), - division: z.string().nullable().optional().title('Division').describe("Employee's division."), - location: z.string().nullable().optional().title('Location').describe("Employee's work location."), - mobilePhone: z.string().nullable().optional().title('Mobile Phone').describe("Employee's mobile phone number."), - workPhone: z.string().nullable().optional().title('Work Phone').describe("Employee's work phone number."), - workPhoneExtension: z - .string() - .nullable() - .optional() - .title('Work Phone Extension') - .describe("Employee's work phone extension."), - homePhone: z.string().nullable().optional().title('Home Phone').describe("Employee's home phone number."), - workEmail: z.string().nullable().optional().title('Work Email').describe("Employee's work email address."), - homeEmail: z.string().nullable().optional().title('Home Email').describe("Employee's home email address."), - hireDate: z.string().nullable().optional().title('Hire Date').describe("Employee's hire date (YYYY-MM-DD)."), - terminationDate: z - .string() - .nullable() - .optional() - .title('Termination Date') - .describe("Employee's termination date (YYYY-MM-DD)."), - status: z.literal('Active').or(z.literal('Inactive')).title('Status').describe("Employee's status."), -}) +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, diff --git a/integrations/bamboohr/integration.definition.ts b/integrations/bamboohr/integration.definition.ts index 2f1b0d2d41a..f2071b69f03 100644 --- a/integrations/bamboohr/integration.definition.ts +++ b/integrations/bamboohr/integration.definition.ts @@ -1,32 +1,29 @@ /* bplint-disable */ import { IntegrationDefinition, z } from '@botpress/sdk' - import { actions, events, subdomain } from './definitions' -export default new IntegrationDefinition({ - name: 'bamboohr', - version: '1.0.0', +export const INTEGRATION_NAME = 'bamboohr' +export const INTEGRATION_VERSION = '2.0.0' +export default new IntegrationDefinition({ + name: INTEGRATION_NAME, + version: INTEGRATION_VERSION, title: 'BambooHR', description: 'Retrieve your BambooHR information', readme: 'hub.md', icon: 'icon.svg', - // Disabled for now due to issues with OAuth link requiring subdomain - // TODO: Add separate page to allow users to enter subdomain - // configuration: { - // identifier: { - // linkTemplateScript: 'linkTemplate.vrl', - // required: true, - // }, - // schema: z.object({ - // subdomain, - // }), - // }, + configuration: { + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + required: true, + }, + schema: z.object({}), + }, configurations: { - apiKey: { - title: 'API Key configuration', + 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'), @@ -49,6 +46,7 @@ export default new IntegrationDefinition({ 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 @@ -65,10 +63,12 @@ export default new IntegrationDefinition({ 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.'), }), diff --git a/integrations/bamboohr/linkTemplate.vrl b/integrations/bamboohr/linkTemplate.vrl index f8414b8a602..447bb8e71e6 100644 --- a/integrations/bamboohr/linkTemplate.vrl +++ b/integrations/bamboohr/linkTemplate.vrl @@ -1,55 +1,4 @@ webhookId = to_string!(.webhookId) webhookUrl = to_string!(.webhookUrl) -env = to_string!(.env) -# TODO: Needs to be based on configuration info (configuration.subdomain) -# Either send user to internal BP endpoint for configuration, or change skynet to pass configuration -# Set to "asdf" for testing account -subdomain = "asdf" - -# TODO: Update with external-services developer account -clientId = "developer_portal-17cafd58adbea444c0a80db7f996a6ddcf8b3ba3" - -if env == "production" { - clientId = "developer_portal-33ec7a1b727858a29321b932fd37f5a3a05ef58d" -} - -scopes = [ - # Required for base integration functionality - "email", - "openid", - # Required for events - "webhooks.write", - # Required for refresh token - "offline_access", - # Used for integration actions - # If adding new features, add new scopes at https://developers.bamboohr.com/applications/1093 - "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", - "sensitive_employee:address", - "sensitive_employee:creditcards", - "sensitive_employee:protected_info", - "company:info", - "company_file", -] -scopesStr = join!(scopes, "+") - -"https://{{ subdomain }}.bamboohr.com/authorize.php?request=authorize&response_type=code&scope={{ scopesStr }}&client_id={{ clientId }}&state={{ webhookId }}&redirect_uri={{ webhookUrl }}/oauth" \ No newline at end of file +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" \ No newline at end of file diff --git a/integrations/bamboohr/package.json b/integrations/bamboohr/package.json index e08ca4984c6..1431b39682e 100644 --- a/integrations/bamboohr/package.json +++ b/integrations/bamboohr/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@botpress/cli": "workspace:*", + "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", "@types/jsonwebtoken": "^9.0.3", "@types/lodash": "^4.14.191", diff --git a/integrations/bamboohr/src/actions/company.ts b/integrations/bamboohr/src/actions/company.ts index 904dc32c12d..8be6a40ab66 100644 --- a/integrations/bamboohr/src/actions/company.ts +++ b/integrations/bamboohr/src/actions/company.ts @@ -8,6 +8,6 @@ export const getCompanyInfo: bp.IntegrationProps['actions']['getCompanyInfo'] = 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) + throw new RuntimeError(`Failed to get company info: ${error.message}`) } } diff --git a/integrations/bamboohr/src/actions/employees.ts b/integrations/bamboohr/src/actions/employees.ts index 8a93aee18aa..2f56addbcbb 100644 --- a/integrations/bamboohr/src/actions/employees.ts +++ b/integrations/bamboohr/src/actions/employees.ts @@ -14,7 +14,7 @@ export const getEmployeeBasicInfo: bp.IntegrationProps['actions']['getEmployeeBa 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) + throw new RuntimeError(`Failed to get employee basic info: ${error.message}`) } } @@ -30,7 +30,7 @@ export const getEmployeeCustomInfo: bp.IntegrationProps['actions']['getEmployeeC 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) + throw new RuntimeError(`Failed to get employee custom info: ${error.message}`) } } @@ -41,6 +41,6 @@ export const listEmployees: bp.IntegrationProps['actions']['listEmployees'] = as return await bambooHrClient.listEmployees() } catch (thrown) { const error = thrown instanceof Error ? thrown : new Error(String(thrown)) - throw new RuntimeError('Failed to list employees', error) + throw new RuntimeError(`Failed to list employees: ${error.message}`) } } diff --git a/integrations/bamboohr/src/api/auth.ts b/integrations/bamboohr/src/api/auth.ts index 1bab95a2a01..eade0c14558 100644 --- a/integrations/bamboohr/src/api/auth.ts +++ b/integrations/bamboohr/src/api/auth.ts @@ -1,43 +1,42 @@ import { bambooHrOauthTokenResponse } from 'definitions' -import jwt, { type JwtPayload } from 'jsonwebtoken' +import * as types from '../types' import * as bp from '.botpress' const OAUTH_EXPIRATION_MARGIN = 5 * 60 * 1000 // 5 minutes -/** Fetches OAuth token from BambooHR. - * - * Can use either authorization code or refresh token. - * Saves new token in state. - * @returns `accessToken` and `idToken` to use in Authorization header and integration configuration respectively. - */ -const fetchBambooHrOauthToken = async ( - { ctx, client }: Pick, - oAuthInfo: { code: string } | { refreshToken: string } -): Promise<{ +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 bambooHrOauthUrl = `https://${ctx.configuration.subdomain}.bamboohr.com/token.php?request=token` + 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', - AcceptHeaderParameter: 'application/json', }, - body: JSON.stringify({ - client_id: OAUTH_CLIENT_ID, - client_secret: OAUTH_CLIENT_SECRET, - redirect_uri: 'https://webhook.botpress.cloud/oauth', - ...('code' in oAuthInfo - ? { grant_type: 'authorization_code', code: oAuthInfo.code } - : { grant_type: 'refresh_token', refresh_token: oAuthInfo.refreshToken }), - }), + body, }) if (tokenResponse.status < 200 || tokenResponse.status >= 300) { @@ -45,43 +44,42 @@ const fetchBambooHrOauthToken = async ( `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 - await client.setState({ - type: 'integration', - name: 'oauth', - id: ctx.integrationId, - payload: { - accessToken: access_token, - refreshToken: refresh_token, - expiresAt: requestTimestamp + expires_in * 1000 - OAUTH_EXPIRATION_MARGIN, - scopes: scope, - }, - }) - - return { accessToken: access_token, idToken: id_token } + return { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: requestTimestamp + expires_in * 1000 - OAUTH_EXPIRATION_MARGIN, + scopes: scope, + idToken: id_token, + } } -/** Gets authorization information for requests. - * - * Can be either API key or OAuth token, depending on configuration. - * If OAuth token is expired or missing, fetches a new one using the refresh token. - * Users should refresh their authorization header periodically based on the `expiresAt` timestamp. - * - * @returns Authorization information & an expiration timestamp. - */ -export const getBambooHrAuthorization = async ({ +export type BambooHRAuthorization = { authorization: string; expiresAt: number; domain: string } & ( + | { + type: 'apiKey' + } + | { + type: 'oauth' + refreshToken: string + } +) + +export const getCurrentBambooHrAuthorization = async ({ ctx, client, -}: Pick): Promise<{ authorization: string; expiresAt: number }> => { - if (ctx.configurationType === 'apiKey') { +}: types.CommonHandlerProps): Promise => { + if (ctx.configurationType === 'manual') { return { + type: 'apiKey', authorization: `Basic ${Buffer.from(ctx.configuration.apiKey + ':x').toString('base64')}`, expiresAt: Infinity, + domain: ctx.configuration.subdomain, } } @@ -97,26 +95,80 @@ export const getBambooHrAuthorization = async ({ throw new Error('OAuth token missing in state for OAuth-linked integration.', { cause: err }) } - const token = - Date.now() < oauth.expiresAt - ? oauth.accessToken - : (await fetchBambooHrOauthToken({ ctx, client }, oauth)).accessToken + 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 => { + // 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 { authorization: `Bearer ${token}`, expiresAt: oauth.expiresAt } + 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. + * 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) => { +export const handleOauthRequest = async ({ ctx, client, req, logger }: bp.HandlerProps, subdomain: string) => { const code = new URLSearchParams(req.query).get('code') - if (!code) throw new Error('Missing authentication 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) + }) - const { idToken } = await fetchBambooHrOauthToken({ ctx, client }, { code }) + await client.setState({ + type: 'integration', + name: 'oauth', + id: ctx.integrationId, + payload: { ...oauthState, domain: subdomain }, + }) await client.configureIntegration({ - identifier: (jwt.decode(idToken) as JwtPayload).sub, + identifier: subdomain, }) logger.forBot().info('BambooHR OAuth authentication successfully set up.') diff --git a/integrations/bamboohr/src/api/bamboohr-client.ts b/integrations/bamboohr/src/api/bamboohr-client.ts index 8b3043b6792..14adadcacf0 100644 --- a/integrations/bamboohr/src/api/bamboohr-client.ts +++ b/integrations/bamboohr/src/api/bamboohr-client.ts @@ -5,62 +5,53 @@ import { bambooHrEmployeeBasicInfoResponse, bambooHrEmployeeCustomInfoResponse, bambooHrEmployeeDirectoryResponse, - bambooHrEmployeeWebhookFields, bambooHrWebhookCreateResponse, } from 'definitions' -import { getBambooHrAuthorization } from './auth' +import * as types from '../types' +import { BambooHRAuthorization, getCurrentBambooHrAuthorization, refreshBambooHrAuthorization } from './auth' import { parseResponseWithErrors } from './utils' -import * as bp from '.botpress' - const getHeaders = (authorization: string) => ({ Authorization: authorization, 'Content-Type': 'application/json', Accept: 'application/json', }) -type ClientProps = Pick - export class BambooHRClient { - public baseUrl: string - private _headers: Record - private _expiresAt: number - private _props: ClientProps - - public static async create(props: ClientProps): Promise { - const { authorization, expiresAt } = await getBambooHrAuthorization(props) - return new BambooHRClient({ subdomain: props.ctx.configuration.subdomain, authorization, expiresAt, props }) + private _baseUrl: string + private _currentAuth: BambooHRAuthorization + private _props: types.CommonHandlerProps + + public static async create(props: types.CommonHandlerProps): Promise { + const currentAuth = await getCurrentBambooHrAuthorization(props) + return new BambooHRClient({ subdomain: currentAuth.domain, props, currentAuth }) } private constructor({ subdomain, - authorization, - expiresAt, props, + currentAuth, }: { subdomain: string - authorization: string - expiresAt: number - props: ClientProps + props: types.CommonHandlerProps + currentAuth: BambooHRAuthorization }) { - this.baseUrl = `https://${subdomain}.bamboohr.com/api/v1` - this._expiresAt = expiresAt - this._headers = getHeaders(authorization) + this._baseUrl = `https://${subdomain}.bamboohr.com/api/v1` this._props = props + this._currentAuth = currentAuth } - public async _makeRequest({ + private async _makeRequest({ url, ...params }: Pick & { url: URL }): Promise { // Refresh token if too close to expiry - if (Date.now() >= this._expiresAt) { - const { authorization, expiresAt } = await getBambooHrAuthorization(this._props) - this._expiresAt = expiresAt - this._headers = getHeaders(authorization) + if (Date.now() >= this._currentAuth.expiresAt) { + this._currentAuth = await refreshBambooHrAuthorization(this._props, this._currentAuth) } - const res = await fetch(url, { ...params, headers: this._headers }) + 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') @@ -73,16 +64,18 @@ export class BambooHRClient { } public async testAuthorization(): Promise { - const url = new URL(`${this.baseUrl}/employees/0`) + const url = new URL(`${this._baseUrl}/employees/0`) const res = await this._makeRequest({ method: 'GET', url }) return res.ok } - public async createWebhook(webhookUrl: string): Promise> { - const url = new URL(`${this.baseUrl}/webhooks`) + public async createWebhook( + webhookUrl: string, + fields: string[] + ): Promise> { + const url = new URL(`${this._baseUrl}/webhooks`) - const fields = bambooHrEmployeeWebhookFields.keyof().options const body = JSON.stringify({ name: this._props.ctx.integrationId, monitorFields: fields.filter((field) => field !== 'terminationDate'), // terminationDate returns error on monitor @@ -107,15 +100,25 @@ export class BambooHRClient { } public async deleteWebhook(webhookId: string): Promise { - const url = new URL(`${this.baseUrl}/webhooks/${webhookId}`) + const url = new URL(`${this._baseUrl}/webhooks/${webhookId}`) return await this._makeRequest({ method: 'DELETE', url }) } // API Methods + public async getMonitoredFields(): Promise { + 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> { - const url = new URL(`${this.baseUrl}/company_information`) + const url = new URL(`${this._baseUrl}/company_information`) const res = await this._makeRequest({ method: 'GET', url }) const result = await parseResponseWithErrors(res, bambooHrCompanyInfo) @@ -129,7 +132,7 @@ export class BambooHRClient { } public async getEmployeeBasicInfo(employeeId: string): Promise> { - const url = new URL(`${this.baseUrl}/employees/${employeeId}`) + const url = new URL(`${this._baseUrl}/employees/${employeeId}`) const res = await this._makeRequest({ method: 'GET', url }) const result = await parseResponseWithErrors(res, bambooHrEmployeeBasicInfoResponse) @@ -146,7 +149,7 @@ export class BambooHRClient { employeeId: string, fields: string[] ): Promise> { - const url = new URL(`${this.baseUrl}/employees/${employeeId}`) + const url = new URL(`${this._baseUrl}/employees/${employeeId}`) url.searchParams.append('fields', fields.join(',')) const res = await this._makeRequest({ method: 'GET', url }) @@ -161,14 +164,14 @@ export class BambooHRClient { } public async getEmployeePhoto(employeeId: string, size: string): Promise { - const url = new URL(`${this.baseUrl}/employees/${employeeId}/photo/${size}`) + 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> { - const url = new URL(`${this.baseUrl}/employees/directory`) + const url = new URL(`${this._baseUrl}/employees/directory`) const res = await this._makeRequest({ method: 'GET', url }) const result = await parseResponseWithErrors(res, bambooHrEmployeeDirectoryResponse) diff --git a/integrations/bamboohr/src/api/signing.ts b/integrations/bamboohr/src/api/signing.ts index 2b47d4dd4e4..aeb14466b83 100644 --- a/integrations/bamboohr/src/api/signing.ts +++ b/integrations/bamboohr/src/api/signing.ts @@ -1,30 +1,26 @@ +import * as sdk from '@botpress/sdk' import { createHmac, timingSafeEqual } from 'crypto' -import * as bp from '.botpress' -/** Validates BambooHR incoming request signatures - * - * Requires private webhook key stored in state during registration - * - * Throws Error with reason if invalid - */ -export const validateBambooHrSignature = async ({ ctx, client, req }: bp.HandlerProps) => { +export type ValidateBambooHrSignatureResult = + | { + success: true + } + | { + success: false + reason: string + } + +export const validateBambooHrSignature = async ( + req: sdk.Request, + privateKey: string +): Promise => { const signature = req.headers?.['x-bamboohr-signature'] const timestamp = req.headers?.['x-bamboohr-timestamp'] if (!signature || !timestamp) { - throw new Error('Missing signature headers to verify webhook event.') + return { success: false, reason: 'Missing signature headers to verify webhook event.' } } if (!req.body) { - throw new Error('No request body found to verify signature.') - } - - const { state } = await client.getState({ - name: 'webhook', - type: 'integration', - id: ctx.integrationId, - }) - const privateKey = state.payload.privateKey - if (!privateKey) { - throw new Error('No private key found for webhook state.') + return { success: false, reason: 'No request body found to verify signature.' } } const computedBuffer = createHmac('sha256', privateKey) @@ -34,6 +30,8 @@ export const validateBambooHrSignature = async ({ ctx, client, req }: bp.Handler const isValid = computedBuffer.length === signatureBuffer.length && timingSafeEqual(computedBuffer, signatureBuffer) if (!isValid) { - throw new Error('Invalid BambooHR webhook signature.') + return { success: false, reason: 'Invalid BambooHR webhook signature.' } } + + return { success: true } } diff --git a/integrations/bamboohr/src/api/utils.ts b/integrations/bamboohr/src/api/utils.ts index 195b678e034..5af35279530 100644 --- a/integrations/bamboohr/src/api/utils.ts +++ b/integrations/bamboohr/src/api/utils.ts @@ -1,5 +1,4 @@ import { type z } from '@botpress/sdk' -import * as bp from '.botpress' export type ParseResult = { success: true; data: T } | { success: false; error: string; details?: unknown } @@ -29,17 +28,12 @@ export const parseResponseWithErrors = async (res: Response, schema: z.ZodSch } } -export const parseRequestWithErrors = async (req: bp.HandlerProps['req'], schema: z.ZodSchema): Promise => { - let json: unknown +export const safeParseJson = (str: string): ParseResult => { try { - json = JSON.parse(req.body ?? '') - } catch (err) { - throw new Error('BambooHR Webhook Request body is not valid JSON', err as Error) - } - - try { - return schema.parse(json) - } catch (err) { - throw new Error('Request body did not match expected format', err as Error) + 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 } } } diff --git a/integrations/bamboohr/src/error-handling.ts b/integrations/bamboohr/src/error-handling.ts new file mode 100644 index 00000000000..28c42b6d389 --- /dev/null +++ b/integrations/bamboohr/src/error-handling.ts @@ -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}`) + } +} diff --git a/integrations/bamboohr/src/events.ts b/integrations/bamboohr/src/events.ts index 0a9123d452f..59f70f2c7d7 100644 --- a/integrations/bamboohr/src/events.ts +++ b/integrations/bamboohr/src/events.ts @@ -21,6 +21,7 @@ export const handleEmployeeCreatedEvent = async ( }, }) } + export const handleEmployeeDeletedEvent = async ( { client }: bp.HandlerProps, event: z.output diff --git a/integrations/bamboohr/src/handler.ts b/integrations/bamboohr/src/handler.ts index e1f1ec036cd..8ee6c660cca 100644 --- a/integrations/bamboohr/src/handler.ts +++ b/integrations/bamboohr/src/handler.ts @@ -1,40 +1,54 @@ -import type { z } from '@botpress/sdk' import { bambooHrEmployeeWebhookEvent } from 'definitions' -import { handleOauthRequest } from './api/auth' import { validateBambooHrSignature } from './api/signing' -import { parseRequestWithErrors } from './api/utils' +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 === '/oauth' +const _isOauthRequest = ({ req }: bp.HandlerProps) => req.path.startsWith('/oauth') -export const handler = async (props: bp.HandlerProps) => { +export const handler: bp.IntegrationProps['handler'] = async (props) => { const { req, logger } = props if (_isOauthRequest(props)) { - try { - await handleOauthRequest(props) - } catch (err) { - logger.forBot().error('Error in OAuth creation flow: ' + (err as Error).message) - return { status: 500, body: 'Error handling OAuth creation flow' } - } - return { status: 200 } + 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 } - try { - await validateBambooHrSignature(props) - } catch (err) { - logger.forBot().error('Error validating HMAC signature: ' + (err as Error).message) - return { status: 401, body: 'Invalid HMAC signature' } + const signingResult = await validateBambooHrSignature(props.req, privateKey) + if (!signingResult.success) { + logger.forBot().error('HMAC signature validation failed: ' + signingResult.reason) + return } - let event: z.output - try { - event = await parseRequestWithErrors(req, bambooHrEmployeeWebhookEvent) - } catch (err) { - logger.forBot().error('Error parsing request body: ' + (err as Error).message) - return { status: 400, body: 'Invalid request body' } + 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 diff --git a/integrations/bamboohr/src/handlers/oauth.ts b/integrations/bamboohr/src/handlers/oauth.ts new file mode 100644 index 00000000000..42c0aac6f2e --- /dev/null +++ b/integrations/bamboohr/src/handlers/oauth.ts @@ -0,0 +1,21 @@ +import { generateRedirection } from '@botpress/common/src/html-dialogs' +import { isOAuthWizardUrl, getInterstitialUrl } 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)) + } +} diff --git a/integrations/bamboohr/src/index.ts b/integrations/bamboohr/src/index.ts index a988fba9a9b..8a26ceaf368 100644 --- a/integrations/bamboohr/src/index.ts +++ b/integrations/bamboohr/src/index.ts @@ -1,7 +1,6 @@ import { actions } from './actions' import { handler } from './handler' import { register, unregister } from './setup' - import * as bp from '.botpress' export default new bp.Integration({ diff --git a/integrations/bamboohr/src/setup.ts b/integrations/bamboohr/src/setup.ts index 13ef373c6b5..c20ddea734c 100644 --- a/integrations/bamboohr/src/setup.ts +++ b/integrations/bamboohr/src/setup.ts @@ -1,56 +1,82 @@ -import { RuntimeError } from '@botpress/sdk' 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 }) - try { - await bambooHrClient.testAuthorization() - logger.forBot().info('Integration is authorized.') - } catch (thrown) { + await bambooHrClient.testAuthorization().catch((thrown) => { const error = thrown instanceof Error ? thrown : new Error(String(thrown)) - throw new RuntimeError('Error authorizing BambooHR integration: ' + error.message) - } + 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: '', privateKey: '' }, + payload: { id: null, privateKey: null }, }) if (!state.payload.id) { - logger.forBot().info('Setting up webhook with BambooHR...') - - const payload = await bambooHrClient.createWebhook(webhookUrl) + const { id, privateKey } = await bambooHrClient.createWebhook(webhookUrl, fields) await client.setState({ type: 'integration', name: 'webhook', id: ctx.integrationId, - payload, + payload: { id, privateKey }, }) } - - logger.forBot().info('Registered webhook.') } catch (thrown) { - const error = thrown instanceof Error ? thrown : new Error(String(thrown)) - throw new RuntimeError('Error registering BambooHR webhook: ' + error.message) + 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 - const { state } = await client.getOrSetState({ - name: 'webhook', - type: 'integration', - id: ctx.integrationId, - payload: { id: '', privateKey: '' }, - }) + 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 @@ -58,23 +84,32 @@ export const unregister: bp.Integration['unregister'] = async (props) => { return } - try { - const bambooHrClient = await BambooHRClient.create({ client, ctx, logger }) - const res = await bambooHrClient.deleteWebhook(state.payload.id) + const bambooHrClient = await BambooHRClient.create({ client, ctx, logger }).catch((thrown) => { + throw BambooHRRuntimeError.from(thrown, 'Error creating BambooHR client for unregisterstration') + }) - if (!res.ok) { - throw new Error(`Webhook delete failed with status ${res.status} ${res.statusText}`) - } + const res = await bambooHrClient.deleteWebhook(state.payload.id).catch((thrown) => { + throw BambooHRRuntimeError.from(thrown, 'Error deleting BambooHR webhook') + }) - await client.setState({ + 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: '', privateKey: '' }, + payload: { id: null, privateKey: null }, }) - logger.forBot().info('Unregistered webhook.') - } catch (thrown) { - const error = thrown instanceof Error ? thrown : new Error(String(thrown)) - throw new RuntimeError('Error unregistering BambooHR webhook: ' + error.message) - } + .catch((thrown) => { + throw BambooHRRuntimeError.from(thrown, 'Error clearing BambooHR webhook state') + }) + + logger.forBot().info('Unregistered webhook.') + + await client.configureIntegration({ + identifier: null, + }) } diff --git a/integrations/bamboohr/src/types.ts b/integrations/bamboohr/src/types.ts new file mode 100644 index 00000000000..c7ac6153211 --- /dev/null +++ b/integrations/bamboohr/src/types.ts @@ -0,0 +1,3 @@ +import * as bp from '.botpress' + +export type CommonHandlerProps = Pick diff --git a/integrations/bamboohr/src/wizard.ts b/integrations/bamboohr/src/wizard.ts new file mode 100644 index 00000000000..189b0f4df51 --- /dev/null +++ b/integrations/bamboohr/src/wizard.ts @@ -0,0 +1,184 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { handleOauthRequest } from './api/auth' +import * as bp from '.botpress' + +type WizardHandler = oauthWizard.WizardStepHandler + +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.', + 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', + }) + } + + await client.setState({ + type: 'integration', + name: 'oauth', + id: ctx.integrationId, + payload: { + domain: inputValue, + 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, + }) +} diff --git a/integrations/bamboohr/tsconfig.json b/integrations/bamboohr/tsconfig.json index 758f7d7ec50..936f2bd558a 100644 --- a/integrations/bamboohr/tsconfig.json +++ b/integrations/bamboohr/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", "baseUrl": ".", "outDir": "dist", "experimentalDecorators": true, diff --git a/integrations/browser/integration.definition.ts b/integrations/browser/integration.definition.ts index fe070e2d53b..27d63054af1 100644 --- a/integrations/browser/integration.definition.ts +++ b/integrations/browser/integration.definition.ts @@ -1,16 +1,21 @@ +import { posthogHelper } from '@botpress/common' import { IntegrationDefinition } from '@botpress/sdk' import { actionDefinitions } from 'src/definitions/actions' +export const INTEGRATION_NAME = 'browser' +export const INTEGRATION_VERSION = '0.8.2' + export default new IntegrationDefinition({ - name: 'browser', + name: INTEGRATION_NAME, title: 'Browser', - version: '0.8.1', + version: INTEGRATION_VERSION, description: 'Capture screenshots and retrieve web page content with metadata for automated browsing and data extraction.', readme: 'hub.md', icon: 'icon.svg', actions: actionDefinitions, secrets: { + ...posthogHelper.COMMON_SECRET_NAMES, SCREENSHOT_API_KEY: { description: 'ScreenShot key', }, diff --git a/integrations/browser/src/actions/browse-pages.ts b/integrations/browser/src/actions/browse-pages.ts index c6d2abab054..cad6617e8e6 100644 --- a/integrations/browser/src/actions/browse-pages.ts +++ b/integrations/browser/src/actions/browse-pages.ts @@ -1,6 +1,7 @@ import { IntegrationLogger, RuntimeError } from '@botpress/sdk' -import Firecrawl from '@mendable/firecrawl-js' +import Firecrawl, { SdkError } from '@mendable/firecrawl-js' import { FullPage } from 'src/definitions/actions' +import { trackEvent } from '../tracking' import * as bp from '.botpress' const COST_PER_PAGE = 0.0015 @@ -38,6 +39,17 @@ const getPageContent = async (props: { props.logger.forBot().debug(`Firecrawl API call took ${Date.now() - startTime}ms for url: ${props.url}`) + const contentLength = result.markdown?.length || 0 + const isLargePage = contentLength > 50000 + + if (isLargePage) { + await trackEvent('large_page_scraped', { + url: props.url, + contentLength, + durationMs: Date.now() - startTime, + }) + } + return { url: props.url, content: result.markdown!, @@ -48,6 +60,18 @@ const getPageContent = async (props: { } } catch (err) { props.logger.error('There was an error while calling Firecrawl API.', err) + + if (err instanceof SdkError) { + const isRateLimit = err.status === 429 || err.message.includes('rate limit') + await trackEvent('firecrawl_error', { + url: props.url, + errorType: isRateLimit ? 'rate_limited' : 'api_error', + errorMessage: err.message, + statusCode: err.status, + errorCode: err.code, + }) + } + throw new RuntimeError(`There was an error while browsing the page: ${props.url}`) } } @@ -61,13 +85,20 @@ export const browsePages: bp.IntegrationProps['actions']['browsePages'] = async ) const results = pageContentPromises - .filter((promise): promise is PromiseFulfilledResult => promise.status === 'fulfilled') + .filter((promise): promise is PromiseFulfilledResult => promise.status === 'fulfilled') .map((result) => result.value) // only charging for successful pages const cost = results.length * COST_PER_PAGE metadata.setCost(cost) + await trackEvent('pages_browsed', { + urlCount: input.urls.length, + successCount: results.length, + failedCount: input.urls.length - results.length, + durationMs: Date.now() - startTime, + }) + return { results, } diff --git a/integrations/browser/src/actions/capture-screenshot.ts b/integrations/browser/src/actions/capture-screenshot.ts index 5e9df7275a4..c47db3fb264 100644 --- a/integrations/browser/src/actions/capture-screenshot.ts +++ b/integrations/browser/src/actions/capture-screenshot.ts @@ -1,4 +1,5 @@ import axios, { isAxiosError } from 'axios' +import { trackEvent } from '../tracking' import * as bp from '.botpress' const COST_PER_PAGE = 0.0015 @@ -64,6 +65,16 @@ export const captureScreenshot: bp.IntegrationProps['actions']['captureScreensho if (data.screenshot) { metadata.setCost(COST_PER_PAGE) + + await trackEvent('screenshot_captured', { + domain: new URL(input.url).hostname, + width: input.width || 1080, + height: input.height || 1920, + fullPage: input.fullPage || false, + hasJsInjection: !!input.javascriptToInject, + hasCssInjection: !!input.cssToInject, + }) + return { imageUrl: data.screenshot, htmlUrl: data.extracted_html } } else { throw new Error('Screenshot not available') @@ -71,6 +82,11 @@ export const captureScreenshot: bp.IntegrationProps['actions']['captureScreensho } catch (error) { if (isAxiosError(error)) { logger.forBot().error('There was an error while taking the screenshot', error.response?.data) + await trackEvent('screenshot_error', { + domain: new URL(input.url).hostname, + statusCode: error.response?.status, + errorMessage: error.message, + }) } throw error diff --git a/integrations/browser/src/actions/discover-urls.ts b/integrations/browser/src/actions/discover-urls.ts index 2b4eff9942b..5eb601a5ddd 100644 --- a/integrations/browser/src/actions/discover-urls.ts +++ b/integrations/browser/src/actions/discover-urls.ts @@ -1,6 +1,7 @@ import { RuntimeError } from '@botpress/client' import { IntegrationLogger, z, ZodIssueCode } from '@botpress/sdk' -import Firecrawl from '@mendable/firecrawl-js' +import Firecrawl, { SdkError } from '@mendable/firecrawl-js' +import { trackEvent } from '../tracking' import { isValidGlob, matchGlob } from '../utils/globs' import * as bp from '.botpress' @@ -129,14 +130,32 @@ class Accumulator { const firecrawlMap = async (props: { url: string; logger: IntegrationLogger; timeout: number }): Promise => { const firecrawl = new Firecrawl({ apiKey: bp.secrets.FIRECRAWL_API_KEY }) - const result = await firecrawl.map(props.url, { - sitemap: 'include', - limit: 10_000, - timeout: Math.max(1000, props.timeout - 2000), - includeSubdomains: true, - }) + try { + const result = await firecrawl.map(props.url, { + sitemap: 'include', + limit: 10_000, + timeout: Math.max(1000, props.timeout - 2000), + includeSubdomains: true, + }) + + return result.links.map((x) => x.url) + } catch (err) { + props.logger.error('There was an error while calling Firecrawl map API.', err) + + if (err instanceof SdkError) { + const isRateLimit = err.status === 429 || err.message.includes('rate limit') + await trackEvent('firecrawl_error', { + url: props.url, + operation: 'map', + errorType: isRateLimit ? 'rate_limited' : 'api_error', + errorMessage: err.message, + statusCode: err.status, + errorCode: err.code, + }) + } - return result.links.map((x) => x.url) + throw err + } } export const discoverUrls: bp.IntegrationProps['actions']['discoverUrls'] = async ({ input, logger, metadata }) => { @@ -165,6 +184,15 @@ export const discoverUrls: bp.IntegrationProps['actions']['discoverUrls'] = asyn metadata.setCost(accumulator.cost) + await trackEvent('urls_discovered', { + baseUrl: input.url, + urlCount: accumulator.urls.size, + excludedCount: accumulator.excluded, + stopReason: accumulator.stopReason ?? 'end_of_results', + hasIncludeFilters: (input.include?.length ?? 0) > 0, + hasExcludeFilters: (input.exclude?.length ?? 0) > 0, + }) + return { excluded: accumulator.excluded, stopReason: accumulator.stopReason ?? 'end_of_results', diff --git a/integrations/browser/src/actions/get-website-logo.ts b/integrations/browser/src/actions/get-website-logo.ts index db4e068c204..cf9bafedc69 100644 --- a/integrations/browser/src/actions/get-website-logo.ts +++ b/integrations/browser/src/actions/get-website-logo.ts @@ -1,3 +1,4 @@ +import { trackEvent } from '../tracking' import * as bp from '.botpress' const COST_PER_LOOKUP = 0.25 @@ -40,6 +41,12 @@ export const getWebsiteLogo: bp.IntegrationProps['actions']['getWebsiteLogo'] = metadata.setCost(COST_PER_LOOKUP) + await trackEvent('logo_fetched', { + domain, + size: input.size || 128, + greyscale: input.greyscale || false, + }) + return { logoUrl: file.url, } diff --git a/integrations/browser/src/actions/web-search.ts b/integrations/browser/src/actions/web-search.ts index 39ba9d2c700..3ea0c9a7607 100644 --- a/integrations/browser/src/actions/web-search.ts +++ b/integrations/browser/src/actions/web-search.ts @@ -1,4 +1,5 @@ import axios, { isAxiosError } from 'axios' +import { trackEvent } from '../tracking' import { browsePages } from './browse-pages' import * as bp from '.botpress' @@ -31,6 +32,13 @@ export const webSearch: bp.IntegrationProps['actions']['webSearch'] = async ({ const { data: searchResults } = await axios.post('bing/search', input, axiosConfig) logger.forBot().debug(`Search Web using Bing took ${Date.now() - startTime}ms`) + await trackEvent('web_search_performed', { + queryLength: input.query.length, + resultCount: searchResults.length, + browsePages: input.browsePages || false, + durationMs: Date.now() - startTime, + }) + if (!input.browsePages) { return { results: searchResults } } diff --git a/integrations/browser/src/index.ts b/integrations/browser/src/index.ts index 281c859db74..4bc7c52d136 100644 --- a/integrations/browser/src/index.ts +++ b/integrations/browser/src/index.ts @@ -1,20 +1,42 @@ +import { posthogHelper } from '@botpress/common' +import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition' import { browsePages } from './actions/browse-pages' import { captureScreenshot } from './actions/capture-screenshot' import { discoverUrls } from './actions/discover-urls' import { getWebsiteLogo } from './actions/get-website-logo' import { webSearch } from './actions/web-search' +import { trackEvent } from './tracking' import * as bp from '.botpress' -export default new bp.Integration({ - register: async () => {}, - unregister: async () => {}, - actions: { - captureScreenshot, - browsePages, - webSearch, - discoverUrls, - getWebsiteLogo, - }, - channels: {}, - handler: async () => {}, +@posthogHelper.wrapIntegration({ + integrationName: INTEGRATION_NAME, + integrationVersion: INTEGRATION_VERSION, + key: bp.secrets.POSTHOG_KEY, }) +class BrowserIntegration extends bp.Integration { + public constructor() { + super({ + register: async ({ ctx }) => { + await trackEvent('browser_registered', { + integrationId: ctx.integrationId, + }) + }, + unregister: async ({ ctx }) => { + await trackEvent('browser_unregistered', { + integrationId: ctx.integrationId, + }) + }, + actions: { + captureScreenshot, + browsePages, + webSearch, + discoverUrls, + getWebsiteLogo, + }, + channels: {}, + handler: async () => {}, + }) + } +} + +export default new BrowserIntegration() diff --git a/integrations/browser/src/tracking.ts b/integrations/browser/src/tracking.ts new file mode 100644 index 00000000000..72483c49816 --- /dev/null +++ b/integrations/browser/src/tracking.ts @@ -0,0 +1,42 @@ +import { posthogHelper } from '@botpress/common' +import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition' +import * as bp from '.botpress' + +type TrackingEvent = + | 'browser_registered' + | 'browser_unregistered' + | 'screenshot_captured' + | 'web_search_performed' + | 'pages_browsed' + | 'large_page_scraped' + | 'urls_discovered' + | 'logo_fetched' + | 'firecrawl_error' + | 'screenshot_error' + +type EventProperties = Record + +const getPostHogConfig = () => ({ + key: bp.secrets.POSTHOG_KEY, + integrationName: INTEGRATION_NAME, + integrationVersion: INTEGRATION_VERSION, +}) + +export const trackEvent = async ( + event: TrackingEvent, + properties: EventProperties, + distinctId?: string +): Promise => { + try { + await posthogHelper.sendPosthogEvent( + { + distinctId: distinctId ?? 'no id', + event, + properties, + }, + getPostHogConfig() + ) + } catch { + // Silently fail + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c4b68709a..a1f642a9619 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,9 @@ importers: specifier: ^4.17.21 version: 4.17.21 devDependencies: + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common '@botpress/cli': specifier: workspace:* version: link:../../packages/cli