From c8d0f12cab97e2f201cfb7eda8ff2102935e2776 Mon Sep 17 00:00:00 2001 From: vini <117116432+vinishamanek@users.noreply.github.com> Date: Mon, 25 May 2026 09:20:37 -0400 Subject: [PATCH 1/2] feat(integrations/telegram): add wizard setup flow (#15203) --- .../telegram/integration.definition.ts | 22 +- integrations/telegram/linkTemplate.vrl | 4 + integrations/telegram/src/botToken.ts | 25 +++ integrations/telegram/src/index.ts | 189 ++++++++++-------- .../telegram/src/misc/message-handlers.ts | 59 ++++-- integrations/telegram/src/wizard.ts | 84 ++++++++ integrations/telegram/tsconfig.json | 4 +- 7 files changed, 278 insertions(+), 109 deletions(-) create mode 100644 integrations/telegram/linkTemplate.vrl create mode 100644 integrations/telegram/src/botToken.ts create mode 100644 integrations/telegram/src/wizard.ts diff --git a/integrations/telegram/integration.definition.ts b/integrations/telegram/integration.definition.ts index c8ce3de4fe1..f8bcaac8d30 100644 --- a/integrations/telegram/integration.definition.ts +++ b/integrations/telegram/integration.definition.ts @@ -5,14 +5,24 @@ import { telegramMessageChannels } from './definitions/channels' export default new IntegrationDefinition({ name: 'telegram', - version: '1.0.7', + version: '1.0.8', title: 'Telegram', description: 'Engage with your audience in real-time.', icon: 'icon.svg', readme: 'hub.md', configuration: { + identifier: { + linkTemplateScript: 'linkTemplate.vrl', + }, schema: z.object({ - botToken: z.string().min(1).describe('Bot Token').title('Bot Token'), + botToken: z + .string() + .min(1) + .secret() + .hidden() + .optional() + .title('Bot Token') + .describe('Legacy Telegram bot token. New installations store this in integration state.'), typingIndicatorEmoji: z .boolean() .default(false) @@ -20,6 +30,14 @@ export default new IntegrationDefinition({ .describe('Temporarily add an emoji reaction to received messages to indicate when bot is processing message'), }), }, + states: { + credentials: { + type: 'integration', + schema: z.object({ + botToken: z.string().title('Bot Token').min(1).secret().describe('The Telegram bot token'), + }), + }, + }, channels: { channel: { title: 'Channel', diff --git a/integrations/telegram/linkTemplate.vrl b/integrations/telegram/linkTemplate.vrl new file mode 100644 index 00000000000..23372049f7a --- /dev/null +++ b/integrations/telegram/linkTemplate.vrl @@ -0,0 +1,4 @@ +webhookId = to_string!(.webhookId) +webhookUrl = to_string!(.webhookUrl) + +"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}" diff --git a/integrations/telegram/src/botToken.ts b/integrations/telegram/src/botToken.ts new file mode 100644 index 00000000000..aa4e455f5a9 --- /dev/null +++ b/integrations/telegram/src/botToken.ts @@ -0,0 +1,25 @@ +import { RuntimeError } from '@botpress/sdk' +import * as bp from '.botpress' + +export const getStoredBotToken = async ( + client: bp.Client, + integrationId: string, + legacyToken?: string +): Promise => { + const stateResult = await client + .getState({ type: 'integration', name: 'credentials', id: integrationId }) + .catch((thrown: unknown) => { + const err = thrown instanceof Error ? thrown : new Error(String(thrown)) + if (err.message.toLowerCase().includes('not found')) { + return null + } + throw err + }) + + const botToken = stateResult?.state.payload.botToken ?? legacyToken + if (typeof botToken !== 'string' || botToken.trim().length === 0) { + throw new RuntimeError('Bot token is missing or invalid. Please complete the wizard setup again.') + } + + return botToken +} diff --git a/integrations/telegram/src/index.ts b/integrations/telegram/src/index.ts index d912b6636ab..b4250689eeb 100644 --- a/integrations/telegram/src/index.ts +++ b/integrations/telegram/src/index.ts @@ -1,9 +1,9 @@ +import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard' import { sentry as sentryHelpers } from '@botpress/sdk-addons' import { ok } from 'assert/strict' - import { Telegraf } from 'telegraf' import type { User } from 'telegraf/typings/core/types/typegram' - +import { getStoredBotToken } from './botToken' import { handleAudioMessage, handleBlocMessage, @@ -27,24 +27,28 @@ import { getMessageId, mapToRuntimeErrorAndThrow, } from './misc/utils' +import { handler as wizardHandler } from './wizard' import * as bp from '.botpress' const integration = new bp.Integration({ - register: async ({ webhookUrl, ctx }) => { - const telegraf = new Telegraf(ctx.configuration.botToken) + register: async ({ webhookUrl, ctx, client }) => { + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) await telegraf.telegram .setWebhook(webhookUrl) .catch(mapToRuntimeErrorAndThrow('Fail to set webhook. Check your bot token')) }, - unregister: async ({ ctx }) => { - const telegraf = new Telegraf(ctx.configuration.botToken) + unregister: async ({ ctx, client }) => { + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) await telegraf.telegram .deleteWebhook({ drop_pending_updates: true }) .catch(mapToRuntimeErrorAndThrow('Fail to delete webhook')) }, actions: { startTypingIndicator: async ({ input, ctx, client }) => { - const telegraf = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const { conversation } = await client.getConversation({ id: input.conversationId }) const { message } = await client.getMessage({ id: input.messageId }) @@ -68,7 +72,8 @@ const integration = new bp.Integration({ return {} } - const telegraf = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const { conversation } = await client.getConversation({ id: input.conversationId }) const { message } = await client.getMessage({ id: input.messageId }) @@ -99,93 +104,105 @@ const integration = new bp.Integration({ }, }, }, - handler: wrapHandler(async ({ req, client, ctx, logger }) => { - logger.forBot().debug('Handler received request from Telegram with payload:', req.body) - - ok(req.body, 'Handler received an empty body, so the message was ignored') - - const data = JSON.parse(req.body) - - ok(!data.my_chat_member, 'Handler received a chat member update, so the message was ignored') - ok(!data.channel_post, 'Handler received a channel post, so the message was ignored') - ok(!data.edited_channel_post, 'Handler received an edited channel post, so the message was ignored') - ok(!data.edited_message, 'Handler received an edited message, so the message was ignored') - ok(data.message, 'Handler received a non-message update, so the event was ignored') - - const message = data.message as TelegramMessage - const telegramConversationId = message.chat.id - const telegramUserId = message.from?.id - const messageId = message.message_id - - ok(!message.from?.is_bot, 'Handler received a message from a bot, so the message was ignored') - ok(telegramConversationId, 'Handler received message with empty "chat.id" value') - ok(telegramUserId, 'Handler received message with empty "from.id" value') - ok(messageId, 'Handler received an empty message id') - - const fromUser = message.from as User - const userName = getUserNameFromTelegramUser(fromUser) - - const { conversation } = await client.getOrCreateConversation({ - channel: 'channel', - tags: { - id: telegramConversationId.toString(), - fromUserId: telegramUserId.toString(), - fromUserUsername: fromUser.username, - fromUserName: userName, - chatId: telegramConversationId.toString(), - }, - discriminateByTags: ['id'], - }) + handler: async (props) => { + if (isOAuthWizardUrl(props.req.path)) { + return await wizardHandler(props) + } - const { user } = await client.getOrCreateUser({ - tags: { - id: telegramUserId.toString(), - }, - ...(userName && { name: userName }), - discriminateByTags: ['id'], - }) - - const userFieldsToUpdate = { - pictureUrl: !user.pictureUrl - ? await getUserPictureDataUri({ - botToken: ctx.configuration.botToken, - telegramUserId, - logger, - }) - : undefined, - name: user.name !== userName ? userName : undefined, + if (props.req.path.startsWith('/oauth')) { + return { status: 404, body: 'Not Found' } } - if (userFieldsToUpdate.pictureUrl || userFieldsToUpdate.name) { - await client.updateUser({ - ...user, + return await wrapHandler(async ({ req, client, ctx, logger }) => { + logger.forBot().debug('Handler received request from Telegram with payload:', req.body) + + ok(req.body, 'Handler received an empty body, so the message was ignored') + + const data = JSON.parse(req.body) + + ok(!data.my_chat_member, 'Handler received a chat member update, so the message was ignored') + ok(!data.channel_post, 'Handler received a channel post, so the message was ignored') + ok(!data.edited_channel_post, 'Handler received an edited channel post, so the message was ignored') + ok(!data.edited_message, 'Handler received an edited message, so the message was ignored') + ok(data.message, 'Handler received a non-message update, so the event was ignored') + + const message = data.message as TelegramMessage + const telegramConversationId = message.chat.id + const telegramUserId = message.from?.id + const messageId = message.message_id + + ok(!message.from?.is_bot, 'Handler received a message from a bot, so the message was ignored') + ok(telegramConversationId, 'Handler received message with empty "chat.id" value') + ok(telegramUserId, 'Handler received message with empty "from.id" value') + ok(messageId, 'Handler received an empty message id') + + const fromUser = message.from as User + const userName = getUserNameFromTelegramUser(fromUser) + + const { conversation } = await client.getOrCreateConversation({ + channel: 'channel', tags: { - id: user.tags.id, + id: telegramConversationId.toString(), + fromUserId: telegramUserId.toString(), + fromUserUsername: fromUser.username, + fromUserName: userName, + chatId: telegramConversationId.toString(), }, - ...(userFieldsToUpdate.pictureUrl && { pictureUrl: userFieldsToUpdate.pictureUrl }), - ...(userFieldsToUpdate.name && { name: userFieldsToUpdate.name }), + discriminateByTags: ['id'], }) - } - const telegraf = new Telegraf(ctx.configuration.botToken) - const bpMessage = await convertTelegramMessageToBotpressMessage({ - message, - telegram: telegraf.telegram, - logger, - }) + const { user } = await client.getOrCreateUser({ + tags: { + id: telegramUserId.toString(), + }, + ...(userName && { name: userName }), + discriminateByTags: ['id'], + }) - logger.forBot().debug(`Received message from user ${telegramUserId}: ${JSON.stringify(message, null, 2)}`) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + + const userFieldsToUpdate = { + pictureUrl: !user.pictureUrl + ? await getUserPictureDataUri({ + botToken, + telegramUserId, + logger, + }) + : undefined, + name: user.name !== userName ? userName : undefined, + } - await client.createMessage({ - tags: { - id: messageId.toString(), - chatId: telegramConversationId.toString(), - }, - ...bpMessage, - userId: user.id, - conversationId: conversation.id, - }) - }), + if (userFieldsToUpdate.pictureUrl || userFieldsToUpdate.name) { + await client.updateUser({ + ...user, + tags: { + id: user.tags.id, + }, + ...(userFieldsToUpdate.pictureUrl && { pictureUrl: userFieldsToUpdate.pictureUrl }), + ...(userFieldsToUpdate.name && { name: userFieldsToUpdate.name }), + }) + } + + const telegraf = new Telegraf(botToken) + const bpMessage = await convertTelegramMessageToBotpressMessage({ + message, + telegram: telegraf.telegram, + logger, + }) + + logger.forBot().debug(`Received message from user ${telegramUserId}: ${JSON.stringify(message, null, 2)}`) + + await client.createMessage({ + tags: { + id: messageId.toString(), + chatId: telegramConversationId.toString(), + }, + ...bpMessage, + userId: user.id, + conversationId: conversation.id, + }) + })(props) + }, }) export default sentryHelpers.wrapIntegration(integration, { diff --git a/integrations/telegram/src/misc/message-handlers.ts b/integrations/telegram/src/misc/message-handlers.ts index e49d1f5debf..9edd741fb5f 100644 --- a/integrations/telegram/src/misc/message-handlers.ts +++ b/integrations/telegram/src/misc/message-handlers.ts @@ -1,5 +1,6 @@ import { RuntimeError } from '@botpress/client' import { Markup, Telegraf, Telegram } from 'telegraf' +import { getStoredBotToken } from '../botToken' import { markdownHtmlToTelegramPayloads, stdMarkdownToTelegramHtml } from './markdown-to-telegram-html' import { TelegramMessage } from './types' import { ackMessage, getChat, mapToRuntimeErrorAndThrow, sendCard } from './utils' @@ -38,16 +39,17 @@ const sendContentOrFallback = async

SendMediaMethod fallback?: () => Promise }) => { - const { ctx, conversation, ack, logger, payload } = props - const client = new Telegraf(ctx.configuration.botToken) + const { ctx, conversation, ack, logger, payload, client } = props + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) - const sendFn = send(client.telegram) + const sendFn = send(telegraf.telegram) const opts = 'caption' in payload ? { caption: payload.caption } : undefined logger.forBot().debug(`calling ${sendFn.name} to Telegram chat ${chat}: ${url}`) let message: TelegramMessage try { message = await sendFn - .call(client.telegram, chat, url, opts) + .call(telegraf.telegram, chat, url, opts) .catch(mapToRuntimeErrorAndThrow(`Failed to ${sendFn.name}`)) } catch (err) { if (fallback) { @@ -60,7 +62,7 @@ const sendContentOrFallback = async

) => { - const { payload, ctx, conversation, ack, logger } = props + const { payload, ctx, conversation, ack, logger, client } = props const { text } = payload - const client = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) logger.forBot().debug(`Sending markdown message to Telegram chat ${chat}:`, text) const { html, extractedData } = stdMarkdownToTelegramHtml(text) if (!extractedData.images || extractedData.images.length === 0) { - await sendHtmlTextMessage(client, ack, chat, html) + await sendHtmlTextMessage(telegraf, ack, chat, html) return } @@ -84,7 +87,7 @@ export const handleTextMessage = async (props: MessageHandlerProps<'text'>) => { for (const payload of payloads) { if (payload.type === 'text') { - await sendHtmlTextMessage(client, ack, chat, payload.text) + await sendHtmlTextMessage(telegraf, ack, chat, payload.text) } else { await handleImageMessage({ ...props, payload: { imageUrl: payload.imageUrl }, type: 'image' }) } @@ -136,24 +139,34 @@ export const handleLocationMessage = async ({ conversation, ack, logger, + client, }: MessageHandlerProps<'location'>) => { - const client = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) logger.forBot().debug(`Sending location message to Telegram chat ${chat}:`, { latitude: payload.latitude, longitude: payload.longitude, }) - const message = await client.telegram + const message = await telegraf.telegram .sendLocation(chat, payload.latitude, payload.longitude) .catch(mapToRuntimeErrorAndThrow('Fail to send location')) await ackMessage(message, ack) } -export const handleCardMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'card'>) => { - const client = new Telegraf(ctx.configuration.botToken) +export const handleCardMessage = async ({ + payload, + ctx, + conversation, + ack, + logger, + client, +}: MessageHandlerProps<'card'>) => { + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) logger.forBot().debug(`Sending card message to Telegram chat ${chat}:`, payload) - await sendCard(payload, client, chat, ack) + await sendCard(payload, telegraf, chat, ack) } export const handleCarouselMessage = async ({ @@ -162,12 +175,14 @@ export const handleCarouselMessage = async ({ conversation, ack, logger, + client, }: MessageHandlerProps<'carousel'>) => { - const client = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) logger.forBot().debug(`Sending carousel message to Telegram chat ${chat}:`, payload) for (const item of payload.items) { - await sendCard(item, client, chat, ack) + await sendCard(item, telegraf, chat, ack) } } @@ -177,12 +192,14 @@ export const handleDropdownMessage = async ({ conversation, ack, logger, + client, }: MessageHandlerProps<'dropdown'>) => { - const client = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) logger.forBot().debug(`Sending dropdown message to Telegram chat ${chat}:`, payload) - const message = await client.telegram + const message = await telegraf.telegram .sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) .catch(mapToRuntimeErrorAndThrow('Fail to send message')) await ackMessage(message, ack) @@ -194,12 +211,14 @@ export const handleChoiceMessage = async ({ conversation, ack, logger, + client, }: MessageHandlerProps<'choice'>) => { - const client = new Telegraf(ctx.configuration.botToken) + const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken) + const telegraf = new Telegraf(botToken) const chat = getChat(conversation) logger.forBot().debug(`Sending choice message to Telegram chat ${chat}:`, payload) const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) - const message = await client.telegram + const message = await telegraf.telegram .sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) .catch(mapToRuntimeErrorAndThrow('Fail to send message')) await ackMessage(message, ack) diff --git a/integrations/telegram/src/wizard.ts b/integrations/telegram/src/wizard.ts new file mode 100644 index 00000000000..e4c1cf1d0b2 --- /dev/null +++ b/integrations/telegram/src/wizard.ts @@ -0,0 +1,84 @@ +import * as oauthWizard from '@botpress/common/src/oauth-wizard' +import { z } from '@botpress/sdk' +import { Telegraf } from 'telegraf' +import * as bp from '.botpress' + +type WizardHandler = oauthWizard.WizardStepHandler + +const _tokenSchema = z.object({ + botToken: z.string().min(1).secret().title('Bot Token').describe('The bot token from @BotFather'), +}) + +const _tokenForm = { + pageTitle: 'Connect Telegram', + htmlOrMarkdownPageContents: + 'Paste the bot token from @BotFather.
' + + 'Create a new bot with the /newbot command if you do not have one yet.', + schema: _tokenSchema, + nextStepId: 'finalize', +} + +export const handler = async (props: bp.HandlerProps) => { + const wizard = new oauthWizard.OAuthWizardBuilder(props) + .addStep({ id: 'start', handler: _startHandler }) + .addStep({ id: 'finalize', handler: _finalizeHandler }) + .build() + + return await wizard.handleRequest() +} + +const _startHandler: WizardHandler = ({ responses }) => { + return responses.displayForm(_tokenForm) +} + +const _finalizeHandler: WizardHandler = async ({ + formValues, + client, + ctx, + logger, + responses, + setIntegrationIdentifier, +}) => { + if (!formValues) { + return responses.redirectToStep('start') + } + + const parsed = _tokenSchema.safeParse(formValues) + if (!parsed.success) { + return responses.displayForm({ + ..._tokenForm, + errors: parsed.error, + previousValues: formValues as z.input, + }) + } + + const { botToken } = parsed.data + + let botUsername: string + try { + const telegraf = new Telegraf(botToken) + const bot = await telegraf.telegram.getMe() + botUsername = bot.username ?? String(bot.id) + } catch (thrown: unknown) { + const err = thrown instanceof Error ? thrown : new Error(String(thrown)) + logger.forBot().debug('Token validation failed via getMe():', err) + return responses.displayForm({ + ..._tokenForm, + // not a real ZodError instance, displayForm only reads errors.issues + errors: { + issues: [{ code: 'custom', path: ['botToken'], message: 'Invalid bot token. Please check and try again.' }], + } as unknown as z.ZodError>, + previousValues: { botToken }, + }) + } + + await client.setState({ + type: 'integration', + name: 'credentials', + id: ctx.integrationId, + payload: { botToken }, + }) + + setIntegrationIdentifier(botUsername) + return responses.endWizard({ success: true }) +} diff --git a/integrations/telegram/tsconfig.json b/integrations/telegram/tsconfig.json index d46abc5b88f..4d1aa2db641 100644 --- a/integrations/telegram/tsconfig.json +++ b/integrations/telegram/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "paths": { "*": ["./*"] }, - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "preact" }, "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] } From 1aff730840c64d832b7e159f7f99c05bf24e5b85 Mon Sep 17 00:00:00 2001 From: James Helou <147013024+james-helou@users.noreply.github.com> Date: Mon, 25 May 2026 09:52:07 -0400 Subject: [PATCH 2/2] feat(integration/sharepoint): add SharePoint integration (#15206) Co-authored-by: James Helou Co-authored-by: James Helou --- .../sharepoint/definitions/actions/index.ts | 6 + .../sharepoint/definitions/actions/sync.ts | 14 ++ .../sharepoint/definitions/configuration.ts | 24 ++ integrations/sharepoint/definitions/index.ts | 3 + integrations/sharepoint/definitions/states.ts | 27 ++ integrations/sharepoint/eslint.config.mjs | 13 + integrations/sharepoint/hub.md | 62 +++++ integrations/sharepoint/icon.svg | 59 +++++ .../sharepoint/integration.definition.ts | 32 +++ integrations/sharepoint/package.json | 24 ++ .../sharepoint/src/SharepointClient.ts | 230 ++++++++++++++++++ integrations/sharepoint/src/action-wrapper.ts | 9 + integrations/sharepoint/src/actions/index.ts | 1 + integrations/sharepoint/src/actions/sync.ts | 76 ++++++ .../src/files-readonly/actions/index.ts | 2 + .../actions/list-items-in-folder.ts | 62 +++++ .../actions/transfer-file-to-botpress.ts | 19 ++ .../sharepoint/src/files-readonly/mapping.ts | 36 +++ integrations/sharepoint/src/index.ts | 16 ++ .../sharepoint/src/misc/SharepointTypes.ts | 49 ++++ integrations/sharepoint/src/misc/utils.ts | 25 ++ integrations/sharepoint/src/setup/handler.ts | 159 ++++++++++++ integrations/sharepoint/src/setup/index.ts | 3 + integrations/sharepoint/src/setup/register.ts | 87 +++++++ .../sharepoint/src/setup/unregister.ts | 31 +++ integrations/sharepoint/src/setup/utils.ts | 23 ++ integrations/sharepoint/tsconfig.json | 8 + integrations/sharepoint/vitest.config.ts | 2 + pnpm-lock.yaml | 71 ++++-- 29 files changed, 1151 insertions(+), 22 deletions(-) create mode 100644 integrations/sharepoint/definitions/actions/index.ts create mode 100644 integrations/sharepoint/definitions/actions/sync.ts create mode 100644 integrations/sharepoint/definitions/configuration.ts create mode 100644 integrations/sharepoint/definitions/index.ts create mode 100644 integrations/sharepoint/definitions/states.ts create mode 100644 integrations/sharepoint/eslint.config.mjs create mode 100644 integrations/sharepoint/hub.md create mode 100644 integrations/sharepoint/icon.svg create mode 100644 integrations/sharepoint/integration.definition.ts create mode 100644 integrations/sharepoint/package.json create mode 100644 integrations/sharepoint/src/SharepointClient.ts create mode 100644 integrations/sharepoint/src/action-wrapper.ts create mode 100644 integrations/sharepoint/src/actions/index.ts create mode 100644 integrations/sharepoint/src/actions/sync.ts create mode 100644 integrations/sharepoint/src/files-readonly/actions/index.ts create mode 100644 integrations/sharepoint/src/files-readonly/actions/list-items-in-folder.ts create mode 100644 integrations/sharepoint/src/files-readonly/actions/transfer-file-to-botpress.ts create mode 100644 integrations/sharepoint/src/files-readonly/mapping.ts create mode 100644 integrations/sharepoint/src/index.ts create mode 100644 integrations/sharepoint/src/misc/SharepointTypes.ts create mode 100644 integrations/sharepoint/src/misc/utils.ts create mode 100644 integrations/sharepoint/src/setup/handler.ts create mode 100644 integrations/sharepoint/src/setup/index.ts create mode 100644 integrations/sharepoint/src/setup/register.ts create mode 100644 integrations/sharepoint/src/setup/unregister.ts create mode 100644 integrations/sharepoint/src/setup/utils.ts create mode 100644 integrations/sharepoint/tsconfig.json create mode 100644 integrations/sharepoint/vitest.config.ts diff --git a/integrations/sharepoint/definitions/actions/index.ts b/integrations/sharepoint/definitions/actions/index.ts new file mode 100644 index 00000000000..7fb796da120 --- /dev/null +++ b/integrations/sharepoint/definitions/actions/index.ts @@ -0,0 +1,6 @@ +import * as sdk from '@botpress/sdk' +import { addToSync } from './sync' + +export const actions = { + addToSync, +} as const satisfies sdk.IntegrationDefinitionProps['actions'] diff --git a/integrations/sharepoint/definitions/actions/sync.ts b/integrations/sharepoint/definitions/actions/sync.ts new file mode 100644 index 00000000000..1ab1a8cf4b4 --- /dev/null +++ b/integrations/sharepoint/definitions/actions/sync.ts @@ -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({}), + }, +} diff --git a/integrations/sharepoint/definitions/configuration.ts b/integrations/sharepoint/definitions/configuration.ts new file mode 100644 index 00000000000..17d1abbb6d8 --- /dev/null +++ b/integrations/sharepoint/definitions/configuration.ts @@ -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'] diff --git a/integrations/sharepoint/definitions/index.ts b/integrations/sharepoint/definitions/index.ts new file mode 100644 index 00000000000..c0708cc2b9a --- /dev/null +++ b/integrations/sharepoint/definitions/index.ts @@ -0,0 +1,3 @@ +export { configuration } from './configuration' +export { states } from './states' +export { actions } from './actions' diff --git a/integrations/sharepoint/definitions/states.ts b/integrations/sharepoint/definitions/states.ts new file mode 100644 index 00000000000..94eee3d6d2c --- /dev/null +++ b/integrations/sharepoint/definitions/states.ts @@ -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'] diff --git a/integrations/sharepoint/eslint.config.mjs b/integrations/sharepoint/eslint.config.mjs new file mode 100644 index 00000000000..8c81907e7de --- /dev/null +++ b/integrations/sharepoint/eslint.config.mjs @@ -0,0 +1,13 @@ +import rootConfig from '../../eslint.config.mjs' + +export default [ + ...rootConfig, + { + languageOptions: { + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] diff --git a/integrations/sharepoint/hub.md b/integrations/sharepoint/hub.md new file mode 100644 index 00000000000..f8c5db8e1b2 --- /dev/null +++ b/integrations/sharepoint/hub.md @@ -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. diff --git a/integrations/sharepoint/icon.svg b/integrations/sharepoint/icon.svg new file mode 100644 index 00000000000..8baca491054 --- /dev/null +++ b/integrations/sharepoint/icon.svg @@ -0,0 +1,59 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + diff --git a/integrations/sharepoint/integration.definition.ts b/integrations/sharepoint/integration.definition.ts new file mode 100644 index 00000000000..f167e592676 --- /dev/null +++ b/integrations/sharepoint/integration.definition.ts @@ -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.0', + title: 'SharePoint', + description: 'Sync SharePoint document libraries with Botpress knowledge bases.', + readme: 'hub.md', + icon: 'icon.svg', + configuration, + states, + actions, + attributes: { + category: 'Knowledge Base', + 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 }, + }, + }, +})) diff --git a/integrations/sharepoint/package.json b/integrations/sharepoint/package.json new file mode 100644 index 00000000000..44e6f22a74a --- /dev/null +++ b/integrations/sharepoint/package.json @@ -0,0 +1,24 @@ +{ + "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:*", + "@sentry/cli": "^2.39.1" + }, + "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" + } +} diff --git a/integrations/sharepoint/src/SharepointClient.ts b/integrations/sharepoint/src/SharepointClient.ts new file mode 100644 index 00000000000..ae93ed1dc7a --- /dev/null +++ b/integrations/sharepoint/src/SharepointClient.ts @@ -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 { + 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 { + 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 { + const changes = await this.getChanges(null) + if (changes.length > 0) { + return changes.at(-1)!.ChangeToken.StringValue + } + return null + } + + public async getFilePath(listItemIndex: number): Promise { + 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 { + const lib = this.getDocumentLibraryName() + const token = await this._fetchToken() + const url = `${this._baseUrl}/_api/web/lists/getbytitle('${lib}')/GetChanges` + const query: Record = { + 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(url, { query }, { headers: this._odataVerboseHeaders(token) }) + .catch(handleAxiosError) + return res.data.d.results + } + + public async registerWebhook(webhookUrl: string, clientState: string): Promise { + 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 { + 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 { + 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> { + 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> { + 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(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(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> { + const token = await this._fetchToken() + const url = + `${this._baseUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativePath}')/Folders` + + "?$filter=Name ne 'Forms'" + const res = await axios + .get(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 { + const token = await this._fetchToken() + const url = `${this._baseUrl}/_api/web/GetFileByServerRelativeUrl('${serverRelativePath}')/$value` + const res = await axios + .get(url, { headers: { Authorization: `Bearer ${token}` }, responseType: 'arraybuffer' }) + .catch(handleAxiosError) + return res.data + } +} diff --git a/integrations/sharepoint/src/action-wrapper.ts b/integrations/sharepoint/src/action-wrapper.ts new file mode 100644 index 00000000000..0391107e623 --- /dev/null +++ b/integrations/sharepoint/src/action-wrapper.ts @@ -0,0 +1,9 @@ +import { createActionWrapper } from '@botpress/common' +import { SharepointClient } from './SharepointClient' +import * as bp from '.botpress' + +export const wrapAction = createActionWrapper()({ + toolFactories: { + sharepointClient: ({ ctx }: { ctx: bp.Context }) => new SharepointClient(ctx.configuration), + }, +}) diff --git a/integrations/sharepoint/src/actions/index.ts b/integrations/sharepoint/src/actions/index.ts new file mode 100644 index 00000000000..98f3a52ddb9 --- /dev/null +++ b/integrations/sharepoint/src/actions/index.ts @@ -0,0 +1 @@ +export { addToSync } from './sync' diff --git a/integrations/sharepoint/src/actions/sync.ts b/integrations/sharepoint/src/actions/sync.ts new file mode 100644 index 00000000000..0f81ebd26f9 --- /dev/null +++ b/integrations/sharepoint/src/actions/sync.ts @@ -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 + 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 {} +} diff --git a/integrations/sharepoint/src/files-readonly/actions/index.ts b/integrations/sharepoint/src/files-readonly/actions/index.ts new file mode 100644 index 00000000000..f437dfb29e3 --- /dev/null +++ b/integrations/sharepoint/src/files-readonly/actions/index.ts @@ -0,0 +1,2 @@ +export { filesReadonlyListItemsInFolder } from './list-items-in-folder' +export { filesReadonlyTransferFileToBotpress } from './transfer-file-to-botpress' diff --git a/integrations/sharepoint/src/files-readonly/actions/list-items-in-folder.ts b/integrations/sharepoint/src/files-readonly/actions/list-items-in-folder.ts new file mode 100644 index 00000000000..ea01ec62c94 --- /dev/null +++ b/integrations/sharepoint/src/files-readonly/actions/list-items-in-folder.ts @@ -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 + | ReturnType + | ReturnType +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 + }) +} diff --git a/integrations/sharepoint/src/files-readonly/actions/transfer-file-to-botpress.ts b/integrations/sharepoint/src/files-readonly/actions/transfer-file-to-botpress.ts new file mode 100644 index 00000000000..f8104e60934 --- /dev/null +++ b/integrations/sharepoint/src/files-readonly/actions/transfer-file-to-botpress.ts @@ -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 } + } +) diff --git a/integrations/sharepoint/src/files-readonly/mapping.ts b/integrations/sharepoint/src/files-readonly/mapping.ts new file mode 100644 index 00000000000..4587f32767d --- /dev/null +++ b/integrations/sharepoint/src/files-readonly/mapping.ts @@ -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, +}) diff --git a/integrations/sharepoint/src/index.ts b/integrations/sharepoint/src/index.ts new file mode 100644 index 00000000000..53aca17a6d8 --- /dev/null +++ b/integrations/sharepoint/src/index.ts @@ -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: {}, +}) diff --git a/integrations/sharepoint/src/misc/SharepointTypes.ts b/integrations/sharepoint/src/misc/SharepointTypes.ts new file mode 100644 index 00000000000..56864801fa1 --- /dev/null +++ b/integrations/sharepoint/src/misc/SharepointTypes.ts @@ -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 + } + }> + } +} diff --git a/integrations/sharepoint/src/misc/utils.ts b/integrations/sharepoint/src/misc/utils.ts new file mode 100644 index 00000000000..34154327b9a --- /dev/null +++ b/integrations/sharepoint/src/misc/utils.ts @@ -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-----` +} diff --git a/integrations/sharepoint/src/setup/handler.ts b/integrations/sharepoint/src/setup/handler.ts new file mode 100644 index 00000000000..5c3a8be9a08 --- /dev/null +++ b/integrations/sharepoint/src/setup/handler.ts @@ -0,0 +1,159 @@ +import type { ChangeItem } from '../misc/SharepointTypes' +import { SharepointClient } from '../SharepointClient' +import * as bp from '.botpress' + +type ItemCache = Record +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, + 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 } + 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' } +} diff --git a/integrations/sharepoint/src/setup/index.ts b/integrations/sharepoint/src/setup/index.ts new file mode 100644 index 00000000000..6789af5715e --- /dev/null +++ b/integrations/sharepoint/src/setup/index.ts @@ -0,0 +1,3 @@ +export { handler } from './handler' +export { register } from './register' +export { unregister } from './unregister' diff --git a/integrations/sharepoint/src/setup/register.ts b/integrations/sharepoint/src/setup/register.ts new file mode 100644 index 00000000000..b0d905f22a5 --- /dev/null +++ b/integrations/sharepoint/src/setup/register.ts @@ -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 + 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(', ')}`) +} diff --git a/integrations/sharepoint/src/setup/unregister.ts b/integrations/sharepoint/src/setup/unregister.ts new file mode 100644 index 00000000000..ebfccd11691 --- /dev/null +++ b/integrations/sharepoint/src/setup/unregister.ts @@ -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 } } + + 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.`) + } + } +} diff --git a/integrations/sharepoint/src/setup/utils.ts b/integrations/sharepoint/src/setup/utils.ts new file mode 100644 index 00000000000..f493ffaf667 --- /dev/null +++ b/integrations/sharepoint/src/setup/utils.ts @@ -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 => { + 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) + }` + ) + } +} diff --git a/integrations/sharepoint/tsconfig.json b/integrations/sharepoint/tsconfig.json new file mode 100644 index 00000000000..d46abc5b88f --- /dev/null +++ b/integrations/sharepoint/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { "*": ["./*"] }, + "outDir": "dist" + }, + "include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"] +} diff --git a/integrations/sharepoint/vitest.config.ts b/integrations/sharepoint/vitest.config.ts new file mode 100644 index 00000000000..15790f99dc3 --- /dev/null +++ b/integrations/sharepoint/vitest.config.ts @@ -0,0 +1,2 @@ +import config from '../../vitest.config' +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85953bc7b0..038c178c658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1224,10 +1224,10 @@ importers: version: link:../../packages/cli '@hey-api/client-fetch': specifier: ^0.13.1 - version: 0.13.1(@hey-api/openapi-ts@0.97.1(typescript@5.9.3)) + version: 0.13.1(@hey-api/openapi-ts@0.97.2(typescript@5.9.3)) '@hey-api/openapi-ts': specifier: ^0.97.1 - version: 0.97.1(typescript@5.9.3) + version: 0.97.2(typescript@5.9.3) '@types/node': specifier: ^22.16.4 version: 22.16.4 @@ -1883,6 +1883,28 @@ importers: specifier: ^14.1.2 version: 14.1.2 + integrations/sharepoint: + dependencies: + '@azure/msal-node': + specifier: ^3.6.2 + version: 3.6.3 + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common + '@botpress/sdk': + specifier: workspace:* + version: link:../../packages/sdk + axios: + specifier: ^1.10.0 + version: 1.13.6 + devDependencies: + '@botpress/cli': + specifier: workspace:* + version: link:../../packages/cli + '@sentry/cli': + specifier: ^2.39.1 + version: 2.39.1 + integrations/slack: dependencies: '@botpress/common': @@ -5050,15 +5072,15 @@ packages: resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==} engines: {node: '>=22.13.0'} - '@hey-api/openapi-ts@0.97.1': - resolution: {integrity: sha512-LksUJeXAqwf6OhcCCr3/B4YjnBs5rqSqjDUKMBvkgp4OhaCQiJrOvntctFxdnugy8jUojP4yi/eJf5xYzcYzCQ==} + '@hey-api/openapi-ts@0.97.2': + resolution: {integrity: sha512-nA+y0/I5O9loQMeJKumi6BQ40/Y71N0hIMmXZ/I7rh8jEOzYxSxmf5a4TBEI2Ap4RAfZyh7RJzJfVzT98KUYQQ==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' - '@hey-api/shared@0.4.3': - resolution: {integrity: sha512-3tHfZNXgGOt+3P3Kq9cvqmZ9i7e3jtrkip1uDpZTX1+hTNboHhYdjxnT8AbrDuvslTaQHoAOlP4/iCDdzd9Jag==} + '@hey-api/shared@0.4.4': + resolution: {integrity: sha512-UZgaQNEdo/OSGLeNXhSv0VQTHQQm5Q2mHOuoYhFPJkNvLVrz7KZtGdKR8O4QPrhyblshxY+caJli08WKM0gREg==} engines: {node: '>=22.13.0'} '@hey-api/spec-types@0.2.0': @@ -6759,6 +6781,9 @@ packages: '@types/lodash@4.17.0': resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mailchimp__mailchimp_marketing@3.0.10': resolution: {integrity: sha512-UMeHXH1XQUuj6PBWQzripZhH7ENEFMHiH55aGXKKuqhM29kF//xHWwqazY/zwT8TWKHvXAmIg/MKT8ZM1pFWrw==} @@ -14778,9 +14803,9 @@ snapshots: dependencies: graphql: 15.8.0 - '@hey-api/client-fetch@0.13.1(@hey-api/openapi-ts@0.97.1(typescript@5.9.3))': + '@hey-api/client-fetch@0.13.1(@hey-api/openapi-ts@0.97.2(typescript@5.9.3))': dependencies: - '@hey-api/openapi-ts': 0.97.1(typescript@5.9.3) + '@hey-api/openapi-ts': 0.97.2(typescript@5.9.3) '@hey-api/codegen-core@0.8.1': dependencies: @@ -14797,11 +14822,11 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 - '@hey-api/openapi-ts@0.97.1(typescript@5.9.3)': + '@hey-api/openapi-ts@0.97.2(typescript@5.9.3)': dependencies: '@hey-api/codegen-core': 0.8.1 '@hey-api/json-schema-ref-parser': 1.4.2 - '@hey-api/shared': 0.4.3 + '@hey-api/shared': 0.4.4 '@hey-api/spec-types': 0.2.0 '@hey-api/types': 0.1.4 '@lukeed/ms': 2.0.2 @@ -14813,7 +14838,7 @@ snapshots: transitivePeerDependencies: - magicast - '@hey-api/shared@0.4.3': + '@hey-api/shared@0.4.4': dependencies: '@hey-api/codegen-core': 0.8.1 '@hey-api/json-schema-ref-parser': 1.4.2 @@ -16962,12 +16987,14 @@ snapshots: '@types/lodash-es@4.17.12': dependencies: - '@types/lodash': 4.17.0 + '@types/lodash': 4.17.24 '@types/lodash@4.14.195': {} '@types/lodash@4.17.0': {} + '@types/lodash@4.17.24': {} + '@types/mailchimp__mailchimp_marketing@3.0.10': {} '@types/markdown-it@14.1.2': @@ -17179,7 +17206,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.4 + semver: 7.7.2 ts-api-utils: 2.1.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: @@ -18683,7 +18710,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.4 + semver: 7.7.2 ee-first@1.1.1: {} @@ -19887,7 +19914,7 @@ snapshots: extend: 3.0.2 gaxios: 5.1.0 google-auth-library: 8.8.0 - qs: 6.15.0 + qs: 6.13.0 url-template: 2.0.8 uuid: 9.0.1 transitivePeerDependencies: @@ -19899,7 +19926,7 @@ snapshots: extend: 3.0.2 gaxios: 6.1.1 google-auth-library: 9.0.0 - qs: 6.15.0 + qs: 6.13.0 url-template: 2.0.8 uuid: 9.0.1 transitivePeerDependencies: @@ -20757,7 +20784,7 @@ snapshots: jest-util: 29.5.0 natural-compare: 1.4.0 pretty-format: 29.5.0 - semver: 7.7.4 + semver: 7.7.2 transitivePeerDependencies: - supports-color @@ -20942,7 +20969,7 @@ snapshots: dependencies: '@bcherny/json-schema-ref-parser': 10.0.5-fork '@types/json-schema': 7.0.15 - '@types/lodash': 4.17.0 + '@types/lodash': 4.17.24 '@types/prettier': 2.7.3 cli-color: 2.0.3 get-stdin: 8.0.0 @@ -21521,7 +21548,7 @@ snapshots: messaging-api-common@1.0.4: dependencies: '@types/debug': 4.1.12 - '@types/lodash': 4.17.0 + '@types/lodash': 4.17.24 '@types/url-join': 4.0.3 axios: 0.21.4(debug@4.4.1) camel-case: 4.1.2 @@ -23416,7 +23443,7 @@ snapshots: formidable: 1.2.6 methods: 1.1.2 mime: 1.6.0 - qs: 6.15.0 + qs: 6.13.0 readable-stream: 2.3.8 transitivePeerDependencies: - supports-color @@ -23433,7 +23460,7 @@ snapshots: mime: 2.6.0 qs: 6.15.0 readable-stream: 3.6.2 - semver: 7.7.4 + semver: 7.7.2 transitivePeerDependencies: - supports-color @@ -23449,7 +23476,7 @@ snapshots: mime: 2.6.0 qs: 6.15.0 readable-stream: 3.6.2 - semver: 7.7.4 + semver: 7.7.2 transitivePeerDependencies: - supports-color