chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user