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,9 @@
import { createActionWrapper } from '@botpress/common'
import { DropboxClient } from './dropbox-api'
import * as bp from '.botpress'
export const wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
dropboxClient: DropboxClient.create,
},
})
@@ -0,0 +1,169 @@
import * as crypto from 'crypto'
import { Dropbox } from 'dropbox'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../definitions'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { ActionInput, RequestMapping, ResponseMapping } from './mapping'
import { DropboxOAuthClient, getAuthorizationCode, getOAuthClientId, getOAuthClientSecret } from './oauth-client'
import * as bp from '.botpress'
type File = FileEntity.InferredType
type Folder = FolderEntity.InferredType
type Deleted = DeletedEntity.InferredType
type Item = File | Folder | Deleted
export class DropboxClient {
private static readonly _maxResultsPerPage = 100
private readonly _dropboxRestClient: Dropbox
private readonly _accountId: string
private constructor(credentials: { accessToken: string; clientId: string; clientSecret: string; accountId: string }) {
this._dropboxRestClient = new Dropbox(credentials)
this._accountId = credentials.accountId
}
public static async create({ ctx, client }: { client: bp.Client; ctx: bp.Context }) {
const oauthClient = new DropboxOAuthClient({ ctx, client })
const { accessToken, accountId } = await oauthClient.getNewAccessToken()
return new DropboxClient({
accessToken,
clientId: getOAuthClientId({ ctx }),
clientSecret: getOAuthClientSecret({ ctx }),
accountId,
})
}
public getAccountId(): string {
return this._accountId
}
public static async processAuthorizationCode(props: { client: bp.Client; ctx: bp.Context }): Promise<void> {
const authorizationCode = getAuthorizationCode({ ctx: props.ctx })
if (!authorizationCode) {
throw new Error('Authorization code is required')
}
const oauthClient = new DropboxOAuthClient(props)
// For manual configuration (no redirect_uri used), pass empty string
await oauthClient.processAuthorizationCode(authorizationCode, '')
}
@handleErrors('Failed to validate Dropbox authentication')
public async isProperlyAuthenticated(): Promise<boolean> {
const nonce = crypto.randomUUID()
const test = await this._dropboxRestClient.checkApp({
query: nonce,
})
return test.status === 200 && test.result.result === nonce
}
@handleErrors('Failed to upload file')
public async createFileFromText({
dropboxPath,
textContents,
}: {
dropboxPath: string
textContents: string
}): Promise<File> {
const { result: newFile } = await this._dropboxRestClient.filesUpload({
contents: textContents,
path: dropboxPath,
})
return ResponseMapping.mapFile(newFile)
}
@handleErrors('Failed to list items in folder')
public async listItemsInFolder({
path,
recursive,
nextToken,
}: {
path: string
recursive: boolean
nextToken?: string
}): Promise<{ items: Item[]; nextToken: string; hasMore: boolean }> {
const { result } = nextToken
? await this._dropboxRestClient.filesListFolderContinue({
cursor: nextToken,
})
: await this._dropboxRestClient.filesListFolder({
path,
recursive,
include_deleted: false,
limit: DropboxClient._maxResultsPerPage,
})
return {
items: result.entries.map(ResponseMapping.mapItem),
nextToken: result.cursor,
hasMore: result.has_more,
}
}
@handleErrors('Failed to delete item')
public async deleteItem({ path }: { path: string }): Promise<void> {
await this._dropboxRestClient.filesDeleteV2({ path })
}
@handleErrors('Failed to download file')
public async getFileContents({ path }: { path: string }): Promise<Buffer> {
const { result } = await this._dropboxRestClient.filesDownload({ path })
return this._castFileToBuffer(result)
}
@handleErrors('Failed to download folder')
public async downloadFolder({ path }: { path: string }): Promise<Buffer> {
const { result } = await this._dropboxRestClient.filesDownloadZip({ path })
return this._castFileToBuffer(result)
}
private _castFileToBuffer(result: unknown): Buffer {
// This method casts the result of a file download to the expected type.
// For some reason, the Dropbox api documentation (and its sdk) is wrong:
// what is actually returned is a { fileBinary: Buffer, fileBlob: Blob }
// object, not a files.FileMetadata object. This has been the case for over
// 7 years now, even though the developers are aware of the issue.
// See https://www.dropboxforum.com/discussions/101000014/-/282827
const castedResult = result as { fileBinary: Buffer }
return castedResult.fileBinary
}
@handleErrors('Failed to create folder')
public async createFolder({ path }: { path: string }): Promise<Folder> {
const { result } = await this._dropboxRestClient.filesCreateFolderV2({ path })
return ResponseMapping.mapFolder(result.metadata)
}
@handleErrors('Failed to copy item')
public async copyItemToNewPath({ fromPath, toPath }: { fromPath: string; toPath: string }): Promise<Item> {
const { result } = await this._dropboxRestClient.filesCopyV2({ from_path: fromPath, to_path: toPath })
return ResponseMapping.mapItem(result.metadata)
}
@handleErrors('Failed to move item')
public async moveItemToNewPath({ fromPath, toPath }: { fromPath: string; toPath: string }): Promise<Item> {
const { result } = await this._dropboxRestClient.filesMoveV2({ from_path: fromPath, to_path: toPath })
return ResponseMapping.mapItem(result.metadata)
}
@handleErrors('Failed to search items')
public async searchItems(searchParams: ActionInput['searchItems']) {
const { result } = searchParams.nextToken
? await this._dropboxRestClient.filesSearchContinueV2({ cursor: searchParams.nextToken })
: await this._dropboxRestClient.filesSearchV2(
RequestMapping.mapSearchItems(searchParams, DropboxClient._maxResultsPerPage)
)
return {
results: result.matches.map(ResponseMapping.mapSearchMatch),
nextToken: result.has_more ? result.cursor : undefined,
}
}
}
@@ -0,0 +1,41 @@
import { createAsyncFnWrapperWithErrorRedaction, createErrorHandlingDecorator } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { DropboxResponseError } from 'dropbox'
const COMMON_ERRORS: Readonly<Record<string, string>> = {
'from_lookup/not_found/': 'The specified source file or folder was not found',
'path/not_found/': 'The specified path was not found',
'path_lookup/not_found/': 'The specified path was not found',
'path/malformed/': 'The specified path is malformed',
'path/': 'The target path is invalid',
'payload_too_large/': 'The uploaded file is too large. The maximum file size is 150MB',
'reset/': 'The cursor has been invalidated. You must perform a new search',
'too_many_write_operations/': 'Too many write operations in progress',
'too_many_requests/': 'You were rate-limited by Dropbox. Please try again later',
'to/conflict/': 'The target path already exists',
} as const
const _errorRedactor = (error: Error, customMessage: string) => {
if (error instanceof sdk.RuntimeError) {
return error
}
console.warn(customMessage, error)
return _tryToHandleDropboxError(error, customMessage) ?? new sdk.RuntimeError(customMessage)
}
const _tryToHandleDropboxError = (error: Error, customMessage: string) => {
if (!(error instanceof DropboxResponseError && 'error_summary' in error.error)) {
return
}
const errorMessage = Object.entries(COMMON_ERRORS).find(([errorKey]) =>
error.error.error_summary.toString().startsWith(errorKey)
)?.[1]
return errorMessage ? new sdk.RuntimeError(`${customMessage}: ${errorMessage}`) : undefined
}
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(_errorRedactor)
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
@@ -0,0 +1,274 @@
import { describe, it, expect } from 'vitest'
import { FileTree } from './file-tree'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../definitions'
const createFile = (path: string, overrides: Partial<FileEntity.InferredType> = {}): FileEntity.InferredType => ({
path,
id: `file-${path}`,
itemType: 'file',
name: path.split('/').pop() ?? '',
isDeleted: false,
isShared: false,
revision: 'rev1',
size: 100,
fileHash: 'hash123',
isDownloadable: true,
modifiedAt: '2023-01-01T00:00:00Z',
...overrides,
})
const createFolder = (path: string, overrides: Partial<FolderEntity.InferredType> = {}): FolderEntity.InferredType => ({
path,
id: `folder-${path}`,
itemType: 'folder',
name: path.split('/').pop() ?? '',
isDeleted: false,
isShared: false,
...overrides,
})
const createDeletedItem = (path: string): DeletedEntity.InferredType => ({
path,
itemType: 'deleted',
name: path.split('/').pop() ?? '',
isDeleted: true,
})
describe.concurrent('FileTree', () => {
describe.concurrent('getItems', () => {
it('returns all items in the tree', () => {
// Arrange
const fileTree = new FileTree()
const file = createFile('/folder/file.txt')
const folder = createFolder('/folder')
// Act
fileTree.pushItem(folder)
fileTree.pushItem(file)
// Assert
const items = fileTree.getItems()
expect(items).toHaveLength(2)
expect(items).toStrictEqual(expect.arrayContaining([file, folder]))
})
})
describe.concurrent('pushItem', () => {
describe.concurrent('with file items', () => {
it('adds file to tree', () => {
// Arrange
const fileTree = new FileTree()
const file = createFile('/file.txt')
// Act
const diff = fileTree.pushItem(file)
// Assert
expect(diff).toStrictEqual({
added: [file],
updated: [],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual([file])
})
it('updates file if it already exists', () => {
// Arrange
const fileTree = new FileTree()
const originalFile = createFile('/file.txt', { revision: 'v1', size: 100 })
const updatedFile = createFile('/file.txt', { revision: 'v2', size: 200 })
fileTree.pushItem(originalFile)
// Act
const diff = fileTree.pushItem(updatedFile)
// Assert
expect(diff).toStrictEqual({
added: [],
updated: [originalFile],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual([updatedFile])
})
it('replaces folder with file and removes children', () => {
// Arrange
const fileTree = new FileTree()
const folder = createFolder('/folder')
const childFile = createFile('/folder/child.txt')
const newFile = createFile('/folder', { id: 'newfile' })
fileTree.pushItem(folder)
fileTree.pushItem(childFile)
// Act
const diff = fileTree.pushItem(newFile)
// Assert
expect(diff).toStrictEqual({
added: [newFile],
updated: [],
deleted: expect.arrayContaining([folder, childFile]),
})
expect(fileTree.getItems()).toStrictEqual([newFile])
})
})
describe.concurrent('with folder items', () => {
it('adds folder to tree', () => {
// Arrange
const fileTree = new FileTree()
const folder = createFolder('/folder')
// Act
const diff = fileTree.pushItem(folder)
// Assert
expect(diff).toStrictEqual({
added: [folder],
updated: [],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual([folder])
})
it('updates folder if it already exists', () => {
// Arrange
const fileTree = new FileTree()
const originalFolder = createFolder('/folder', { isShared: false })
const updatedFolder = createFolder('/folder', { isShared: true })
fileTree.pushItem(originalFolder)
// Act
const diff = fileTree.pushItem(updatedFolder)
// Assert
expect(diff).toStrictEqual({
added: [],
updated: [originalFolder],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual([updatedFolder])
})
it('replaces file with new folder but keeps children', () => {
// Arrange
const fileTree = new FileTree()
const file = createFile('/folder')
const childFile = createFile('/folder/child.txt')
const newFolder = createFolder('/folder')
fileTree.pushItem(file)
fileTree.pushItem(childFile)
// Act
const diff = fileTree.pushItem(newFolder)
// Assert
expect(diff).toStrictEqual({
added: [newFolder],
updated: [],
deleted: [file],
})
expect(fileTree.getItems()).toStrictEqual(expect.arrayContaining([newFolder, childFile]))
})
it('replaces folder with new folder but keeps children', () => {
// Arrange
const fileTree = new FileTree()
const folder = createFolder('/folder')
const childFile = createFile('/folder/child.txt')
const newFolder = createFolder('/folder')
fileTree.pushItem(folder)
fileTree.pushItem(childFile)
// Act
const diff = fileTree.pushItem(newFolder)
// Assert
expect(diff).toStrictEqual({
added: [],
updated: [newFolder],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual(expect.arrayContaining([newFolder, childFile]))
})
})
describe.concurrent('with deleted items', () => {
it('removes item and all children', () => {
// Arrange
const fileTree = new FileTree()
const folder = createFolder('/folder')
const subFolder = createFolder('/folder/subfolder')
const file1 = createFile('/folder/file1.txt')
const file2 = createFile('/folder/subfolder/file2.txt')
const deletedItem = createDeletedItem('/folder')
fileTree.pushItem(folder)
fileTree.pushItem(subFolder)
fileTree.pushItem(file1)
fileTree.pushItem(file2)
// Act
const diff = fileTree.pushItem(deletedItem)
expect(diff).toStrictEqual({
added: [],
updated: [],
deleted: expect.arrayContaining([folder, subFolder, file1, file2]),
})
expect(fileTree.getItems()).toHaveLength(0)
})
it('does nothing if item does not exist', () => {
// Arrange
const fileTree = new FileTree()
const file = createFile('/file.txt')
const deletedItem = createDeletedItem('/nonexistent')
fileTree.pushItem(file)
// Act
const diff = fileTree.pushItem(deletedItem)
// Assert
expect(diff).toStrictEqual({
added: [],
updated: [],
deleted: [],
})
expect(fileTree.getItems()).toStrictEqual([file])
})
it('handles deep nested structure modifications', () => {
// Arrange
const fileTree = new FileTree()
fileTree.pushItem(createFolder('/root'))
fileTree.pushItem(createFolder('/root/folder1'))
fileTree.pushItem(createFolder('/root/folder2'))
fileTree.pushItem(createFile('/root/folder1/file1.txt'))
fileTree.pushItem(createFile('/root/folder1/file2.txt'))
fileTree.pushItem(createFile('/root/folder2/file3.txt'))
expect(fileTree.getItems()).toHaveLength(6)
// Act
const diff = fileTree.pushItem(createDeletedItem('/root/folder1'))
// Assert
expect(fileTree.getItems()).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({ path: '/root' }),
expect.objectContaining({ path: '/root/folder2' }),
expect.objectContaining({ path: '/root/folder2/file3.txt' }),
])
)
expect(diff).toStrictEqual({
added: [],
updated: [],
deleted: expect.arrayContaining([
expect.objectContaining({ path: '/root/folder1' }),
expect.objectContaining({ path: '/root/folder1/file1.txt' }),
expect.objectContaining({ path: '/root/folder1/file2.txt' }),
]),
})
})
})
})
})
@@ -0,0 +1,146 @@
import * as sdk from '@botpress/sdk'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../definitions'
/*
From the Dropbox API documentation:
Iterate through each entry in order and process them as follows to keep your
local state in sync:
- For each FileMetadata, store the new entry at the given path in your local
state. If the required parent folders don't exist yet, create them. If
there's already something else at the given path, replace it and remove all
its children.
- For each FolderMetadata, store the new entry at the given path in your local
state. If the required parent folders don't exist yet, create them. If
there's already something else at the given path, replace it but leave the
children as they are.
- For each DeletedMetadata, if your local state has something at the given
path, remove it and all its children. If there's nothing at the given path,
ignore this entry.
In this implementation, we do most of this, but we do not create the parents
because we don't need them.
*/
type FileOrFolder = FileEntity.InferredType | FolderEntity.InferredType
type Item = FileOrFolder | DeletedEntity.InferredType
export type FileTreeDiff = { added: FileOrFolder[]; deleted: FileOrFolder[]; updated: FileOrFolder[] }
export class FileTree {
private readonly _tree = new Map<string, FileOrFolder>() // path -> item
public static fromJSON(json: string): FileTree {
const fileTree = new FileTree()
const items = JSON.parse(json) as Item[]
for (const item of items) {
fileTree.pushItem(item)
}
return fileTree
}
public getItems() {
return Array.from(this._tree.values())
}
public toJSON() {
return JSON.stringify(this.getItems())
}
public pushItem(item: Item): FileTreeDiff {
const normalizedItem = this._normalizePath(item)
switch (normalizedItem.itemType) {
case 'file':
return this._pushFile(normalizedItem)
case 'folder':
return this._pushFolder(normalizedItem)
case 'deleted':
return this._handleDeletion(normalizedItem)
default:
normalizedItem satisfies never
}
throw new sdk.RuntimeError(`Unknown item type: ${item.itemType}`)
}
public pushItems(items: Item[]): FileTreeDiff {
return items.reduce<FileTreeDiff>(
(acc, item) => {
const itemDiff = this.pushItem(item)
acc.added.push(...itemDiff.added)
acc.deleted.push(...itemDiff.deleted)
acc.updated.push(...itemDiff.updated)
return acc
},
{ added: [], deleted: [], updated: [] }
)
}
private _normalizePath(item: Item): Item {
let normalizedPath = item.path.trim().normalize()
normalizedPath = item.path.startsWith('/') ? normalizedPath : `/${normalizedPath}`
return { ...item, path: normalizedPath }
}
private _pushFile(fileItem: FileEntity.InferredType): FileTreeDiff {
const deletedChildren: FileOrFolder[] = []
const previousVersion = this._tree.get(fileItem.path)
// From the Dropbox API documentation:
// If there's already something else at the given path, replace it and
// remove all its children:
for (const childPath of this._tree.keys()) {
if (childPath.startsWith(fileItem.path) && childPath !== fileItem.path) {
deletedChildren.push(this._tree.get(childPath)!)
this._tree.delete(childPath)
}
}
this._tree.set(fileItem.path, fileItem)
return {
added: previousVersion && previousVersion.itemType === 'file' ? [] : [fileItem],
updated: previousVersion && previousVersion.itemType === 'file' ? [previousVersion] : [],
deleted: [
...deletedChildren,
...(previousVersion && previousVersion.itemType !== 'file' ? [previousVersion] : []),
],
}
}
private _pushFolder(folderItem: FolderEntity.InferredType): FileTreeDiff {
const previousVersion = this._tree.get(folderItem.path)
this._tree.set(folderItem.path, folderItem)
return {
added: previousVersion && previousVersion.itemType === 'folder' ? [] : [folderItem],
updated: previousVersion && previousVersion.itemType === 'folder' ? [previousVersion] : [],
deleted: previousVersion && previousVersion.itemType !== 'folder' ? [previousVersion] : [],
}
}
private _handleDeletion(deletedItem: DeletedEntity.InferredType): FileTreeDiff {
const deletedChildren: FileOrFolder[] = []
// Remove the deleted item and all its children
for (const [path, child] of this._tree.entries()) {
if (path.startsWith(deletedItem.path)) {
deletedChildren.push(child)
this._tree.delete(path)
}
}
return {
added: [],
updated: [],
deleted: deletedChildren,
}
}
}
@@ -0,0 +1,2 @@
export { DropboxClient } from './dropbox-client'
export * from './error-handling'
@@ -0,0 +1,2 @@
export * from './response-mapping'
export * from './request-mapping'
@@ -0,0 +1,22 @@
import * as dropbox from 'dropbox'
import * as bp from '.botpress'
export type ActionInput = { [K in keyof bp.ActionProps]: bp.ActionProps[K]['input'] }
export namespace RequestMapping {
export const mapSearchItems = (
actionInput: ActionInput['searchItems'],
maxResults?: number
): dropbox.files.SearchV2Arg => ({
query: actionInput.query,
options: {
file_categories: actionInput.fileCategories?.map((cat) => ({ '.tag': cat })),
file_extensions: actionInput.fileExtensions,
filename_only: actionInput.fileNameOnly,
order_by: actionInput.orderBy && { '.tag': actionInput.orderBy },
path: actionInput.path,
max_results: maxResults,
file_status: { '.tag': 'active' },
},
})
}
@@ -0,0 +1,63 @@
import * as dropbox from 'dropbox'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../../definitions'
export type File = FileEntity.InferredType
export type Folder = FolderEntity.InferredType
export type Deleted = DeletedEntity.InferredType
export type DropboxFileReference = dropbox.files.FileMetadata
export type DropboxFolderReference = dropbox.files.FolderMetadata
export type DropboxDeletedMetadata = dropbox.files.DeletedMetadata
export type DropboxReference = DropboxFileReference | DropboxFolderReference | DropboxDeletedMetadata
export namespace ResponseMapping {
export const mapFile = (dropboxFile: DropboxFileReference): File => ({
id: dropboxFile.id,
itemType: 'file',
name: dropboxFile.name,
fileHash: dropboxFile.content_hash ?? '',
isDeleted: false,
isDownloadable: dropboxFile.is_downloadable ?? true,
isShared: dropboxFile.sharing_info !== undefined,
modifiedAt: dropboxFile.server_modified,
path: dropboxFile.path_display ?? dropboxFile.path_lower ?? '',
revision: dropboxFile.rev,
size: dropboxFile.size,
webUrl: dropboxFile.preview_url,
symlinkTarget: dropboxFile.symlink_info?.target,
})
export const mapFolder = (dropboxFolder: DropboxFolderReference): Folder => ({
id: dropboxFolder.id,
itemType: 'folder',
name: dropboxFolder.name,
isDeleted: false,
isShared: dropboxFolder.sharing_info !== undefined,
path: dropboxFolder.path_display ?? dropboxFolder.path_lower ?? '',
webUrl: dropboxFolder.preview_url,
})
export const mapDeleted = (dropboxDeleted: DropboxDeletedMetadata): Deleted => ({
itemType: 'deleted',
name: dropboxDeleted.name,
path: dropboxDeleted.path_display ?? dropboxDeleted.path_lower ?? '',
isDeleted: true,
})
export const mapItem = (dropboxItem: DropboxReference): File | Folder | Deleted =>
_isDropboxFileReference(dropboxItem)
? mapFile(dropboxItem)
: _isDropboxFolderReference(dropboxItem)
? mapFolder(dropboxItem)
: mapDeleted(dropboxItem)
export const mapSearchMatch = (searchMatch: dropbox.files.SearchMatchV2) => ({
item: mapItem((searchMatch.metadata as dropbox.files.MetadataV2Metadata).metadata),
matchType: searchMatch.match_type?.['.tag'],
})
const _isDropboxFileReference = (item: DropboxReference): item is DropboxFileReference =>
'.tag' in item && item['.tag'] === 'file'
const _isDropboxFolderReference = (item: DropboxReference): item is DropboxFolderReference =>
'.tag' in item && item['.tag'] === 'folder'
}
@@ -0,0 +1,113 @@
import * as sdk from '@botpress/sdk'
import { DropboxAuth } from 'dropbox'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import * as bp from '.botpress'
export const getOAuthClientId = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'manual' ? ctx.configuration.clientId : bp.secrets.APP_KEY
export const getOAuthClientSecret = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'manual' ? ctx.configuration.clientSecret : bp.secrets.APP_SECRET
export const getAuthorizationCode = ({ ctx }: { ctx: bp.Context }): string | undefined => {
if (ctx.configurationType === 'manual') {
return ctx.configuration.authorizationCode
}
return undefined
}
export class DropboxOAuthClient {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _dropboxAuth: DropboxAuth
public constructor({ ctx, client }: { client: bp.Client; ctx: bp.Context }) {
this._client = client
this._ctx = ctx
this._dropboxAuth = new DropboxAuth({
clientId: getOAuthClientId({ ctx }),
clientSecret: getOAuthClientSecret({ ctx }),
})
}
public async getNewAccessToken() {
const { refreshToken, accountId, grantedScopes } = await this._getAuthState()
const accessToken = await this._exchangeRefreshTokenForAccessToken(refreshToken)
return { accessToken, accountId, grantedScopes }
}
@handleErrors('Failed to exchange authorization code. Please reconfigure the integration.')
public async processAuthorizationCode(authorizationCode: string, redirectUri: string): Promise<void> {
const result = await this._exchangeAuthorizationCodeForRefreshToken(authorizationCode, redirectUri)
await this._client.setState({
id: this._ctx.integrationId,
type: 'integration',
name: 'authorization',
payload: {
refreshToken: result.refresh_token,
accountId: result.account_id,
grantedScopes: result.scope.split(' '),
authorizationCode,
},
})
}
private async _exchangeAuthorizationCodeForRefreshToken(authorizationCode: string, redirectUri: string) {
const response = await this._dropboxAuth.getAccessTokenFromCode(redirectUri, authorizationCode)
// NOTE: DropboxAuth.getAccessTokenFromCode is not properly typed: the
// response is not an empty object, but an object with the following properties:
const result = sdk.z
.object({
access_token: sdk.z.string().optional(),
refresh_token: sdk.z.string().min(1),
token_type: sdk.z.literal('bearer'),
scope: sdk.z.string().min(1),
account_id: sdk.z.string().min(1),
})
.parse(response.result)
// NOTE: the zod schema serves as a sanity check to ensure the response
// does indeed contain the expected properties.
return result
}
@handleErrors('Failed to get authorization state. Please reconfigure the integration.')
private async _getAuthState(): Promise<bp.states.authorization.Authorization['payload']> {
try {
const result = await this._client.getState({
id: this._ctx.integrationId,
type: 'integration',
name: 'authorization',
})
if (!result?.state?.payload) {
throw new Error('Authorization state not found. Please complete the OAuth wizard to configure the integration.')
}
return result.state.payload
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
throw error
}
throw new Error(
'Failed to retrieve authorization state. Please complete the OAuth wizard to configure the integration.'
)
}
}
@handleErrors('Failed to exchange refresh token for access token')
private async _exchangeRefreshTokenForAccessToken(refreshToken: string): Promise<string> {
this._dropboxAuth.setRefreshToken(refreshToken)
// NOTE: DropboxAuth.refreshAccessToken is not properly typed: it actually
// returns a promise, not void
await (this._dropboxAuth.refreshAccessToken() as unknown as Promise<void>)
return this._dropboxAuth.getAccessToken()
}
}
@@ -0,0 +1,22 @@
import { File as FileEntity, Folder as FolderEntity } from '../../definitions'
import * as bp from '.botpress'
type FilesReadonlyFile = bp.events.Events['fileCreated']['file']
type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder']
export const mapFile = (file: FileEntity.InferredType): FilesReadonlyFile => ({
id: file.id,
absolutePath: file.path,
name: file.name,
type: 'file',
contentHash: file.fileHash,
lastModifiedDate: file.modifiedAt,
sizeInBytes: file.size,
})
export const mapFolder = (folder: FolderEntity.InferredType): FilesReadonlyFolder => ({
id: folder.id,
absolutePath: folder.path,
name: folder.name,
type: 'folder',
})
+134
View File
@@ -0,0 +1,134 @@
import { reporting } from '@botpress/sdk-addons'
import { File as FileEntity, Folder as FolderEntity } from '../definitions'
import { wrapAction } from './action-wrapper'
import * as filesReadonlyMapping from './files-readonly/mapping'
import { register, unregister } from './setup'
import * as webhookEvents from './webhook-events'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
actions: {
createFile: wrapAction({ actionName: 'createFile' }, async ({ dropboxClient }, { contents, path }) => ({
newFile: await dropboxClient.createFileFromText({ dropboxPath: path, textContents: contents }),
})),
listItemsInFolder: wrapAction(
{ actionName: 'listItemsInFolder' },
({ dropboxClient }, { path, recursive, nextToken }) =>
dropboxClient
.listItemsInFolder({ path: path ?? '', recursive: recursive ?? false, nextToken })
.then(({ items, nextToken, hasMore }) => ({
items: items.filter((it) => it.itemType !== 'deleted'),
nextToken: hasMore ? nextToken : undefined,
}))
),
deleteItem: wrapAction({ actionName: 'deleteItem' }, ({ dropboxClient }, { path }) =>
dropboxClient.deleteItem({ path })
),
downloadFile: wrapAction({ actionName: 'downloadFile' }, async ({ dropboxClient, client }, { path }) => {
const fileBuffer = await dropboxClient.getFileContents({ path })
const file = await client.uploadFile({
key: `dropbox:${path}`,
content: fileBuffer,
})
return { fileUrl: file.file.url }
}),
downloadFolder: wrapAction({ actionName: 'downloadFolder' }, async ({ dropboxClient, client }, { path }) => {
const folderZipBuffer = await dropboxClient.downloadFolder({ path })
const file = await client.uploadFile({
key: `dropbox:${path}.zip`,
content: folderZipBuffer,
})
return { zipUrl: file.file.url }
}),
createFolder: wrapAction({ actionName: 'createFolder' }, async ({ dropboxClient }, { path }) => ({
newFolder: await dropboxClient.createFolder({ path }),
})),
copyItem: wrapAction({ actionName: 'copyItem' }, async ({ dropboxClient }, { fromPath, toPath }) => ({
newItem: (await dropboxClient.copyItemToNewPath({ fromPath, toPath })) as
| FileEntity.InferredType
| FolderEntity.InferredType,
})),
moveItem: wrapAction({ actionName: 'moveItem' }, async ({ dropboxClient }, { fromPath, toPath }) => ({
newItem: (await dropboxClient.moveItemToNewPath({ fromPath, toPath })) as
| FileEntity.InferredType
| FolderEntity.InferredType,
})),
searchItems: wrapAction(
{ actionName: 'searchItems' },
async ({ dropboxClient }, searchParams) =>
await dropboxClient.searchItems(searchParams).then(({ nextToken, results }) => ({
nextToken,
results: results
.filter(({ item }) => item.itemType !== 'deleted')
.map(({ item, matchType }) => ({
item: item as FileEntity.InferredType | FolderEntity.InferredType,
matchType,
})),
}))
),
filesReadonlyTransferFileToBotpress: wrapAction(
{ actionName: 'filesReadonlyTransferFileToBotpress' },
async ({ dropboxClient, client }, { file: fileToTransfer, fileKey, shouldIndex }) => {
const fileBuffer = await dropboxClient.getFileContents({ path: fileToTransfer.id })
const { file: uploadedFile } = await client.uploadFile({
key: fileKey,
content: fileBuffer,
index: shouldIndex,
})
return { botpressFileId: uploadedFile.id }
}
),
filesReadonlyListItemsInFolder: wrapAction(
{ actionName: 'filesReadonlyListItemsInFolder' },
async ({ dropboxClient }, { folderId, filters, nextToken: prevToken }) => {
const parentId = folderId ?? ''
const { items, nextToken, hasMore } = await dropboxClient.listItemsInFolder({
path: parentId,
recursive: false,
nextToken: prevToken,
})
const mappedAndFilteredItems = items
.filter((item) => item.itemType !== 'deleted')
.map((item) =>
item.itemType === 'file' ? filesReadonlyMapping.mapFile(item) : filesReadonlyMapping.mapFolder(item)
)
.filter(
(item) =>
!(
(filters?.itemType && item.type !== filters.itemType) ||
(filters?.maxSizeInBytes &&
item.type === 'file' &&
item.sizeInBytes &&
item.sizeInBytes > filters.maxSizeInBytes) ||
(filters?.modifiedAfter &&
item.type === 'file' &&
item.lastModifiedDate &&
new Date(item.lastModifiedDate) < new Date(filters.modifiedAfter))
)
)
return {
items: mappedAndFilteredItems,
meta: { nextToken: hasMore ? nextToken : undefined },
}
}
),
},
handler: webhookEvents.handler,
channels: {},
})
export default reporting.wrapIntegration(integration)
+140
View File
@@ -0,0 +1,140 @@
import * as sdk from '@botpress/sdk'
import { DropboxClient } from './dropbox-api'
import { getAuthorizationCode } from './dropbox-api/oauth-client'
import * as bp from '.botpress'
type RegisterProps = Parameters<bp.IntegrationProps['register']>[0]
export const register: bp.IntegrationProps['register'] = async (props) => {
if (await _isAlreadyAuthenticatedWithSameCredentials(props)) {
return
}
await _authenticate(props)
await _saveRegistrationDate(props)
}
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
const _isAlreadyAuthenticatedWithSameCredentials = async (props: RegisterProps): Promise<boolean> => {
try {
const dropboxClient = await DropboxClient.create(props)
const authenticationSucceeded = await dropboxClient.isProperlyAuthenticated()
if (!authenticationSucceeded) {
return false
}
if (props.ctx.configurationType !== 'manual') {
return true
}
// For manual configurations, compare the authorization code from context with the one in state
const { state } = await props.client.getState({
type: 'integration',
id: props.ctx.integrationId,
name: 'authorization',
})
const currentAuthorizationCode = getAuthorizationCode({ ctx: props.ctx })
return state.payload.authorizationCode === currentAuthorizationCode
} catch {}
return false
}
const _authenticateWithRefreshToken = async (props: RegisterProps): Promise<boolean> => {
const dropboxClient = await DropboxClient.create(props)
return dropboxClient.isProperlyAuthenticated()
}
const _authenticateWithAuthorizationCode = async (props: RegisterProps): Promise<boolean> => {
const { logger } = props
await DropboxClient.processAuthorizationCode(props)
const dropboxClient = await DropboxClient.create(props)
const authenticated = await dropboxClient.isProperlyAuthenticated()
if (authenticated) {
logger.forBot().info('Successfully created Dropbox client from authorization code')
}
return authenticated
}
const _authenticateManual = async (props: RegisterProps): Promise<boolean> => {
const { ctx, logger } = props
const authorizationCode = getAuthorizationCode({ ctx })
if (!authorizationCode) {
logger.forBot().info('No authorization code provided, using existing refresh token from state')
return _authenticateWithRefreshToken(props).catch((err) => {
logger.forBot().warn({ err }, 'Failed to authenticate with existing refresh token')
return false
})
}
logger.forBot().info('Using authorization code from context')
let isAuthenticated = false
isAuthenticated = await _authenticateWithAuthorizationCode(props).catch((err) => {
logger?.forBot().warn({ err }, 'Failed to create Dropbox client from authorization code; falling back')
return false
})
if (!isAuthenticated) {
isAuthenticated = await _authenticateWithAuthorizationCode(props).catch((err) => {
logger.forBot().error({ err }, 'Failed to authenticate with fallback')
return false
})
}
return isAuthenticated
}
const _authenticateOAuth = async (props: RegisterProps): Promise<boolean> => {
const { logger } = props
logger.forBot().info('Using refresh token from state')
return _authenticateWithRefreshToken(props).catch((err) => {
logger.forBot().warn({ err }, 'Failed to authenticate with existing refresh token')
return false
})
}
const _getAuthFailureMessage = (configurationType: string): string => {
if (configurationType === 'manual') {
return (
'Dropbox authentication failed. ' +
'Please note that the Access Code is only valid for a few minutes. ' +
'You may need to reauthorize your Dropbox application by navigating ' +
"to the authorization URL and update the integration's config accordingly."
)
}
return (
'Dropbox authentication failed. ' +
'Please use the OAuth wizard to re-authenticate your Dropbox application. ' +
'You can access the wizard through the integration configuration page.'
)
}
const _authenticate = async (props: RegisterProps): Promise<void> => {
const { ctx, logger } = props
if (ctx.configurationType !== 'manual') {
const authenticationSucceeded = await _authenticateOAuth(props)
if (!authenticationSucceeded) {
logger.forBot().info('No existing OAuth credentials found. Please complete the OAuth wizard to authenticate.')
}
return
}
const authenticationSucceeded = await _authenticateManual(props)
if (!authenticationSucceeded) {
throw new sdk.RuntimeError(_getAuthFailureMessage(ctx.configurationType ?? ''))
}
}
const _saveRegistrationDate = async (props: RegisterProps): Promise<void> => {
await props.client.setState({
id: props.ctx.integrationId,
type: 'integration',
name: 'setupMeta',
payload: {
integrationRegisteredAt: new Date().toISOString(),
},
})
}
@@ -0,0 +1,43 @@
import * as sdk from '@botpress/sdk'
import * as crypto from 'crypto'
import { getOAuthClientSecret } from '../dropbox-api/oauth-client'
import { handleFileChangeEvent, isFileChangeNotification } from './handlers/file-change'
import { isWebhookVerificationRequest, handleWebhookVerificationRequest } from './handlers/webhook-verification'
import { oauthCallbackHandler } from './oauth'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
if (props.req.path.startsWith('/oauth')) {
return await oauthCallbackHandler(props)
}
if (isWebhookVerificationRequest(props)) {
return await handleWebhookVerificationRequest(props)
}
_validatePayloadSignature(props)
if (isFileChangeNotification(props)) {
return await handleFileChangeEvent(props)
}
throw new sdk.RuntimeError('Unsupported webhook event')
}
const _validatePayloadSignature = (props: bp.HandlerProps) => {
const bodySignatureFromDropbox = props.req.headers['X-Dropbox-Signature'] ?? props.req.headers['x-dropbox-signature']
if (!bodySignatureFromDropbox) {
throw new sdk.RuntimeError('Missing Dropbox signature in request headers')
}
const clientSecret = getOAuthClientSecret({ ctx: props.ctx })
const bodySignatureFromBotpress = crypto
.createHmac('sha256', clientSecret)
.update(props.req.body ?? '')
.digest('hex')
if (bodySignatureFromDropbox !== bodySignatureFromBotpress) {
throw new sdk.RuntimeError('Dropbox signature does not match the expected signature')
}
}
@@ -0,0 +1,173 @@
import * as sdk from '@botpress/sdk'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../../definitions'
import { DropboxClient } from '../../dropbox-api'
import { FileTree, type FileTreeDiff } from '../../dropbox-api/file-tree'
import * as filesReadonlyMapping from '../../files-readonly/mapping'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = sdk.z.object({
list_folder: sdk.z.object({
accounts: sdk.z.array(sdk.z.string()),
}),
})
export const isFileChangeNotification = (props: bp.HandlerProps) =>
props.req.method.toUpperCase() === 'POST' &&
props.req.body &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
export const handleFileChangeEvent: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
const dropboxClient = await DropboxClient.create(props)
const accountId = dropboxClient.getAccountId()
if (!payload.list_folder.accounts.includes(accountId)) {
// If the account ID is not in the list of accounts, we can ignore this notification
return
}
const { syncCursor: prevSyncCursor, fileTree } = await _getSyncState(props)
const { newSyncCursor } = await _collectChangesAndBroadcast(props, prevSyncCursor, fileTree)
await _updateSyncState(props, newSyncCursor, fileTree)
}
const _getSyncState = async (props: bp.HandlerProps): Promise<{ syncCursor?: string; fileTree: FileTree }> => {
const { state } = await props.client.getOrSetState({
id: props.ctx.integrationId,
type: 'integration',
name: 'realTimeSync',
payload: {
syncCursor: '',
fileTreeJson: '[]',
},
})
return { syncCursor: state.payload.syncCursor || undefined, fileTree: FileTree.fromJSON(state.payload.fileTreeJson) }
}
const _updateSyncState = async (props: bp.HandlerProps, syncCursor: string, fileTree: FileTree) => {
await props.client.setState({
id: props.ctx.integrationId,
type: 'integration',
name: 'realTimeSync',
payload: {
syncCursor,
fileTreeJson: fileTree.toJSON(),
},
})
}
const _collectChangesAndBroadcast = async (
props: bp.HandlerProps,
prevSyncCursor: string | undefined,
fileTree: FileTree
) => {
const { items: modifiedItems, newSyncCursor } = await _getModifiedItems(props, prevSyncCursor)
const diff = fileTree.pushItems(modifiedItems)
const integrationSetupDate = await _getIntegrationSetupDate(props)
const isFileModifiedAfterSetup = (item: FileEntity.InferredType | FolderEntity.InferredType) =>
item.itemType === 'file' &&
// If it is the first sync (prevSyncCursor is undefined), omit files that
// were created/modified before the integration was set up:
(prevSyncCursor ? true : !item.modifiedAt || new Date(item.modifiedAt) > integrationSetupDate)
await _broadcastChanges(props, {
deleted: diff.deleted,
updated: diff.updated.filter(isFileModifiedAfterSetup),
added: diff.added.filter(isFileModifiedAfterSetup),
})
return { newSyncCursor }
}
const _getModifiedItems = async (props: bp.HandlerProps, prevSyncCursor: string | undefined) => {
const dropboxClient = await DropboxClient.create(props)
let currentSyncCursor: string | undefined = prevSyncCursor
let hasMore: boolean = false
const items: (FileEntity.InferredType | FolderEntity.InferredType | DeletedEntity.InferredType)[] = []
do {
const itemBatch = await dropboxClient.listItemsInFolder({ path: '', recursive: true, nextToken: currentSyncCursor })
items.push(...itemBatch.items)
currentSyncCursor = itemBatch.nextToken
hasMore = itemBatch.hasMore
} while (hasMore)
return {
newSyncCursor: currentSyncCursor,
items,
}
}
const _getIntegrationSetupDate = async (props: bp.HandlerProps): Promise<Date> => {
try {
const { state } = await props.client.getState({
type: 'integration',
id: props.ctx.integrationId,
name: 'setupMeta',
})
return new Date(state.payload.integrationRegisteredAt)
} catch {
return new Date(0)
}
}
const _broadcastChanges = async (props: bp.HandlerProps, fileTreeDiff: FileTreeDiff) => {
for (const diffBatch of _getDiffBatches(fileTreeDiff)) {
await props.client.createEvent({
type: 'aggregateFileChanges',
payload: {
modifiedItems: {
created: (diffBatch.added as FileEntity.InferredType[]).map(filesReadonlyMapping.mapFile),
updated: (diffBatch.updated as FileEntity.InferredType[]).map(filesReadonlyMapping.mapFile),
deleted: diffBatch.deleted.map((item) =>
item.itemType === 'file' ? filesReadonlyMapping.mapFile(item) : filesReadonlyMapping.mapFolder(item)
),
},
},
})
}
}
const _getDiffBatches = function* ({ added, updated, deleted }: FileTreeDiff) {
const MAX_BATCH_SIZE = 50
let addedCursor = 0
let updatedCursor = 0
let deletedCursor = 0
// Continue until all arrays are exhausted:
while (addedCursor < added.length || updatedCursor < updated.length || deletedCursor < deleted.length) {
const currentBatch: FileTreeDiff = { added: [], deleted: [], updated: [] }
for (let currentBatchSize = 0; currentBatchSize < MAX_BATCH_SIZE; ) {
const startSize = currentBatchSize
if (addedCursor < added.length && currentBatchSize < MAX_BATCH_SIZE) {
currentBatch.added.push(added[addedCursor++]!)
currentBatchSize++
}
if (updatedCursor < updated.length && currentBatchSize < MAX_BATCH_SIZE) {
currentBatch.updated.push(updated[updatedCursor++]!)
currentBatchSize++
}
if (deletedCursor < deleted.length && currentBatchSize < MAX_BATCH_SIZE) {
currentBatch.deleted.push(deleted[deletedCursor++]!)
currentBatchSize++
}
// If no item was added, the array is exhausted:
if (currentBatchSize === startSize) {
break
}
}
yield currentBatch
}
}
@@ -0,0 +1,22 @@
import * as bp from '.botpress'
export const isWebhookVerificationRequest = (props: bp.HandlerProps) => {
const searchParams = new URLSearchParams(props.req.query)
return props.req.method === 'GET' && searchParams.has('challenge')
}
export const handleWebhookVerificationRequest: bp.IntegrationProps['handler'] = async ({ req }) => {
const searchParams = new URLSearchParams(req.query)
const challenge = searchParams.get('challenge')
if (!challenge) {
return
}
return {
headers: { 'Content-Type': 'text/plain', 'X-Content-Type-Options': 'nosniff' },
status: 200,
body: challenge,
}
}
@@ -0,0 +1 @@
export * from './handler-dispatcher'
@@ -0,0 +1,22 @@
import { isOAuthWizardUrl, getInterstitialUrl, generateRedirection } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'
export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth endpoint',
}
}
try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth registration Error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,145 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { DropboxOAuthClient, getOAuthClientId } from '../../dropbox-api/oauth-client'
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-confirm', handler: _startHandler })
.addStep({ id: 'redirect-to-dropbox', handler: _redirectToDropboxHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'end', handler: _endHandler })
.build()
const response = await wizard.handleRequest()
return response
}
const _startHandler: WizardHandler = async (props) => {
const { responses } = props
// When nothing is connected yet there's nothing to reset, so skip the
// confirmation and go straight to authorizing with Dropbox.
if (!(await _isAlreadyConnected(props))) {
return _redirectToDropboxHandler(props)
}
return responses.displayButtons({
pageTitle: 'Reset Configuration',
htmlOrMarkdownPageContents:
'This wizard will reset your configuration, so the bot will stop working on Dropbox until a new configuration is put in place, continue?',
buttons: [
{
action: 'navigate',
label: 'Yes',
navigateToStep: 'redirect-to-dropbox',
buttonType: 'primary',
},
{
action: 'close',
label: 'No',
buttonType: 'secondary',
},
],
})
}
const _isAlreadyConnected = async ({ client, ctx }: bp.HandlerProps): Promise<boolean> => {
try {
const result = await client.getState({
id: ctx.integrationId,
type: 'integration',
name: 'authorization',
})
return Boolean(result?.state?.payload?.refreshToken)
} catch {
return false
}
}
const _redirectToDropboxHandler: WizardHandler = async (props) => {
const { responses, ctx } = props
const clientId = getOAuthClientId({ ctx })
if (!clientId) {
return responses.endWizard({
success: false,
errorMessage: 'Dropbox App Key (APP_KEY) is not configured. Please configure it in the integration settings.',
})
}
const redirectUri = _getOAuthRedirectUri()
const dropboxAuthUrl =
'https://www.dropbox.com/oauth2/authorize?' +
'response_type=code' +
'&token_access_type=offline' +
`&client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${encodeURIComponent(ctx.webhookId)}`
return responses.redirectToExternalUrl(dropboxAuthUrl)
}
const _getOAuthRedirectUri = (ctx?: bp.Context) => oauthWizard.getWizardStepUrl('oauth-callback', ctx).toString()
const _oauthCallbackHandler: WizardHandler = async (props) => {
const { responses, query, client, ctx, logger, setIntegrationIdentifier } = props
const oauthError = query.get('error')
const oauthErrorDescription = query.get('error_description')
if (oauthError) {
const errorMessage = oauthErrorDescription
? `OAuth error: ${oauthError} - ${oauthErrorDescription}`
: `OAuth error: ${oauthError}`
logger.forBot().warn(errorMessage)
return responses.endWizard({
success: false,
errorMessage,
})
}
const authorizationCode = query.get('code')
if (!authorizationCode) {
const errorMessage =
'No authorization code received from Dropbox. ' +
'This may happen if you denied the authorization request, ' +
'if the authorization code expired, or if there was an error during the OAuth flow. ' +
'Please try authorizing again.'
logger.forBot().warn(errorMessage)
return responses.endWizard({
success: false,
errorMessage,
})
}
try {
const redirectUri = _getOAuthRedirectUri()
const oauthClient = new DropboxOAuthClient({ client, ctx })
await oauthClient.processAuthorizationCode(authorizationCode, redirectUri)
logger.forBot().info('Successfully exchanged authorization code for refresh token')
setIntegrationIdentifier(ctx.webhookId)
return responses.endWizard({
success: true,
})
} catch (error) {
const errorMessage =
error instanceof Error
? `Failed to process authorization code: ${error.message}. Please make sure the code is correct and hasn't expired.`
: 'Failed to process authorization code. Please try again.'
logger.forBot().error({ err: error }, errorMessage)
return responses.endWizard({
success: false,
errorMessage,
})
}
}
const _endHandler: WizardHandler = ({ responses }) => {
return responses.endWizard({
success: true,
})
}