diff --git a/integrations/github/integration.definition.ts b/integrations/github/integration.definition.ts index a040769a089..12fdd289000 100644 --- a/integrations/github/integration.definition.ts +++ b/integrations/github/integration.definition.ts @@ -1,5 +1,6 @@ import * as sdk from '@botpress/sdk' import { sentry as sentryHelpers } from '@botpress/sdk-addons' +import filesReadonly from './bp_modules/files-readonly' import { INTEGRATION_NAME } from './src/const' import { actions, events, configuration, configurations, channels, user, secrets, states } from './src/definitions' @@ -7,7 +8,7 @@ import { actions, events, configuration, configurations, channels, user, secrets export default new sdk.IntegrationDefinition({ name: INTEGRATION_NAME, title: 'GitHub', - version: '1.1.10', + version: '1.2.0', icon: 'icon.svg', readme: 'hub.md', description: 'Manage GitHub issues, pull requests, and repositories.', @@ -25,4 +26,16 @@ export default new sdk.IntegrationDefinition({ attributes: { category: 'Developer Tools', }, -}) +}).extend(filesReadonly, ({}) => ({ + entities: {}, + actions: { + listItemsInFolder: { + name: 'filesReadonlyListItemsInFolder', + attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO }, + }, + transferFileToBotpress: { + name: 'filesReadonlyTransferFileToBotpress', + attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO }, + }, + }, +})) diff --git a/integrations/github/package.json b/integrations/github/package.json index 06d93b9f904..bc47421ec5a 100644 --- a/integrations/github/package.json +++ b/integrations/github/package.json @@ -21,5 +21,8 @@ "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", "@sentry/cli": "^2.39.1" + }, + "bpDependencies": { + "files-readonly": "../../interfaces/files-readonly" } } diff --git a/integrations/github/src/events/push/push-received.ts b/integrations/github/src/events/push/push-received.ts new file mode 100644 index 00000000000..9cad544552e --- /dev/null +++ b/integrations/github/src/events/push/push-received.ts @@ -0,0 +1,136 @@ +import type { PushEvent } from '@octokit/webhooks-types' +import * as mapping from '../../files-readonly/mapping' +import * as bp from '.botpress' + +const MAX_BATCH_SIZE = 50 + +export const firePushReceived = async ({ + githubEvent, + client, + logger, +}: bp.HandlerProps & { githubEvent: PushEvent }) => { + try { + const repo = githubEvent.repository + const owner = repo.owner.login ?? repo.owner.name + const repoName = repo.name + const defaultRef = `refs/heads/${repo.default_branch ?? repo.master_branch ?? 'main'}` + + if (githubEvent.ref !== defaultRef) { + logger.forBot().debug(`Ignoring push to non-default branch: ${githubEvent.ref}`) + return + } + + const fileStates = new Map() + + for (const commit of githubEvent.commits) { + for (const filePath of commit.added ?? []) { + const prev = fileStates.get(filePath) + fileStates.set(filePath, prev === 'deleted' ? 'updated' : 'created') + } + for (const filePath of commit.modified ?? []) { + const prev = fileStates.get(filePath) + if (prev !== 'created') { + fileStates.set(filePath, 'updated') + } + } + for (const filePath of commit.removed ?? []) { + const prev = fileStates.get(filePath) + if (prev === 'created') { + fileStates.delete(filePath) + } else { + fileStates.set(filePath, 'deleted') + } + } + } + + if (fileStates.size === 0) { + return + } + + const created: bp.events.Events['aggregateFileChanges']['modifiedItems']['created'] = [] + const updated: bp.events.Events['aggregateFileChanges']['modifiedItems']['updated'] = [] + const deleted: bp.events.Events['aggregateFileChanges']['modifiedItems']['deleted'] = [] + + for (const [filePath, state] of fileStates) { + const file = mapping.mapPushFileToFile(owner, repoName, filePath) + if (state === 'created') { + created.push(file) + } else if (state === 'updated') { + updated.push(file) + } else { + deleted.push(file) + } + } + + await _emitFileChangeEvents({ client, logger, changes: { created, updated, deleted } }) + } catch (err: unknown) { + const error = err instanceof Error ? err : new Error(String(err)) + logger.forBot().error('Failed to process push event; swallowing to prevent webhook retries', error) + } +} + +const _emitFileChangeEvents = async ({ + client, + logger, + changes, +}: { + client: bp.Client + logger: bp.Logger + changes: FileChanges +}) => { + try { + for (const batch of _getBatches(changes)) { + await client.createEvent({ + type: 'aggregateFileChanges', + payload: { + modifiedItems: batch, + }, + }) + } + } catch (err: unknown) { + const error = err instanceof Error ? err : new Error(String(err)) + logger.forBot().error('Failed to emit file-change events; swallowing to prevent webhook retries', error) + } +} + +type FileChanges = { + created: bp.events.Events['aggregateFileChanges']['modifiedItems']['created'] + updated: bp.events.Events['aggregateFileChanges']['modifiedItems']['updated'] + deleted: bp.events.Events['aggregateFileChanges']['modifiedItems']['deleted'] +} + +function* _getBatches(changes: FileChanges): Generator { + let createdIdx = 0 + let updatedIdx = 0 + let deletedIdx = 0 + + while ( + createdIdx < changes.created.length || + updatedIdx < changes.updated.length || + deletedIdx < changes.deleted.length + ) { + const batch: FileChanges = { created: [], updated: [], deleted: [] } + let size = 0 + + while (size < MAX_BATCH_SIZE) { + const startSize = size + if (createdIdx < changes.created.length && size < MAX_BATCH_SIZE) { + batch.created.push(changes.created[createdIdx++]!) + size++ + } + if (updatedIdx < changes.updated.length && size < MAX_BATCH_SIZE) { + batch.updated.push(changes.updated[updatedIdx++]!) + size++ + } + if (deletedIdx < changes.deleted.length && size < MAX_BATCH_SIZE) { + batch.deleted.push(changes.deleted[deletedIdx++]!) + size++ + } + if (size === startSize) { + break + } + } + + yield batch + } +} diff --git a/integrations/github/src/files-readonly/actions/index.ts b/integrations/github/src/files-readonly/actions/index.ts new file mode 100644 index 00000000000..f437dfb29e3 --- /dev/null +++ b/integrations/github/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/github/src/files-readonly/actions/list-items-in-folder.ts b/integrations/github/src/files-readonly/actions/list-items-in-folder.ts new file mode 100644 index 00000000000..a0bc4234059 --- /dev/null +++ b/integrations/github/src/files-readonly/actions/list-items-in-folder.ts @@ -0,0 +1,146 @@ +import { wrapActionAndInjectOctokit } from 'src/misc/action-wrapper' +import * as mapping from '../mapping' + +const REPOS_PAGE_SIZE = 100 +const CONTENTS_PAGE_SIZE = 100 + +export const filesReadonlyListItemsInFolder = wrapActionAndInjectOctokit( + { actionName: 'filesReadonlyListItemsInFolder', errorMessage: 'Failed to list items in folder' }, + async ({ octokit, owner }, { folderId, filters, nextToken: prevToken }) => { + if (!folderId) { + return await _listRepos({ octokit, owner, prevToken, filters }) + } + + const repoInfo = mapping.decodeRepoFolderId(folderId) + if (repoInfo) { + return await _listRepoRoot({ octokit, ...repoInfo, prevToken, filters }) + } + + const contentInfo = mapping.decodeContentId(folderId) + if (contentInfo) { + return await _listFolderContents({ octokit, ...contentInfo, parentId: folderId, filters, prevToken }) + } + + return { items: [], meta: { nextToken: undefined } } + } +) + +const _listRepos = async ({ + octokit, + owner, + prevToken, + filters, +}: { + octokit: { rest: any } + owner: string + prevToken?: string + filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string } +}) => { + if (filters?.itemType === 'file') { + return { items: [], meta: { nextToken: undefined } } + } + + const page = prevToken ? parseInt(prevToken, 10) : 1 + + let repos: mapping.GitHubRepo[] + try { + const response = await octokit.rest.repos.listForOrg({ + org: owner, + per_page: REPOS_PAGE_SIZE, + page, + type: 'all', + }) + repos = response.data as mapping.GitHubRepo[] + } catch (err: unknown) { + if (err && typeof err === 'object' && 'status' in err && err.status === 404) { + const response = await octokit.rest.repos.listForAuthenticatedUser({ + per_page: REPOS_PAGE_SIZE, + page, + }) + repos = response.data as mapping.GitHubRepo[] + } else { + throw err + } + } + + const items = repos.map(mapping.mapRepoToFolder) + + const hasMore = repos.length === REPOS_PAGE_SIZE + return { + items, + meta: { nextToken: hasMore ? String(page + 1) : undefined }, + } +} + +const _listRepoRoot = async ({ + octokit, + owner, + repo, + prevToken, + filters, +}: { + octokit: { rest: any } + owner: string + repo: string + prevToken?: string + filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string } +}) => { + return _listFolderContents({ + octokit, + owner, + repo, + path: '', + parentId: mapping.encodeRepoFolderId(owner, repo), + filters, + prevToken, + }) +} + +const _listFolderContents = async ({ + octokit, + owner, + repo, + path, + parentId, + filters, + prevToken, +}: { + octokit: { rest: any } + owner: string + repo: string + path: string + parentId: string + filters?: { itemType?: string; maxSizeInBytes?: number; modifiedAfter?: string } + prevToken?: string +}) => { + const response = await octokit.rest.repos.getContent({ + owner, + repo, + path: path || '', + }) + + const contents = Array.isArray(response.data) ? response.data : [response.data] + const contentItems = contents as mapping.GitHubContentItem[] + + const startIndex = prevToken ? parseInt(prevToken, 10) : 0 + const pageItems = contentItems.slice(startIndex, startIndex + CONTENTS_PAGE_SIZE) + + let mappedItems = pageItems.map((item) => mapping.mapContentItemToItem(owner, repo, item, parentId)) + + if (filters?.itemType) { + const filterType = filters.itemType === 'folder' ? 'folder' : 'file' + mappedItems = mappedItems.filter((item) => item.type === filterType) + } + + if (filters?.maxSizeInBytes) { + mappedItems = mappedItems.filter( + (item) => item.type !== 'file' || !item.sizeInBytes || item.sizeInBytes <= filters.maxSizeInBytes! + ) + } + + const hasMore = startIndex + CONTENTS_PAGE_SIZE < contentItems.length + return { + items: mappedItems, + meta: { nextToken: hasMore ? String(startIndex + CONTENTS_PAGE_SIZE) : undefined }, + } +} diff --git a/integrations/github/src/files-readonly/actions/transfer-file-to-botpress.ts b/integrations/github/src/files-readonly/actions/transfer-file-to-botpress.ts new file mode 100644 index 00000000000..7b6f5b6dca5 --- /dev/null +++ b/integrations/github/src/files-readonly/actions/transfer-file-to-botpress.ts @@ -0,0 +1,32 @@ +import * as sdk from '@botpress/sdk' +import { wrapActionAndInjectOctokit } from 'src/misc/action-wrapper' +import * as mapping from '../mapping' + +export const filesReadonlyTransferFileToBotpress = wrapActionAndInjectOctokit( + { actionName: 'filesReadonlyTransferFileToBotpress', errorMessage: 'Failed to transfer file to Botpress' }, + async ({ octokit, client }, { file, fileKey, shouldIndex }) => { + const contentInfo = mapping.decodeContentId(file.id) + if (!contentInfo) { + throw new sdk.RuntimeError(`Invalid file ID: ${file.id}`) + } + + const { owner, repo, path } = contentInfo + + const response = await octokit.rest.repos.getContent({ + owner, + repo, + path, + mediaType: { format: 'raw' }, + }) + + const content = typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + + const { file: uploadedFile } = await client.uploadFile({ + key: fileKey, + content, + index: shouldIndex, + }) + + return { botpressFileId: uploadedFile.id } + } +) diff --git a/integrations/github/src/files-readonly/mapping.test.ts b/integrations/github/src/files-readonly/mapping.test.ts new file mode 100644 index 00000000000..a9172cc6e3a --- /dev/null +++ b/integrations/github/src/files-readonly/mapping.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { encodeRepoFolderId, decodeRepoFolderId, encodeContentId, decodeContentId } from './mapping' + +describe.concurrent('encodeRepoFolderId / decodeRepoFolderId', () => { + it('should round-trip a simple owner/repo', () => { + const encoded = encodeRepoFolderId('acme', 'widgets') + expect(encoded).toBe('repo:acme/widgets') + expect(decodeRepoFolderId(encoded)).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('should round-trip owner/repo with hyphens and dots', () => { + const encoded = encodeRepoFolderId('my-org', 'my-repo.js') + expect(decodeRepoFolderId(encoded)).toEqual({ owner: 'my-org', repo: 'my-repo.js' }) + }) + + it('should return null for ids missing the repo: prefix', () => { + expect(decodeRepoFolderId('acme/widgets')).toBeNull() + expect(decodeRepoFolderId('folder:acme/widgets')).toBeNull() + }) + + it('should return null for an id with only an owner (no repo segment)', () => { + expect(decodeRepoFolderId('repo:acme')).toBeNull() + }) + + it('should return null for an empty payload after prefix', () => { + expect(decodeRepoFolderId('repo:')).toBeNull() + }) + + it('should return null for ids with empty owner or repo segments', () => { + expect(decodeRepoFolderId('repo:/widgets')).toBeNull() + expect(decodeRepoFolderId('repo:acme/')).toBeNull() + expect(decodeRepoFolderId('repo:/')).toBeNull() + }) + + it('should NOT match content ids (3+ path segments)', () => { + expect(decodeRepoFolderId('repo:acme/widgets/src')).toBeNull() + expect(decodeRepoFolderId('repo:acme/widgets/src/index.ts')).toBeNull() + expect(decodeRepoFolderId('repo:acme/widgets/a/b/c/d')).toBeNull() + }) +}) + +describe.concurrent('encodeContentId / decodeContentId', () => { + it('should round-trip a single-segment path', () => { + const encoded = encodeContentId('acme', 'widgets', 'README.md') + expect(encoded).toBe('repo:acme/widgets/README.md') + expect(decodeContentId(encoded)).toEqual({ owner: 'acme', repo: 'widgets', path: 'README.md' }) + }) + + it('should round-trip a deeply nested path', () => { + const encoded = encodeContentId('acme', 'widgets', 'src/utils/helpers/index.ts') + expect(encoded).toBe('repo:acme/widgets/src/utils/helpers/index.ts') + expect(decodeContentId(encoded)).toEqual({ + owner: 'acme', + repo: 'widgets', + path: 'src/utils/helpers/index.ts', + }) + }) + + it('should round-trip a directory path (no extension)', () => { + const encoded = encodeContentId('acme', 'widgets', 'src/components') + expect(decodeContentId(encoded)).toEqual({ + owner: 'acme', + repo: 'widgets', + path: 'src/components', + }) + }) + + it('should return null for ids missing the repo: prefix', () => { + expect(decodeContentId('acme/widgets/README.md')).toBeNull() + }) + + it('should return null for a repo folder id (only 2 segments, no path)', () => { + expect(decodeContentId('repo:acme/widgets')).toBeNull() + }) + + it('should return null for a single segment after prefix', () => { + expect(decodeContentId('repo:acme')).toBeNull() + }) +}) + +describe.concurrent('decodeRepoFolderId and decodeContentId are mutually exclusive on well-formed ids', () => { + it('repo folder id is decoded only by decodeRepoFolderId', () => { + const id = encodeRepoFolderId('org', 'repo') + expect(decodeRepoFolderId(id)).not.toBeNull() + // decodeContentId technically parses it but yields an empty path — the caller should + // try decodeRepoFolderId first, which is the intended dispatch order. + }) + + it('content id is decoded only by decodeContentId, never by decodeRepoFolderId', () => { + const id = encodeContentId('org', 'repo', 'src/main.ts') + expect(decodeRepoFolderId(id)).toBeNull() + expect(decodeContentId(id)).toEqual({ owner: 'org', repo: 'repo', path: 'src/main.ts' }) + }) + + it('subdirectory folder id is decoded only by decodeContentId', () => { + const id = encodeContentId('org', 'repo', 'src') + expect(decodeRepoFolderId(id)).toBeNull() + expect(decodeContentId(id)).toEqual({ owner: 'org', repo: 'repo', path: 'src' }) + }) +}) diff --git a/integrations/github/src/files-readonly/mapping.ts b/integrations/github/src/files-readonly/mapping.ts new file mode 100644 index 00000000000..c60af10d169 --- /dev/null +++ b/integrations/github/src/files-readonly/mapping.ts @@ -0,0 +1,106 @@ +import * as bp from '.botpress' + +type FilesReadonlyFile = bp.events.Events['fileCreated']['file'] +type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder'] +type FilesReadonlyItem = FilesReadonlyFile | FilesReadonlyFolder + +export type GitHubContentItem = { + name: string + path: string + sha: string + size: number + type: 'file' | 'dir' | 'submodule' | 'symlink' +} + +export type GitHubRepo = { + id: number + name: string + full_name: string + default_branch: string + owner: { login: string } +} + +const REPO_PREFIX = 'repo:' +const PATH_SEPARATOR = '/' + +export const encodeRepoFolderId = (owner: string, repo: string): string => `${REPO_PREFIX}${owner}/${repo}` + +export const decodeRepoFolderId = (folderId: string): { owner: string; repo: string } | null => { + if (!folderId.startsWith(REPO_PREFIX)) { + return null + } + const parts = folderId.slice(REPO_PREFIX.length).split('/') + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null + } + return { owner: parts[0], repo: parts[1] } +} + +export const encodeContentId = (owner: string, repo: string, path: string): string => + `${REPO_PREFIX}${owner}/${repo}/${path}` + +export const decodeContentId = (id: string): { owner: string; repo: string; path: string } | null => { + if (!id.startsWith(REPO_PREFIX)) { + return null + } + const rest = id.slice(REPO_PREFIX.length) + const slashIdx1 = rest.indexOf('/') + if (slashIdx1 === -1) { + return null + } + const owner = rest.slice(0, slashIdx1) + const slashIdx2 = rest.indexOf('/', slashIdx1 + 1) + if (slashIdx2 === -1) { + return null + } + const repo = rest.slice(slashIdx1 + 1, slashIdx2) + const path = rest.slice(slashIdx2 + 1) + return { owner, repo, path } +} + +export const mapRepoToFolder = (repo: GitHubRepo): FilesReadonlyFolder => ({ + id: encodeRepoFolderId(repo.owner.login, repo.name), + name: repo.full_name, + type: 'folder', + absolutePath: `/${repo.full_name}`, +}) + +export const mapContentItemToItem = ( + owner: string, + repo: string, + item: GitHubContentItem, + parentId?: string +): FilesReadonlyItem => { + const id = encodeContentId(owner, repo, item.path) + const absolutePath = `/${owner}/${repo}/${item.path}` + + if (item.type === 'dir') { + return { + id, + name: item.name, + type: 'folder', + parentId, + absolutePath, + } + } + + return { + id, + name: item.name, + type: 'file', + parentId, + absolutePath, + sizeInBytes: item.size, + contentHash: item.sha, + } +} + +export const mapPushFileToFile = (owner: string, repo: string, filePath: string): FilesReadonlyFile => { + const name = filePath.split(PATH_SEPARATOR).pop() ?? filePath + return { + id: encodeContentId(owner, repo, filePath), + name, + type: 'file', + absolutePath: `/${owner}/${repo}/${filePath}`, + } +} diff --git a/integrations/github/src/handler.ts b/integrations/github/src/handler.ts index 31e7d25e155..a061030e618 100644 --- a/integrations/github/src/handler.ts +++ b/integrations/github/src/handler.ts @@ -14,10 +14,12 @@ import { firePullRequestOpened } from './events/pull-request/pull-request-opened import { firePullRequestReviewCommentCreated } from './events/pull-request/pull-request-review-comment-created' import { firePullRequestReviewCommentReplied } from './events/pull-request/pull-request-review-comment-replied' import { firePullRequestReviewSubmitted } from './events/pull-request/pull-request-review-submitted' +import { firePushReceived } from './events/push/push-received' import { GithubSettings } from './misc/github-settings' import { isIssueOpenedEvent, isPingEvent, + isPushEvent, isPullRequestCommentCreatedEvent, isPullRequestReviewCommentCreatedEvent, isPullRequestReviewCommentReplyCreatedEvent, @@ -48,6 +50,7 @@ const EVENT_HANDLERS: Readonly[]> = [ [isDiscussionCreatedEvent, fireDiscussionCreated], [isDiscussionCommentCreatedEvent, fireDiscussionCommentCreated], [isDiscussionCommentReplyCreatedEvent, fireDiscussionCommentReplied], + [isPushEvent, firePushReceived], ] as const export const handler: bp.IntegrationProps['handler'] = async (props) => { diff --git a/integrations/github/src/index.ts b/integrations/github/src/index.ts index d79d3f72157..ebb2f94262e 100644 --- a/integrations/github/src/index.ts +++ b/integrations/github/src/index.ts @@ -1,6 +1,7 @@ import { sentry as sentryHelpers } from '@botpress/sdk-addons' import actions from './actions' import channels from './channels' +import { filesReadonlyListItemsInFolder, filesReadonlyTransferFileToBotpress } from './files-readonly/actions' import { handler } from './handler' import { register, unregister } from './setup' import * as bp from '.botpress' @@ -9,7 +10,11 @@ const integration = new bp.Integration({ register, unregister, handler, - actions, + actions: { + ...actions, + filesReadonlyListItemsInFolder, + filesReadonlyTransferFileToBotpress, + }, channels, }) diff --git a/integrations/github/src/misc/guards.ts b/integrations/github/src/misc/guards.ts index 5c2ecb463f2..208335d4f94 100644 --- a/integrations/github/src/misc/guards.ts +++ b/integrations/github/src/misc/guards.ts @@ -8,6 +8,7 @@ import type { PullRequestOpenedEvent, PullRequestReviewCommentCreatedEvent, PullRequestReviewSubmittedEvent, + PushEvent, WebhookEvent, } from '@octokit/webhooks-types' @@ -53,3 +54,6 @@ export const isDiscussionCommentCreatedEvent = (event: WebhookEvent): event is D export const isDiscussionCommentReplyCreatedEvent = (event: WebhookEvent): event is DiscussionCommentCreatedEvent => 'discussion' in event && 'comment' in event && event.action === 'created' && !!event.comment.parent_id + +export const isPushEvent = (event: WebhookEvent): event is PushEvent => + 'ref' in event && 'commits' in event && 'head_commit' in event diff --git a/integrations/linear/integration.definition.ts b/integrations/linear/integration.definition.ts index 3491368102b..0b29772a572 100644 --- a/integrations/linear/integration.definition.ts +++ b/integrations/linear/integration.definition.ts @@ -1,13 +1,15 @@ import { posthogHelper } from '@botpress/common' import { IntegrationDefinition } from '@botpress/sdk' +import * as sdk from '@botpress/sdk' import proactiveConversation from 'bp_modules/proactive-conversation' import deletable from './bp_modules/deletable' +import filesReadonly from './bp_modules/files-readonly' import listable from './bp_modules/listable' import { actions, channels, events, configuration, configurations, user, states, entities } from './definitions' export const INTEGRATION_NAME = 'linear' -export const INTEGRATION_VERSION = '2.0.2' +export const INTEGRATION_VERSION = '2.1.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, @@ -65,3 +67,16 @@ export default new IntegrationDefinition({ }, actions: { getOrCreateConversation: { name: 'getOrCreateIssueConversation' } }, })) + .extend(filesReadonly, ({}) => ({ + entities: {}, + actions: { + listItemsInFolder: { + name: 'filesReadonlyListItemsInFolder', + attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO }, + }, + transferFileToBotpress: { + name: 'filesReadonlyTransferFileToBotpress', + attributes: { ...sdk.WELL_KNOWN_ATTRIBUTES.HIDDEN_IN_STUDIO }, + }, + }, + })) diff --git a/integrations/linear/package.json b/integrations/linear/package.json index 707ee670559..f32a9ae64f4 100644 --- a/integrations/linear/package.json +++ b/integrations/linear/package.json @@ -26,6 +26,7 @@ "bpDependencies": { "listable": "../../interfaces/listable", "deletable": "../../interfaces/deletable", - "proactive-conversation": "../../interfaces/proactive-conversation" + "proactive-conversation": "../../interfaces/proactive-conversation", + "files-readonly": "../../interfaces/files-readonly" } } diff --git a/integrations/linear/src/actions/index.ts b/integrations/linear/src/actions/index.ts index f60cc45ed3a..8d596a0be88 100644 --- a/integrations/linear/src/actions/index.ts +++ b/integrations/linear/src/actions/index.ts @@ -1,3 +1,4 @@ +import { filesReadonlyListItemsInFolder, filesReadonlyTransferFileToBotpress } from '../files-readonly/actions' import { createIssue } from './create-issue' import { deleteIssue } from './delete-issue' import { findTarget } from './find-target' @@ -33,4 +34,6 @@ export default { getOrCreateIssueConversation, issueDelete, issueList, + filesReadonlyListItemsInFolder, + filesReadonlyTransferFileToBotpress, } satisfies Partial diff --git a/integrations/linear/src/files-readonly/actions/index.ts b/integrations/linear/src/files-readonly/actions/index.ts new file mode 100644 index 00000000000..f437dfb29e3 --- /dev/null +++ b/integrations/linear/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/linear/src/files-readonly/actions/list-items-in-folder.ts b/integrations/linear/src/files-readonly/actions/list-items-in-folder.ts new file mode 100644 index 00000000000..2bd7f7c60e8 --- /dev/null +++ b/integrations/linear/src/files-readonly/actions/list-items-in-folder.ts @@ -0,0 +1,117 @@ +import * as sdk from '@botpress/sdk' +import { getLinearClient } from '../../misc/utils' +import * as mapping from '../mapping' +import * as bp from '.botpress' + +const PAGE_SIZE = 50 + +export const filesReadonlyListItemsInFolder: bp.IntegrationProps['actions']['filesReadonlyListItemsInFolder'] = async ({ + input, + client, + ctx, +}) => { + const { folderId, nextToken: prevToken } = input + + try { + const linearClient = await getLinearClient({ client, ctx }) + + if (!folderId) { + return await _listTeams(linearClient, prevToken) + } + + if (folderId.startsWith(mapping.PREFIXES.TEAM)) { + return await _listTeamIssues(linearClient, folderId, prevToken) + } + + if (folderId.startsWith(mapping.PREFIXES.PROJECT)) { + return await _listProjectIssues(linearClient, folderId, prevToken) + } + + throw new sdk.RuntimeError(`Invalid folderId: ${folderId}`) + } catch (err: unknown) { + if (err instanceof sdk.RuntimeError) { + throw err + } + throw new sdk.RuntimeError(`Failed to list items in folder: ${err instanceof Error ? err.message : String(err)}`) + } +} + +const _listTeams = async (linearClient: any, prevToken?: string) => { + const teams = await linearClient.teams({ after: prevToken, first: PAGE_SIZE }) + const nodes = teams.nodes ?? [] + + const items = nodes.map((team: any) => mapping.mapTeamToFolder({ id: team.id, name: team.name, key: team.key })) + + return { + items, + meta: { nextToken: teams.pageInfo?.hasNextPage ? teams.pageInfo.endCursor : undefined }, + } +} + +const _listTeamIssues = async (linearClient: any, folderId: string, prevToken?: string) => { + const teamId = folderId.slice(mapping.PREFIXES.TEAM.length) + const team = await linearClient.team(teamId) + + if (!team) { + throw new sdk.RuntimeError(`Team not found: ${teamId}`) + } + + const issues = await team.issues({ after: prevToken, first: PAGE_SIZE }) + const nodes = issues.nodes ?? [] + + const items = nodes.map((issue: any) => + mapping.mapIssueToFile({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + updatedAt: issue.updatedAt?.toISOString?.() ?? new Date(issue.updatedAt).toISOString(), + teamKey: team.key, + }) + ) + + const projects = await team.projects({ first: PAGE_SIZE }) + const projectNodes = projects?.nodes ?? [] + if (!prevToken && projectNodes.length > 0) { + const projectFolders = projectNodes.map((project: any) => + mapping.mapProjectToFolder({ id: project.id, name: project.name }, team.key) + ) + items.unshift(...projectFolders) + } + + return { + items, + meta: { nextToken: issues.pageInfo?.hasNextPage ? issues.pageInfo.endCursor : undefined }, + } +} + +const _listProjectIssues = async (linearClient: any, folderId: string, prevToken?: string) => { + const projectId = folderId.slice(mapping.PREFIXES.PROJECT.length) + const project = await linearClient.project(projectId) + + if (!project) { + throw new sdk.RuntimeError(`Project not found: ${projectId}`) + } + + const issues = await project.issues({ after: prevToken, first: PAGE_SIZE }) + const nodes = issues.nodes ?? [] + + const items = await Promise.all( + nodes.map(async (issue: any) => { + const team = await issue.team + return mapping.mapIssueToFile({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + updatedAt: issue.updatedAt?.toISOString?.() ?? new Date(issue.updatedAt).toISOString(), + teamKey: team?.key, + }) + }) + ) + + return { + items, + meta: { nextToken: issues.pageInfo?.hasNextPage ? issues.pageInfo.endCursor : undefined }, + } +} diff --git a/integrations/linear/src/files-readonly/actions/transfer-file-to-botpress.ts b/integrations/linear/src/files-readonly/actions/transfer-file-to-botpress.ts new file mode 100644 index 00000000000..5c7aa0b0a68 --- /dev/null +++ b/integrations/linear/src/files-readonly/actions/transfer-file-to-botpress.ts @@ -0,0 +1,54 @@ +import * as sdk from '@botpress/sdk' +import { getLinearClient } from '../../misc/utils' +import * as mapping from '../mapping' +import * as bp from '.botpress' + +export const filesReadonlyTransferFileToBotpress: bp.IntegrationProps['actions']['filesReadonlyTransferFileToBotpress'] = + async ({ input, client, ctx }) => { + const { file, fileKey, shouldIndex } = input + + if (!file.id.startsWith(mapping.PREFIXES.ISSUE)) { + throw new sdk.RuntimeError(`Invalid file ID: ${file.id}. Only issues can be transferred.`) + } + + try { + const issueId = file.id.slice(mapping.PREFIXES.ISSUE.length) + const linearClient = await getLinearClient({ client, ctx }) + const issue = await linearClient.issue(issueId) + + if (!issue) { + throw new sdk.RuntimeError(`Issue not found: ${issueId}`) + } + + const markdown = _buildIssueMarkdown(issue) + + const { file: uploadedFile } = await client.uploadFile({ + key: fileKey, + content: markdown, + index: shouldIndex, + }) + + return { botpressFileId: uploadedFile.id } + } catch (err: unknown) { + if (err instanceof sdk.RuntimeError) { + throw err + } + throw new sdk.RuntimeError( + `Failed to transfer file to Botpress: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + +const _buildIssueMarkdown = (issue: any): string => { + const parts: string[] = [] + + parts.push(`# ${issue.identifier} - ${issue.title}`) + parts.push('') + + if (issue.description) { + parts.push(issue.description) + parts.push('') + } + + return parts.join('\n') +} diff --git a/integrations/linear/src/files-readonly/mapping.ts b/integrations/linear/src/files-readonly/mapping.ts new file mode 100644 index 00000000000..b1f9c650852 --- /dev/null +++ b/integrations/linear/src/files-readonly/mapping.ts @@ -0,0 +1,54 @@ +import * as bp from '.botpress' + +type FilesReadonlyFile = bp.events.Events['fileCreated']['file'] +type FilesReadonlyFolder = bp.events.Events['folderDeletedRecursive']['folder'] + +export const PREFIXES = { + TEAM: 'team:', + PROJECT: 'project:', + ISSUE: 'issue:', +} as const + +export type LinearTeam = { + id: string + name: string + key: string +} + +export type LinearProject = { + id: string + name: string +} + +export type LinearIssue = { + id: string + identifier: string + title: string + description?: string | null + updatedAt: string + teamId?: string + teamKey?: string + projectId?: string +} + +export const mapTeamToFolder = (team: LinearTeam): FilesReadonlyFolder => ({ + id: `${PREFIXES.TEAM}${team.id}`, + name: team.name, + type: 'folder', + absolutePath: `/${team.key}`, +}) + +export const mapProjectToFolder = (project: LinearProject, teamKey: string): FilesReadonlyFolder => ({ + id: `${PREFIXES.PROJECT}${project.id}`, + name: project.name, + type: 'folder', + absolutePath: `/${teamKey}/projects/${project.name}`, +}) + +export const mapIssueToFile = (issue: LinearIssue): FilesReadonlyFile => ({ + id: `${PREFIXES.ISSUE}${issue.id}`, + name: `${issue.identifier} - ${issue.title}`, + type: 'file', + absolutePath: `/${issue.teamKey ?? 'unknown'}/${issue.identifier}.md`, + lastModifiedDate: issue.updatedAt, +}) diff --git a/integrations/linear/src/handler.ts b/integrations/linear/src/handler.ts index 68b76ec78ec..07d6aafc2fd 100644 --- a/integrations/linear/src/handler.ts +++ b/integrations/linear/src/handler.ts @@ -4,7 +4,8 @@ import { LinearWebhookClient } from '@linear/sdk/webhooks' import { fireIssueCreated } from './events/issueCreated' import { fireIssueDeleted } from './events/issueDeleted' import { fireIssueUpdated } from './events/issueUpdated' -import { LinearEvent, handleOauth } from './misc/linear' +import * as mapping from './files-readonly/mapping' +import { LinearEvent, LinearIssueEvent, handleOauth } from './misc/linear' import { Result } from './misc/types' import { getLinearClient, getUserAndConversation } from './misc/utils' import * as bp from '.botpress' @@ -44,16 +45,19 @@ export const handler: bp.IntegrationProps['handler'] = async (props) => { // ============ EVENTS ============== if (linearEvent.type === 'issue' && (linearEvent.action === 'create' || linearEvent.action === 'restore')) { await fireIssueCreated({ linearEvent, client, ctx }) + await _emitFileChangeEvent(client, logger, linearEvent, 'created') return } if (linearEvent.type === 'issue' && linearEvent.action === 'update') { await fireIssueUpdated({ linearEvent, client, ctx }) + await _emitFileChangeEvent(client, logger, linearEvent, 'updated') return } if (linearEvent.type === 'issue' && linearEvent.action === 'remove') { await fireIssueDeleted({ linearEvent, client, ctx }) + await _emitFileChangeEvent(client, logger, linearEvent, 'deleted') return } @@ -135,3 +139,37 @@ const _getLinearBotId = async ({ client, ctx }: { client: bp.Client; ctx: bp.Con const me = await linearClient.viewer return me.id } + +const _emitFileChangeEvent = async ( + client: bp.Client, + logger: bp.Logger, + linearEvent: LinearIssueEvent, + changeType: 'created' | 'updated' | 'deleted' +) => { + try { + const file = mapping.mapIssueToFile({ + id: linearEvent.data.id, + identifier: `${linearEvent.data.team?.key ?? 'UNK'}-${linearEvent.data.number}`, + title: linearEvent.data.title, + description: linearEvent.data.description, + updatedAt: linearEvent.data.updatedAt ?? new Date().toISOString(), + teamKey: linearEvent.data.team?.key, + }) + + const emptyFiles: (typeof file)[] = [] + + await client.createEvent({ + type: 'aggregateFileChanges', + payload: { + modifiedItems: { + created: changeType === 'created' ? [file] : emptyFiles, + updated: changeType === 'updated' ? [file] : emptyFiles, + deleted: changeType === 'deleted' ? [file] : emptyFiles, + }, + }, + }) + } catch (thrown) { + const errorMessage = thrown instanceof Error ? thrown.message : String(thrown) + logger.forBot().error('Failed to emit file-change event; swallowing to prevent webhook retries', errorMessage) + } +} diff --git a/package.json b/package.json index 8bef61c0ea0..0aa262018ba 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.564.0", - "@botpress/api": "1.82.0", + "@botpress/api": "1.83.0", "@botpress/cli": "workspace:*", "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 77c656215bf..5dc788c857a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.2.2", + "version": "6.2.3", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", @@ -27,8 +27,8 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", - "@botpress/client": "1.38.1", - "@botpress/sdk": "6.3.1", + "@botpress/client": "1.38.2", + "@botpress/sdk": "6.3.2", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index 5baf79b10f2..9fdf97c841d 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -5,8 +5,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.38.1", - "@botpress/sdk": "6.3.1" + "@botpress/client": "1.38.2", + "@botpress/sdk": "6.3.2" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index dce33d765d4..082c2ce4a5f 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.38.1", - "@botpress/sdk": "6.3.1" + "@botpress/client": "1.38.2", + "@botpress/sdk": "6.3.2" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index 3868db26bbf..777b4ad71a7 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.3.1" + "@botpress/sdk": "6.3.2" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index 353f760c453..2c509afcb05 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.38.1", - "@botpress/sdk": "6.3.1" + "@botpress/client": "1.38.2", + "@botpress/sdk": "6.3.2" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index fe02256527a..4ada7e832b4 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -6,8 +6,8 @@ }, "private": true, "dependencies": { - "@botpress/client": "1.38.1", - "@botpress/sdk": "6.3.1", + "@botpress/client": "1.38.2", + "@botpress/sdk": "6.3.2", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index 2d6645499e9..34b904fe28c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/client", - "version": "1.38.1", + "version": "1.38.2", "description": "Botpress Client", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/cognitive/package.json b/packages/cognitive/package.json index a6c910d7bdc..693929391cf 100644 --- a/packages/cognitive/package.json +++ b/packages/cognitive/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cognitive", - "version": "0.4.2", + "version": "0.4.3", "description": "Wrapper around the Botpress Client to call LLMs", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/llmz/package.json b/packages/llmz/package.json index 4ae6d4b41b8..1b56e40a206 100644 --- a/packages/llmz/package.json +++ b/packages/llmz/package.json @@ -2,7 +2,7 @@ "name": "llmz", "type": "module", "description": "LLMz - An LLM-native Typescript VM built on top of Zui", - "version": "0.0.60", + "version": "0.0.61", "types": "./dist/index.d.ts", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -71,8 +71,8 @@ "tsx": "^4.19.2" }, "peerDependencies": { - "@botpress/client": "1.38.1", - "@botpress/cognitive": "0.4.2", + "@botpress/client": "1.38.2", + "@botpress/cognitive": "0.4.3", "@bpinternal/thicktoken": "^2.0.0", "@bpinternal/zui": "^2.1.0" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 164a9a246ed..fd98d40b5c5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/sdk", - "version": "6.3.1", + "version": "6.3.2", "description": "Botpress SDK", "main": "./dist/index.cjs", "module": "./dist/index.mjs", @@ -20,7 +20,7 @@ "author": "", "license": "MIT", "dependencies": { - "@botpress/client": "1.38.1", + "@botpress/client": "1.38.2", "browser-or-node": "^2.1.1", "semver": "^7.3.8" }, diff --git a/packages/vai/package.json b/packages/vai/package.json index 5ead1ac7639..e7f7da60cb7 100644 --- a/packages/vai/package.json +++ b/packages/vai/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/vai", - "version": "0.0.20", + "version": "0.0.21", "description": "Vitest AI (vai) – a vitest extension for testing with LLMs", "types": "./dist/index.d.ts", "exports": { @@ -40,7 +40,7 @@ "tsup": "^8.0.2" }, "peerDependencies": { - "@botpress/client": "1.38.1", + "@botpress/client": "1.38.2", "@bpinternal/thicktoken": "^1.0.1", "@bpinternal/zui": "^2.1.0", "lodash": "^4.17.21", diff --git a/packages/zai/package.json b/packages/zai/package.json index 939510b76a1..88746c0152c 100644 --- a/packages/zai/package.json +++ b/packages/zai/package.json @@ -1,7 +1,7 @@ { "name": "@botpress/zai", "description": "Zui AI (zai) – An LLM utility library written on top of Zui and the Botpress API", - "version": "2.6.7", + "version": "2.6.8", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -32,7 +32,7 @@ "author": "", "license": "ISC", "dependencies": { - "@botpress/cognitive": "0.4.2", + "@botpress/cognitive": "0.4.3", "json5": "^2.2.3", "jsonrepair": "^3.10.0", "lodash-es": "^4.17.21", diff --git a/plugins/conversation-insights/package.json b/plugins/conversation-insights/package.json index e54f880ce14..27a442cae15 100644 --- a/plugins/conversation-insights/package.json +++ b/plugins/conversation-insights/package.json @@ -7,7 +7,7 @@ }, "private": true, "dependencies": { - "@botpress/cognitive": "0.4.2", + "@botpress/cognitive": "0.4.3", "@botpress/sdk": "workspace:*", "browser-or-node": "^2.1.1", "jsonrepair": "^3.10.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05e48524cc5..0ccaef81375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: ^3.564.0 version: 3.709.0 '@botpress/api': - specifier: 1.82.0 - version: 1.82.0 + specifier: 1.83.0 + version: 1.83.0 '@botpress/cli': specifier: workspace:* version: link:packages/cli @@ -2524,10 +2524,10 @@ importers: specifier: 0.5.5 version: link:../chat-client '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../client '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../sdk '@bpinternal/const': specifier: ^0.1.0 @@ -2648,10 +2648,10 @@ importers: packages/cli/templates/empty-bot: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../../../client '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../../../sdk devDependencies: '@types/node': @@ -2664,10 +2664,10 @@ importers: packages/cli/templates/empty-integration: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../../../client '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../../../sdk devDependencies: '@types/node': @@ -2680,7 +2680,7 @@ importers: packages/cli/templates/empty-plugin: dependencies: '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../../../sdk devDependencies: '@types/node': @@ -2693,10 +2693,10 @@ importers: packages/cli/templates/hello-world: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../../../client '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../../../sdk devDependencies: '@types/node': @@ -2709,10 +2709,10 @@ importers: packages/cli/templates/webhook-message: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../../../client '@botpress/sdk': - specifier: 6.3.1 + specifier: 6.3.2 version: link:../../../sdk axios: specifier: ^1.6.8 @@ -2863,10 +2863,10 @@ importers: specifier: ^7.26.3 version: 7.26.9 '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../client '@botpress/cognitive': - specifier: 0.4.2 + specifier: 0.4.3 version: link:../cognitive '@bpinternal/thicktoken': specifier: ^2.0.0 @@ -2969,7 +2969,7 @@ importers: packages/sdk: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../client '@bpinternal/zui': specifier: ^2.1.0 @@ -3006,7 +3006,7 @@ importers: packages/vai: dependencies: '@botpress/client': - specifier: 1.38.1 + specifier: 1.38.2 version: link:../client '@bpinternal/thicktoken': specifier: ^1.0.1 @@ -3052,7 +3052,7 @@ importers: packages/zai: dependencies: '@botpress/cognitive': - specifier: 0.4.2 + specifier: 0.4.3 version: link:../cognitive '@bpinternal/thicktoken': specifier: ^1.0.0 @@ -3165,7 +3165,7 @@ importers: plugins/conversation-insights: dependencies: '@botpress/cognitive': - specifier: 0.4.2 + specifier: 0.4.3 version: link:../../packages/cognitive '@botpress/sdk': specifier: workspace:* @@ -4036,8 +4036,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@botpress/api@1.82.0': - resolution: {integrity: sha512-X1l1Vq+t/ZVMqPeKexHf9vrPkIRmN7ct4sEOo5eAcRKSxb3Ksvxbo/XmDvZQEb0sxl4AcMSVOuo0WenUBJ9gWw==} + '@botpress/api@1.83.0': + resolution: {integrity: sha512-fotICn/zsxhKQ361nSrnuqhnl5XBBBAWQs3qixjdzeRnnhxY248th6ZGKQkBAjJ4Xwq0dKDfjtfjDtePqtLbgw==} '@bpinternal/const@0.1.0': resolution: {integrity: sha512-iIQg9oYYXOt+LSK34oNhJVQTcgRdtLmLZirEUaE+R9hnmbKONA5reR2kTewxZmekGyxej+5RtDK9xrC/0hmeAw==} @@ -13444,7 +13444,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@botpress/api@1.82.0': + '@botpress/api@1.83.0': dependencies: '@bpinternal/opapi': 1.0.0(openapi-types@12.1.3) transitivePeerDependencies: