chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,6 @@
import * as sdk from '@botpress/sdk'
import { addToSync } from './sync'
export const actions = {
addToSync,
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,14 @@
import { z } from '@botpress/sdk'
export const addToSync = {
title: 'Add Libraries to Sync',
description: 'Register additional SharePoint document libraries for real-time sync without re-deploying.',
input: {
schema: z.object({
documentLibraryNames: z.string().array().min(1).title('Document Library Names').describe('Libraries to add.'),
}),
},
output: {
schema: z.object({}),
},
}
@@ -0,0 +1,24 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
export const configuration = {
schema: z.object({
clientId: z.string().min(1).title('Client ID').describe('Azure AD application client ID'),
tenantId: z.string().min(1).title('Tenant ID').describe('Azure AD tenant ID'),
thumbprint: z.string().min(1).title('Certificate Thumbprint').describe('Certificate thumbprint'),
privateKey: z.string().min(1).title('Private Key').describe('PEM-formatted certificate private key'),
primaryDomain: z
.string()
.min(1)
.title('Primary Domain')
.describe('SharePoint primary domain (e.g. contoso, without .sharepoint.com)'),
siteName: z.string().min(1).title('Site Name').describe('SharePoint site name'),
documentLibraryNames: z
.string()
.array()
.optional()
.title('Document Library Names')
.describe(
'Document libraries to subscribe to for real-time sync. Not needed for knowledge-connector browsing only.'
),
}),
} satisfies IntegrationDefinitionProps['configuration']
@@ -0,0 +1,3 @@
export { configuration } from './configuration'
export { states } from './states'
export { actions } from './actions'
@@ -0,0 +1,27 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
export const states = {
configuration: {
type: 'integration',
schema: z.object({
subscriptions: z
.record(
z.object({
webhookSubscriptionId: z.string().min(1),
changeToken: z.string().min(1),
itemPathCache: z
.record(
z.object({
absolutePath: z.string(),
name: z.string(),
})
)
.default({}),
expiresAt: z.string().optional().describe('ISO 8601 expiry date of the SharePoint webhook subscription.'),
})
)
.title('Webhook Subscriptions')
.describe('Active SharePoint webhook subscriptions keyed by document library name.'),
}),
},
} satisfies IntegrationDefinitionProps['states']
+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,
},
},
},
]
+62
View File
@@ -0,0 +1,62 @@
# SharePoint
Connect one or many SharePoint document libraries to Botpress. Supports both real-time webhook sync to knowledge bases and file browsing via the knowledge-connector plugin.
> **Webhook expiry:** SharePoint webhook subscriptions expire after **30 days**. The integration automatically renews them on incoming notifications before they expire.
## Knowledge-Connector
This integration is compatible with the Botpress knowledge-connector plugin. Once configured, you can browse SharePoint document libraries directly from the knowledge base UI and select files to index.
## Actions
### Add To Sync
Dynamically add new document libraries to your sync configuration without re-deploying.
**Input:**
- `documentLibraryNames` — Array of library names to add.
## Setup
### 1. Register an app in Microsoft Entra
1. Open **App registrations** in the Microsoft Entra admin center.
2. Click **New registration**, give it a name, click **Register**.
3. Note the **Application (client) ID** and **Directory (tenant) ID**.
### 2. Create a self-signed certificate
```bash
# Generate PKCS#8 private key and self-signed certificate in one step
openssl req -x509 -newkey rsa:2048 -keyout myPrivateKey.key \
-out myCertificate.crt -days 365 -nodes \
-subj "/CN=BotpressSharePoint"
```
The `myPrivateKey.key` file contains your private key (starts with `-----BEGIN PRIVATE KEY-----`). The `myCertificate.crt` file is what you upload to Azure AD.
### 3. Upload the certificate to your app registration
Go to **Certificates & secrets → Certificates → Upload certificate** and upload the `.crt` file.
After uploading, Azure shows the thumbprint (40 hex characters). You can also compute it locally:
```bash
openssl x509 -in myCertificate.crt -fingerprint -sha1 -noout \
| sed 's/SHA1 Fingerprint=//' | tr -d ':'
# → e.g. A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2
```
Use this value (no colons) as the **Certificate Thumbprint** in the integration configuration.
### 4. Grant API permissions
Under **API Permissions**, add the following **Application permissions** and grant admin consent:
- **SharePoint → Sites.FullControl.All** — required to create and delete webhook subscriptions (read/write alone is insufficient for push notifications)
Click **Grant admin consent** when done.
> **Note:** This integration authenticates directly against the SharePoint REST API (not Microsoft Graph), so only SharePoint permissions are required.
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Livello_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1992.333 1946"
enable-background="new 0 0 1992.333 1946" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="1946" width="1992.333" x="-995.333" y="-949"></sliceSourceBounds>
</sfw>
</metadata>
<circle fill="#036C70" cx="1019.333" cy="556" r="556"/>
<circle fill="#1A9BA1" cx="1482.667" cy="1065.667" r="509.667"/>
<circle fill="#37C6D0" cx="1088.833" cy="1552.167" r="393.833"/>
<path opacity="0.1" enable-background="new " d="M1112,501.79v988.753c-0.23,34.357-21.05,65.222-52.82,78.303
c-10.116,4.279-20.987,6.484-31.97,6.487H695.463c-0.463-7.877-0.463-15.29-0.463-23.167c-0.154-7.734,0.155-15.47,0.927-23.167
c8.48-148.106,99.721-278.782,235.837-337.77v-86.18c-302.932-48.005-509.592-332.495-461.587-635.427
c0.333-2.098,0.677-4.195,1.034-6.289c2.306-15.626,5.556-31.099,9.73-46.333h546.27C1073.964,417.178,1111.822,455.036,1112,501.79
z"/>
<path opacity="0.2" enable-background="new " d="M980.877,463.333H471.21c-51.486,302.386,151.908,589.256,454.293,640.742
c9.156,1.559,18.35,2.888,27.573,3.986c-143.633,68.11-248.3,261.552-257.196,420.938c-0.771,7.697-1.081,15.433-0.927,23.167
c0,7.877,0,15.29,0.463,23.167c0.836,15.574,2.85,31.063,6.023,46.333h279.39c34.357-0.23,65.222-21.05,78.303-52.82
c4.279-10.115,6.485-20.987,6.487-31.97V548.123C1065.443,501.387,1027.613,463.537,980.877,463.333z"/>
<path opacity="0.2" enable-background="new " d="M980.877,463.333H471.21c-51.475,302.414,151.95,589.297,454.364,640.773
c6.186,1.053,12.389,2.001,18.607,2.844c-139,73.021-239.543,266-248.254,422.05h284.95c46.681-0.353,84.437-38.109,84.79-84.79
V548.123C1065.489,501.369,1027.631,463.511,980.877,463.333z"/>
<path opacity="0.2" enable-background="new " d="M934.543,463.333H471.21c-48.606,285.482,130.279,560.404,410.977,631.616
C775.901,1216.384,710.711,1368.301,695.927,1529h238.617c46.754-0.178,84.612-38.036,84.79-84.79V548.123
C1019.308,501.306,981.361,463.359,934.543,463.333z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="177.0788" y1="1551.0284" x2="842.2545" y2="398.9716" gradientTransform="matrix(1 0 0 -1 0 1948)">
<stop offset="0" style="stop-color:#058F92"/>
<stop offset="0.5" style="stop-color:#038489"/>
<stop offset="1" style="stop-color:#026D71"/>
</linearGradient>
<path fill="url(#SVGID_1_)" d="M84.929,463.333h849.475c46.905,0,84.929,38.024,84.929,84.929v849.475
c0,46.905-38.024,84.929-84.929,84.929H84.929c-46.905,0-84.929-38.024-84.929-84.929V548.262
C0,501.357,38.024,463.333,84.929,463.333z"/>
<path fill="#FFFFFF" d="M379.331,962.621c-19.903-13.202-36.528-30.777-48.604-51.384c-11.701-21.542-17.533-45.781-16.912-70.288
c-1.042-33.181,10.155-65.586,31.46-91.045c22.388-25.49,51.326-44.366,83.678-54.581c36.871-12.136,75.49-18.116,114.304-17.699
c51.043-1.865,102.015,5.272,150.583,21.082v106.567c-21.103-12.784-44.088-22.166-68.11-27.8
c-26.065-6.392-52.81-9.597-79.647-9.545c-28.3-1.039-56.419,4.913-81.871,17.329c-19.65,8.475-32.392,27.807-32.433,49.206
c-0.08,12.981,4.907,25.481,13.9,34.843c10.622,11.037,23.187,20.021,37.067,26.503c15.444,7.691,38.611,17.916,69.5,30.673
c3.401,1.075,6.716,2.407,9.915,3.985c30.401,11.881,59.729,26.344,87.663,43.229c21.154,13.043,38.908,30.924,51.801,52.171
c13.218,24.085,19.625,51.315,18.533,78.767c1.509,34.066-8.913,67.591-29.468,94.798c-20.488,25.012-47.88,43.446-78.767,53.005
c-36.329,11.387-74.245,16.892-112.312,16.309c-34.154,0.155-68.258-2.635-101.933-8.34c-28.434-4.653-56.182-12.807-82.612-24.279
v-112.358c25.264,18.043,53.489,31.529,83.4,39.847c29.81,9.289,60.798,14.251,92.018,14.734c28.895,1.83,57.739-4.291,83.4-17.699
c17.976-10.144,28.909-29.358,28.449-49.994c0.12-14.359-5.56-28.158-15.753-38.271c-12.676-12.444-27.352-22.671-43.414-30.256
c-18.533-9.267-45.824-21.483-81.871-36.65C432.618,993.951,405.161,979.594,379.331,962.621z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
import filesReadonly from './bp_modules/files-readonly'
import { actions, configuration, states } from './definitions'
export default new sdk.IntegrationDefinition({
name: 'sharepoint',
version: '1.0.2',
title: 'SharePoint',
description: 'Sync SharePoint document libraries with Botpress knowledge bases.',
readme: 'hub.md',
icon: 'icon.svg',
configuration,
states,
actions,
attributes: {
category: 'File Management',
repo: 'botpress',
},
}).extend(filesReadonly, ({}) => ({
// Enables knowledge-connector plugin to browse and index SharePoint files
entities: {},
actions: {
listItemsInFolder: {
name: 'filesReadonlyListItemsInFolder',
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
},
transferFileToBotpress: {
name: 'filesReadonlyTransferFileToBotpress',
attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO },
},
},
}))
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@botpresshub/sharepoint",
"dependencies": {
"@azure/msal-node": "^3.6.2",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.10.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,230 @@
import * as msal from '@azure/msal-node'
import * as sdk from '@botpress/sdk'
import axios from 'axios'
import {
ChangeItem,
ChangeResponse,
SharePointFilesResponse,
SharePointFoldersResponse,
SharePointListsResponse,
} from './misc/SharepointTypes'
import { formatPrivateKey, handleAxiosError } from './misc/utils'
import * as bp from '.botpress'
export class SharepointClient {
private _cca: msal.ConfidentialClientApplication
private _primaryDomain: string
private _siteName: string
private _documentLibraryName: string | undefined
public constructor(integrationConfiguration: bp.configuration.Configuration, documentLibraryName?: string) {
this._cca = new msal.ConfidentialClientApplication({
auth: {
clientId: integrationConfiguration.clientId.trim(),
authority: `https://login.microsoftonline.com/${integrationConfiguration.tenantId.trim()}`,
clientCertificate: {
thumbprint: integrationConfiguration.thumbprint.trim(),
privateKey: formatPrivateKey(integrationConfiguration.privateKey),
},
},
})
this._primaryDomain = integrationConfiguration.primaryDomain.trim()
this._siteName = integrationConfiguration.siteName.trim()
this._documentLibraryName = documentLibraryName?.trim()
}
public getDocumentLibraryName(): string {
if (!this._documentLibraryName) {
throw new sdk.RuntimeError('[SharepointClient] documentLibraryName is not set on this client instance')
}
return this._documentLibraryName
}
private async _fetchToken(): Promise<string> {
const tokenRequest = {
scopes: [`https://${this._primaryDomain}.sharepoint.com/.default`],
}
const token = await this._cca.acquireTokenByClientCredential(tokenRequest)
if (!token) {
throw new sdk.RuntimeError('Error acquiring SharePoint OAuth token')
}
return token.accessToken
}
private get _baseUrl(): string {
return `https://${this._primaryDomain}.sharepoint.com/sites/${this._siteName}`
}
private _odataVerboseHeaders(accessToken: string) {
return {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json;odata=verbose',
}
}
private async _getDocumentLibraryListId(): Promise<string> {
const lib = this.getDocumentLibraryName()
const url = `${this._baseUrl}/_api/web/lists/getbytitle('${lib}')?$select=Title,Id`
const token = await this._fetchToken()
const res = await axios.get(url, { headers: this._odataVerboseHeaders(token) }).catch(handleAxiosError)
return res.data.d.Id
}
public async getLatestChangeToken(): Promise<string | null> {
const changes = await this.getChanges(null)
if (changes.length > 0) {
return changes.at(-1)!.ChangeToken.StringValue
}
return null
}
public async getFilePath(listItemIndex: number): Promise<string | null> {
const lib = this.getDocumentLibraryName()
const url =
`${this._baseUrl}/_api/web/lists/getbytitle('${lib}')/items(${listItemIndex})` +
'/File?$select=Name,ServerRelativeUrl'
const token = await this._fetchToken()
const res: { data: { d: { Name: string | null; ServerRelativeUrl: string | null } } } = await axios
.get(url, { headers: this._odataVerboseHeaders(token) })
.catch(() => ({ data: { d: { Name: null, ServerRelativeUrl: null } } }))
const { Name, ServerRelativeUrl } = res.data.d
if (!Name || !ServerRelativeUrl) {
return null
}
return ServerRelativeUrl
}
public async getChanges(changeToken: string | null): Promise<ChangeItem[]> {
const lib = this.getDocumentLibraryName()
const token = await this._fetchToken()
const url = `${this._baseUrl}/_api/web/lists/getbytitle('${lib}')/GetChanges`
const query: Record<string, unknown> = {
Item: true,
Add: true,
Update: true,
DeleteObject: true,
Move: true,
Restore: true,
}
if (changeToken !== null && changeToken !== 'initial-sync-token') {
query.ChangeTokenStart = { StringValue: changeToken }
}
const res = await axios
.post<ChangeResponse>(url, { query }, { headers: this._odataVerboseHeaders(token) })
.catch(handleAxiosError)
return res.data.d.results
}
public async registerWebhook(webhookUrl: string, clientState: string): Promise<string> {
const listId = await this._getDocumentLibraryListId()
const url = `${this._baseUrl}/_api/web/lists('${listId}')/subscriptions`
const token = await this._fetchToken()
const res = await axios
.post(
url,
{
clientState,
resource: `${this._baseUrl}/_api/web/lists('${listId}')`,
notificationUrl: webhookUrl,
expirationDateTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
},
{ headers: this._odataVerboseHeaders(token) }
)
.catch(handleAxiosError)
return res.data.d.id
}
public async renewWebhook(subscriptionId: string, newExpirationDateTime: string): Promise<void> {
const listId = await this._getDocumentLibraryListId()
const url = `${this._baseUrl}/_api/web/lists('${listId}')/subscriptions('${subscriptionId}')`
const token = await this._fetchToken()
await axios
.patch(url, { expirationDateTime: newExpirationDateTime }, { headers: this._odataVerboseHeaders(token) })
.catch(handleAxiosError)
}
public async unregisterWebhook(webhookId: string): Promise<void> {
const listId = await this._getDocumentLibraryListId()
const url = `${this._baseUrl}/_api/web/lists('${listId}')/subscriptions('${webhookId}')`
const token = await this._fetchToken()
await axios.delete(url, { headers: this._odataVerboseHeaders(token) }).catch(handleAxiosError)
}
public async listWebhookSubscriptions(): Promise<Array<{ id: string; notificationUrl: string }>> {
const listId = await this._getDocumentLibraryListId()
const url = `${this._baseUrl}/_api/web/lists('${listId}')/subscriptions`
const token = await this._fetchToken()
const res = await axios.get(url, { headers: this._odataVerboseHeaders(token) }).catch(handleAxiosError)
return (res.data.d.results as Array<{ id: string; notificationUrl: string }>).map((s) => ({
id: s.id,
notificationUrl: s.notificationUrl,
}))
}
public async listDocumentLibraries(): Promise<Array<{ name: string; serverRelativeUrl: string }>> {
const token = await this._fetchToken()
const url =
`${this._baseUrl}/_api/web/lists` +
'?$filter=BaseTemplate eq 101 and Hidden eq false' +
'&$select=Title,RootFolder/ServerRelativeUrl&$expand=RootFolder'
const res = await axios
.get<SharePointListsResponse>(url, { headers: this._odataVerboseHeaders(token) })
.catch(handleAxiosError)
return res.data.d.results.map((lib) => ({
name: lib.Title,
serverRelativeUrl: lib.RootFolder.ServerRelativeUrl,
}))
}
public async listFiles(
serverRelativePath: string,
nextUrl?: string
): Promise<{
files: Array<{ name: string; serverRelativeUrl: string; length: number; timeLastModified: string; eTag: string }>
nextUrl?: string
}> {
const token = await this._fetchToken()
const url =
nextUrl ??
`${this._baseUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativePath}')/Files` +
'?$select=Name,ServerRelativeUrl,Length,TimeLastModified,ETag&$top=100'
const res = await axios
.get<SharePointFilesResponse>(url, { headers: this._odataVerboseHeaders(token) })
.catch(handleAxiosError)
return {
files: res.data.d.results.map((f) => ({
name: f.Name,
serverRelativeUrl: f.ServerRelativeUrl,
length: parseInt(f.Length, 10),
timeLastModified: f.TimeLastModified,
eTag: f.ETag,
})),
nextUrl: res.data.d.__next,
}
}
public async listSubfolders(serverRelativePath: string): Promise<Array<{ name: string; serverRelativeUrl: string }>> {
const token = await this._fetchToken()
const url =
`${this._baseUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativePath}')/Folders` +
"?$filter=Name ne 'Forms'"
const res = await axios
.get<SharePointFoldersResponse>(url, { headers: this._odataVerboseHeaders(token) })
.catch(handleAxiosError)
return res.data.d.results.map((f) => ({
name: f.Name,
serverRelativeUrl: f.ServerRelativeUrl,
}))
}
public async downloadFile(serverRelativePath: string): Promise<ArrayBuffer> {
const token = await this._fetchToken()
const url = `${this._baseUrl}/_api/web/GetFileByServerRelativeUrl('${serverRelativePath}')/$value`
const res = await axios
.get<ArrayBuffer>(url, { headers: { Authorization: `Bearer ${token}` }, responseType: 'arraybuffer' })
.catch(handleAxiosError)
return res.data
}
}
@@ -0,0 +1,9 @@
import { createActionWrapper } from '@botpress/common'
import { SharepointClient } from './SharepointClient'
import * as bp from '.botpress'
export const wrapAction = createActionWrapper<bp.IntegrationProps>()({
toolFactories: {
sharepointClient: ({ ctx }: { ctx: bp.Context }) => new SharepointClient(ctx.configuration),
},
})
@@ -0,0 +1 @@
export { addToSync } from './sync'
@@ -0,0 +1,76 @@
import * as sdk from '@botpress/sdk'
import { cleanupWebhook } from '../setup/utils'
import { SharepointClient } from '../SharepointClient'
import * as bp from '.botpress'
export const addToSync: bp.Integration['actions']['addToSync'] = async ({ client, ctx, input, logger }) => {
const webhookUrl = `https://webhook.botpress.cloud/${ctx.webhookId}`
let rawState: { payload: { subscriptions: unknown } } | undefined
try {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
rawState = state as typeof rawState
} catch {
// State not yet initialized — treat as empty
}
const subscriptions = (rawState?.payload?.subscriptions ?? {}) as Record<
string,
{
webhookSubscriptionId: string
changeToken: string
itemPathCache: Record<string, { absolutePath: string; name: string }>
expiresAt?: string
}
>
const libs = input.documentLibraryNames
const newLibs = libs.filter((lib) => !(lib in subscriptions))
if (newLibs.length === 0) {
logger.forBot().info('[addToSync] All requested libraries are already subscribed')
return {}
}
const results = await Promise.allSettled(
newLibs.map(async (lib) => {
let webhookSubscriptionId: string | undefined
try {
const spClient = new SharepointClient(ctx.configuration, lib)
logger.forBot().info(`[addToSync] (${lib}) Creating webhook → ${webhookUrl}`)
webhookSubscriptionId = await spClient.registerWebhook(webhookUrl, ctx.webhookId)
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
const changeToken = await spClient.getLatestChangeToken()
logger.forBot().info(`[addToSync] (${lib}) Done`)
return { lib, webhookSubscriptionId, changeToken: changeToken ?? 'initial-sync-token', expiresAt }
} catch (error) {
if (webhookSubscriptionId) {
await cleanupWebhook(webhookSubscriptionId, ctx, lib, logger)
}
logger.forBot().error(`[addToSync] (${lib}) Failed: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
})
)
const newSubscriptions: typeof subscriptions = {}
for (const result of results) {
if (result.status === 'fulfilled') {
const { lib, webhookSubscriptionId, changeToken, expiresAt } = result.value
newSubscriptions[lib] = { webhookSubscriptionId, changeToken, itemPathCache: {}, expiresAt }
}
}
if (Object.keys(newSubscriptions).length === 0) {
throw new sdk.RuntimeError('[addToSync] All libraries failed to register. Check logs for details.')
}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { subscriptions: { ...subscriptions, ...newSubscriptions } },
})
return {}
}
@@ -0,0 +1,2 @@
export { filesReadonlyListItemsInFolder } from './list-items-in-folder'
export { filesReadonlyTransferFileToBotpress } from './transfer-file-to-botpress'
@@ -0,0 +1,62 @@
import { wrapAction } from '../../action-wrapper'
import * as mapping from '../mapping'
export const filesReadonlyListItemsInFolder = wrapAction(
{ actionName: 'filesReadonlyListItemsInFolder' },
async ({ sharepointClient }, { folderId, filters, nextToken }) => {
// Root: list all document libraries as folders
if (!folderId) {
const libs = await sharepointClient.listDocumentLibraries()
const items = libs.map(mapping.mapLibrary)
const filtered = applyFilters(items, filters)
return { items: filtered, meta: { nextToken: undefined } }
}
// Subsequent pages: nextToken is the __next URL from a prior files call
if (nextToken) {
const { files, nextUrl } = await sharepointClient.listFiles(folderId, nextToken)
const items = files.map((f) => mapping.mapFile(f, folderId))
const filtered = applyFilters(items, filters)
return { items: filtered, meta: { nextToken: nextUrl } }
}
// First page: fetch folders and first page of files in parallel
const [subfolders, { files, nextUrl }] = await Promise.all([
filters?.itemType === 'file' ? Promise.resolve([]) : sharepointClient.listSubfolders(folderId),
filters?.itemType === 'folder'
? Promise.resolve({ files: [], nextUrl: undefined })
: sharepointClient.listFiles(folderId),
])
const folderItems = subfolders.map((f) => mapping.mapFolder(f, folderId))
const fileItems = files.map((f) => mapping.mapFile(f, folderId))
const items = [...folderItems, ...fileItems]
const filtered = applyFilters(items, filters)
return { items: filtered, meta: { nextToken: nextUrl } }
}
)
type Item =
| ReturnType<typeof mapping.mapFile>
| ReturnType<typeof mapping.mapFolder>
| ReturnType<typeof mapping.mapLibrary>
type Filters = { itemType?: 'file' | 'folder'; maxSizeInBytes?: number; modifiedAfter?: string } | undefined
function applyFilters(items: Item[], filters: Filters): Item[] {
if (!filters) return items
return items.filter((item) => {
if (filters.itemType && item.type !== filters.itemType) return false
if (item.type === 'file') {
if (filters.maxSizeInBytes && item.sizeInBytes && item.sizeInBytes > filters.maxSizeInBytes) return false
if (
filters.modifiedAfter &&
item.lastModifiedDate &&
new Date(item.lastModifiedDate) < new Date(filters.modifiedAfter)
) {
return false
}
}
return true
})
}
@@ -0,0 +1,19 @@
import { wrapAction } from '../../action-wrapper'
export const filesReadonlyTransferFileToBotpress = wrapAction(
{ actionName: 'filesReadonlyTransferFileToBotpress' },
async ({ sharepointClient, client }, { file, fileKey, shouldIndex }) => {
if (!file.absolutePath) {
throw new Error(`Cannot transfer file: absolutePath is missing for file id=${file.id}`)
}
const content = await sharepointClient.downloadFile(file.absolutePath)
const { file: uploaded } = await client.uploadFile({
key: fileKey,
content,
index: shouldIndex,
})
return { botpressFileId: uploaded.id }
}
)
@@ -0,0 +1,36 @@
import * as bp from '.botpress'
type FilesReadonlyFile = bp.events.Events['fileCreated']['file']
type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder']
export const mapFile = (
file: { name: string; serverRelativeUrl: string; length: number; timeLastModified: string; eTag: string },
parentPath: string
): FilesReadonlyFile => ({
type: 'file',
id: file.serverRelativeUrl,
name: file.name,
parentId: parentPath,
absolutePath: file.serverRelativeUrl,
sizeInBytes: file.length,
lastModifiedDate: new Date(file.timeLastModified).toISOString(),
contentHash: file.eTag,
})
export const mapFolder = (
folder: { name: string; serverRelativeUrl: string },
parentPath: string
): FilesReadonlyFolder => ({
type: 'folder',
id: folder.serverRelativeUrl,
name: folder.name,
parentId: parentPath,
absolutePath: folder.serverRelativeUrl,
})
export const mapLibrary = (lib: { name: string; serverRelativeUrl: string }): FilesReadonlyFolder => ({
type: 'folder',
id: lib.serverRelativeUrl,
name: lib.name,
absolutePath: lib.serverRelativeUrl,
})
+16
View File
@@ -0,0 +1,16 @@
import { addToSync } from './actions'
import { filesReadonlyListItemsInFolder, filesReadonlyTransferFileToBotpress } from './files-readonly/actions'
import { register, unregister, handler } from './setup'
import * as bp from '.botpress'
export default new bp.Integration({
register,
unregister,
handler,
actions: {
addToSync,
filesReadonlyListItemsInFolder,
filesReadonlyTransferFileToBotpress,
},
channels: {},
})
@@ -0,0 +1,49 @@
export type ChangeItem = {
ChangeToken: { StringValue: string }
// 1=Add 2=Update 3=Delete 4=Rename 5=MoveAway 6=MoveTo 7=Restore
ChangeType: 1 | 2 | 3 | 4 | 5 | 6 | 7
ItemId: number
}
export type ChangeResponse = {
d: {
results: ChangeItem[]
}
}
export type SharePointFile = {
Name: string
ServerRelativeUrl: string
Length: string
TimeLastModified: string
ETag: string
}
export type SharePointFilesResponse = {
d: {
__next?: string
results: SharePointFile[]
}
}
export type SharePointFolder = {
Name: string
ServerRelativeUrl: string
}
export type SharePointFoldersResponse = {
d: {
results: SharePointFolder[]
}
}
export type SharePointListsResponse = {
d: {
results: Array<{
Title: string
RootFolder: {
ServerRelativeUrl: string
}
}>
}
}
+25
View File
@@ -0,0 +1,25 @@
import { RuntimeError } from '@botpress/sdk'
import { AxiosError } from 'axios'
export const handleAxiosError = (error: AxiosError): never => {
if (error.response) {
const body = typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data)
throw new RuntimeError(`SharePoint API ${error.response.status}: ${body.slice(0, 500)}`)
} else if (error.request) {
throw new RuntimeError(`SharePoint API no response: ${error.message}`)
} else {
throw new RuntimeError(`SharePoint API error: ${error.message}`)
}
}
export const formatPrivateKey = (privateKey: string): string => {
const trimmed = privateKey.trim()
const headerMatch = trimmed.match(/-----BEGIN ([^-]+)-----/)
if (headerMatch) {
const keyType = headerMatch[1]
const stripped = trimmed.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
return `-----BEGIN ${keyType}-----\n${stripped}\n-----END ${keyType}-----`
}
const stripped = trimmed.replace(/\s+/g, '')
return `-----BEGIN PRIVATE KEY-----\n${stripped}\n-----END PRIVATE KEY-----`
}
@@ -0,0 +1,159 @@
import type { ChangeItem } from '../misc/SharepointTypes'
import { SharepointClient } from '../SharepointClient'
import * as bp from '.botpress'
type ItemCache = Record<string, { absolutePath: string; name: string }>
type AggregatePayload = bp.events.Events['aggregateFileChanges']
type Created = AggregatePayload['modifiedItems']['created']
type Updated = AggregatePayload['modifiedItems']['updated']
type Deleted = AggregatePayload['modifiedItems']['deleted']
function applyChange(
ch: ChangeItem,
cache: ItemCache,
pathMap: Map<number, string | null | undefined>,
created: Created,
updated: Updated,
deleted: Deleted,
logger: bp.Logger,
lib: string
) {
const itemId = ch.ItemId.toString()
switch (ch.ChangeType) {
case 1:
case 6:
case 7: {
const spPath = pathMap.get(ch.ItemId)
if (!spPath) break
const name = spPath.split('/').pop() ?? spPath
cache[itemId] = { absolutePath: spPath, name }
created.push({ type: 'file', id: itemId, name, absolutePath: spPath })
break
}
case 2:
case 4: {
const spPath = pathMap.get(ch.ItemId)
if (!spPath) break
const name = spPath.split('/').pop() ?? spPath
cache[itemId] = { absolutePath: spPath, name }
updated.push({ type: 'file', id: itemId, name, absolutePath: spPath })
break
}
case 3:
case 5: {
const cached = cache[itemId]
if (!cached) {
logger.forBot().warn(`[Handler] (${lib}) Delete for unknown item ${itemId} — skipping`)
break
}
delete cache[itemId]
deleted.push({ type: 'file', id: itemId, name: cached.name, absolutePath: cached.absolutePath })
break
}
default:
logger.forBot().debug(`[Handler] (${lib}) Skipping unsupported ChangeType=${ch.ChangeType}`)
}
}
const RENEW_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000
const SUBSCRIPTION_DURATION_MS = 30 * 24 * 60 * 60 * 1000
export const handler: bp.IntegrationProps['handler'] = async ({ ctx, req, client, logger }) => {
const queryParams = new URLSearchParams(req.query)
if (queryParams.has('validationtoken')) {
return { status: 200, body: queryParams.get('validationtoken') ?? '' }
}
let body: unknown
try {
body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
} catch {
logger.forBot().error('[Handler] Failed to parse webhook body')
return { status: 400, body: 'Bad Request' }
}
const parsed = body as { value?: Array<{ clientState?: string }> }
if (Array.isArray(parsed?.value)) {
const notifClientState = parsed.value[0]?.clientState
if (notifClientState !== ctx.webhookId) {
logger.forBot().error(`[Handler] Rejected notification with unexpected clientState: ${notifClientState}`)
return { status: 200, body: 'OK' }
}
}
let statePayload: { subscriptions: Record<string, unknown> }
try {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
statePayload = state.payload as typeof statePayload
} catch {
logger.forBot().info('[Handler] No state found — nothing to process')
return { status: 200, body: 'OK' }
}
const subs = statePayload.subscriptions as Record<
string,
{ webhookSubscriptionId: string; changeToken: string; itemPathCache: ItemCache; expiresAt?: string }
>
const updatedSubs = { ...subs }
for (const [lib, sub] of Object.entries(subs)) {
try {
const spClient = new SharepointClient(ctx.configuration, lib)
// Renew if expiresAt is missing or within 5 days
let currentSub = { ...sub }
const msUntilExpiry = currentSub.expiresAt ? new Date(currentSub.expiresAt).getTime() - Date.now() : 0
if (!currentSub.expiresAt || msUntilExpiry < RENEW_THRESHOLD_MS) {
const newExpiry = new Date(Date.now() + SUBSCRIPTION_DURATION_MS).toISOString()
await spClient.renewWebhook(currentSub.webhookSubscriptionId, newExpiry)
currentSub = { ...currentSub, expiresAt: newExpiry }
logger.forBot().info(`[Handler] (${lib}) Renewed webhook subscription until ${newExpiry}`)
}
updatedSubs[lib] = currentSub
const changes = await spClient.getChanges(currentSub.changeToken)
if (changes.length === 0) continue
const newToken = changes.at(-1)!.ChangeToken.StringValue
const cache: ItemCache = { ...(currentSub.itemPathCache ?? {}) }
const created: Created = []
const updated: Updated = []
const deleted: Deleted = []
// Pre-fetch paths in parallel for all items that need a lookup (add/update/rename/restore/move-in)
const needsLookup = changes.filter((ch) => [1, 2, 4, 6, 7].includes(ch.ChangeType))
const pathResults = await Promise.all(needsLookup.map((ch) => spClient.getFilePath(ch.ItemId)))
const pathMap = new Map(needsLookup.map((ch, i) => [ch.ItemId, pathResults[i]]))
for (const ch of changes) {
applyChange(ch, cache, pathMap, created, updated, deleted, logger, lib)
}
if (created.length > 0 || updated.length > 0 || deleted.length > 0) {
await client.createEvent({
type: 'aggregateFileChanges',
payload: { modifiedItems: { created, updated, deleted } },
})
logger
.forBot()
.info(
`[Handler] (${lib}) Emitted aggregateFileChanges: +${created.length} ~${updated.length} -${deleted.length}`
)
}
updatedSubs[lib] = { ...currentSub, changeToken: newToken, itemPathCache: cache }
} catch (error) {
logger.forBot().error(`[Handler] (${lib}) Failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { subscriptions: updatedSubs as bp.states.States['configuration']['payload']['subscriptions'] },
})
return { status: 200, body: 'OK' }
}
@@ -0,0 +1,3 @@
export { handler } from './handler'
export { register } from './register'
export { unregister } from './unregister'
@@ -0,0 +1,87 @@
import { SharepointClient } from '../SharepointClient'
import { cleanupWebhook } from './utils'
import * as bp from '.botpress'
type Subscriptions = Record<
string,
{
webhookSubscriptionId: string
changeToken: string
itemPathCache: Record<string, { absolutePath: string; name: string }>
expiresAt: string
}
>
export const register: bp.IntegrationProps['register'] = async ({ ctx, webhookUrl, client, logger }) => {
// Read existing subscriptions so dynamically added libraries (via addToSync) are preserved across re-registrations
let existingSubscriptions: Subscriptions = {}
try {
const { state } = await client.getState({ type: 'integration', name: 'configuration', id: ctx.integrationId })
existingSubscriptions = state.payload.subscriptions as Subscriptions
} catch {
// State doesn't exist yet — start fresh
}
const libs = ctx.configuration.documentLibraryNames ?? []
if (libs.length === 0) {
logger.forBot().info('[Registration] No documentLibraryNames configured — skipping webhook setup')
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { subscriptions: existingSubscriptions },
})
return
}
const results = await Promise.allSettled(
libs.map(async (lib) => {
let webhookSubscriptionId: string | undefined
try {
const spClient = new SharepointClient(ctx.configuration, lib)
// Delete any stale subscriptions pointing to this webhook URL before creating a fresh one
const existing = await spClient.listWebhookSubscriptions()
const stale = existing.filter((s) => s.notificationUrl === webhookUrl)
if (stale.length > 0) {
logger.forBot().info(`[Registration] (${lib}) Removing ${stale.length} stale subscription(s)`)
await Promise.allSettled(stale.map((s) => spClient.unregisterWebhook(s.id)))
}
logger.forBot().info(`[Registration] (${lib}) Creating webhook → ${webhookUrl}`)
webhookSubscriptionId = await spClient.registerWebhook(webhookUrl, ctx.webhookId)
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
const changeToken = await spClient.getLatestChangeToken()
logger.forBot().info(`[Registration] (${lib}) Registered successfully`)
return { lib, webhookSubscriptionId, changeToken: changeToken ?? 'initial-sync-token', expiresAt }
} catch (error) {
if (webhookSubscriptionId) {
await cleanupWebhook(webhookSubscriptionId, ctx, lib, logger)
}
logger
.forBot()
.error(`[Registration] (${lib}) Failed: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
})
)
// Merge: preserve existing addToSync subscriptions, overwrite config-declared ones with fresh registrations
const subscriptions: Subscriptions = { ...existingSubscriptions }
for (const result of results) {
if (result.status === 'fulfilled') {
const { lib, webhookSubscriptionId, changeToken, expiresAt } = result.value
subscriptions[lib] = { webhookSubscriptionId, changeToken, itemPathCache: {}, expiresAt }
}
}
await client.setState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
payload: { subscriptions },
})
logger.forBot().info(`[Registration] Done. Subscribed: ${Object.keys(subscriptions).join(', ')}`)
}
@@ -0,0 +1,31 @@
import { SharepointClient } from '../SharepointClient'
import * as bp from '.botpress'
export const unregister: bp.IntegrationProps['unregister'] = async ({ client, ctx, logger }) => {
let state: { payload: { subscriptions: Record<string, { webhookSubscriptionId: string }> } }
try {
const result = await client.getState({
type: 'integration',
name: 'configuration',
id: ctx.integrationId,
})
state = result.state as typeof state
} catch {
logger.forBot().info('[Unregister] No state found — nothing to clean up')
return
}
for (const [lib, { webhookSubscriptionId }] of Object.entries(state.payload.subscriptions)) {
try {
logger.forBot().info(`[Unregister] (${lib}) Deleting webhook ${webhookSubscriptionId}`)
const spClient = new SharepointClient(ctx.configuration, lib)
await spClient.unregisterWebhook(webhookSubscriptionId)
logger.forBot().info(`[Unregister] (${lib}) Unregistered successfully`)
} catch (error) {
logger
.forBot()
.error(`[Unregister] (${lib}) Failed: ${error instanceof Error ? error.message : String(error)}. Continuing.`)
}
}
}
@@ -0,0 +1,23 @@
import { SharepointClient } from '../SharepointClient'
import * as bp from '.botpress'
export const cleanupWebhook = async (
webhookSubscriptionId: string,
ctx: bp.Context,
lib: string,
logger: bp.Logger
): Promise<void> => {
try {
const spClient = new SharepointClient(ctx.configuration, lib)
await spClient.unregisterWebhook(webhookSubscriptionId)
logger.forBot().info(`[Setup] (${lib}) Cleaned up orphaned webhook`)
} catch (cleanupError) {
logger
.forBot()
.error(
`[Setup] (${lib}) Failed to clean up webhook: ${
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
}`
)
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config