Files
botpress--botpress/integrations/sharepoint/src/setup/handler.ts
T
2026-07-13 13:34:48 +08:00

160 lines
5.8 KiB
TypeScript

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' }
}