Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion integrations/monday/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default new IntegrationDefinition({
name: 'monday',
title: 'Monday',
description: 'Manage items in Monday boards.',
version: '1.1.2',
version: '1.1.3',
readme: 'hub.md',
icon: 'icon.svg',
states: {
Expand Down
91 changes: 55 additions & 36 deletions integrations/monday/src/oauth-wizard/wizard.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,88 @@
import { OAUTH_IDENTIFIER_HEADER, RuntimeError, type Response } from '@botpress/sdk'
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { RuntimeError, type Response } from '@botpress/sdk'
import { createOAuthMondayClient } from 'src/misc/auth'
import { exchangeCodeForTokens } from 'src/misc/monday-client'
import * as bp from '.botpress'

const OAUTH_CONFIGURATION_ERROR_MESSAGE = 'Unable to complete the Monday OAuth setup. Please try again.'
const BASE_WIZARD_PATH = '/oauth/wizard/'
const DISABLE_INTERSTITIAL_HEADER = { 'x-bp-disable-interstitial': 'true' } as const
const SCOPES = 'boards:read boards:write'

export const handler = async (props: bp.HandlerProps) => {
if (!isOAuthWizardUrl(props.req.path)) {
throw new RuntimeError('Invalid OAuth wizard URL')
}
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>

const stepId = props.req.path.slice(BASE_WIZARD_PATH.length)
const query = new URLSearchParams(props.req.query)
const getMondayInstallUrl = () => {
const url = new URL('https://auth.monday.com/oauth2/authorize')
url.search = new URLSearchParams({
client_id: bp.secrets.CLIENT_ID,
response_type: 'install',
}).toString()
return url.toString()
}

if (stepId === 'oauth-redirect') {
return await _oauthRedirectHandler(props)
}
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 })
.build()

if (stepId === 'oauth-callback') {
return await _oauthCallbackHandler(props, query)
}
return await wizard.handleRequest()
}

throw new RuntimeError(`Unknown OAuth wizard step: ${stepId}`)
const _startHandler: WizardHandler = ({ responses }) => {
return responses.displayButtons({
pageTitle: 'Connect Monday.com',
htmlOrMarkdownPageContents:
`1. Open the <a href="${getMondayInstallUrl()}" target="_blank" rel="noopener noreferrer">Monday.com install page</a> and install the Botpress app in your workspace.\n` +
'2. Come back to this page after the installation is complete.\n' +
'3. Click **Next step** to start the OAuth connection.',
buttons: [
{
action: 'navigate',
label: 'Next step',
navigateToStep: 'oauth-redirect',
buttonType: 'primary',
},
],
})
}

const _oauthRedirectHandler = async ({ ctx }: bp.HandlerProps) => {
const _oauthRedirectHandler: WizardHandler = async ({ ctx, responses }) => {
try {
const url = new URL('https://auth.monday.com/oauth2/authorize')
const params = new URLSearchParams({
client_id: bp.secrets.CLIENT_ID,
redirect_uri: getOAuthRedirectUri(),
redirect_uri: getOAuthRedirectUri().toString(),
response_type: 'code',
scope: SCOPES,
state: ctx.webhookId,
force_install_if_needed: String(true),
})
url.search = params.toString()

return redirectToUrl(url)
return responses.redirectToExternalUrl(url.toString())
} catch (thrown) {
return redirectToInterstitial(false, _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE))
return responses.endWizard({
success: false,
errorMessage: _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE),
})
}
}

const _oauthCallbackHandler = async ({ ctx, client }: bp.HandlerProps, query: URLSearchParams) => {
const _oauthCallbackHandler: WizardHandler = async ({ ctx, client, query, responses, setIntegrationIdentifier }) => {
try {
const code = query.get('code')
const state = query.get('state')

if (!code) {
return redirectToInterstitial(false, 'Missing OAuth code')
return responses.endWizard({ success: false, errorMessage: 'Missing OAuth code' })
}

if (state !== ctx.webhookId) {
return redirectToInterstitial(false, 'Invalid OAuth state')
return responses.endWizard({ success: false, errorMessage: 'Invalid OAuth state' })
}

const credentials = await _exchangeCodeForTokens({ code, redirectUri: getOAuthRedirectUri() })
const credentials = await _exchangeCodeForTokens({ code, redirectUri: getOAuthRedirectUri().toString() })
const mondayClient = createOAuthMondayClient(credentials.accessToken)
await mondayClient.validateAccessToken()

Expand All @@ -72,18 +95,14 @@ const _oauthCallbackHandler = async ({ ctx, client }: bp.HandlerProps, query: UR
},
})

await client.configureIntegration({ identifier: ctx.webhookId })
setIntegrationIdentifier(ctx.webhookId)

const response = redirectToInterstitial(true)
return {
...response,
headers: {
...response.headers,
[OAUTH_IDENTIFIER_HEADER]: ctx.webhookId,
},
}
return responses.endWizard({ success: true })
} catch (thrown) {
return redirectToInterstitial(false, _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE))
return responses.endWizard({
success: false,
errorMessage: _formatWizardError(thrown, OAUTH_CONFIGURATION_ERROR_MESSAGE),
})
}
}

Expand All @@ -106,11 +125,11 @@ const _exchangeCodeForTokens = async ({ code, redirectUri }: { code: string; red
}
}

const getWizardStepUrl = (stepId: string) => new URL(`${BASE_WIZARD_PATH}${stepId}`, process.env.BP_WEBHOOK_URL)
const getWizardStepUrl = (stepId: string) => oauthWizard.getWizardStepUrl(stepId)

const getOAuthRedirectUri = () => getWizardStepUrl('oauth-callback').toString()
const getOAuthRedirectUri = () => getWizardStepUrl('oauth-callback')

export const isOAuthWizardUrl = (path: string) => path.startsWith(BASE_WIZARD_PATH)
export const isOAuthWizardUrl = oauthWizard.isOAuthWizardUrl

const redirectToUrl = (url: URL): Response => ({
status: 303,
Expand Down
2 changes: 1 addition & 1 deletion integrations/zendesk/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { actions, events, configuration, channels, states, user } from './src/de
export default new sdk.IntegrationDefinition({
name: 'zendesk',
title: 'Zendesk',
version: '3.1.2',
version: '3.1.3',
icon: 'icon.svg',
description:
'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.',
Expand Down
28 changes: 18 additions & 10 deletions integrations/zendesk/src/definitions/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from '@botpress/sdk'
import { omit } from 'lodash'
import { omit, pickBy } from 'lodash'

const requesterSchema = z.object({
name: z.string().optional().title('Name').describe('Requester name'),
Expand Down Expand Up @@ -73,23 +73,31 @@ export const userSchema = z.object({
userFields: z.record(z.string()).optional().title('User Fields').describe('Custom user fields'),
})

const _zdUserSchema = userSchema.transform((data) => ({
...omit(data, ['createdAt', 'updatedAt', 'externalId', 'userFields', 'remotePhotoUrl']),
created_at: data.createdAt,
updated_at: data.updatedAt,
external_id: data.externalId,
user_fields: data.userFields,
remote_photo_url: data.remotePhotoUrl,
}))
const _zdUserSchema = userSchema
.omit({ userFields: true })
.extend({
userFields: z.record(z.string().nullable()).optional(),
})
.transform((data) => ({
...omit(data, ['createdAt', 'updatedAt', 'externalId', 'userFields', 'remotePhotoUrl']),
created_at: data.createdAt,
updated_at: data.updatedAt,
external_id: data.externalId,
user_fields: data.userFields,
remote_photo_url: data.remotePhotoUrl,
}))

export type ZendeskUser = z.output<typeof _zdUserSchema>
export type User = z.input<typeof userSchema>

export const transformUser = (ticket: ZendeskUser): User => {
const userFields = ticket.user_fields
? (pickBy(ticket.user_fields, (value): value is string => value !== null) as Record<string, string>)
: undefined
return {
...omit(ticket, ['external_id', 'user_fields', 'created_at', 'updated_at', 'remote_photo_url']),
externalId: ticket.external_id,
userFields: ticket.user_fields,
userFields,
createdAt: ticket.created_at,
updatedAt: ticket.updated_at,
remotePhotoUrl: ticket.remote_photo_url,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@botpress/cli",
"version": "6.8.1",
"version": "6.8.2",
"description": "Botpress CLI",
"scripts": {
"build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen",
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { registerYargs } from './register-yargs'

const logError = (thrown: unknown) => {
const error = errors.BotpressCLIError.map(thrown)
new Logger().error(error.message)
// genuine crashes only: print the full chain so headless callers (no -v) still get the reason.
new Logger().error(errors.BotpressCLIError.fullStack(error))
}

const onError = (thrown: unknown) => {
Expand All @@ -18,7 +19,8 @@ const onError = (thrown: unknown) => {
}

const yargsFail = (msg: string) => {
logError(`${msg}\n`)
// usage errors are bad input, not crashes; show the clean message and help, never a stack.
new Logger().error(`${msg}\n`)
yargs.showHelp()
process.exit(1)
}
Expand Down
4 changes: 1 addition & 3 deletions packages/cli/src/command-implementations/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ export abstract class BaseCommand<C extends CommandDefinition> {
const error = errors.BotpressCLIError.map(thrown)

this.logger.error(error.message)

const stack = error.stack ?? 'No stack trace available'
this.logger.debug(`[${this._cmdName}] ${stack}`)
this.logger.debug(`[${this._cmdName}] ${errors.BotpressCLIError.fullStack(error)}`)

exitCode = 1
} finally {
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/command-implementations/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,16 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {
tunnel.send(res)
})
.catch((thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, 'An error occurred while handling request')
const err = errors.BotpressCLIError.wrap(
thrown,
`An error occurred while handling request ${req.method} ${req.path}`
)
this.logger.error(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
tunnel.send({
requestId: req.id,
status: 500,
body: err.message,
body: 'Internal error while handling request',
})
})
})
Expand Down Expand Up @@ -210,6 +214,7 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {
} catch (thrown) {
const error = errors.BotpressCLIError.wrap(thrown, 'Build failed')
this.logger.error(error.message)
this.logger.debug(errors.BotpressCLIError.fullStack(error))
return
}

Expand Down Expand Up @@ -296,7 +301,7 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {
},
this.logger
).catch((thrown) => {
throw errors.BotpressCLIError.wrap(thrown, 'Could not start dev worker')
throw errors.BotpressCLIError.wrap(thrown, `Could not start dev worker on port ${port}`)
})

return worker
Expand All @@ -321,6 +326,7 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {
const resp = await api.client.getIntegration({ id: devId }).catch(async (thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, `Could not find existing dev integration with id "${devId}"`)
this.logger.warn(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
return { integration: undefined }
})

Expand Down Expand Up @@ -372,6 +378,7 @@ export class DevCommand extends ProjectCommand<DevCommandDefinition> {
const resp = await api.client.getBot({ id: devId }).catch(async (thrown) => {
const err = errors.BotpressCLIError.wrap(thrown, `Could not find existing dev bot with id "${devId}"`)
this.logger.warn(err.message)
this.logger.debug(errors.BotpressCLIError.fullStack(err))
return { bot: undefined }
})

Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { AxiosError } from 'axios'
import { describe, expect, it } from 'vitest'
import { BotpressCLIError } from './errors'

describe('BotpressCLIError.map', () => {
it('maps a bare Error without duplicating its message', () => {
const mapped = BotpressCLIError.map(new Error('boom'))
expect(mapped).toBeInstanceOf(BotpressCLIError)
expect(mapped.message).toBe('boom')
})

it('preserves the original thrown error as the cause', () => {
const original = new Error('boom')
const mapped = BotpressCLIError.map(original)
expect(mapped.cause()).toBe(original)
})

it('returns a BotpressCLIError unchanged (idempotent)', () => {
const err = new BotpressCLIError('already mapped')
expect(BotpressCLIError.map(err)).toBe(err)
})
})

describe('BotpressCLIError.wrap', () => {
it('chains the cause message when the cause has one', () => {
const wrapped = BotpressCLIError.wrap(new Error('real cause'), 'Build failed')
expect(wrapped.message).toBe('Build failed: real cause')
})

it('omits the dangling colon when the cause has no message, but keeps it for fullStack', () => {
const wrapped = BotpressCLIError.wrap(new Error('', { cause: new Error('DEEP_CAUSE_MARKER') }), 'Build failed')
expect(wrapped.message).toBe('Build failed')
expect(BotpressCLIError.fullStack(wrapped)).toContain('DEEP_CAUSE_MARKER')
})
})

describe('BotpressCLIError.fullStack', () => {
it('walks the preserved cause so the original throw site is included', () => {
const mapped = BotpressCLIError.map(new Error('boom'))
// 'caused by:' only appears when a cause is preserved; it would be absent if map() severed it
expect(BotpressCLIError.fullStack(mapped)).toContain('caused by:')
})

it('recursively follows native Error.cause', () => {
const inner = new Error('INNER_CAUSE_MARKER')
const outer = new Error('outer', { cause: inner })

const mapped = BotpressCLIError.map(outer)
expect(mapped.cause()).toBe(outer) // outer is preserved (one level)
expect(BotpressCLIError.fullStack(mapped)).toContain('INNER_CAUSE_MARKER')
})

it('follows axios transport causes without changing the mapped message', () => {
const cause = new Error('AXIOS_CAUSE_MARKER')
const axiosError = new AxiosError('')
axiosError.cause = cause

const mapped = BotpressCLIError.map(axiosError)

expect(mapped.message).toBe('')
expect(BotpressCLIError.fullStack(mapped)).toContain('AXIOS_CAUSE_MARKER')
})
})
Loading
Loading