chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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',
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user