chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { File, Folder } from './entities'
|
||||
const { z } = sdk
|
||||
|
||||
export const actions = {
|
||||
listItemsInFolder: {
|
||||
title: 'List items in a folder',
|
||||
description: 'List files and folders in dropbox within a directory',
|
||||
input: {
|
||||
schema: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Path')
|
||||
.describe("The folder's path. Leave empty to list the contents of the root folder"),
|
||||
recursive: z
|
||||
.boolean()
|
||||
.title('List recursively?')
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Whether to recursively visit subfolders'),
|
||||
nextToken: z
|
||||
.string()
|
||||
.title('Pagination token')
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('The cursor to continue the pagination'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
items: z
|
||||
.array(z.union([File.schema, Folder.schema]))
|
||||
.title('Items')
|
||||
.describe('The list of files and folders in the folder'),
|
||||
nextToken: z
|
||||
.string()
|
||||
.title('Pagination token')
|
||||
.optional()
|
||||
.describe(
|
||||
"The cursor to use for pagination. If the cursor is present, you may use it to fetch the next page of results. If it's not present, it means there are no more results"
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
createFile: {
|
||||
title: 'Upload File',
|
||||
description: 'Upload a file to dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
contents: z.string().min(1).title('Contents').describe("The file's contents"),
|
||||
path: z.string().min(1).title('Path').describe("The file's path, including the file name and extension"),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({ newFile: File.schema.title('New File').describe('The newly-uploaded file') }),
|
||||
},
|
||||
},
|
||||
createFolder: {
|
||||
title: 'Create Folder',
|
||||
description: 'Create a folder in dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
path: z.string().min(1).title('Path').describe("The folder's path"),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({ newFolder: Folder.schema.title('New Folder').describe('The newly-created folder') }),
|
||||
},
|
||||
},
|
||||
deleteItem: {
|
||||
title: 'Delete Item',
|
||||
description: 'Deletes a file or folder from dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
path: z.string().title('Path').min(1).describe('The path of the item to delete'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
},
|
||||
downloadFile: {
|
||||
title: 'Download File',
|
||||
description: 'Download a file from dropbox',
|
||||
input: {
|
||||
schema: z.object({ path: z.string().title('Path').min(1).describe("The file's path on Dropbox") }),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
fileUrl: z.string().title('Download URL').describe('The url to download the file from'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
downloadFolder: {
|
||||
title: 'Download Folder',
|
||||
description: 'Download a folder from dropbox',
|
||||
input: {
|
||||
schema: z.object({ path: z.string().title('Path').min(1).describe("The folder's path on Dropbox") }),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
zipUrl: z
|
||||
.string()
|
||||
.title('Download URL')
|
||||
.describe('The url to download the folder from, compressed as a zip archive.'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
copyItem: {
|
||||
title: 'Copy Item',
|
||||
description: 'Copy a file or folder to a new location in dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
fromPath: z.string().title('Source Path').min(1).describe("The item's source path"),
|
||||
toPath: z.string().title('Destination Path').min(1).describe("The item's destination path"),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
newItem: z.union([File.schema, Folder.schema]).title('New item').describe('The newly-created item'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
moveItem: {
|
||||
title: 'Move Item',
|
||||
description: 'Move a file or folder in dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
fromPath: z.string().title('Source Path').min(1).describe("The item's source path"),
|
||||
toPath: z.string().title('Destination Path').min(1).describe("The item's destination path"),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
newItem: z.union([File.schema, Folder.schema]).title('Moved item').describe('The item that was moved'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
searchItems: {
|
||||
title: 'Search Items',
|
||||
description: 'Search for files and folders in dropbox',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().title('Search query').min(1).describe('Keywords to search for'),
|
||||
path: z.string().title('Path').optional().describe('The path to search in'),
|
||||
orderBy: z
|
||||
.enum(['relevance', 'last_modified_time'])
|
||||
.title('Order By')
|
||||
.optional()
|
||||
.describe('How to sort the results'),
|
||||
fileNameOnly: z
|
||||
.boolean()
|
||||
.title('Search only within names?')
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Whether to search only in file names'),
|
||||
fileExtensions: z
|
||||
.array(z.string().title('File extension').describe('The file extension to search for'))
|
||||
.optional()
|
||||
.title('Filter by extension')
|
||||
.describe('The file extensions to search for'),
|
||||
fileCategories: z
|
||||
.array(
|
||||
z.enum([
|
||||
'image',
|
||||
'document',
|
||||
'pdf',
|
||||
'spreadsheet',
|
||||
'presentation',
|
||||
'audio',
|
||||
'video',
|
||||
'folder',
|
||||
'paper',
|
||||
'others',
|
||||
])
|
||||
)
|
||||
.optional()
|
||||
.title('Filter by categories')
|
||||
.describe('The file categories to search for'),
|
||||
nextToken: z
|
||||
.string()
|
||||
.title('Pagination Token')
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('The cursor to continue the pagination'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
results: z
|
||||
.array(
|
||||
z.object({
|
||||
item: z.union([File.schema, Folder.schema]).title('Item').describe('The matched item'),
|
||||
matchType: z
|
||||
.enum(['filename', 'file_content', 'filename_and_content', 'image_content', 'metadata', 'other'])
|
||||
.title('Match type')
|
||||
.describe('The type of match')
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.title('Matches')
|
||||
.describe('A list of matches for the query'),
|
||||
nextToken: z
|
||||
.string()
|
||||
.title('Pagination Token')
|
||||
.optional()
|
||||
.describe(
|
||||
"The cursor to use for pagination. If the cursor is present, you may use it to fetch the next page of results. If it's not present, it means there are no more results"
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const configuration = {
|
||||
identifier: { linkTemplateScript: 'linkTemplate.vrl', required: false },
|
||||
schema: z.object({}),
|
||||
} satisfies sdk.IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const configurations = {
|
||||
manual: {
|
||||
title: 'Manual Configuration',
|
||||
description: 'Configure the Dropbox integration manually using your own app.',
|
||||
schema: z.object({
|
||||
clientId: z.string().title('App Key').describe('Available in the App Console on Dropbox'),
|
||||
clientSecret: z.string().title('App Secret').describe('Available in the App Console on Dropbox').secret(),
|
||||
authorizationCode: z
|
||||
.string()
|
||||
.title('Access Code')
|
||||
.describe('Obtained by navigating to the authorization URL')
|
||||
.secret(),
|
||||
}),
|
||||
},
|
||||
} satisfies sdk.IntegrationDefinitionProps['configurations']
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
const { z } = sdk
|
||||
|
||||
export namespace BaseItem {
|
||||
const _fields = {
|
||||
id: z.string().title('ID').describe('Unique identifier of the item.'),
|
||||
itemType: z.string().title('Item Type').describe('The type of the item.'),
|
||||
name: z.string().title('Name').describe('The name of the item, including extension.'),
|
||||
path: z.string().title('Path').describe("The path to the item in the user's Dropbox."),
|
||||
webUrl: z.string().optional().title('Web URL').describe('The URL to view the item on the Dropbox website.'),
|
||||
isDeleted: z.boolean().title('Is Deleted?').describe('Whether the item has been deleted.'),
|
||||
isShared: z.boolean().title('Is Shared?').describe('Whether the item is shared with other users.'),
|
||||
} as const
|
||||
|
||||
export const schema = z.object(_fields)
|
||||
export type InferredType = sdk.z.infer<typeof schema>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
const { z } = sdk
|
||||
|
||||
export namespace Deleted {
|
||||
const _fields = {
|
||||
itemType: z.literal('deleted').title('Item Type').describe('The type of the item.'),
|
||||
name: z.string().title('Name').describe('The name of the item.'),
|
||||
path: z.string().title('Path').describe("The path to the item in the user's Dropbox."),
|
||||
isDeleted: z.literal(true).title('Is Deleted?').describe('Whether the item has been deleted.'),
|
||||
} as const
|
||||
|
||||
export const schema = z.object(_fields)
|
||||
export type InferredType = sdk.z.infer<typeof schema>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { BaseItem } from './abstract/base-item'
|
||||
const { z } = sdk
|
||||
|
||||
export namespace File {
|
||||
const _fields = {
|
||||
...BaseItem.schema.shape,
|
||||
itemType: z.literal('file').title('Item Type').describe('The type of the item.'),
|
||||
revision: z.string().min(1).title('Revision').describe("The file's revision"),
|
||||
size: z.number().title('Size').describe("The file's size in bytes"),
|
||||
fileHash: z.string().min(1).title('Checksum').describe("The file's validation hash"),
|
||||
isDownloadable: z.boolean().title('Is Downloadable?').describe('Whether the file is downloadable'),
|
||||
symlinkTarget: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Symlink Target')
|
||||
.describe('The target of the symlink, if the file is a symlink'),
|
||||
modifiedAt: z
|
||||
.string()
|
||||
.title('Modified At')
|
||||
.describe('The date and time the file was last modified, formatted as an ISO 8601 timestamp.'),
|
||||
} as const
|
||||
|
||||
export const schema = z.object(_fields)
|
||||
export type InferredType = sdk.z.infer<typeof schema>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { BaseItem } from './abstract/base-item'
|
||||
const { z } = sdk
|
||||
|
||||
export namespace Folder {
|
||||
const _fields = {
|
||||
...BaseItem.schema.shape,
|
||||
itemType: z.literal('folder').title('Item Type').describe('The type of the item.'),
|
||||
} as const
|
||||
|
||||
export const schema = z.object(_fields)
|
||||
export type InferredType = sdk.z.infer<typeof schema>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
import { Deleted } from './deleted'
|
||||
import { File } from './file'
|
||||
import { Folder } from './folder'
|
||||
|
||||
export { File, Folder, Deleted }
|
||||
|
||||
export const entities = {
|
||||
file: {
|
||||
title: 'File',
|
||||
description: "A file in a user's Dropbox",
|
||||
schema: File.schema,
|
||||
},
|
||||
folder: {
|
||||
title: 'Folder',
|
||||
description: "A folder in a user's Dropbox",
|
||||
schema: Folder.schema,
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['entities']
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './configuration'
|
||||
export * from './actions'
|
||||
export * from './entities'
|
||||
export * from './secrets'
|
||||
export * from './states'
|
||||
@@ -0,0 +1,6 @@
|
||||
import sdk from '@botpress/sdk'
|
||||
|
||||
export const secrets = {
|
||||
APP_KEY: { description: 'Dropbox App Key' },
|
||||
APP_SECRET: { description: 'Dropbox App Secret' },
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const states = {
|
||||
authorization: {
|
||||
type: 'integration',
|
||||
schema: sdk.z.object({
|
||||
refreshToken: sdk.z
|
||||
.string()
|
||||
.title('Refresh Token')
|
||||
.describe('Refresh token used to generate a new access token')
|
||||
.secret(),
|
||||
grantedScopes: sdk.z.array(sdk.z.string()).title('Granted Scopes').describe('Scopes granted by the user'),
|
||||
accountId: sdk.z.string().title('Account ID').describe('User ID of the authenticated user'),
|
||||
authorizationCode: sdk.z
|
||||
.string()
|
||||
.title('Access Code')
|
||||
.describe('The Access Code that was exchanged for the refresh token')
|
||||
.secret(),
|
||||
}),
|
||||
},
|
||||
realTimeSync: {
|
||||
type: 'integration',
|
||||
schema: sdk.z.object({
|
||||
syncCursor: sdk.z
|
||||
.string()
|
||||
.title('Sync Cursor')
|
||||
.describe('The cursor used to track the sync state of the tracked Dropbox account.'),
|
||||
fileTreeJson: sdk.z.string().title('File Tree JSON').describe('The JSON representation of the file tree.'),
|
||||
}),
|
||||
},
|
||||
setupMeta: {
|
||||
type: 'integration',
|
||||
schema: sdk.z.object({
|
||||
integrationRegisteredAt: sdk.z
|
||||
.string()
|
||||
.datetime()
|
||||
.title('Registered At')
|
||||
.describe('Date and time when the user configured the integration'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Description
|
||||
|
||||
Integrate your bot with Dropbox to list, manage, and transfer files seamlessly between Dropbox and the Botpress files API.
|
||||
|
||||
# Configuration
|
||||
|
||||
Currently, the Dropbox integration only supports OAuth authentication using your own Dropbox app.
|
||||
|
||||
## Configuring the integration with a custom app
|
||||
|
||||
1. Sign in to your Dropbox account and access the [Dropbox App Console](https://www.dropbox.com/developers/apps).
|
||||
2. Create a new app by clicking the "Create app" button.
|
||||
- Choose the "Scoped access" option.
|
||||
- Give access to either a single folder or your entire Dropbox account.
|
||||
- Name your app and click "Create app".
|
||||
3. From your app's settings page, copy the **App Key** and **App Secret**. You will need these to configure the integration on Botpress.
|
||||
4. In your app's Permissions page, add the following scopes:
|
||||
- `account_info.read`
|
||||
- `files.metadata.write`
|
||||
- `files.content.read`
|
||||
- `files.content.write`
|
||||
- `sharing.read`
|
||||
5. After adding the scopes, click the "Submit" button to apply the changes.
|
||||
6. In your web browser, navigate to `https://www.dropbox.com/oauth2/authorize?response_type=code&token_access_type=offline&client_id=<App Key>`. **Make sure to replace `<App Key>` with the App Key you copied from step 3**.
|
||||
- For example, if your **App Key** is `abcde`, the URL should end in `client_id=abcde`.
|
||||
7. Authorize your Dropbox app to access your Dropbox account. You will then be presented with an **Access Code**. Copy this code.
|
||||
8. On Botpress, enter your **App key**, **App secret**, and **Access Code** in the integration configuration page.
|
||||
9. Save the configuration to enable the integration.
|
||||
|
||||
### Extra configuration for the File Synchronizer plugin
|
||||
|
||||
If you are using the File Synchronizer plugin, you need to set up a webhook in
|
||||
your Dropbox app to receive real-time file change notifications.
|
||||
|
||||
1. On Botpress, copy the **Webhook URL** from the integration configuration page. This URL usually starts with `https://webhook.botpress.cloud/`.
|
||||
2. From the Dropbox App Console, navigate to your app's settings page.
|
||||
3. In the **Webhook** section, paste the **Webhook URL** you copied from Botpress and click `Add`.
|
||||
4. Your bot should now be able to receive real-time file change notifications from Dropbox.
|
||||
|
||||
# Using the integration
|
||||
|
||||
The integration provides actions to manage your Dropbox files effectively:
|
||||
|
||||
- List files and folders in your Dropbox account
|
||||
- Download files from Dropbox to your bot's storage
|
||||
- Upload files from your bot to Dropbox
|
||||
- Create, delete, and organize folders
|
||||
- Move or copy files between locations
|
||||
|
||||
Use these actions in your workflows to seamlessly handle file operations between your bot and Dropbox.
|
||||
|
||||
# Limitations
|
||||
|
||||
Standard Dropbox API limitations apply. For more information, visit the [Dropbox API documentation](https://www.dropbox.com/developers/documentation).
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg"><path fill="#0061FF" d="m135.37 26.015 121.13 90.71-121.13 90.71-121.13-90.71z"/><path fill="#0061FF" d="m376.63 26.015 121.13 90.71-121.13 90.71-121.13-90.71zm-120.26 278.55 121.13 90.71-121.13 90.71-121.13-90.71z"/><path fill="#0061FF" d="m376.63 206.565 121.13 90.71-121.13 90.71-121.13-90.71zm-241.26 1 121.13 90.71-121.13 90.71-121.13-90.71z"/></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,34 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import filesReadonly from './bp_modules/files-readonly'
|
||||
import { actions, configuration, configurations, entities, secrets, states } from './definitions'
|
||||
|
||||
export default new sdk.IntegrationDefinition({
|
||||
name: 'dropbox',
|
||||
title: 'Dropbox',
|
||||
version: '2.0.4',
|
||||
description: 'Manage your files and folders effortlessly.',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration,
|
||||
configurations,
|
||||
actions,
|
||||
entities,
|
||||
secrets,
|
||||
states,
|
||||
attributes: {
|
||||
category: 'File Management',
|
||||
repo: 'botpress',
|
||||
},
|
||||
}).extend(filesReadonly, ({}) => ({
|
||||
entities: {},
|
||||
actions: {
|
||||
listItemsInFolder: {
|
||||
name: 'filesReadonlyListItemsInFolder',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
transferFileToBotpress: {
|
||||
name: 'filesReadonlyTransferFileToBotpress',
|
||||
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start-confirm?state={{ webhookId }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@botpresshub/dropbox",
|
||||
"dependencies": {
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"dropbox": "^10.34.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:bplint": "bp lint",
|
||||
"check:type": "tsc --noEmit",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"files-readonly": "../../interfaces/files-readonly"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user