chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+279
View File
@@ -0,0 +1,279 @@
import * as sdk from '@botpress/sdk'
export const actions = {
createPage: {
title: 'Create Page',
description: 'Create a new page in Notion',
input: {
schema: sdk.z.object({
parentType: sdk.z.enum(['data source', 'page']).title('Parent Type').describe('The type of the parent'),
parentId: sdk.z
.string()
.min(1)
.title('Parent ID')
.describe('The ID of the parent to add the page to. Can be found in the URL of the parent'),
title: sdk.z.string().title('Page Title').describe('The title of the page'),
dataSourceTitleName: sdk.z
.string()
.title('Data Source Title Name')
.describe('The name of the title property in the data source. If not provided, the default is "Name".')
.optional()
.default('Name'),
}),
},
output: {
schema: sdk.z.object({
pageId: sdk.z.string().title('Page ID').describe('The ID of the page that was created'),
}),
},
},
updatePageProperties: {
title: 'Update Page Properties',
description: 'Update one or more properties on a Notion page using raw Notion properties JSON',
input: {
schema: sdk.z.object({
pageId: sdk.z
.string()
.min(1)
.title('Page ID')
.describe('The ID of the page to update. Can be found in the page URL'),
propertiesJson: sdk.z
.string()
.min(2)
.title('Properties (JSON)')
.describe(
'Stringified JSON object for the Notion properties payload (same format as Notion pages.update API endpoint but without the "properties" key). Check the Notion API documentation for the correct format. https://developers.notion.com/reference/patch-page'
)
.placeholder('{"In stock": { "checkbox": true }}'),
}),
},
output: {
schema: sdk.z.object({
pageId: sdk.z.string().title('Page ID').describe('The updated page ID'),
}),
},
},
addComment: {
title: 'Add Comment',
description: 'Add a comment to a page, block, or discussion in Notion',
input: {
schema: sdk.z.object({
parentType: sdk.z.enum(['page', 'block', 'discussion']).title('Parent Type').describe('The type of the parent'),
parentId: sdk.z
.string()
.min(1)
.title('Parent ID')
.describe('The ID of the parent to add the comment to. Can be found in the URL of the parent'),
commentBody: sdk.z.string().min(1).title('Comment Body').describe('Must be plain text'),
}),
},
output: {
schema: sdk.z.object({
commentId: sdk.z.string().title('Comment ID').describe('The ID of the comment that was created'),
discussionId: sdk.z
.string()
.optional()
.title('Discussion ID')
.describe('The ID of the discussion that was created'),
}),
},
},
deleteBlock: {
title: 'Delete Block',
description: 'Delete a block in Notion',
input: {
schema: sdk.z.object({
blockId: sdk.z
.string()
.min(1)
.title('Block ID')
.describe('The ID of the block to delete. Can be found in the URL of the block'),
}),
},
output: {
schema: sdk.z.object({
blockId: sdk.z.string().title('Block ID').describe('The ID of the block that was deleted'),
}),
},
},
getDb: {
title: 'Get Database',
description: 'Get a database from Notion',
input: {
schema: sdk.z.object({
databaseId: sdk.z
.string()
.min(1)
.title('Database ID')
.describe('The ID of the database to fetch. Can be found in the URL of the database'),
}),
},
output: {
schema: sdk.z.object({
object: sdk.z.string().optional().title('Database Object').describe('The type of object returned'),
dataSources: sdk.z
.array(
sdk.z.object({
id: sdk.z.string().title('Data Source ID').describe('The ID of the data source'),
name: sdk.z.string().title('Data Source Name').describe('The name of the data source'),
})
)
.title('Data Sources')
.describe('List of data sources in the database'),
}),
},
},
getDataSource: {
title: 'Get Data Source',
description: 'Get a data source from Notion',
input: {
schema: sdk.z.object({
dataSourceId: sdk.z
.string()
.min(1)
.title('Data Source ID')
.describe('The ID of the data source to fetch. Can be found in the URL of the data source'),
}),
},
output: {
schema: sdk.z.object({
object: sdk.z.string().title('Data Source Object').describe('A stringified representation of the data source'),
properties: sdk.z
.record(sdk.z.string(), sdk.z.object({}).passthrough())
.title('Data Source Properties')
.describe('Schema of properties for the data source as they appear in Notion'),
pages: sdk.z
.array(
sdk.z.object({
id: sdk.z.string().title('Page ID').describe('The ID of the page'),
title: sdk.z.string().title('Page Title').describe('The title of the page'),
pageProperties: sdk.z
.record(sdk.z.string(), sdk.z.object({}).passthrough())
.title('Page Properties')
.describe('Schema of properties for the page as they appear in Notion'),
})
)
.title('Pages')
.describe('List of pages in the data source'),
}),
},
},
getPage: {
title: 'Get Page',
description: 'Get a page from Notion',
input: {
schema: sdk.z.object({
pageId: sdk.z
.string()
.min(1)
.title('Page ID')
.describe('The ID of the page to fetch. Can be found in the URL of the page'),
}),
},
output: {
schema: sdk.z.object({
object: sdk.z.string().optional().title('Result Object').describe('The type of object returned'),
id: sdk.z.string().optional().title('Result ID').describe('The ID of the object returned'),
created_time: sdk.z.string().optional().title('Created Time').describe('The time the object was created'),
parent: sdk.z.object({}).passthrough().optional().title('Parent').describe('The parent of the object'),
created_by: sdk.z
.object({
object: sdk.z.string().optional().title('Created By Object').describe('The type of object returned'),
id: sdk.z.string().optional().title('Created By ID').describe('The ID of the object returned'),
})
.optional()
.title('Created By')
.describe('The user who created the object'),
archived: sdk.z.boolean().optional().title('Archived').describe('Whether the object is archived'),
properties: sdk.z
.record(sdk.z.string(), sdk.z.object({}).passthrough())
.optional()
.title('Page Properties')
.describe('Schema of properties for the page as they appear in Notion'),
}),
},
},
appendBlocksToPage: {
title: 'Append Blocks to Page',
description: 'Append a markdown text to a page in Notion. The markdown text will be converted to notion blocks.',
input: {
schema: sdk.z.object({
pageId: sdk.z
.string()
.min(1)
.title('Page ID')
.describe('The ID of the page to append the blocks to. Can be found in the URL of the page'),
markdownText: sdk.z.string().title('Markdown Text').describe('The markdown text to append to the page'),
}),
},
output: {
schema: sdk.z.object({
pageId: sdk.z.string().title('Page ID').describe('The ID of the page where blocks were appended'),
blockIds: sdk.z.array(sdk.z.string()).title('Block IDs').describe('The IDs of the blocks that were created'),
}),
},
},
searchByTitle: {
title: 'Search by Title',
description:
'Search for pages and databases in Notion. Optionally filter by title. Only returns items that have been shared with the integration.',
input: {
schema: sdk.z.object({
title: sdk.z
.string()
.optional()
.title('Title')
.describe(
'Optional search query to match against page and database titles. If not provided, returns all accessible pages and databases.'
),
}),
},
output: {
schema: sdk.z.object({
results: sdk.z
.array(
sdk.z.object({
id: sdk.z.string().title('ID').describe('The ID of the page or database'),
title: sdk.z.string().title('Title').describe('The title of the page or database'),
type: sdk.z.string().title('Type').describe('The type of the result (page or database)'),
url: sdk.z.string().title('URL').describe('The URL to the page or database'),
})
)
.title('Results')
.describe('Array of pages and databases matching the search query'),
}),
},
},
getPageContent: {
title: 'Get Page Content',
description: 'Get the content blocks of a page or block in Notion',
input: {
schema: sdk.z.object({
pageId: sdk.z
.string()
.min(1)
.title('Page or Block ID')
.describe('The ID of the page or block to fetch content from. Can be found in the URL'),
}),
},
output: {
schema: sdk.z.object({
blocks: sdk.z
.array(
sdk.z.object({
blockId: sdk.z.string().title('Block ID').describe('The unique ID of the block'),
parentId: sdk.z.string().optional().title('Parent ID').describe('The ID of the parent page or block'),
type: sdk.z.string().title('Type').describe('The type of the block (paragraph, heading_1, etc.)'),
hasChildren: sdk.z.boolean().title('Has Children').describe('Whether the block has nested child blocks'),
richText: sdk.z
.array(sdk.z.object({}).passthrough())
.title('Rich Text')
.describe('The rich text content of the block with formatting information'),
})
)
.title('Blocks')
.describe('Array of content blocks from the page'),
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,36 @@
import * as sdk from '@botpress/sdk'
export const configuration = {
schema: sdk.z.object({}),
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
required: true,
},
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
export const identifier = {
extractScript: 'extract.vrl',
fallbackHandlerScript: 'fallbackHandler.vrl',
} as const satisfies sdk.IntegrationDefinitionProps['identifier']
export const configurations = {
customApp: {
title: 'Manual configuration with a custom Notion integration',
description: 'Configure the integration using a Notion integration token.',
schema: sdk.z.object({
internalIntegrationSecret: sdk.z
.string()
.min(1)
.title('Internal Integration Secret')
.describe('Can be found on Notion in your integration settings under the Configuration tab.'),
webhookVerificationSecret: sdk.z
.string()
.min(1)
.optional()
.title('Webhook Verification Secret')
.describe(
'Note: Requires saving the integration in order to generate a new secret. Once the integration has been saved, configure the notion webhook, verify the webhook url, and copy the secret from the bot logs.'
),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
+35
View File
@@ -0,0 +1,35 @@
import * as sdk from '@botpress/sdk'
export const BASE_EVENT_PAYLOAD = sdk.z.object({
workspace_id: sdk.z.string().min(1),
type: sdk.z.string().min(1),
entity: sdk.z.object({
type: sdk.z.enum(['page', 'block', 'database', 'comment']).title('Type').describe('The type of entity'),
id: sdk.z.string().min(1).title('ID').describe('The ID of the entity'),
}),
data: sdk.z.object({}).passthrough(),
})
export const events = {
commentCreated: {
title: 'Comment Created',
description: 'A comment was created in Notion',
schema: BASE_EVENT_PAYLOAD.extend({
type: sdk.z.literal('comment.created').title('Type').describe('The type of event'),
entity: BASE_EVENT_PAYLOAD.shape.entity
.extend({
type: sdk.z.literal('comment').title('Type').describe('The type of entity (comment)'),
})
.title('Entity')
.describe('The entity that the event is related to'),
workspace_id: sdk.z.string().min(1).title('Workspace ID').describe('The ID of the Notion workspace'),
workspace_name: sdk.z.string().min(1).title('Workspace Name').describe('The name of the Notion workspace'),
data: sdk.z
.object({
page_id: sdk.z.string().min(1).title('Page ID').describe('The ID of the page the comment was created on'),
})
.title('Data')
.describe('Additional data about the event'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['events']
+6
View File
@@ -0,0 +1,6 @@
export { actions } from './actions'
export { configuration, configurations, identifier } from './configuration'
export { events } from './events'
export { secrets } from './secrets'
export { states } from './states'
export { user } from './user-tags'
@@ -0,0 +1,64 @@
import { z } from '@botpress/sdk'
const richText = z
.object({
text: z.object({ content: z.string() }),
type: z.literal('text').optional(),
})
.passthrough()
const titleProp = z.object({ type: z.literal('title'), title: z.array(richText) })
const richTextProp = z.object({ rich_text: z.array(richText) })
const numberProp = z.object({ number: z.number().nullable() })
const checkboxProp = z.object({ checkbox: z.boolean() })
const selectProp = z.object({ select: z.object({ name: z.string() }).nullable() })
const statusProp = z.object({ status: z.object({ name: z.string() }).nullable() })
const multiSelectProp = z.object({ multi_select: z.array(z.object({ name: z.string() })) })
const dateProp = z.object({
date: z
.object({
start: z.string(),
end: z.string().optional().nullable(),
})
.nullable(),
})
const urlProp = z.object({ url: z.string().nullable() })
const emailProp = z.object({ email: z.string().nullable() })
const phoneProp = z.object({ phone_number: z.string().nullable() })
const peopleProp = z.object({ people: z.array(z.object({ id: z.string() })) })
const relationProp = z.object({ relation: z.array(z.object({ id: z.string() })) })
const filesProp = z.object({
files: z.array(
z.union([
z.object({
external: z.object({ url: z.string() }),
name: z.string(),
type: z.literal('external').optional(),
}),
z.object({
file: z.object({ url: z.string(), expiry_time: z.string().optional() }),
name: z.string(),
type: z.literal('file').optional(),
}),
])
),
})
export const updatePagePropertiesSchema = z.record(
z.union([
titleProp,
richTextProp,
numberProp,
checkboxProp,
selectProp,
statusProp,
multiSelectProp,
dateProp,
urlProp,
emailProp,
phoneProp,
peopleProp,
relationProp,
filesProp,
])
)
@@ -0,0 +1,16 @@
import * as sdk from '@botpress/sdk'
export const secrets = {
CLIENT_ID: {
description: 'The client ID of the Botpress Notion Integration.',
},
CLIENT_SECRET: {
description: 'The client secret of the Botpress Notion Integration.',
},
WEBHOOK_VERIFICATION_SECRET: {
description: 'The Notion-provided secret for verifying incoming webhooks.',
},
POSTHOG_KEY: {
description: 'The Posthog key of the Botpress Notion Integration.',
},
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
+13
View File
@@ -0,0 +1,13 @@
import * as sdk from '@botpress/sdk'
export const states = {
oauth: {
type: 'integration',
schema: sdk.z.object({
authToken: sdk.z
.string()
.title('OAuth Authentication Token')
.describe('The token used to authenticate with Notion. Currently, these tokens never expire'),
}),
},
} as const satisfies sdk.IntegrationDefinitionProps['states']
@@ -0,0 +1,10 @@
import * as sdk from '@botpress/sdk'
export const user = {
tags: {
id: {
title: 'Notion User ID',
description: 'The ID of the user on Notion.',
},
},
} as const satisfies sdk.IntegrationDefinitionProps['user']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+1
View File
@@ -0,0 +1 @@
to_string!(parse_json!(.body).workspace_id)
+16
View File
@@ -0,0 +1,16 @@
verification_token = parse_json!(.body).verification_token
# Notion sends a verification token in the body of the request to verify the
# webhook endpoint, but this request does not contain a workspace_id, so the
# extract.vrl script fails and the request does not get handled by the
# integration. To circumvent this, we log the verification token so that it
# can be manually retrieved from the logs.
msg, err = "Notion verification token received: " + verification_token
if err == null{
# The log() function is not available in wasm, so we have to get creative:
assert!(false, message: msg)
}
{}
+56
View File
@@ -0,0 +1,56 @@
The Notion Integration for Botpress Studio allows you to do the following things:
## Migrating from version `2.x` to `3.x`
Version `3.x` of the Notion integration brings a lot of features to the table. Here is a summary of the changes coming to Notion:
- Upgraded to Notion API version **2025-09-03**
- Page interactions: Get Page, Get Page Content, Append Blocks to Page, Update Page Properties
- Search by Title
- Comment created Event
- Consolidate comment actions into one action - `Add Comment`
Another change that the update brings is new manual configuration. It now asks for:
- **Internal Integration Secret (required)**: Same as API Token but changed the name to match what is found in Notion's integration's page.
- **Webhook Verification Secret**: This is used to verify webhook events. Can be found in the bot logs when configuring the webhooks.
## Migrating from version `0.x` or `1.x` to `2.x`
Version `2.0` of the Notion integration adds OAuth support, which is now the default configuration option.
If you previously created a Notion integration in the Notion developer portal and wish to keep using this integration, please select the manual configuration option and follow the instructions below.
Otherwise, select the automatic configuration option and click the authorization button, then follow the on-screen instructions to connect your Botpress chatbot to Notion.
## Configuration
### Automatic configuration with OAuth (recommended)
This is the simplest way to set up the integration. To set up the Notion integration using OAuth, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to Notion. This method is recommended as it simplifies the configuration process and ensures secure communication between your chatbot and Notion.
When using this configuration mode, a Botpress-managed Notion application will be used to connect to your Notion account. Actions taken by the bot will be attributed to this application, not your personal Notion account.
**Note:** Ensure that you have chosen the correct workspace which can be found on the top right during OAuth.
### Manual configuration with a custom Notion integration
#### Step 1 - Create Integration
Create a Notion integration [Create an integration - Notion Developers](https://developers.notion.com/docs/create-a-notion-integration)
#### Step 2 - Give access to Notion Assets
Give your integration access to all the pages and databases that you want to use with Botpress
#### Step 3 - Configure your Bot
You need a token to get your newly created Notion Integration _(not the same as Botpress Studio's Notion Integration)_ connected with Botpress Studio:
- `Internal Integration Secret` - You'll find this by going to your integration under `https://www.notion.so/my-integrations`. Once you click on your integration, under the "Configuration" tab, find the "Internal Integration Secret" field. Click "Show" then "Copy". Paste the copied token under `Internal Integration Secret` field for Notion integration under the "Integrations" tab for your bot.
With that you just need to enable your integration and you can start expanding your Bot's capabilities with Notion.
#### Step 4 - Setup Webhooks (optional)
After saving Step 3 configuration, copy the Botpress integration webhook URL. In your Notion integration's Webhooks tab, paste it in `Webhook URL` and click `verify`. Copy the secret from your Bot logs and paste it back in the verification field. Then add this secret to the `Webhook Verification Secret` field in your Botpress Notion integration configuration to validate webhook events.
+4
View File
@@ -0,0 +1,4 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.9473 18.1239L254.183 1.90564C281.227 -0.413975 288.186 1.13982 305.184 13.4879L375.486 62.899C387.086 71.3959 390.952 73.7092 390.952 82.9717V353.975C390.952 370.959 384.765 381.003 363.133 382.54L107.376 397.984C91.1378 398.759 83.4095 396.444 74.9057 385.629L23.1344 318.458C13.8577 306.095 10 296.844 10 286.022V45.1365C10 31.2473 16.1887 19.6619 33.9473 18.1239Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M254.183 1.90564L33.9473 18.1239C16.1887 19.6619 10 31.2473 10 45.1365V286.022C10 296.844 13.8577 306.095 23.1344 318.458L74.9057 385.629C83.4095 396.444 91.1378 398.759 107.376 397.984L363.133 382.54C384.765 381.003 390.952 370.959 390.952 353.975V82.9717C390.952 74.1922 387.478 71.656 377.238 64.1797C376.674 63.7684 376.091 63.3421 375.486 62.899L305.184 13.4879C288.186 1.13982 281.227 -0.413975 254.183 1.90564ZM113.175 78.5266C92.2938 79.9366 87.5491 80.257 75.6911 70.6238L45.5362 46.6901C42.4606 43.5952 44.0032 39.7345 51.7248 38.9688L263.456 23.5258C281.224 21.9783 290.495 28.165 297.453 33.5669L333.768 59.8199C335.314 60.5888 339.171 65.2155 334.533 65.2155L115.87 78.3453C114.95 78.4068 114.058 78.467 113.194 78.5253L113.178 78.5264L113.175 78.5266ZM88.8193 351.654V121.583C88.8193 111.548 91.9105 106.912 101.178 106.133L352.305 91.4685C360.822 90.6965 364.679 96.1047 364.679 106.133V334.66C364.679 344.705 363.13 353.21 349.216 353.975L108.906 367.879C94.9984 368.644 88.8193 364.019 88.8193 351.654ZM326.053 133.924C327.594 140.88 326.053 147.829 319.085 148.611L307.506 150.918V320.771C297.453 326.175 288.183 329.263 280.458 329.263C268.09 329.263 264.992 325.4 255.728 313.825L179.991 194.927V309.965L203.957 315.373C203.957 315.373 203.957 329.263 184.621 329.263L131.317 332.356C129.768 329.263 131.317 321.549 136.724 320.003L150.634 316.148V164.048L131.32 162.5C129.771 155.544 133.629 145.516 144.455 144.737L201.638 140.883L280.458 261.329V154.778L260.362 152.472C258.819 143.969 264.992 137.794 272.72 137.029L326.053 133.924Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,40 @@
import * as sdk from '@botpress/sdk'
import filesReadonly from './bp_modules/files-readonly'
import { actions, configuration, configurations, events, identifier, secrets, states, user } from './definitions'
export const INTEGRATION_NAME = 'notion'
export const INTEGRATION_VERSION = '3.0.7'
export default new sdk.IntegrationDefinition({
name: INTEGRATION_NAME,
version: INTEGRATION_VERSION,
title: 'Notion',
description: 'Add pages and comments, manage databases, and engage in discussions — all within your chatbot.',
icon: 'icon.svg',
readme: 'hub.md',
attributes: {
category: 'File Management',
guideSlug: 'notion',
repo: 'botpress',
},
actions,
configuration,
configurations,
identifier,
events,
secrets,
states,
user,
}).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 },
},
},
}))
+13
View File
@@ -0,0 +1,13 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
env = to_string!(.env)
clientId = "1c8d872b-594c-8091-bc5a-0037a3cc5f94"
if env == "production" {
clientId = "1c8d872b-594c-8080-8623-0037a51e20ae"
}
redirectUri = "{{ webhookUrl }}/oauth"
"https://api.notion.com/v1/oauth/authorize?client_id={{ clientId }}&response_type=code&owner=user&redirect_uri={{ redirectUri }}&state={{ webhookId }}"
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@botpresshub/notion",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@notionhq/client": "^5.6.0",
"@tryfabric/martian": "^1.2.4",
"notion-to-md": "^4.0.0-alpha.4"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"bpDependencies": {
"files-readonly": "../../interfaces/files-readonly"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { createActionWrapper } from '@botpress/common'
import { NotionClient, wrapAsyncFnWithTryCatch } from './notion-api'
import * as bp from '.botpress'
export const wrapAction: typeof _wrapActionAndInjectTools = (meta, actionImpl) =>
_wrapActionAndInjectTools(meta, (props) =>
wrapAsyncFnWithTryCatch(() => {
props.logger.forBot().debug(`Running action "${meta.actionName}"`)
return actionImpl(props as Parameters<typeof actionImpl>[0], props.input)
}, `Action Error: ${meta.errorMessage}`)()
)
const _wrapActionAndInjectTools = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
notionClient: NotionClient.create,
},
extraMetadata: {} as {
errorMessage: string
},
})
@@ -0,0 +1,11 @@
import { wrapAction } from '../action-wrapper'
export const addComment = wrapAction(
{ actionName: 'addComment', errorMessage: 'Failed to add comment' },
async (
{ notionClient },
{ parentType, parentId, commentBody }: { parentType: string; parentId: string; commentBody: string }
) => {
return await notionClient.addComment({ parentType, parentId, commentBody })
}
)
@@ -0,0 +1,12 @@
import { markdownToBlocks } from '@tryfabric/martian'
import { wrapAction } from '../action-wrapper'
export const appendBlocksToPage = wrapAction(
{ actionName: 'appendBlocksToPage', errorMessage: 'Failed to append blocks to page' },
async ({ notionClient }, input) => {
const { pageId, markdownText } = input
const blocks = markdownToBlocks(markdownText)
// @ts-expect-error - @tryfabric/martian uses notion@1.0.4 types which needs to be updated to notion@5.6.0 but it works. To find a better solution
return await notionClient.appendBlocksToPage({ pageId, blocks })
}
)
@@ -0,0 +1,11 @@
import { wrapAction } from '../action-wrapper'
export const createPage = wrapAction(
{ actionName: 'createPage', errorMessage: 'Failed to create page' },
async ({ notionClient }, { parentType, parentId, title, dataSourceTitleName }) => {
if (!dataSourceTitleName) {
dataSourceTitleName = 'Name' // default title property name for data sources
}
return await notionClient.createPage({ parentType, parentId, title, dataSourceTitleName })
}
)
@@ -0,0 +1,8 @@
import { wrapAction } from '../action-wrapper'
export const deleteBlock = wrapAction(
{ actionName: 'deleteBlock', errorMessage: 'Failed to delete block' },
async ({ notionClient }, { blockId }) => {
return await notionClient.deleteBlock({ blockId })
}
)
@@ -0,0 +1,59 @@
import * as sdk from '@botpress/sdk'
import { wrapAction } from '../../action-wrapper'
import * as mapping from '../../files-readonly/mapping'
import type { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
export const filesReadonlyListItemsInFolder = wrapAction(
{ actionName: 'filesReadonlyListItemsInFolder', errorMessage: 'Failed to list items in folder' },
async ({ notionClient }, { folderId, nextToken: prevToken }) => {
if (!folderId) {
return await _enumerateTopLevelItems({ notionClient, prevToken, folderId })
} else if (folderId.startsWith(mapping.PREFIXES.PAGE_FOLDER)) {
return await _enumeratePageAndChildItems({ folderId, notionClient, prevToken })
} else if (folderId.startsWith(mapping.PREFIXES.DB_FOLDER)) {
return await _enumerateDbChildItems({ folderId, notionClient, prevToken })
}
throw new sdk.RuntimeError(`Invalid folderId: ${folderId}`)
}
)
type EnumerateItemsFn = (props: {
folderId: string | undefined
prevToken: string | undefined
notionClient: NotionClient
}) => Promise<bp.actions.Actions['filesReadonlyListItemsInFolder']['output']>
const _enumerateTopLevelItems: EnumerateItemsFn = async ({ notionClient, prevToken }) => {
const { nextToken, results } = await notionClient.enumerateTopLevelItems({ nextToken: prevToken })
return { meta: { nextToken }, items: mapping.mapEntities(results) }
}
const _enumeratePageAndChildItems: EnumerateItemsFn = async ({ folderId, prevToken, notionClient }) => {
const pageId = folderId!.slice(mapping.PREFIXES.PAGE_FOLDER.length)
const { nextToken, results } = await notionClient.enumeratePageChildren({
pageId,
nextToken: prevToken,
})
const items = mapping.mapEntities(results)
const page = await notionClient.getPage({ pageId })
if (page) {
items.push(mapping.mapPageToFile(page))
}
return { meta: { nextToken }, items }
}
const _enumerateDbChildItems: EnumerateItemsFn = async ({ folderId, prevToken, notionClient }) => {
const dbId = folderId!.slice(mapping.PREFIXES.DB_FOLDER.length)
const { nextToken, results } = await notionClient.enumerateDataSourceChildren({
dataSourceId: dbId,
nextToken: prevToken,
})
return { meta: { nextToken }, items: mapping.mapEntities(results) }
}
@@ -0,0 +1,23 @@
import * as sdk from '@botpress/sdk'
import { wrapAction } from '../../action-wrapper'
import * as mapping from '../../files-readonly/mapping'
export const filesReadonlyTransferFileToBotpress = wrapAction(
{ actionName: 'filesReadonlyTransferFileToBotpress', errorMessage: 'Failed to transfer file to Botpress' },
async ({ notionClient, client }, { file, fileKey, shouldIndex }) => {
if (!file.id.startsWith(mapping.PREFIXES.PAGE)) {
throw new sdk.RuntimeError(`Invalid fileId: ${file.id}`)
}
const fileId = file.id.slice(mapping.PREFIXES.PAGE.length)
const { markdown } = await notionClient.downloadPageAsMarkdown({ pageId: fileId })
const { file: uploadedFile } = await client.uploadFile({
key: fileKey,
content: markdown,
index: shouldIndex,
})
return { botpressFileId: uploadedFile.id }
}
)
@@ -0,0 +1,43 @@
import { RuntimeError } from '@botpress/client'
import { RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints'
import { wrapAction } from '../action-wrapper'
export const getDataSource = wrapAction(
{ actionName: 'getDataSource', errorMessage: 'Failed to fetch data source' },
async ({ notionClient }, { dataSourceId }) => {
const [dataSource, pagesResult] = await Promise.all([
notionClient.getDataSource({ dataSourceId }),
notionClient.enumerateDataSourceChildren({ dataSourceId }),
])
if (!dataSource) {
throw new RuntimeError(`Data source with ID ${dataSourceId} not found`)
}
const pages = pagesResult.results.map((page) => {
let title = ''
if (page.object === 'page' && 'properties' in page) {
const titleProp = Object.values(page.properties).find(
(prop): prop is { type: 'title'; title: RichTextItemResponse[]; id: string } =>
prop !== null && 'type' in prop && prop.type === 'title'
)
if (titleProp && 'title' in titleProp) {
title = titleProp.title.map((t) => t.plain_text).join('')
}
}
return {
id: page.id,
title,
pageProperties: page.properties,
}
})
return {
object: dataSource.object,
properties: dataSource.properties,
pages,
}
}
)
+13
View File
@@ -0,0 +1,13 @@
import { RuntimeError } from '@botpress/sdk'
import { wrapAction } from '../action-wrapper'
export const getDb = wrapAction(
{ actionName: 'getDb', errorMessage: 'Failed to fetch database' },
async ({ notionClient }, { databaseId }) => {
const database = await notionClient.getDatabase({ databaseId })
if (!database) {
throw new RuntimeError(`Database with ID ${databaseId} not found`)
}
return database
}
)
@@ -0,0 +1,8 @@
import { wrapAction } from '../action-wrapper'
export const getPageContent = wrapAction(
{ actionName: 'getPageContent', errorMessage: 'Failed to fetch page content' },
async ({ notionClient }, { pageId }) => {
return await notionClient.getPageContent({ pageId })
}
)
@@ -0,0 +1,9 @@
import { wrapAction } from '../action-wrapper'
export const getPage = wrapAction(
{ actionName: 'getPage', errorMessage: 'Failed to fetch page' },
async ({ notionClient }, { pageId }) => {
const page = await notionClient.getPage({ pageId })
return page ?? {}
}
)
+28
View File
@@ -0,0 +1,28 @@
import { addComment } from './add-comment'
import { appendBlocksToPage } from './append-blocks-to-page'
import { createPage } from './create-page'
import { deleteBlock } from './delete-block'
import { filesReadonlyListItemsInFolder } from './files-readonly/list-items-in-folder'
import { filesReadonlyTransferFileToBotpress } from './files-readonly/transfer-file-to-botpress'
import { getDataSource } from './get-data-source'
import { getDb } from './get-db'
import { getPage } from './get-page'
import { getPageContent } from './get-page-content'
import { searchByTitle } from './search-by-title'
import { updatePageProperties } from './update-page-properties'
import * as bp from '.botpress'
export const actions = {
addComment,
createPage,
appendBlocksToPage,
deleteBlock,
filesReadonlyListItemsInFolder,
filesReadonlyTransferFileToBotpress,
getDb,
getDataSource,
getPage,
getPageContent,
searchByTitle,
updatePageProperties,
} as const satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,8 @@
import { wrapAction } from '../action-wrapper'
export const searchByTitle = wrapAction(
{ actionName: 'searchByTitle', errorMessage: 'Failed to search by title' },
async ({ notionClient }, { title }) => {
return await notionClient.searchByTitle({ title })
}
)
@@ -0,0 +1,39 @@
import { RuntimeError } from '@botpress/client'
import { updatePagePropertiesSchema } from '../../definitions/notion-schemas'
import { wrapAction } from '../action-wrapper'
export const updatePageProperties = wrapAction(
{ actionName: 'updatePageProperties', errorMessage: 'Failed to update page properties' },
async ({ notionClient }, { pageId, propertiesJson }) => {
let parsed: unknown
if (!propertiesJson) {
throw new RuntimeError('propertiesJson is required')
}
try {
parsed = JSON.parse(propertiesJson)
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(`propertiesJson must be valid JSON: ${error.message}`)
}
const updatePagePropertiesResult = updatePagePropertiesSchema.safeParse(parsed)
if (!updatePagePropertiesResult.success) {
throw new RuntimeError(
`propertiesJson must be a valid Notion properties object. Check the Notion API documentation for the correct format. ${updatePagePropertiesResult.error.message}`
)
}
const updatedPage = await notionClient
.updatePageProperties({ pageId, properties: updatePagePropertiesResult.data })
.catch((thrown) => {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(`Failed to update page properties: ${error.message}`)
})
return {
pageId: updatedPage.id,
}
}
)
@@ -0,0 +1,2 @@
export * from './mapping'
export * from './path-utils'
@@ -0,0 +1,127 @@
import * as types from '../notion-api/types'
import * as bp from '.botpress'
export type FilesReadonlyEntity = bp.actions.Actions['filesReadonlyListItemsInFolder']['output']['items'][number]
type FilesReadonlyFile = FilesReadonlyEntity & { type: 'file' }
type FilesReadonlyFolder = FilesReadonlyEntity & { type: 'folder' }
/*
This files handles the mapping of Notion entities to Botpress files-readonly
entities. Since we're mapping Pages and Data sources to Files and Folders, we need
to be careful about the parent-child relationships, since Files cannot contain
other Files or Folders.
From the Notion API documentation:
General parenting rules:
- Pages can be parented by other pages, data sources, blocks, or by the whole workspace.
- Blocks can be parented by pages, data sources, or blocks.
- Data sources can be parented by pages, blocks, or by the whole workspace.
---
To simplify, however, we'll limit ourselves to mapping only Pages and Databases
as follows:
- Pages are mapped to a folder and a file
- The folder has id `pagefolder:${page.id}` and name equal to the page title
- The file has id `page:${page.id}` and name `page.md`
- Databases are mapped to a folder only
- The folder has id `dbfolder:${db.id}` and name equal to the database title
- Blocks are ignored
When we are asked to enumerate a Folder, we simply remove the folder prefix from
the id and use it to call the Notion API to get the children of the page or
database.
When we are asked to transfer a file (page) to Botpress, we fetch the page by
its id, convert it to markdown, and save it to Botpress as `page.md`.
*/
export const PREFIXES = {
PAGE_FOLDER: 'pagefolder:',
DB_FOLDER: 'dbfolder:',
PAGE: 'page:',
DB: 'db:',
} as const
export const PAGE_FILE_NAME = 'page.mdx'
export const mapEntities = (entities: types.NotionItem[]): FilesReadonlyEntity[] => entities.map(_mapEntity)
const _mapEntity = (entity: types.NotionItem): FilesReadonlyEntity => {
if (entity.object === 'page' || (entity.object === 'block' && entity.type === 'child_page')) {
return mapPageToFolder(entity)
} else if (entity.object === 'data_source') {
return mapDataSourceToFolder(entity)
} else if (entity.object === 'block' && entity.type === 'child_database') {
return mapDatabaseToFolder(entity)
}
// Default to data source folder for any other cases
return mapDataSourceToFolder(entity as types.NotionDataSource)
}
export const mapPageToFolder = (page: types.NotionPage | types.NotionChildPage): FilesReadonlyFolder => ({
type: 'folder',
id: PREFIXES.PAGE_FOLDER + page.id,
name: getPageTitle(page),
parentId: _getParentId(page),
})
export const mapPageToFile = (page: types.NotionPage | types.NotionChildPage): FilesReadonlyFile => ({
type: 'file',
id: PREFIXES.PAGE + page.id,
name: PAGE_FILE_NAME,
parentId: PREFIXES.PAGE_FOLDER + page.id,
lastModifiedDate: page.last_edited_time,
})
export const mapDataSourceToFolder = (ds: types.NotionDataSource): FilesReadonlyFolder => ({
type: 'folder',
id: PREFIXES.DB_FOLDER + ds.id,
name: getDataSourceTitle(ds),
parentId: _getParentId(ds),
})
export const mapDatabaseToFolder = (db: types.NotionChildDatabase): FilesReadonlyFolder => ({
type: 'folder',
id: PREFIXES.DB_FOLDER + db.id,
name: getDatabaseTitle(db),
parentId: _getParentId(db),
})
export const getPageTitle = (page: types.NotionPage | types.NotionChildPage): string => {
if (page.object === 'block') {
return page.child_page.title
}
const titleProperty = Object.values(page.properties)
.filter((prop) => prop.type === 'title')
.find((prop) => prop.title[0]?.plain_text)
return titleProperty?.title[0]?.plain_text ?? `Untitled Page (${_uuidToShortId(page.id)})`
}
export const getDataSourceTitle = (ds: types.NotionDataSource): string => {
return ds.title[0]?.plain_text ?? `Untitled Data Source (${_uuidToShortId(ds.id)})`
}
export const getDatabaseTitle = (db: types.NotionChildDatabase): string => {
return db.child_database.title
}
const _uuidToShortId = (uuid: string): string => uuid.replaceAll('-', '')
const _getParentId = ({ parent }: types.NotionItem): string | undefined => {
switch (parent.type) {
case 'page_id':
return PREFIXES.PAGE_FOLDER + parent.page_id
case 'database_id':
return PREFIXES.DB_FOLDER + parent.database_id
case 'data_source_id':
return PREFIXES.DB_FOLDER + parent.data_source_id
case 'block_id':
break
case 'workspace':
break
default:
parent satisfies never
}
return undefined
}
@@ -0,0 +1,42 @@
import { NotionClient } from '../notion-api'
import * as types from '../notion-api/types'
import * as mapping from './mapping'
export const retrieveParentPath = async (
parentObject: types.NotionItem['parent'],
notionClient: NotionClient
): Promise<string> => {
const parentPathFragments: string[] = []
let currentParent = parentObject
while (
currentParent.type === 'database_id' ||
currentParent.type === 'data_source_id' ||
currentParent.type === 'page_id'
) {
if (currentParent.type === 'database_id' || currentParent.type === 'data_source_id') {
const dataSourceId =
currentParent.type === 'database_id' ? currentParent.database_id : currentParent.data_source_id
const ds = await notionClient.getDataSource({ dataSourceId })
if (!ds) {
return '/'
}
parentPathFragments.unshift(mapping.getDataSourceTitle(ds))
currentParent = ds.parent
continue
}
const page = await notionClient.getPage({ pageId: currentParent.page_id })
if (!page) {
return '/'
}
parentPathFragments.unshift(mapping.getPageTitle(page))
currentParent = page.parent
}
return `/${parentPathFragments.join('/')}`
}
+23
View File
@@ -0,0 +1,23 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { actions } from './actions'
import { register, unregister } from './setup'
import { handler } from './webhook-events'
import * as bp from '.botpress'
const integrationConfig: bp.IntegrationProps = {
register,
unregister,
actions,
channels: {},
handler,
}
export default posthogHelper.wrapIntegration(
{
integrationName: INTEGRATION_NAME,
key: bp.secrets.POSTHOG_KEY,
integrationVersion: INTEGRATION_VERSION,
},
integrationConfig
)
@@ -0,0 +1,31 @@
import type { NotionPagePropertyTypes } from '../types'
/**
* the properties with type `null` cannot be updated via the API
*/
export const NOTION_PROPERTY_STRINGIFIED_TYPE_MAP: Record<NotionPagePropertyTypes, string> = {
date: '{start:string;end:string}',
url: 'string',
select: '{name:string}',
phone_number: 'string',
checkbox: '{start:string;end:string}',
files: 'Array<{name:string;external:{url:string}}>',
email: 'string',
number: 'number',
title: 'Array<{type:"text",text:{content:string;link:null}}>',
created_time: '{start:string;end:string}',
last_edited_time: 'null',
last_edited_by: 'null',
rich_text: 'Array<{type:"text",text:{content:string;link:null}}>',
people: 'Array<{object:"user";id:string}>',
relation: 'Array<{id:string}>',
rollup: 'null',
formula: 'null',
multi_select: 'Array<{name:string}>',
created_by: '{start:string;end:string}',
status: '{name:string}',
unique_id: '{start:string;end:string}',
button: 'null',
verification: 'null',
place: 'null',
}
@@ -0,0 +1,8 @@
import { expect, test } from 'vitest'
import { MOCK_RESPONSE_1, MOCK_RESPONSE_1_PROCESSED } from './fixtures/mock-responses'
import { getDbStructure } from './db-structure'
test('getDBStructure', () => {
const structure = getDbStructure(MOCK_RESPONSE_1)
expect(structure).toBe(MOCK_RESPONSE_1_PROCESSED)
})
@@ -0,0 +1,31 @@
import type { GetDataSourceResponse } from '@notionhq/client/build/src/api-endpoints'
import { NOTION_PROPERTY_STRINGIFIED_TYPE_MAP } from './consts'
/**
* @returns a stringified type definition of the database properties
* This can be useful when instructing GPT to parse some data to fit the db model
* which can be then passed as properties to `addPageToDb`
*
* These are based on the [Notion Page Properties](https://developers.notion.com/reference/page-property-values)
*/
export function getDbStructure(response: GetDataSourceResponse): string {
// Ensure we have a full response with properties
if (!('properties' in response)) {
return '{}'
}
const properties = Object.entries(response.properties)
const stringifiedTypes: string = properties.reduce((_stringifiedTypes, [key, value], index) => {
_stringifiedTypes += `${key}:{type:"${value.type}";"${value.type}":${
NOTION_PROPERTY_STRINGIFIED_TYPE_MAP[value.type]
}}`
if (index === properties.length - 1) {
_stringifiedTypes += '}'
} else {
_stringifiedTypes += ','
}
return _stringifiedTypes
}, '{')
return stringifiedTypes
}
@@ -0,0 +1,169 @@
import { GetDataSourceResponse } from '@notionhq/client/build/src/api-endpoints'
import { NOTION_PROPERTY_STRINGIFIED_TYPE_MAP } from '../consts'
export const MOCK_RESPONSE_1: GetDataSourceResponse = {
object: 'data_source',
id: 'e819c5b1-77f8-4a7d-953c-3dc9e9c46037',
cover: {
type: 'external',
external: {
url: 'https://images.unsplash.com/photo-1546177461-79dfec0b0928?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1974&q=80',
},
},
icon: { type: 'external', external: { url: 'https://www.notion.so/icons/book-closed_lightgray.svg' } },
created_time: '2023-07-06T21:38:00.000Z',
created_by: { object: 'user', id: '2b76f457-7e30-4f66-8e74-da55469e64c8' },
last_edited_by: { object: 'user', id: '2b76f457-7e30-4f66-8e74-da55469e64c8' },
last_edited_time: '2023-07-07T00:30:00.000Z',
title: [
{
type: 'text',
text: { content: 'Reading List', link: null },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default',
},
plain_text: 'Reading List',
href: null,
},
],
description: [
{
type: 'text',
text: {
content:
"📚 The modern day reading list includes more than just books. We've created a dashboard to help you track books, articles, podcasts, and videos. Each media type has its own view based on the Type property. \n\n✂️ One more thing... if you install the ",
link: null,
},
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default',
},
plain_text:
"📚 The modern day reading list includes more than just books. We've created a dashboard to help you track books, articles, podcasts, and videos. Each media type has its own view based on the Type property. \n\n✂️ One more thing... if you install the ",
href: null,
},
{
type: 'text',
text: { content: 'Notion Web Clipper', link: { url: 'https://www.notion.so/web-clipper' } },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default',
},
plain_text: 'Notion Web Clipper',
href: 'https://www.notion.so/web-clipper',
},
{
type: 'text',
text: {
content:
', you can save links off the web directly to this table.\n\n👇 Click through the different database tabs to see other views. Sort content by status, author, type, or publisher.',
link: null,
},
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default',
},
plain_text:
', you can save links off the web directly to this table.\n\n👇 Click through the different database tabs to see other views. Sort content by status, author, type, or publisher.',
href: null,
},
],
is_inline: false,
properties: {
Score: {
id: ')Y7%22',
name: 'Score',
description: '',
type: 'select',
select: {
options: [
{ id: '5c944de7-3f4b-4567-b3a1-fa2c71c540b6', name: '⭐️⭐️⭐️⭐️⭐️', color: 'default', description: '' },
{ id: 'b7307e35-c80a-4cb5-bb6b-6054523b394a', name: '⭐️⭐️⭐️⭐️', color: 'default', description: '' },
{ id: '9b1e1349-8e24-40ba-bbca-84a61296bc81', name: '⭐️⭐️⭐️', color: 'default', description: '' },
{ id: '66d3d050-086c-4a91-8c56-d55dc67e7789', name: '⭐️⭐️', color: 'default', description: '' },
{ id: 'd3782c76-0396-467f-928e-46bf0c9d5fba', name: '⭐️', color: 'default', description: '' },
{ id: 'f8966551-1d96-4106-b0c5-7ca459029bab', name: 'TBD', color: 'default', description: '' },
],
},
},
Type: {
id: '%2F7eo',
name: 'Type',
description: '',
type: 'select',
select: {
options: [
{ id: '42a0b2e8-c8da-4e5d-a2f2-5ccba15d1034', name: 'Book', color: 'default', description: '' },
{ id: 'f96d0d0a-5564-4a20-ab15-5f040d49759e', name: 'Article', color: 'default', description: '' },
{ id: '4ac85597-5db1-4e0a-9c02-445575c38f76', name: 'TV Series', color: 'default', description: '' },
{ id: '2991748a-5745-4c3b-9c9b-2d6846a6fa1f', name: 'Film', color: 'default', description: '' },
{ id: '82f3bace-be25-410d-87fe-561c9c22492f', name: 'Podcast', color: 'default', description: '' },
{ id: '861f1076-1cc4-429a-a781-54947d727a4a', name: 'Academic Journal', color: 'default', description: '' },
{ id: '9cc30548-59d6-4cd3-94bc-d234081525c4', name: 'Essay Resource', color: 'default', description: '' },
],
},
},
Status: {
id: 'UMCM',
name: 'Status',
description: '',
type: 'status',
status: {
options: [
{ id: '387caf66-e381-4bfa-bddc-ef3c33b1670e', name: 'Not started', color: 'red', description: '' },
{ id: '2bbcb9a8-1df1-47b6-a6af-2dafbf814871', name: 'In progress', color: 'blue', description: '' },
{ id: 'aac65976-840a-47bd-8f97-782176d2d4f2', name: 'Done', color: 'green', description: '' },
],
groups: [
{
id: '7c521ba2-0e95-4bc2-95f6-48f6e44f1dc7',
name: 'To-do',
color: 'gray',
option_ids: ['387caf66-e381-4bfa-bddc-ef3c33b1670e'],
},
{
id: '4f3189a4-747d-4398-95ee-8b7c111a6f7a',
name: 'In progress',
color: 'blue',
option_ids: ['2bbcb9a8-1df1-47b6-a6af-2dafbf814871'],
},
{
id: 'b1ae6339-5453-45aa-8e00-b6e1b34e9064',
name: 'Complete',
color: 'green',
option_ids: ['aac65976-840a-47bd-8f97-782176d2d4f2'],
},
],
},
},
Link: { id: 'VVMi', name: 'Link', type: 'url', url: {}, description: '' },
Completed: { id: 'qGBj', name: 'Completed', type: 'date', date: {}, description: '' },
Author: { id: 'qNw_', name: 'Author', type: 'rich_text', rich_text: {}, description: '' },
Name: { id: 'title', name: 'Name', type: 'title', title: {}, description: '' },
},
parent: { type: 'data_source_id', data_source_id: 'parent-ds-id', database_id: 'parent-db-id' },
database_parent: { type: 'workspace', workspace: true },
in_trash: false,
url: 'https://www.notion.so/e819c5b177f84a7d953c3dc9e9c46037',
public_url: null,
archived: false,
}
export const MOCK_RESPONSE_1_PROCESSED = `{Score:{type:"select";"select":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.select}},Type:{type:"select";"select":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.select}},Status:{type:"status";"status":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.status}},Link:{type:"url";"url":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.url}},Completed:{type:"date";"date":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.date}},Author:{type:"rich_text";"rich_text":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.rich_text}},Name:{type:"title";"title":${NOTION_PROPERTY_STRINGIFIED_TYPE_MAP.title}}}`
@@ -0,0 +1 @@
export * from './db-structure'
@@ -0,0 +1,8 @@
import {
createAsyncFnWrapperWithErrorRedaction,
createErrorHandlingDecorator,
defaultErrorRedactor,
} from '@botpress/common'
export const wrapAsyncFnWithTryCatch = createAsyncFnWrapperWithErrorRedaction(defaultErrorRedactor)
export const handleErrorsDecorator = createErrorHandlingDecorator(wrapAsyncFnWithTryCatch)
@@ -0,0 +1,2 @@
export * from './notion-client'
export * from './error-handling'
@@ -0,0 +1,399 @@
import * as notionhq from '@notionhq/client'
import {
BlockObjectRequest,
BlockObjectResponse,
DataSourceObjectResponse,
PartialDataSourceObjectResponse,
PartialPageObjectResponse,
RichTextItemResponse,
UpdatePageParameters,
CreatePageParameters,
CreateCommentParameters,
} from '@notionhq/client/build/src/api-endpoints'
import { getDbStructure } from './db-structure'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { NotionOAuthClient } from './notion-oauth-client'
import { NotionToMdxClient } from './notion-to-mdx-client'
import type * as types from './types'
import * as bp from '.botpress'
export class NotionClient {
private readonly _notion: notionhq.Client
private readonly _notionToMdxClient: NotionToMdxClient
private constructor(credentials: { accessToken: string }) {
this._notion = new notionhq.Client({
auth: credentials.accessToken,
})
this._notionToMdxClient = new NotionToMdxClient(this._notion)
}
public static async create({ ctx, client }: { client: bp.Client; ctx: bp.Context }): Promise<NotionClient> {
const accessToken = await NotionClient._getAccessToken({ ctx, client })
return new NotionClient({
accessToken,
})
}
public static async processAuthorizationCode(
props: { client: bp.Client; ctx: bp.Context },
authorizationCode: string
): Promise<{ workspaceId: string }> {
const oauthClient = new NotionOAuthClient(props)
return await oauthClient.processAuthorizationCode(authorizationCode)
}
private static async _getAccessToken({ ctx, client }: { client: bp.Client; ctx: bp.Context }): Promise<string> {
if (ctx.configurationType === 'customApp') {
return ctx.configuration.internalIntegrationSecret
}
const oauthClient = new NotionOAuthClient({ ctx, client })
const { accessToken } = await oauthClient.getNewAccessToken()
return accessToken
}
@handleErrors('Authentication failed. Please reconfigure the integration.')
public async testAuthentication(): Promise<void> {
void (await this._notion.users.me({}))
}
@handleErrors('Failed to add page to database')
public async addPageToDb({
databaseId,
properties,
}: {
databaseId: string
properties: Record<types.NotionPagePropertyTypes, any>
}): Promise<{ pageId: string }> {
const response = await this._notion.pages.create({
parent: { database_id: databaseId },
properties,
})
return { pageId: response.id }
}
@handleErrors('Failed to create page')
public async createPage({
parentType,
parentId,
title,
dataSourceTitleName,
}: {
parentType: string
parentId: string
title: string
dataSourceTitleName: string
}): Promise<{ pageId: string }> {
let parent: CreatePageParameters['parent']
let properties: CreatePageParameters['properties']
if (parentType === 'data source') {
const dataSource = await this._notion.dataSources.retrieve({ data_source_id: parentId })
if (!dataSource.properties[dataSourceTitleName]) {
throw new Error(`Title property "${dataSourceTitleName}" not found in data source properties`)
}
parent = { data_source_id: parentId, type: 'data_source_id' }
properties = { [dataSourceTitleName]: { title: [{ text: { content: title } }] } }
} else {
parent = { page_id: parentId, type: 'page_id' }
properties = { title: { title: [{ text: { content: title } }] } }
}
const response = await this._notion.pages.create({ parent, properties })
return { pageId: response.id }
}
@handleErrors('Failed to add comment')
public async addComment({
parentType,
parentId,
commentBody,
}: {
parentType: string
parentId: string
commentBody: string
}): Promise<{ commentId: string; discussionId?: string }> {
let body: CreateCommentParameters
if (parentType === 'page') {
body = {
parent: { page_id: parentId, type: 'page_id' },
rich_text: [{ type: 'text', text: { content: commentBody } }],
}
} else if (parentType === 'block') {
body = {
parent: { block_id: parentId, type: 'block_id' },
rich_text: [{ type: 'text', text: { content: commentBody } }],
}
} else if (parentType === 'discussion') {
body = { discussion_id: parentId, rich_text: [{ type: 'text', text: { content: commentBody } }] }
} else {
throw new Error(`Invalid parent type: ${parentType}`)
}
const response = await this._notion.comments.create(body)
return { commentId: response.id, discussionId: 'discussion_id' in response ? response.discussion_id : undefined }
}
@handleErrors('Failed to update page properties')
public async updatePageProperties({
pageId,
properties,
}: {
pageId: string
properties: UpdatePageParameters['properties']
}) {
return await this._notion.pages.update({
page_id: pageId,
properties,
})
}
@handleErrors('Failed to delete block')
public async deleteBlock({ blockId }: { blockId: string }): Promise<{ blockId: string }> {
const response = await this._notion.blocks.delete({ block_id: blockId })
return { blockId: response.id }
}
@handleErrors('Failed to append block to page')
public async appendBlocksToPage({
pageId,
blocks,
}: {
pageId: string
blocks: BlockObjectRequest[]
}): Promise<{ pageId: string; blockIds: string[] }> {
const response = await this._notion.blocks.children.append({
block_id: pageId,
children: blocks,
})
return {
pageId,
blockIds: response.results.map((block) => block.id),
}
}
@handleErrors('Failed to search by title')
public async searchByTitle({ title }: { title?: string }) {
const [response, dataSourceResponse] = await Promise.all([
this._notion.search({
query: title,
filter: { property: 'object', value: 'page' },
}),
this._notion.search({
query: title,
filter: { property: 'object', value: 'data_source' },
}),
])
const allResults = [...response.results, ...dataSourceResponse.results]
const formattedResults = this._formatSearchResults(allResults)
return { results: formattedResults }
}
@handleErrors('Failed to get database')
public async getDbWithStructure({ databaseId }: { databaseId: string }) {
const response = await this._notion.dataSources.retrieve({ data_source_id: databaseId })
// TODO: do not return the raw response; perform mapping
return { ...response, structure: getDbStructure(response) }
}
@handleErrors('Failed to enumerate items')
public async enumerateTopLevelItems({ nextToken }: { nextToken?: string }) {
const { next_cursor, results } = await this._notion.search({ start_cursor: nextToken })
// Discard partial or nested results:
const filteredResults = results.filter(
(res) => 'parent' in res && res.parent.type === 'workspace' && !res.in_trash
) as types.NotionTopLevelItem[]
return {
results: filteredResults,
nextToken: next_cursor ?? undefined,
}
}
@handleErrors('Failed to enumerate page children')
public async enumeratePageChildren({ pageId, nextToken }: { pageId: string; nextToken?: string }) {
const { next_cursor, results } = await this._notion.blocks.children.list({
block_id: pageId,
start_cursor: nextToken,
})
const filteredResults = results.filter(
(res) => 'parent' in res && !res.in_trash && ['child_page', 'child_database'].includes(res.type)
) as types.NotionPageChild[]
return {
results: filteredResults,
nextToken: next_cursor ?? undefined,
}
}
@handleErrors('Failed to get data source')
public async getDataSource({ dataSourceId }: { dataSourceId: string }) {
const ds = await this._notion.dataSources.retrieve({ data_source_id: dataSourceId })
return 'parent' in ds && 'created_time' in ds ? ds : undefined
}
@handleErrors('Failed to retrieve page')
public async getPage({ pageId }: { pageId: string }) {
const page = await this._notion.pages.retrieve({ page_id: pageId })
return 'parent' in page && 'created_time' in page ? page : undefined
}
@handleErrors('Failed to get page content')
public async getPageContent({ pageId }: { pageId: string }) {
const blocks: types.BlockContent[] = []
let nextCursor: string | undefined
do {
const response = await this._notion.blocks.children.list({
block_id: pageId,
start_cursor: nextCursor,
})
for (const block of response.results) {
if (!this._isBlockObjectResponse(block)) {
continue
}
const blockType = block.type
const richText = this._extractRichTextFromBlockSwitch(block)
let parentId: string | undefined
if (block.parent.type === 'page_id') {
parentId = block.parent.page_id
} else if (block.parent.type === 'block_id') {
parentId = block.parent.block_id
} else {
parentId = undefined
}
blocks.push({
blockId: block.id,
parentId,
type: blockType,
hasChildren: block.has_children,
richText,
})
}
nextCursor = response.next_cursor ?? undefined
} while (nextCursor)
return { blocks }
}
@handleErrors('Failed to get database')
public async getDatabase({ databaseId }: { databaseId: string }) {
const db = await this._notion.databases.retrieve({ database_id: databaseId })
return 'parent' in db && 'created_time' in db
? {
object: db.object,
dataSources: db.data_sources,
}
: undefined
}
@handleErrors('Failed to enumerate data source children')
public async enumerateDataSourceChildren({ dataSourceId, nextToken }: { dataSourceId: string; nextToken?: string }) {
const { next_cursor, results } = await this._notion.dataSources.query({
data_source_id: dataSourceId,
in_trash: false,
start_cursor: nextToken,
})
const filteredResults = results.filter(
(res): res is types.NotionDataSourceChild => 'parent' in res && !res.in_trash
)
return {
results: filteredResults,
nextToken: next_cursor ?? undefined,
}
}
@handleErrors('Failed to download page as markdown')
public async downloadPageAsMarkdown({ pageId }: { pageId: string }): Promise<{ markdown: string }> {
const markdown = await this._notionToMdxClient.convertNotionPageToMarkdown({ pageId })
return { markdown }
}
private _formatSearchResults(
results: (PartialPageObjectResponse | PartialDataSourceObjectResponse | DataSourceObjectResponse)[]
) {
return results
.filter(
(result): result is types.NotionTopLevelItem => 'parent' in result && !('archived' in result && result.archived)
)
.map((result) => {
let resultTitle = ''
let resultUrl = ''
if (result.object === 'page' && 'properties' in result) {
const titleProp = Object.values(result.properties).find(
(prop): prop is { type: 'title'; title: RichTextItemResponse[]; id: string } =>
typeof prop === 'object' && prop !== null && 'type' in prop && prop.type === 'title'
)
if (titleProp) {
resultTitle = titleProp.title.map((t) => t.plain_text).join('')
}
if ('url' in result) {
resultUrl = result.url
}
} else if (result.object === 'data_source' && 'title' in result && Array.isArray(result.title)) {
resultTitle = result.title.map((t) => t.plain_text).join('')
if ('url' in result) {
resultUrl = result.url
}
}
return {
id: result.id,
title: resultTitle,
type: result.object,
url: resultUrl,
}
})
}
private _isBlockObjectResponse(block: unknown): block is BlockObjectResponse {
return typeof block === 'object' && block !== null && 'type' in block && typeof block.type === 'string'
}
private _extractRichTextFromBlockSwitch(block: BlockObjectResponse): RichTextItemResponse[] {
switch (block.type) {
case 'paragraph':
return block[block.type].rich_text
case 'heading_1':
return block[block.type].rich_text
case 'heading_2':
return block[block.type].rich_text
case 'heading_3':
return block[block.type].rich_text
case 'bulleted_list_item':
return block[block.type].rich_text
case 'numbered_list_item':
return block[block.type].rich_text
case 'to_do':
return block[block.type].rich_text
case 'toggle':
return block[block.type].rich_text
case 'quote':
return block[block.type].rich_text
case 'callout':
return block[block.type].rich_text
case 'code':
return block[block.type].rich_text
default:
return []
}
}
}
@@ -0,0 +1,62 @@
import { Client as NotionHQClient } from '@notionhq/client'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import * as bp from '.botpress'
const REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
export class NotionOAuthClient {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
private readonly _notion: NotionHQClient
public constructor({ ctx, client }: { client: bp.Client; ctx: bp.Context }) {
this._client = client
this._ctx = ctx
this._notion = new NotionHQClient({})
}
public async getNewAccessToken() {
const { authToken } = await this._getAuthState()
return { accessToken: authToken }
}
@handleErrors('Failed to exchange authorization code. Please reconfigure the integration.')
public async processAuthorizationCode(authorizationCode: string): Promise<{ workspaceId: string }> {
const result = await this._exchangeAuthorizationCodeForAccessToken(authorizationCode)
await this._client.setState({
id: this._ctx.integrationId,
type: 'integration',
name: 'oauth',
payload: {
authToken: result.access_token,
},
})
return { workspaceId: result.workspace_id }
}
private async _exchangeAuthorizationCodeForAccessToken(authorizationCode: string) {
const response = await this._notion.oauth.token({
client_id: bp.secrets.CLIENT_ID,
client_secret: bp.secrets.CLIENT_SECRET,
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
})
return response
}
private async _getAuthState(): Promise<bp.states.oauth.Oauth['payload']> {
const { state } = await this._client.getState({
id: this._ctx.integrationId,
type: 'integration',
name: 'oauth',
})
return state.payload
}
}
@@ -0,0 +1,48 @@
import * as notionhq from '@notionhq/client'
import * as notionToMd from 'notion-to-md'
import * as notionToMdExporter from 'notion-to-md/plugins/exporter'
import * as notionToMdRenderer from 'notion-to-md/plugins/renderer'
import { handleErrorsDecorator as handleErrors } from './error-handling'
export class NotionToMdxClient {
private _notionToMdConverter?: notionToMd.NotionConverter
private _notionToMdBuffer: Record<string, string> = {}
public constructor(private _notion: notionhq.Client) {}
@handleErrors('Failed to convert Notion page to MDX')
public async convertNotionPageToMarkdown({ pageId }: { pageId: string }): Promise<string> {
const converter = this._getNotionToMdConverter()
await converter.convert(pageId)
return this._popPageFromBuffer({ pageId })
}
private _getNotionToMdConverter() {
if (!this._notionToMdConverter) {
this._notionToMdConverter = new notionToMd.NotionConverter(this._notion)
.withExporter(
new notionToMdExporter.DefaultExporter({
outputType: 'buffer',
buffer: this._notionToMdBuffer,
})
)
.withRenderer(
new notionToMdRenderer.MDXRenderer({
frontmatter: true,
})
)
.configureFetcher({
fetchPageProperties: true,
})
}
return this._notionToMdConverter
}
private _popPageFromBuffer({ pageId }: { pageId: string }) {
const markdown = Reflect.get(this._notionToMdBuffer, pageId)
void Reflect.deleteProperty(this._notionToMdBuffer, pageId)
return markdown
}
}
@@ -0,0 +1,35 @@
import type * as notionhq from '@notionhq/client'
import type { PageObjectResponse, RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints'
export type NotionPagePropertyTypes = Valueof<PageObjectResponse['properties']>['type']
export type BlockContent = {
blockId: string
parentId: string | undefined
type: string
hasChildren: boolean
richText: RichTextItemResponse[]
}
export type NotionTopLevelItem = Extract<
Awaited<ReturnType<notionhq.Client['search']>>['results'][number],
{ parent: any }
>
export type NotionPageChild = Extract<
Awaited<ReturnType<notionhq.Client['blocks']['children']['list']>>['results'][number],
{ parent: any; type: 'child_page' | 'child_database' }
>
export type NotionDataSourceChild = Extract<
Awaited<ReturnType<notionhq.Client['dataSources']['query']>>['results'][number],
{ parent: any }
>
export type NotionItem = NotionTopLevelItem | NotionPageChild | NotionDataSourceChild
export type NotionPage = Extract<NotionItem, { object: 'page' }>
export type NotionDataSource = Extract<NotionItem, { object: 'data_source' }>
export type NotionChildPage = Extract<NotionItem, { object: 'block'; type: 'child_page' }>
export type NotionChildDatabase = Extract<NotionItem, { object: 'block'; type: 'child_database' }>
type Valueof<T> = T[Extract<keyof T, string>]
+20
View File
@@ -0,0 +1,20 @@
import { RuntimeError } from '@botpress/sdk'
import { NotionClient } from './notion-api'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async (props) => {
try {
const notionClient = await NotionClient.create(props)
await notionClient.testAuthentication()
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(`Registering Notion integration failed: ${error.message}`)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async (props) => {
const { client } = props
await client.configureIntegration({
identifier: null,
})
}
@@ -0,0 +1,78 @@
import * as sdk from '@botpress/sdk'
import * as crypto from 'crypto'
import * as handlers from './handlers'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
props.logger.forBot().debug('Received webhook event: ' + props.req.body)
if (handlers.isWebhookVerificationRequest(props)) {
return await handlers.handleWebhookVerificationRequest(props)
} else if (handlers.isOAuthCallback(props)) {
return await handlers.handleOAuthCallback(props)
}
_validatePayloadSignature(props)
try {
if (handlers.isDatabaseDeletedEvent(props)) {
return await handlers.handleDatabaseDeletedEvent(props)
} else if (handlers.isPageCreatedEvent(props)) {
return await handlers.handlePageCreatedEvent(props)
} else if (handlers.isPageDeletedEvent(props)) {
return await handlers.handlePageDeletedEvent(props)
} else if (handlers.isPageMovedEvent(props)) {
return await handlers.handlePageMovedEvent(props)
} else if (handlers.isCommentCreatedEvent(props)) {
return await handlers.handleCommentCreatedEvent(props)
}
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
props.logger.forBot().error(`Handling webhook event failed: ${error.message}`)
return { status: 200 }
}
props.logger.forBot().info('Unsupported webhook event received')
return { status: 200 }
}
const _validatePayloadSignature = (props: bp.HandlerProps) => {
const rawSignatureHeader = props.req.headers['X-Notion-Signature'] ?? props.req.headers['x-notion-signature']
if (!rawSignatureHeader) {
throw new sdk.RuntimeError('Missing Notion signature in request headers')
}
// Notion signature may be prefixed with "v1=" - extract just the hash part
const bodySignatureFromNotion = rawSignatureHeader.includes('=')
? rawSignatureHeader.split('=')[1]
: rawSignatureHeader
let bodySignatureFromBotpress: string
if (props.ctx.configurationType === 'customApp') {
if (!props.ctx.configuration.webhookVerificationSecret) {
throw new sdk.RuntimeError('Webhook verification secret is not set in the integration configuration')
}
bodySignatureFromBotpress = crypto
.createHmac('sha256', props.ctx.configuration.webhookVerificationSecret)
.update(props.req.body ?? '')
.digest('hex')
} else {
bodySignatureFromBotpress = crypto
.createHmac('sha256', bp.secrets.WEBHOOK_VERIFICATION_SECRET)
.update(props.req.body ?? '')
.digest('hex')
}
const notionSignatureBuffer = Buffer.from(bodySignatureFromNotion ?? '')
const expectedSignatureBuffer = Buffer.from(bodySignatureFromBotpress)
const payloadSignatureMatchesExpectedSignature = crypto.timingSafeEqual(
notionSignatureBuffer,
expectedSignatureBuffer
)
if (!payloadSignatureMatchesExpectedSignature) {
throw new sdk.RuntimeError('Notion signature does not match the expected signature')
}
}
@@ -0,0 +1,29 @@
import { events } from 'definitions/events'
import * as bp from '.botpress'
const commentCreatedPayloadSchema = events.commentCreated.schema
export const isCommentCreatedEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
commentCreatedPayloadSchema.safeParse(JSON.parse(props.req.body)).success
)
export const handleCommentCreatedEvent: bp.IntegrationProps['handler'] = async (props) => {
const { logger, client, req } = props
const payload = commentCreatedPayloadSchema.parse(JSON.parse(req.body!))
logger.forBot().debug('Creating comment created event: ' + JSON.stringify(payload))
try {
await client.createEvent({
type: 'commentCreated',
payload,
})
logger.forBot().debug('Successfully created comment created event')
} catch (error) {
logger.forBot().error('Failed to create comment event: ' + (error instanceof Error ? error.message : String(error)))
throw error
}
}
@@ -0,0 +1,46 @@
import * as sdk from '@botpress/sdk'
import { BASE_EVENT_PAYLOAD } from 'definitions/events'
import * as filesReadonly from '../../files-readonly'
import { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = BASE_EVENT_PAYLOAD.extend({
type: sdk.z.literal('database.deleted'),
entity: BASE_EVENT_PAYLOAD.shape.entity.extend({
type: sdk.z.literal('database'),
}),
})
export const isDatabaseDeletedEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handleDatabaseDeletedEvent: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
const notionClient = await NotionClient.create(props)
// Try to get as data source first (new API)
const deletedDataSource = await notionClient.getDataSource({ dataSourceId: payload.entity.id })
if (!deletedDataSource) {
console.debug(`Notion data source ${payload.entity.id} not found. Ignoring database.deleted event.`)
return
}
const dataSourceName = filesReadonly.getDataSourceTitle(deletedDataSource)
const parentPath = await filesReadonly.retrieveParentPath(deletedDataSource.parent, notionClient)
const absolutePath = `/${parentPath}/${dataSourceName}`
await props.client.createEvent({
type: 'folderDeletedRecursive',
payload: {
folder: {
...filesReadonly.mapDataSourceToFolder(deletedDataSource),
absolutePath,
},
},
})
}
@@ -0,0 +1,7 @@
export * from './database-deleted'
export * from './oauth-callback'
export * from './page-created'
export * from './page-deleted'
export * from './page-moved'
export * from './webhook-verification'
export * from './comment-created'
@@ -0,0 +1,33 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
export const isOAuthCallback = (props: bp.HandlerProps): boolean => props.req.path.startsWith('/oauth')
export const handleOAuthCallback: bp.IntegrationProps['handler'] = async ({ client, ctx, req, logger }) => {
try {
const searchParams = new URLSearchParams(req.query)
const error = searchParams.get('error')
if (error) {
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
}
const authorizationCode = searchParams.get('code')
if (!authorizationCode) {
throw new Error('Authorization code not present in OAuth callback')
}
const { workspaceId } = await NotionClient.processAuthorizationCode({ client, ctx }, authorizationCode)
await client.configureIntegration({
identifier: workspaceId,
})
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
@@ -0,0 +1,45 @@
import * as sdk from '@botpress/sdk'
import { BASE_EVENT_PAYLOAD } from 'definitions/events'
import * as filesReadonly from '../../files-readonly'
import { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = BASE_EVENT_PAYLOAD.extend({
type: sdk.z.enum(['page.created', 'page.undeleted']),
entity: BASE_EVENT_PAYLOAD.shape.entity.extend({
type: sdk.z.literal('page'),
}),
})
export const isPageCreatedEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handlePageCreatedEvent: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
const notionClient = await NotionClient.create(props)
const createdPage = await notionClient.getPage({ pageId: payload.entity.id })
if (!createdPage) {
console.debug(`Notion page ${payload.entity.id} not found. Ignoring page.created event.`)
return
}
const pageName = filesReadonly.getPageTitle(createdPage)
const parentPath = await filesReadonly.retrieveParentPath(createdPage.parent, notionClient)
const absolutePath = `/${parentPath}/${pageName}/${filesReadonly.PAGE_FILE_NAME}`
await props.client.createEvent({
type: 'fileCreated',
payload: {
file: {
...filesReadonly.mapPageToFile(createdPage),
absolutePath,
},
},
})
}
@@ -0,0 +1,45 @@
import * as sdk from '@botpress/sdk'
import { BASE_EVENT_PAYLOAD } from 'definitions/events'
import * as filesReadonly from '../../files-readonly'
import { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = BASE_EVENT_PAYLOAD.extend({
type: sdk.z.literal('page.deleted'),
entity: BASE_EVENT_PAYLOAD.shape.entity.extend({
type: sdk.z.literal('page'),
}),
})
export const isPageDeletedEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handlePageDeletedEvent: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
const notionClient = await NotionClient.create(props)
const deletedPage = await notionClient.getPage({ pageId: payload.entity.id })
if (!deletedPage) {
console.debug(`Notion page ${payload.entity.id} not found. Ignoring page.deleted event.`)
return
}
const pageName = filesReadonly.getPageTitle(deletedPage)
const parentPath = await filesReadonly.retrieveParentPath(deletedPage.parent, notionClient)
const absolutePath = `/${parentPath}/${pageName}`
await props.client.createEvent({
type: 'folderDeletedRecursive',
payload: {
folder: {
...filesReadonly.mapPageToFolder(deletedPage),
absolutePath,
},
},
})
}
@@ -0,0 +1,45 @@
import * as sdk from '@botpress/sdk'
import { BASE_EVENT_PAYLOAD } from 'definitions/events'
import * as filesReadonly from '../../files-readonly'
import { NotionClient } from '../../notion-api'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = BASE_EVENT_PAYLOAD.extend({
type: sdk.z.literal('page.moved'),
entity: BASE_EVENT_PAYLOAD.shape.entity.extend({
type: sdk.z.literal('page'),
}),
})
export const isPageMovedEvent = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handlePageMovedEvent: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
const notionClient = await NotionClient.create(props)
const movedPage = await notionClient.getPage({ pageId: payload.entity.id })
if (!movedPage) {
console.debug(`Notion page ${payload.entity.id} not found. Ignoring page.moved event.`)
return
}
const pageName = filesReadonly.getPageTitle(movedPage)
const parentPath = await filesReadonly.retrieveParentPath(movedPage.parent, notionClient)
const absolutePath = `/${parentPath}/${pageName}`
await props.client.createEvent({
type: 'fileUpdated',
payload: {
file: {
...filesReadonly.mapPageToFile(movedPage),
absolutePath: `${absolutePath}/${filesReadonly.PAGE_FILE_NAME}`,
},
},
})
}
@@ -0,0 +1,30 @@
import * as sdk from '@botpress/sdk'
import * as bp from '.botpress'
const NOTIFICATION_PAYLOAD = sdk.z.object({
verification_token: sdk.z.string(),
})
export const isWebhookVerificationRequest = (props: bp.HandlerProps): boolean =>
Boolean(
props.req.method.toUpperCase() === 'POST' &&
props.req.body?.length &&
NOTIFICATION_PAYLOAD.safeParse(JSON.parse(props.req.body)).success
)
export const handleWebhookVerificationRequest: bp.IntegrationProps['handler'] = async (props) => {
const payload: sdk.z.infer<typeof NOTIFICATION_PAYLOAD> = JSON.parse(props.req.body!)
if (!payload.verification_token) {
return
}
console.debug('Webhook verification token received', {
verificationToken: payload.verification_token,
botId: props.ctx.botId,
integrationId: props.ctx.integrationId,
webhookId: props.ctx.webhookId,
})
props.logger.forBot().info('Webhook verification token received', { verificationToken: payload.verification_token })
}
@@ -0,0 +1 @@
export * from './handler-dispatcher'
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"noErrorTruncation": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config