diff --git a/packages/botpress/client.test.ts b/packages/botpress/client.test.ts new file mode 100644 index 000000000..9e9083b6f --- /dev/null +++ b/packages/botpress/client.test.ts @@ -0,0 +1,290 @@ +/** + * Covers the transport: the Bearer token, the optional workspace/bot scoping + * headers, and how workspace discovery behaves. Network access is mocked, so + * this runs in CI. + */ +import { AuthMissingError } from 'corsair/core'; +import { + BotpressBotIdMissingError, + BotpressWorkspaceIdMissingError, + discoverBotpressWorkspaceId, + makeBotpressRequest, +} from './client'; +import { botpress } from './index'; + +type Captured = { + url: string; + method: string; + headers: Record; + body?: string; +}; + +type MockResponse = { + ok?: boolean; + status?: number; + body?: unknown; + headers?: Record; +}; + +let captured: Captured | undefined; +let attempts = 0; + +/** + * Installs a fetch stub that answers each call with the next response in the + * list, repeating the last one once the list is exhausted. + */ +function mockFetchSequence(responses: MockResponse[]) { + captured = undefined; + attempts = 0; + global.fetch = (async (url: unknown, init?: RequestInit) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) { + raw.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + for (const [key, value] of Object.entries( + (raw ?? {}) as Record, + )) { + headers[key.toLowerCase()] = value; + } + } + captured = { + url: String(url), + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + }; + + const response = + responses[Math.min(attempts, responses.length - 1)] ?? + ({} as MockResponse); + attempts++; + + const status = response.status ?? 200; + const payload = response.body ?? {}; + return { + ok: response.ok ?? status < 400, + status, + statusText: 'OK', + url: String(url), + headers: new Headers({ + 'Content-Type': 'application/json', + ...response.headers, + }), + json: async () => payload, + text: async () => JSON.stringify(payload), + }; + }) as unknown as typeof global.fetch; +} + +function mockFetch(response: MockResponse) { + mockFetchSequence([response]); +} + +describe('makeBotpressRequest', () => { + it('sends the bearer token and no scoping headers by default', async () => { + mockFetch({ body: { id: 1 } }); + + await makeBotpressRequest('/v1/admin/account/me', 'test-token'); + + expect(captured?.headers.authorization).toBe('Bearer test-token'); + expect(captured?.headers['x-workspace-id']).toBeUndefined(); + expect(captured?.headers['x-bot-id']).toBeUndefined(); + }); + + it('attaches x-workspace-id when a workspace id is supplied', async () => { + mockFetch({ body: {} }); + + await makeBotpressRequest('/v1/admin/bots', 'test-token', { + method: 'POST', + workspaceId: 'wkspace_123', + }); + + expect(captured?.headers['x-workspace-id']).toBe('wkspace_123'); + }); + + it('attaches x-bot-id when a bot id is supplied', async () => { + mockFetch({ body: {} }); + + await makeBotpressRequest('/v1/chat/conversations', 'test-token', { + method: 'GET', + botId: 'bot_123', + }); + + expect(captured?.headers['x-bot-id']).toBe('bot_123'); + }); + + it('targets the single api.botpress.cloud host', async () => { + mockFetch({ body: {} }); + + await makeBotpressRequest('/v1/admin/workspaces/wkspace_123', 'test-token'); + + expect(captured?.url).toContain('https://api.botpress.cloud/'); + expect(captured?.url).toContain('/v1/admin/workspaces/wkspace_123'); + }); + + it('sends a body on POST and PUT but not on GET or DELETE', async () => { + mockFetch({ body: {} }); + await makeBotpressRequest('/v1/admin/bots', 'test-token', { + method: 'POST', + body: { name: 'Acme bot' }, + }); + expect(captured?.method).toBe('POST'); + expect(captured?.body).toContain('Acme bot'); + + mockFetch({ body: {} }); + await makeBotpressRequest('/v1/admin/workspaces/w1', 'test-token', { + method: 'DELETE', + body: { name: 'ignored' } as Record, + }); + expect(captured?.method).toBe('DELETE'); + expect(captured?.body).toBeUndefined(); + }); + + it('rejects a blank token before fetch', async () => { + mockFetch({ body: {} }); + attempts = 0; + + await expect( + makeBotpressRequest('/v1/admin/account/me', ''), + ).rejects.toBeInstanceOf(AuthMissingError); + await expect( + makeBotpressRequest('/v1/admin/account/me', ' '), + ).rejects.toBeInstanceOf(AuthMissingError); + expect(attempts).toBe(0); + }); + + it('trims the bearer token', async () => { + mockFetch({ body: {} }); + + await makeBotpressRequest('/v1/admin/account/me', ' test-token '); + + expect(captured?.headers.authorization).toBe('Bearer test-token'); + }); + + it('rejects a blank workspace or bot id before fetch', async () => { + mockFetch({ body: {} }); + attempts = 0; + + await expect( + makeBotpressRequest('/v1/admin/bots', 'test-token', { + workspaceId: ' ', + }), + ).rejects.toBeInstanceOf(BotpressWorkspaceIdMissingError); + await expect( + makeBotpressRequest('/v1/chat/conversations', 'test-token', { + botId: '', + }), + ).rejects.toBeInstanceOf(BotpressBotIdMissingError); + expect(attempts).toBe(0); + }); + + it('trims workspace and bot ids on the wire', async () => { + mockFetch({ body: {} }); + + await makeBotpressRequest('/v1/chat/conversations', 'test-token', { + workspaceId: ' wkspace_123 ', + botId: ' bot_123 ', + }); + + expect(captured?.headers['x-workspace-id']).toBe('wkspace_123'); + expect(captured?.headers['x-bot-id']).toBe('bot_123'); + }); + + it('retries once Botpress answers 429 and honours Retry-After', async () => { + mockFetchSequence([ + { status: 429, body: {}, headers: { 'Retry-After': '1' } }, + { status: 200, body: { workspaces: [] } }, + ]); + + const result = await makeBotpressRequest<{ workspaces: unknown[] }>( + '/v1/admin/workspaces', + 'test-token', + ); + + expect(attempts).toBe(2); + expect(result.workspaces).toEqual([]); + }); +}); + +describe('discoverBotpressWorkspaceId', () => { + it('returns the single workspace a token can reach', async () => { + mockFetch({ body: { workspaces: [{ id: 'wkspace_123' }] } }); + + await expect(discoverBotpressWorkspaceId('test-token')).resolves.toBe( + 'wkspace_123', + ); + expect(captured?.url).toContain('/v1/admin/workspaces'); + expect(captured?.headers['x-workspace-id']).toBeUndefined(); + }); + + it('refuses to guess when several workspaces are reachable', async () => { + mockFetch({ + body: { workspaces: [{ id: 'wkspace_1' }, { id: 'wkspace_2' }] }, + }); + + await expect( + discoverBotpressWorkspaceId('test-token'), + ).rejects.toBeInstanceOf(BotpressWorkspaceIdMissingError); + }); + + it('reports a missing workspace when the token reaches none', async () => { + mockFetch({ body: { workspaces: [] } }); + + await expect( + discoverBotpressWorkspaceId('test-token'), + ).rejects.toBeInstanceOf(BotpressWorkspaceIdMissingError); + }); + + it('reports a missing workspace when the only id is blank', async () => { + mockFetch({ body: { workspaces: [{ id: ' ' }] } }); + + await expect( + discoverBotpressWorkspaceId('test-token'), + ).rejects.toBeInstanceOf(BotpressWorkspaceIdMissingError); + }); + + it('trims the single reachable workspace id', async () => { + mockFetch({ body: { workspaces: [{ id: ' wkspace_123 ' }] } }); + + await expect(discoverBotpressWorkspaceId('test-token')).resolves.toBe( + 'wkspace_123', + ); + }); +}); + +describe('keyBuilder', () => { + it('rejects a blank options.key', async () => { + const plugin = botpress({ key: ' ' }); + const ctx = { + authType: 'api_key', + keys: { get_api_key: async () => null }, + }; + + await expect( + plugin.keyBuilder!(ctx as never, 'endpoint'), + ).rejects.toBeInstanceOf(AuthMissingError); + }); + + it('trims options.key', async () => { + const plugin = botpress({ key: ' pat_123 ' }); + + await expect( + plugin.keyBuilder!({ authType: 'api_key' } as never, 'endpoint'), + ).resolves.toBe('pat_123'); + }); + + it('rejects a blank stored api key', async () => { + const plugin = botpress(); + const ctx = { + authType: 'api_key', + keys: { get_api_key: async () => ' ' }, + }; + + await expect( + plugin.keyBuilder!(ctx as never, 'endpoint'), + ).rejects.toBeInstanceOf(AuthMissingError); + }); +}); diff --git a/packages/botpress/client.ts b/packages/botpress/client.ts new file mode 100644 index 000000000..2c8b17a9d --- /dev/null +++ b/packages/botpress/client.ts @@ -0,0 +1,173 @@ +import { AuthMissingError } from 'corsair/core'; +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +const BOTPRESS_API_BASE = 'https://api.botpress.cloud'; + +/** + * Botpress documents no published rate-limit numbers for the Admin/Billing/ + * Files/Chat surface covered here. It answers over-limit requests with a + * standard 429, so the retry loop reacts to that rather than pacing + * proactively against a budget it is never told. + */ +const BOTPRESS_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 3, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +/** + * Raised when an operation needs a workspace id and none could be determined. + * + * A Botpress Personal Access Token can reach several workspaces, so the + * workspace is a second credential rather than something derivable from the + * token alone — confirmed live: `POST /v1/admin/bots` answers 400 + * `request/headers must have required property 'x-workspace-id'` when the + * header is omitted. + */ +export class BotpressWorkspaceIdMissingError extends Error { + constructor() { + super( + 'Botpress requires a workspace id for this operation. Set `workspaceId` ' + + 'in the plugin options, or store one under the `workspace_id` key.', + ); + this.name = 'BotpressWorkspaceIdMissingError'; + } +} + +/** + * Raised when an operation needs a bot id and none was supplied. + * + * Unlike the workspace, the bot varies per call rather than per account — a + * workspace can hold many bots — so it is taken as an explicit input field on + * the chat, files, knowledge-base and table operations rather than resolved + * as account-level configuration. Confirmed live: + * `GET /v1/files/tags` answers 400 "Request is missing some required + * authentication params" without `x-bot-id`, and `GET /v1/chat/conversations` + * answers 400 `request/headers must have required property 'x-bot-id'`. + */ +export class BotpressBotIdMissingError extends Error { + constructor() { + super('Botpress requires a `botId` input for this operation.'); + this.name = 'BotpressBotIdMissingError'; + } +} + +type BotpressWorkspaceListPage = { + workspaces?: { id?: string }[]; +}; + +/** + * Resolves the workspace reachable by a token. + * + * Only used when no workspace id was configured. `GET /v1/admin/workspaces` + * is scoped to the token's own account and needs no `x-workspace-id` header + * (confirmed live), so it is safe to call before a workspace id is known. + * Discovery is only unambiguous for a single workspace; with several, the + * caller has to say which one. + */ +export async function discoverBotpressWorkspaceId( + personalAccessToken: string, +): Promise { + const payload = await makeBotpressRequest( + '/v1/admin/workspaces', + personalAccessToken, + { method: 'GET' }, + ); + + const workspaces = payload?.workspaces ?? []; + const only = workspaces.length === 1 ? workspaces[0] : undefined; + if (!only?.id?.trim()) throw new BotpressWorkspaceIdMissingError(); + + return only.id.trim(); +} + +export type BotpressRequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: Record; + query?: Record< + string, + string | number | boolean | string[] | Record | undefined + >; + /** + * Scopes the request to a workspace via `x-workspace-id`. Harmless to send + * on requests that do not need it (confirmed live against the public hub + * and path-scoped workspace endpoints), so callers that have a resolved + * workspace id can pass it unconditionally. + */ + workspaceId?: string; + /** + * Scopes the request to a bot via `x-bot-id`, required by the chat, files, + * knowledge-base and table operations. + */ + botId?: string; +}; + +/** + * Issues a Botpress request with Bearer auth, optional workspace/bot scoping + * headers, and rate-limit retries. + * + * Every admin, billing, files, chat and table operation in this catalog lives + * on the single `api.botpress.cloud` host — confirmed live. Earlier + * reconnaissance for this integration assumed the Chat API lived on a + * separate `chat.botpress.cloud` host per Botpress's docs prose; a live probe + * of `chat.botpress.cloud/v1/chat/conversations` returned a webhook-handler + * 404 ("Integration with webhook ID \"v1\" not found"), while the same path + * against `api.botpress.cloud` with an `x-bot-id` header succeeded. The docs + * describe the separate host for the SDK/webhook messaging surface, not for + * these direct REST calls. + */ +export async function makeBotpressRequest( + path: string, + personalAccessToken: string, + options: BotpressRequestOptions = {}, +): Promise { + const token = personalAccessToken.trim(); + if (!token) { + throw new AuthMissingError('botpress', 'api_key'); + } + + const { method = 'GET', body, query, workspaceId, botId } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + if (workspaceId !== undefined) { + if (!workspaceId.trim()) throw new BotpressWorkspaceIdMissingError(); + headers['x-workspace-id'] = workspaceId.trim(); + } + if (botId !== undefined) { + if (!botId.trim()) throw new BotpressBotIdMissingError(); + headers['x-bot-id'] = botId.trim(); + } + + const config: OpenAPIConfig = { + BASE: BOTPRESS_API_BASE, + VERSION: '1', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: headers, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: path, + body: method === 'POST' || method === 'PUT' ? body : undefined, + mediaType: 'application/json; charset=utf-8', + query, + }; + + return await request(config, requestOptions, { + rateLimitConfig: BOTPRESS_RATE_LIMIT_CONFIG, + }); +} diff --git a/packages/botpress/endpoints.test.ts b/packages/botpress/endpoints.test.ts new file mode 100644 index 000000000..2e0ef27b1 --- /dev/null +++ b/packages/botpress/endpoints.test.ts @@ -0,0 +1,798 @@ +/** + * Exercises all 53 endpoint wrappers: the HTTP method and path each one + * builds, the scoping headers they attach, the cache writes they perform, and + * what reaches the event log. Network access is mocked, so this runs in CI. + */ +import { logEventFromContext } from 'corsair/core'; +import { + Account, + Billing, + Bots, + Chat, + Files, + Hub, + Integrations, + KnowledgeBases, + Plugins, + Tools, + Workspaces, +} from './endpoints'; +import { resolveWorkspaceId } from './endpoints/shared'; +import { isNonIdempotent } from './error-handlers'; +import { botpressEndpointSchemas } from './index'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +const mockLogEvent = logEventFromContext as jest.MockedFunction< + typeof logEventFromContext +>; + +type Store = { upsertByEntityId: jest.Mock; deleteByEntityId: jest.Mock }; + +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + }; +} + +type Ctx = Parameters[0]; + +function makeCtx() { + const db = { + workspaces: makeStore(), + bots: makeStore(), + integrations: makeStore(), + }; + const ctx = { + key: 'test-botpress-token', + options: { workspaceId: 'wkspace_test' }, + db, + } as unknown as Ctx; + return { ctx, db }; +} + +let lastUrl = ''; +let lastMethod = ''; +let lastHeaders: Record = {}; +let lastBody: string | undefined; + +/** + * A response carrying every key any operation's unwrap step reads, so one + * fixture serves every entry in the routing table below. Object-shaped keys + * are non-null so `.field` access on a wrapped response never throws. + */ +const RESPONSE_BODY = { + account: { id: 'acc_1', email: 'a@example.com' }, + id: 'wkspace_1', + name: 'Test', + workspaces: [{ id: 'wkspace_2', name: 'Listed' }], + bot: { id: 'bot_1' }, + integration: { id: 'int_1', name: 'n', version: '1.0.0' }, + integrations: [{ id: 'int_2', name: 'n2', version: '1.0.0' }], + conversation: { id: 'conv_1' }, + conversations: [], + message: { id: 'msg_1' }, + workflow: { id: 'wf_1' }, + invoices: [], + usages: [], + data: [], + issues: [], + iaks: [], + tags: [], + values: [], + knowledgeBases: [], + row: { id: 1 }, + plugin: { id: 'plug_1', name: 'n', version: '1.0.0' }, + plugins: [], + interface: { id: 'iface_1', name: 'n', version: '1.0.0' }, + interfaces: [], + code: 'export default {}', + quota: { period: 'monthly', value: 0, type: 'bot_count' }, + total: 0, + lineItems: [], + chargedInvoices: [], + failedInvoices: [], + available: true, + suggestions: [], + value: null, + result: {}, + // Present on every paginated list response so the routing table's list + // operations all get a token to preserve; see "pagination" below. + meta: { nextToken: 'next_abc' }, +}; + +beforeEach(() => { + mockLogEvent.mockClear(); + lastUrl = ''; + lastMethod = ''; + lastHeaders = {}; + lastBody = undefined; + global.fetch = (async (url: unknown, init?: RequestInit) => { + lastUrl = String(url); + lastMethod = init?.method ?? 'GET'; + lastBody = typeof init?.body === 'string' ? init.body : undefined; + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) { + raw.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + for (const [key, value] of Object.entries( + (raw ?? {}) as Record, + )) { + headers[key.toLowerCase()] = value; + } + } + lastHeaders = headers; + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => RESPONSE_BODY, + text: async () => JSON.stringify(RESPONSE_BODY), + }; + }) as unknown as typeof global.fetch; +}); + +/** [registry path, invocation, expected method, expected path] */ +const OPERATIONS: [string, (ctx: Ctx) => Promise, string, string][] = [ + ['account.get', (c) => Account.get(c, {}), 'GET', '/v1/admin/account/me'], + [ + 'account.update', + (c) => Account.update(c, { displayName: 'A' }), + 'PUT', + '/v1/admin/account/me', + ], + [ + 'account.getPreference', + (c) => Account.getPreference(c, { key: 'theme' }), + 'GET', + '/v1/admin/account/preferences/theme', + ], + [ + 'account.setPreference', + (c) => Account.setPreference(c, { key: 'theme', value: 'dark' }), + 'POST', + '/v1/admin/account/preferences/theme', + ], + + [ + 'workspaces.create', + (c) => Workspaces.create(c, { name: 'W' }), + 'POST', + '/v1/admin/workspaces', + ], + [ + 'workspaces.get', + (c) => Workspaces.get(c, { id: 'w1' }), + 'GET', + '/v1/admin/workspaces/w1', + ], + [ + 'workspaces.update', + (c) => Workspaces.update(c, { id: 'w1', name: 'Renamed' }), + 'PUT', + '/v1/admin/workspaces/w1', + ], + [ + 'workspaces.delete', + (c) => Workspaces.delete(c, { id: 'w1' }), + 'DELETE', + '/v1/admin/workspaces/w1', + ], + [ + 'workspaces.list', + (c) => Workspaces.list(c, {}), + 'GET', + '/v1/admin/workspaces', + ], + [ + 'workspaces.listPublic', + (c) => Workspaces.listPublic(c, {}), + 'GET', + '/v1/admin/workspaces/public', + ], + [ + 'workspaces.checkHandleAvailability', + (c) => Workspaces.checkHandleAvailability(c, { handle: 'acme' }), + 'PUT', + '/v1/admin/workspaces/handle-availability', + ], + [ + 'workspaces.setPreference', + (c) => Workspaces.setPreference(c, { key: 'theme', value: 'dark' }), + 'POST', + '/v1/admin/workspaces/preferences/theme', + ], + [ + 'workspaces.getQuota', + (c) => Workspaces.getQuota(c, { id: 'w1', type: 'bot_count' }), + 'GET', + '/v1/admin/workspaces/w1/quota', + ], + [ + 'workspaces.getAllQuotaCompletion', + (c) => Workspaces.getAllQuotaCompletion(c, {}), + 'GET', + '/v1/admin/workspaces/usages/quota-completion', + ], + [ + 'workspaces.breakDownUsageByBot', + (c) => Workspaces.breakDownUsageByBot(c, { id: 'w1', type: 'bot_count' }), + 'GET', + '/v1/admin/workspaces/w1/usages/by-bot', + ], + + [ + 'billing.listInvoices', + (c) => Billing.listInvoices(c, { workspaceId: 'w1' }), + 'GET', + '/v1/admin/workspaces/w1/billing/invoices', + ], + [ + 'billing.getUpcomingInvoice', + (c) => Billing.getUpcomingInvoice(c, { workspaceId: 'w1' }), + 'GET', + '/v1/admin/workspaces/w1/billing/upcoming-invoice', + ], + [ + 'billing.chargeUnpaidInvoices', + (c) => + Billing.chargeUnpaidInvoices(c, { + workspaceId: 'w1', + invoiceIds: ['i1'], + }), + 'POST', + '/v1/admin/workspaces/w1/billing/invoices/charge-unpaid', + ], + [ + 'billing.listUsageHistory', + (c) => Billing.listUsageHistory(c, { id: 'w1', type: 'bot_count' }), + 'GET', + '/v1/admin/usages/w1/history', + ], + + [ + 'bots.create', + (c) => Bots.create(c, { name: 'B' }), + 'POST', + '/v1/admin/bots', + ], + [ + 'bots.update', + (c) => Bots.update(c, { id: 'bot1', name: 'Renamed' }), + 'PUT', + '/v1/admin/bots/bot1', + ], + [ + 'bots.listActionRuns', + (c) => Bots.listActionRuns(c, { id: 'bot1' }), + 'GET', + '/v1/admin/bots/bot1/action-runs', + ], + [ + 'bots.listIssues', + (c) => Bots.listIssues(c, { id: 'bot1' }), + 'GET', + '/v1/admin/bots/bot1/issues', + ], + + [ + 'chat.createConversation', + (c) => + Chat.createConversation(c, { + botId: 'bot1', + channel: 'webchat', + tags: {}, + }), + 'POST', + '/v1/chat/conversations', + ], + [ + 'chat.listConversations', + (c) => Chat.listConversations(c, { botId: 'bot1' }), + 'GET', + '/v1/chat/conversations', + ], + [ + 'chat.sendMessage', + (c) => + Chat.sendMessage(c, { + botId: 'bot1', + conversationId: 'conv1', + userId: 'user1', + type: 'text', + payload: { text: 'hi' }, + tags: {}, + }), + 'POST', + '/v1/chat/messages', + ], + [ + 'chat.updateWorkflow', + (c) => + Chat.updateWorkflow(c, { botId: 'bot1', id: 'wf1', status: 'completed' }), + 'PUT', + '/v1/chat/workflows/wf1', + ], + + [ + 'integrations.create', + (c) => Integrations.create(c, { name: 'n', version: '1.0.0' }), + 'POST', + '/v1/admin/integrations', + ], + [ + 'integrations.get', + (c) => Integrations.get(c, { name: 'n', version: '1.0.0' }), + 'GET', + '/v1/admin/integrations/n/1.0.0', + ], + [ + 'integrations.list', + (c) => Integrations.list(c, {}), + 'GET', + '/v1/admin/integrations', + ], + [ + 'integrations.validateUpdate', + (c) => Integrations.validateUpdate(c, { id: 'int1' }), + 'PUT', + '/v1/admin/integrations/int1/validate', + ], + [ + 'integrations.requestVerification', + (c) => Integrations.requestVerification(c, { integrationId: 'int1' }), + 'POST', + '/v1/admin/integrations/request-verification', + ], + [ + 'integrations.listApiKeys', + (c) => Integrations.listApiKeys(c, { integrationId: 'int1' }), + 'GET', + '/v1/admin/integrations/iaks', + ], + [ + 'integrations.deleteShareableId', + (c) => + Integrations.deleteShareableId(c, { + botId: 'bot1', + integrationId: 'int1', + }), + 'DELETE', + '/v1/admin/bots/bot1/integrations/int1/shareable-id', + ], + + [ + 'hub.listIntegrations', + (c) => Hub.listIntegrations(c, {}), + 'GET', + '/v1/admin/hub/integrations', + ], + [ + 'hub.getIntegration', + (c) => Hub.getIntegration(c, { name: 'n', version: '1.0.0' }), + 'GET', + '/v1/admin/hub/integrations/n/1.0.0', + ], + [ + 'hub.getIntegrationById', + (c) => Hub.getIntegrationById(c, { id: 'int1' }), + 'GET', + '/v1/admin/hub/integrations/int1', + ], + [ + 'hub.listInterfaces', + (c) => Hub.listInterfaces(c, {}), + 'GET', + '/v1/admin/hub/interfaces', + ], + [ + 'hub.getInterface', + (c) => Hub.getInterface(c, { name: 'n', version: '1.0.0' }), + 'GET', + '/v1/admin/hub/interfaces/n/1.0.0', + ], + [ + 'hub.getInterfaceById', + (c) => Hub.getInterfaceById(c, { id: 'iface1' }), + 'GET', + '/v1/admin/hub/interfaces/iface1', + ], + [ + 'hub.listPlugins', + (c) => Hub.listPlugins(c, {}), + 'GET', + '/v1/admin/hub/plugins', + ], + [ + 'hub.getPlugin', + (c) => Hub.getPlugin(c, { name: 'n', version: '1.0.0' }), + 'GET', + '/v1/admin/hub/plugins/n/1.0.0', + ], + [ + 'hub.getPluginById', + (c) => Hub.getPluginById(c, { id: 'plug1' }), + 'GET', + '/v1/admin/hub/plugins/plug1', + ], + [ + 'hub.getPluginCode', + (c) => Hub.getPluginCode(c, { id: 'plug1', platform: 'node' }), + 'GET', + '/v1/admin/hub/plugins/plug1/code/node', + ], + [ + 'hub.getDereferencedPluginById', + (c) => + Hub.getDereferencedPluginById(c, { + id: 'plug1', + interfaces: { hitl: 'int1' }, + }), + 'GET', + '/v1/admin/hub/plugins/plug1/dereferenced', + ], + + ['plugins.list', (c) => Plugins.list(c, {}), 'GET', '/v1/admin/plugins'], + + [ + 'files.delete', + (c) => Files.delete(c, { botId: 'bot1', id: 'file1' }), + 'DELETE', + '/v1/files/file1', + ], + [ + 'files.listTags', + (c) => Files.listTags(c, { botId: 'bot1' }), + 'GET', + '/v1/files/tags', + ], + [ + 'files.listTagValues', + (c) => Files.listTagValues(c, { botId: 'bot1', tag: 'color' }), + 'GET', + '/v1/files/tags/color/values', + ], + + [ + 'knowledgeBases.list', + (c) => KnowledgeBases.list(c, { botId: 'bot1' }), + 'GET', + '/v1/files/knowledge-bases', + ], + [ + 'knowledgeBases.delete', + (c) => KnowledgeBases.delete(c, { botId: 'bot1', id: 'kb1' }), + 'DELETE', + '/v1/files/knowledge-bases/kb1', + ], + + [ + 'tools.runVrl', + (c) => Tools.runVrl(c, { data: { a: 1 }, script: '. = .' }), + 'POST', + '/v1/admin/helper/vrl', + ], + [ + 'tools.getTableRow', + (c) => Tools.getTableRow(c, { botId: 'bot1', table: 't1', id: 1 }), + 'GET', + '/v1/tables/t1/row', + ], +]; + +describe('operation routing', () => { + for (const [path, invoke, method, expectedPath] of OPERATIONS) { + it(`${path} issues ${method} ${expectedPath}`, async () => { + const { ctx } = makeCtx(); + await invoke(ctx); + + expect(lastMethod).toBe(method); + expect(new URL(lastUrl).pathname).toBe(expectedPath); + }); + } +}); + +describe('scoping headers', () => { + const workspaceScoped = [ + 'bots.create', + 'integrations.create', + 'integrations.get', + 'integrations.list', + 'integrations.requestVerification', + 'integrations.listApiKeys', + 'plugins.list', + 'workspaces.setPreference', + 'tools.runVrl', + ]; + const botScoped = [ + 'chat.createConversation', + 'chat.listConversations', + 'chat.sendMessage', + 'chat.updateWorkflow', + 'files.delete', + 'files.listTags', + 'files.listTagValues', + 'knowledgeBases.list', + 'knowledgeBases.delete', + 'tools.getTableRow', + ]; + + it('attaches x-workspace-id to every workspace-scoped operation', async () => { + const matched = OPERATIONS.filter(([p]) => workspaceScoped.includes(p)); + // Without this, a renamed/removed operation would silently shrink the + // loop below to zero iterations and the test would still pass. + expect(matched).toHaveLength(workspaceScoped.length); + + for (const [path, invoke] of matched) { + const { ctx } = makeCtx(); + await invoke(ctx); + expect(lastHeaders['x-workspace-id']).toBe('wkspace_test'); + } + }); + + it('attaches x-bot-id to every bot-scoped operation', async () => { + const matched = OPERATIONS.filter(([p]) => botScoped.includes(p)); + expect(matched).toHaveLength(botScoped.length); + + for (const [path, invoke] of matched) { + const { ctx } = makeCtx(); + await invoke(ctx); + expect(lastHeaders['x-bot-id']).toBe('bot1'); + } + }); + + it('sends neither scoping header for public hub browsing', async () => { + const matched = OPERATIONS.filter(([p]) => p.startsWith('hub.')); + expect(matched).toHaveLength(11); + + for (const [path, invoke] of matched) { + const { ctx } = makeCtx(); + await invoke(ctx); + expect(lastHeaders['x-workspace-id']).toBeUndefined(); + expect(lastHeaders['x-bot-id']).toBeUndefined(); + } + }); +}); + +describe('operation coverage', () => { + it('exercises every operation the plugin registers', () => { + const registered = Object.keys(botpressEndpointSchemas).sort(); + const exercised = OPERATIONS.map(([path]) => path).sort(); + + expect(exercised).toEqual(registered); + expect(registered).toHaveLength(53); + }); + + it('treats the confirmed non-idempotent POSTs, and only those, as unsafe to retry', () => { + // `error-handlers.ts` decides whether a network failure may be retried. + // `runVrl` and the `setPreference` routes are POST but excluded there + // (VRL has no persisted side effect; the preference routes are absolute + // setters) — this asserts the predicate against the routing table + // including that carve-out, so neither can drift unnoticed. + const posts = OPERATIONS.filter(([, , method]) => method === 'POST') + .map(([path]) => path) + .sort(); + const idempotentPosts = [ + 'account.setPreference', + 'tools.runVrl', + 'workspaces.setPreference', + ]; + const expectedNonIdempotent = posts + .filter((path) => !idempotentPosts.includes(path)) + .sort(); + const nonIdempotent = OPERATIONS.map(([path]) => path) + .filter(isNonIdempotent) + .sort(); + + expect(nonIdempotent).toEqual(expectedNonIdempotent); + expect(posts).toHaveLength(10); + expect(nonIdempotent).toHaveLength(7); + }); +}); + +describe('pagination', () => { + // Every list operation whose real Botpress response carries a + // `meta.nextToken` (confirmed from `@botpress/client` v2.2.0's type + // declarations - `billing.listInvoices`, `billing.listUsageHistory`, + // `workspaces.breakDownUsageByBot` and `integrations.listApiKeys` are + // deliberately excluded: their real responses have no `meta` at all). + const paginatedPaths = [ + 'workspaces.list', + 'workspaces.listPublic', + 'bots.listActionRuns', + 'bots.listIssues', + 'chat.listConversations', + 'integrations.list', + 'hub.listIntegrations', + 'hub.listInterfaces', + 'hub.listPlugins', + 'plugins.list', + 'files.listTags', + 'files.listTagValues', + 'knowledgeBases.list', + ]; + const paginatedOperations = OPERATIONS.filter(([path]) => + paginatedPaths.includes(path), + ); + + it('covers exactly the operations whose real response carries a continuation token', () => { + expect(paginatedOperations.map(([path]) => path).sort()).toEqual( + [...paginatedPaths].sort(), + ); + expect(paginatedOperations).toHaveLength(13); + }); + + it('preserves the continuation token instead of discarding it', async () => { + for (const [path, invoke] of paginatedOperations) { + const { ctx } = makeCtx(); + const result = (await invoke(ctx)) as { nextToken?: string }; + expect(result.nextToken).toBe('next_abc'); + } + }); +}); + +describe('caching', () => { + it('mirrors a workspace on create, get, update and list', async () => { + const { ctx, db } = makeCtx(); + + await Workspaces.create(ctx, { name: 'W' }); + await Workspaces.get(ctx, { id: 'w1' }); + await Workspaces.update(ctx, { id: 'w1', name: 'Renamed' }); + await Workspaces.list(ctx, {}); + + expect(db.workspaces.upsertByEntityId).toHaveBeenCalledTimes(4); + }); + + it('evicts a workspace on delete', async () => { + const { ctx, db } = makeCtx(); + + await Workspaces.delete(ctx, { id: 'w1' }); + + expect(db.workspaces.deleteByEntityId).toHaveBeenCalledWith('w1'); + }); + + it('mirrors a bot on create and update', async () => { + const { ctx, db } = makeCtx(); + + await Bots.create(ctx, { name: 'B' }); + await Bots.update(ctx, { id: 'bot1', name: 'Renamed' }); + + expect(db.bots.upsertByEntityId).toHaveBeenCalledTimes(2); + }); + + it('mirrors an integration on create, get and list', async () => { + const { ctx, db } = makeCtx(); + + await Integrations.create(ctx, { name: 'n', version: '1.0.0' }); + await Integrations.get(ctx, { name: 'n', version: '1.0.0' }); + await Integrations.list(ctx, {}); + + expect(db.integrations.upsertByEntityId).toHaveBeenCalledTimes(3); + }); +}); + +describe('event log', () => { + it('keeps free text out of the payload when auditPayload names the fields', async () => { + const { ctx } = makeCtx(); + await Workspaces.update(ctx, { id: 'w1', about: 'a long free-text bio' }); + + const payload = mockLogEvent.mock.calls.at(-1)?.[2] as + | Record + | undefined; + expect(payload).toEqual({ id: 'w1', fields: ['id', 'about'] }); + }); + + it('never logs a VRL script or its data', async () => { + const { ctx } = makeCtx(); + await Tools.runVrl(ctx, { + data: { secret: 'x' }, + script: '.secret = null', + }); + + const payload = mockLogEvent.mock.calls.at(-1)?.[2] as + | Record + | undefined; + expect(payload).toEqual({ fields: ['data', 'script'] }); + }); + + it('logs the invoice count, not the invoice ids, for a charge', async () => { + const { ctx } = makeCtx(); + await Billing.chargeUnpaidInvoices(ctx, { + workspaceId: 'w1', + invoiceIds: ['i1', 'i2'], + }); + + const payload = mockLogEvent.mock.calls.at(-1)?.[2] as + | Record + | undefined; + expect(payload).toEqual({ + workspaceId: 'w1', + fields: ['workspaceId', 'invoiceIds'], + invoiceCount: 2, + }); + }); +}); + +describe('request bodies', () => { + it('omits fields the caller did not supply', async () => { + const { ctx } = makeCtx(); + await Workspaces.update(ctx, { id: 'w1', name: 'Renamed' }); + + const body = JSON.parse(lastBody ?? '{}'); + expect(body).toEqual({ name: 'Renamed' }); + expect(body).not.toHaveProperty('about'); + expect(body).not.toHaveProperty('handle'); + }); + + it('sends no body on a delete', async () => { + const { ctx } = makeCtx(); + await Workspaces.delete(ctx, { id: 'w1' }); + + expect(lastBody).toBeUndefined(); + }); +}); + +describe('delete results', () => { + it('reports an empty object rather than the provider-empty body', async () => { + const { ctx } = makeCtx(); + + await expect(Workspaces.delete(ctx, { id: 'w1' })).resolves.toEqual({}); + await expect( + Files.delete(ctx, { botId: 'bot1', id: 'file1' }), + ).resolves.toEqual({}); + }); +}); + +describe('input schemas', () => { + it('rejects a blank invoice id', () => { + const schema = + botpressEndpointSchemas['billing.chargeUnpaidInvoices'].input; + + expect( + schema.safeParse({ workspaceId: 'w1', invoiceIds: [''] }).success, + ).toBe(false); + expect( + schema.safeParse({ workspaceId: 'w1', invoiceIds: [' '] }).success, + ).toBe(false); + expect( + schema.safeParse({ workspaceId: 'w1', invoiceIds: ['i1'] }).success, + ).toBe(true); + }); + + it('rejects a whitespace id', () => { + const schema = botpressEndpointSchemas['workspaces.get'].input; + + expect(schema.safeParse({ id: ' ' }).success).toBe(false); + expect(schema.safeParse({ id: 'w1' }).success).toBe(true); + }); +}); + +describe('resolveWorkspaceId', () => { + it('ignores a whitespace configured id', async () => { + await expect( + resolveWorkspaceId({ + key: 'test-botpress-token', + options: { workspaceId: ' ' }, + }), + ).resolves.toBe('wkspace_2'); + }); + + it('trims a stored workspace id', async () => { + await expect( + resolveWorkspaceId({ + key: 'test-botpress-token', + options: {}, + keys: { get_workspace_id: async () => ' stored_ws ' }, + }), + ).resolves.toBe('stored_ws'); + }); +}); diff --git a/packages/botpress/endpoints/account.ts b/packages/botpress/endpoints/account.ts new file mode 100644 index 000000000..6e5ea6049 --- /dev/null +++ b/packages/botpress/endpoints/account.ts @@ -0,0 +1,81 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactBody } from './shared'; +import type { BotpressAccount, BotpressEndpointOutputs } from './types'; + +/** Gets the authenticated account. No workspace scoping — identity comes from the PAT. */ +export const get: BotpressEndpoints['accountGet'] = async (ctx) => { + const result = await botpressCall<{ account: BotpressAccount }>( + ctx, + '/v1/admin/account/me', + ); + + await logEventFromContext(ctx, 'botpress.account.get', {}, 'completed'); + return result.account; +}; + +/** Updates the authenticated account's display name or profile picture. */ +export const update: BotpressEndpoints['accountUpdate'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ account: BotpressAccount }>( + ctx, + '/v1/admin/account/me', + { + method: 'PUT', + body: compactBody({ + displayName: input.displayName, + profilePicture: input.profilePicture, + refresh: input.refresh, + }), + }, + ); + + await logEventFromContext( + ctx, + 'botpress.account.update', + auditPayload(input, []), + 'completed', + ); + return result.account; +}; + +/** Gets a single account preference by key. */ +export const getPreference: BotpressEndpoints['accountGetPreference'] = async ( + ctx, + input, +) => { + const result = await botpressCall< + BotpressEndpointOutputs['accountGetPreference'] + >(ctx, `/v1/admin/account/preferences/${encodeURIComponent(input.key)}`); + + await logEventFromContext( + ctx, + 'botpress.account.getPreference', + auditPayload(input, ['key']), + 'completed', + ); + return result; +}; + +/** Sets an account preference by key. */ +export const setPreference: BotpressEndpoints['accountSetPreference'] = async ( + ctx, + input, +) => { + await botpressCall( + ctx, + `/v1/admin/account/preferences/${encodeURIComponent(input.key)}`, + { method: 'POST', body: { value: input.value } }, + ); + + await logEventFromContext( + ctx, + 'botpress.account.setPreference', + auditPayload(input, ['key']), + 'completed', + ); + return {}; +}; diff --git a/packages/botpress/endpoints/billing.ts b/packages/botpress/endpoints/billing.ts new file mode 100644 index 000000000..839e5f1b7 --- /dev/null +++ b/packages/botpress/endpoints/billing.ts @@ -0,0 +1,94 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactQuery } from './shared'; +import type { BotpressEndpointOutputs } from './types'; + +/** Lists invoices billed to a workspace. */ +export const listInvoices: BotpressEndpoints['billingListInvoices'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + invoices: BotpressEndpointOutputs['billingListInvoices']; + }>( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.workspaceId)}/billing/invoices`, + ); + + await logEventFromContext( + ctx, + 'botpress.billing.listInvoices', + auditPayload(input, ['workspaceId']), + 'completed', + ); + return result.invoices ?? []; +}; + +/** Previews the upcoming invoice for a workspace before it is billed. */ +export const getUpcomingInvoice: BotpressEndpoints['billingGetUpcomingInvoice'] = + async (ctx, input) => { + const result = await botpressCall< + BotpressEndpointOutputs['billingGetUpcomingInvoice'] + >( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.workspaceId)}/billing/upcoming-invoice`, + ); + + await logEventFromContext( + ctx, + 'botpress.billing.getUpcomingInvoice', + auditPayload(input, ['workspaceId']), + 'completed', + ); + return result; + }; + +/** + * Charges outstanding invoices for a workspace. + * + * A real financial action — see `error-handlers.ts` (`isNonIdempotent`). + * Verified structurally (request shape, auth, error handling) rather than by + * actually charging anything against a live account with a real payment + * method. + */ +export const chargeUnpaidInvoices: BotpressEndpoints['billingChargeUnpaidInvoices'] = + async (ctx, input) => { + const result = await botpressCall< + BotpressEndpointOutputs['billingChargeUnpaidInvoices'] + >( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.workspaceId)}/billing/invoices/charge-unpaid`, + { method: 'POST', body: { invoiceIds: input.invoiceIds } }, + ); + + await logEventFromContext( + ctx, + 'botpress.billing.chargeUnpaidInvoices', + { + ...auditPayload(input, ['workspaceId']), + invoiceCount: input.invoiceIds.length, + }, + 'completed', + ); + return result; + }; + +/** Lists usage history for a workspace or bot id against a quota type. */ +export const listUsageHistory: BotpressEndpoints['billingListUsageHistory'] = + async (ctx, input) => { + const result = await botpressCall<{ + usages: BotpressEndpointOutputs['billingListUsageHistory']; + }>(ctx, `/v1/admin/usages/${encodeURIComponent(input.id)}/history`, { + method: 'GET', + query: compactQuery({ type: input.type }), + }); + + await logEventFromContext( + ctx, + 'botpress.billing.listUsageHistory', + auditPayload(input, ['id', 'type']), + 'completed', + ); + return result.usages ?? []; + }; diff --git a/packages/botpress/endpoints/bots.ts b/packages/botpress/endpoints/bots.ts new file mode 100644 index 000000000..b6f1dd54f --- /dev/null +++ b/packages/botpress/endpoints/bots.ts @@ -0,0 +1,172 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheBot } from './persist'; +import { + botpressCall, + compactBody, + compactQuery, + resolveWorkspaceId, +} from './shared'; +import type { BotpressActionRun, BotpressBot, BotpressBotIssue } from './types'; + +/** + * Creates a bot in a workspace. + * + * No workspace id in the path (confirmed live: `POST /v1/admin/bots` answers + * 400 without `x-workspace-id`), so the acting workspace is resolved and + * required here. + */ +export const create: BotpressEndpoints['botsCreate'] = async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ bot: BotpressBot }>( + ctx, + '/v1/admin/bots', + { + method: 'POST', + body: compactBody({ + name: input.name, + description: input.description, + tags: input.tags, + dev: input.dev, + code: input.code, + url: input.url, + states: input.states, + events: input.events, + recurringEvents: input.recurringEvents, + actions: input.actions, + configuration: input.configuration, + user: input.user, + conversation: input.conversation, + message: input.message, + subscriptions: input.subscriptions, + maxExecutionTime: input.maxExecutionTime, + medias: input.medias, + secrets: input.secrets, + type: input.type, + moduleFormat: input.moduleFormat, + }), + workspaceId, + }, + ); + + await cacheBot(ctx.db?.bots, result.bot); + + await logEventFromContext( + ctx, + 'botpress.bots.create', + auditPayload(input, ['name']), + 'completed', + ); + return result.bot; +}; + +/** + * Updates a bot's configuration, tags or lifecycle flags. + * + * Unlike `bots.create`, the target bot is identified by `id` in the path, the + * same way workspace-by-id updates need no ambient `x-workspace-id` header + * (confirmed live for the workspace case) — not independently confirmed live + * for this specific route since it is a mutating call, so this is inferred + * from that pattern rather than directly tested. + */ +export const update: BotpressEndpoints['botsUpdate'] = async (ctx, input) => { + const result = await botpressCall<{ bot: BotpressBot }>( + ctx, + `/v1/admin/bots/${encodeURIComponent(input.id)}`, + { + method: 'PUT', + body: compactBody({ + name: input.name, + description: input.description, + tags: input.tags, + blocked: input.blocked, + alwaysAlive: input.alwaysAlive, + maxExecutionTime: input.maxExecutionTime, + url: input.url, + authentication: input.authentication, + configuration: input.configuration, + user: input.user, + message: input.message, + conversation: input.conversation, + events: input.events, + actions: input.actions, + states: input.states, + recurringEvents: input.recurringEvents, + integrations: input.integrations, + plugins: input.plugins, + subscriptions: input.subscriptions, + code: input.code, + medias: input.medias, + secrets: input.secrets, + layers: input.layers, + type: input.type, + moduleFormat: input.moduleFormat, + }), + }, + ); + + await cacheBot(ctx.db?.bots, result.bot); + + await logEventFromContext( + ctx, + 'botpress.bots.update', + auditPayload(input, ['id']), + 'completed', + ); + return result.bot; +}; + +/** Lists action-execution history for a bot's integration instances. */ +export const listActionRuns: BotpressEndpoints['botsListActionRuns'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + data?: BotpressActionRun[]; + meta?: { nextToken?: string }; + }>(ctx, `/v1/admin/bots/${encodeURIComponent(input.id)}/action-runs`, { + method: 'GET', + query: compactQuery({ + integrationName: input.integrationName, + timestampFrom: input.timestampFrom, + timestampUntil: input.timestampUntil, + nextToken: input.nextToken, + pageSize: input.pageSize, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.bots.listActionRuns', + auditPayload(input, ['id']), + 'completed', + ); + return { data: result.data ?? [], nextToken: result.meta?.nextToken }; +}; + +/** Lists configuration and runtime issues detected for a bot. */ +export const listIssues: BotpressEndpoints['botsListIssues'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + issues?: BotpressBotIssue[]; + meta?: { nextToken?: string }; + }>(ctx, `/v1/admin/bots/${encodeURIComponent(input.id)}/issues`, { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.bots.listIssues', + auditPayload(input, ['id']), + 'completed', + ); + return { issues: result.issues ?? [], nextToken: result.meta?.nextToken }; +}; diff --git a/packages/botpress/endpoints/chat.ts b/packages/botpress/endpoints/chat.ts new file mode 100644 index 000000000..a9f55d101 --- /dev/null +++ b/packages/botpress/endpoints/chat.ts @@ -0,0 +1,148 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactBody, compactQuery } from './shared'; +import type { + BotpressConversation, + BotpressMessage, + BotpressWorkflow, +} from './types'; + +/** + * Creates a conversation on a channel. + * + * Scoped by `x-bot-id` rather than `x-workspace-id` (confirmed live: + * `GET /v1/chat/conversations` answers 400 `request/headers must have + * required property 'x-bot-id'` without it, and succeeds with it against + * `api.botpress.cloud` — see `client.ts` for the host correction). The + * channel must be one an integration installed on the bot actually serves; + * confirmed live against a bot with zero integrations installed, which + * answered "Must specify either integrationId or integrationAlias" — this + * plugin does not install integrations, so full exercise of this route needs + * a bot with at least one channel-providing integration installed. + */ +export const createConversation: BotpressEndpoints['chatCreateConversation'] = + async (ctx, input) => { + const result = await botpressCall<{ conversation: BotpressConversation }>( + ctx, + '/v1/chat/conversations', + { + method: 'POST', + body: compactBody({ + channel: input.channel, + tags: input.tags, + properties: input.properties, + }), + botId: input.botId, + }, + ); + + await logEventFromContext( + ctx, + 'botpress.chat.createConversation', + auditPayload(input, ['botId', 'channel']), + 'completed', + ); + return result.conversation; + }; + +/** Lists a bot's conversations, optionally filtered by tags, channel or date range. */ +export const listConversations: BotpressEndpoints['chatListConversations'] = + async (ctx, input) => { + const result = await botpressCall<{ + conversations?: BotpressConversation[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/chat/conversations', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + tags: input.tags, + sortField: input.sortField, + sortDirection: input.sortDirection, + participantIds: input.participantIds, + integrationName: input.integrationName, + channel: input.channel, + afterDate: input.afterDate, + beforeDate: input.beforeDate, + minMessageCount: input.minMessageCount, + maxMessageCount: input.maxMessageCount, + }), + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.chat.listConversations', + auditPayload(input, ['botId', 'channel']), + 'completed', + ); + return { + conversations: result.conversations ?? [], + nextToken: result.meta?.nextToken, + }; + }; + +/** Sends a message into a conversation on behalf of a user. */ +export const sendMessage: BotpressEndpoints['chatSendMessage'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ message: BotpressMessage }>( + ctx, + '/v1/chat/messages', + { + method: 'POST', + body: { + payload: input.payload, + userId: input.userId, + conversationId: input.conversationId, + type: input.type, + // Required by CreateMessageRequestBody, so sent as-is (not compacted). + tags: input.tags, + ...compactBody({ schedule: input.schedule, origin: input.origin }), + }, + botId: input.botId, + }, + ); + + await logEventFromContext( + ctx, + 'botpress.chat.sendMessage', + auditPayload(input, ['botId', 'conversationId', 'userId', 'type']), + 'completed', + ); + return result.message; +}; + +/** Updates a workflow's status, output or failure reason. */ +export const updateWorkflow: BotpressEndpoints['chatUpdateWorkflow'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ workflow: BotpressWorkflow }>( + ctx, + `/v1/chat/workflows/${encodeURIComponent(input.id)}`, + { + method: 'PUT', + body: compactBody({ + status: input.status, + output: input.output, + timeoutAt: input.timeoutAt, + failureReason: input.failureReason, + tags: input.tags, + userId: input.userId, + eventId: input.eventId, + }), + botId: input.botId, + }, + ); + + await logEventFromContext( + ctx, + 'botpress.chat.updateWorkflow', + auditPayload(input, ['botId', 'id', 'status']), + 'completed', + ); + return result.workflow; +}; diff --git a/packages/botpress/endpoints/files.ts b/packages/botpress/endpoints/files.ts new file mode 100644 index 000000000..cd046c282 --- /dev/null +++ b/packages/botpress/endpoints/files.ts @@ -0,0 +1,78 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactQuery } from './shared'; + +/** + * Deletes a file from a bot's storage. + * + * Scoped by `x-bot-id`, not `x-workspace-id` — confirmed live: `GET + * /v1/files/tags` answers 400 "Request is missing some required + * authentication params" without it, and succeeds with it. + */ +export const remove: BotpressEndpoints['filesDelete'] = async (ctx, input) => { + await botpressCall(ctx, `/v1/files/${encodeURIComponent(input.id)}`, { + method: 'DELETE', + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.files.delete', + auditPayload(input, ['botId', 'id']), + 'completed', + ); + return {}; +}; + +/** Lists tags used across a bot's files. */ +export const listTags: BotpressEndpoints['filesListTags'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + tags?: string[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/files/tags', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + }), + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.files.listTags', + auditPayload(input, ['botId']), + 'completed', + ); + return { tags: result.tags ?? [], nextToken: result.meta?.nextToken }; +}; + +/** Lists all values seen for a given file tag. */ +export const listTagValues: BotpressEndpoints['filesListTagValues'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + values?: string[]; + meta?: { nextToken?: string }; + }>(ctx, `/v1/files/tags/${encodeURIComponent(input.tag)}/values`, { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + }), + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.files.listTagValues', + auditPayload(input, ['botId', 'tag']), + 'completed', + ); + return { values: result.values ?? [], nextToken: result.meta?.nextToken }; +}; diff --git a/packages/botpress/endpoints/hub.ts b/packages/botpress/endpoints/hub.ts new file mode 100644 index 000000000..71b05d6eb --- /dev/null +++ b/packages/botpress/endpoints/hub.ts @@ -0,0 +1,258 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactQuery } from './shared'; +import type { + BotpressEndpointOutputs, + BotpressPublicIntegration, + BotpressPublicInterface, + BotpressPublicPlugin, +} from './types'; + +/** + * Public hub browsing. Confirmed live that none of these need + * `x-workspace-id`: `GET /v1/admin/hub/integrations` and + * `GET /v1/admin/hub/plugins` both succeeded with only the bearer token. + */ +export const listIntegrations: BotpressEndpoints['hubListIntegrations'] = + async (ctx, input) => { + const result = await botpressCall<{ + integrations?: BotpressPublicIntegration[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/hub/integrations', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + limit: input.limit, + name: input.name, + version: input.version, + interfaceId: input.interfaceId, + interfaceName: input.interfaceName, + installedByBotId: input.installedByBotId, + verificationStatus: input.verificationStatus, + search: input.search, + sortBy: input.sortBy, + direction: input.direction, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.hub.listIntegrations', + auditPayload(input, ['name', 'search']), + 'completed', + ); + return { + integrations: result.integrations ?? [], + nextToken: result.meta?.nextToken, + }; + }; + +/** Gets a public integration by name and version. */ +export const getIntegration: BotpressEndpoints['hubGetIntegration'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ integration: BotpressPublicIntegration }>( + ctx, + `/v1/admin/hub/integrations/${encodeURIComponent(input.name)}/${encodeURIComponent(input.version)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getIntegration', + auditPayload(input, ['name', 'version']), + 'completed', + ); + return result.integration; +}; + +/** Gets a public integration by id. */ +export const getIntegrationById: BotpressEndpoints['hubGetIntegrationById'] = + async (ctx, input) => { + const result = await botpressCall<{ + integration: BotpressPublicIntegration; + }>(ctx, `/v1/admin/hub/integrations/${encodeURIComponent(input.id)}`); + + await logEventFromContext( + ctx, + 'botpress.hub.getIntegrationById', + auditPayload(input, ['id']), + 'completed', + ); + return result.integration; + }; + +/** Lists public interfaces available in the hub. */ +export const listInterfaces: BotpressEndpoints['hubListInterfaces'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + interfaces?: BotpressPublicInterface[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/hub/interfaces', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + name: input.name, + version: input.version, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.hub.listInterfaces', + auditPayload(input, ['name']), + 'completed', + ); + return { + interfaces: result.interfaces ?? [], + nextToken: result.meta?.nextToken, + }; +}; + +/** Gets a public interface by name and version. */ +export const getInterface: BotpressEndpoints['hubGetInterface'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ interface: BotpressPublicInterface }>( + ctx, + `/v1/admin/hub/interfaces/${encodeURIComponent(input.name)}/${encodeURIComponent(input.version)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getInterface', + auditPayload(input, ['name', 'version']), + 'completed', + ); + return result.interface; +}; + +/** Gets a public interface by id. */ +export const getInterfaceById: BotpressEndpoints['hubGetInterfaceById'] = + async (ctx, input) => { + const result = await botpressCall<{ interface: BotpressPublicInterface }>( + ctx, + `/v1/admin/hub/interfaces/${encodeURIComponent(input.id)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getInterfaceById', + auditPayload(input, ['id']), + 'completed', + ); + return result.interface; + }; + +/** Lists public plugins available in the hub. */ +export const listPlugins: BotpressEndpoints['hubListPlugins'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + plugins?: BotpressPublicPlugin[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/hub/plugins', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + name: input.name, + version: input.version, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.hub.listPlugins', + auditPayload(input, ['name']), + 'completed', + ); + return { plugins: result.plugins ?? [], nextToken: result.meta?.nextToken }; +}; + +/** Gets a public plugin by name and version. */ +export const getPlugin: BotpressEndpoints['hubGetPlugin'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ plugin: BotpressPublicPlugin }>( + ctx, + `/v1/admin/hub/plugins/${encodeURIComponent(input.name)}/${encodeURIComponent(input.version)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getPlugin', + auditPayload(input, ['name', 'version']), + 'completed', + ); + return result.plugin; +}; + +/** Gets a public plugin by id. */ +export const getPluginById: BotpressEndpoints['hubGetPluginById'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ plugin: BotpressPublicPlugin }>( + ctx, + `/v1/admin/hub/plugins/${encodeURIComponent(input.id)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getPluginById', + auditPayload(input, ['id']), + 'completed', + ); + return result.plugin; +}; + +/** Gets a public plugin's source code for a target platform. */ +export const getPluginCode: BotpressEndpoints['hubGetPluginCode'] = async ( + ctx, + input, +) => { + const result = await botpressCall< + BotpressEndpointOutputs['hubGetPluginCode'] + >( + ctx, + `/v1/admin/hub/plugins/${encodeURIComponent(input.id)}/code/${encodeURIComponent(input.platform)}`, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getPluginCode', + auditPayload(input, ['id', 'platform']), + 'completed', + ); + return result; +}; + +/** + * Gets a public plugin with its interface entity references resolved against + * the backing integrations supplied in `interfaces`. + */ +export const getDereferencedPluginById: BotpressEndpoints['hubGetDereferencedPluginById'] = + async (ctx, input) => { + const result = await botpressCall<{ plugin: Record }>( + ctx, + `/v1/admin/hub/plugins/${encodeURIComponent(input.id)}/dereferenced`, + { method: 'GET', query: compactQuery({ interfaces: input.interfaces }) }, + ); + + await logEventFromContext( + ctx, + 'botpress.hub.getDereferencedPluginById', + auditPayload(input, ['id']), + 'completed', + ); + return result.plugin; + }; diff --git a/packages/botpress/endpoints/index.ts b/packages/botpress/endpoints/index.ts new file mode 100644 index 000000000..c7a19276a --- /dev/null +++ b/packages/botpress/endpoints/index.ts @@ -0,0 +1,154 @@ +import { + get as accountGet, + update as accountUpdate, + getPreference, + setPreference, +} from './account'; +import { + chargeUnpaidInvoices, + getUpcomingInvoice, + listInvoices, + listUsageHistory, +} from './billing'; +import { + create as botsCreate, + update as botsUpdate, + listActionRuns, + listIssues, +} from './bots'; +import { + createConversation, + listConversations, + sendMessage, + updateWorkflow, +} from './chat'; +import { remove as filesDelete, listTags, listTagValues } from './files'; +import { + getDereferencedPluginById, + getIntegrationById, + getInterface, + getInterfaceById, + getPlugin, + getPluginById, + getPluginCode, + getIntegration as hubGetIntegration, + listIntegrations as hubListIntegrations, + listPlugins as hubListPlugins, + listInterfaces, +} from './hub'; +import { + deleteShareableId, + create as integrationsCreate, + get as integrationsGet, + list as integrationsList, + listApiKeys, + requestVerification, + validateUpdate, +} from './integrations'; +import { + remove as knowledgeBasesDelete, + list as knowledgeBasesList, +} from './knowledge-bases'; +import { list as pluginsList } from './plugins'; +import { getTableRow, runVrl } from './tools'; +import { + breakDownUsageByBot, + checkHandleAvailability, + getAllQuotaCompletion, + getQuota, + create as workspacesCreate, + remove as workspacesDelete, + get as workspacesGet, + list as workspacesList, + listPublic as workspacesListPublic, + setPreference as workspacesSetPreference, + update as workspacesUpdate, +} from './workspaces'; + +export const Account = { + get: accountGet, + update: accountUpdate, + getPreference, + setPreference, +}; + +export const Workspaces = { + create: workspacesCreate, + get: workspacesGet, + update: workspacesUpdate, + delete: workspacesDelete, + list: workspacesList, + listPublic: workspacesListPublic, + checkHandleAvailability, + setPreference: workspacesSetPreference, + getQuota, + getAllQuotaCompletion, + breakDownUsageByBot, +}; + +export const Billing = { + listInvoices, + getUpcomingInvoice, + chargeUnpaidInvoices, + listUsageHistory, +}; + +export const Bots = { + create: botsCreate, + update: botsUpdate, + listActionRuns, + listIssues, +}; + +export const Chat = { + createConversation, + listConversations, + sendMessage, + updateWorkflow, +}; + +export const Integrations = { + create: integrationsCreate, + get: integrationsGet, + list: integrationsList, + validateUpdate, + requestVerification, + listApiKeys, + deleteShareableId, +}; + +export const Hub = { + listIntegrations: hubListIntegrations, + getIntegration: hubGetIntegration, + getIntegrationById, + listInterfaces, + getInterface, + getInterfaceById, + listPlugins: hubListPlugins, + getPlugin, + getPluginById, + getPluginCode, + getDereferencedPluginById, +}; + +export const Plugins = { + list: pluginsList, +}; + +export const Files = { + delete: filesDelete, + listTags, + listTagValues, +}; + +export const KnowledgeBases = { + list: knowledgeBasesList, + delete: knowledgeBasesDelete, +}; + +export const Tools = { + runVrl, + getTableRow, +}; + +export * from './types'; diff --git a/packages/botpress/endpoints/integrations.ts b/packages/botpress/endpoints/integrations.ts new file mode 100644 index 000000000..74c079d4e --- /dev/null +++ b/packages/botpress/endpoints/integrations.ts @@ -0,0 +1,265 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheIntegration } from './persist'; +import { + botpressCall, + compactBody, + compactQuery, + resolveWorkspaceId, +} from './shared'; +import type { BotpressEndpointOutputs, BotpressIntegration } from './types'; + +/** + * Creates an integration in a workspace. + * + * No workspace id in the path (confirmed live: `GET /v1/admin/integrations` + * answers 400 without `x-workspace-id`, and `POST` shares the same route + * family), so the acting workspace is resolved and required here. + */ +export const create: BotpressEndpoints['integrationsCreate'] = async ( + ctx, + input, +) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ integration: BotpressIntegration }>( + ctx, + '/v1/admin/integrations', + { + method: 'POST', + body: compactBody({ + name: input.name, + version: input.version, + title: input.title, + description: input.description, + url: input.url, + code: input.code, + configuration: input.configuration, + configurations: input.configurations, + states: input.states, + events: input.events, + actions: input.actions, + entities: input.entities, + channels: input.channels, + user: input.user, + interfaces: input.interfaces, + identifier: input.identifier, + extraOperations: input.extraOperations, + sdkVersion: input.sdkVersion, + secrets: input.secrets, + icon: input.icon, + readme: input.readme, + public: input.public, + visibility: input.visibility, + layers: input.layers, + attributes: input.attributes, + }), + workspaceId, + }, + ); + + await cacheIntegration(ctx.db?.integrations, result.integration); + + await logEventFromContext( + ctx, + 'botpress.integrations.create', + auditPayload(input, ['name', 'version']), + 'completed', + ); + return result.integration; +}; + +/** + * Gets an integration by name and version (`"latest"` resolves to the newest + * version). Confirmed live: `GET /v1/admin/integrations/{name}/{version}` + * needs `x-workspace-id` — unlike a by-id lookup, a name is only unambiguous + * within a workspace. + */ +export const get: BotpressEndpoints['integrationsGet'] = async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ integration: BotpressIntegration }>( + ctx, + `/v1/admin/integrations/${encodeURIComponent(input.name)}/${encodeURIComponent(input.version)}`, + { workspaceId }, + ); + + await cacheIntegration(ctx.db?.integrations, result.integration); + + await logEventFromContext( + ctx, + 'botpress.integrations.get', + auditPayload(input, ['name', 'version']), + 'completed', + ); + return result.integration; +}; + +/** Lists integrations owned by the workspace. */ +export const list: BotpressEndpoints['integrationsList'] = async ( + ctx, + input, +) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ + integrations?: BotpressIntegration[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/integrations', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + limit: input.limit, + name: input.name, + version: input.version, + interfaceId: input.interfaceId, + interfaceName: input.interfaceName, + installedByBotId: input.installedByBotId, + verificationStatus: input.verificationStatus, + search: input.search, + sortBy: input.sortBy, + direction: input.direction, + visibility: input.visibility, + dev: input.dev, + }), + workspaceId, + }); + + const integrations = result.integrations ?? []; + await Promise.all( + integrations.map((integration) => + cacheIntegration(ctx.db?.integrations, integration), + ), + ); + + await logEventFromContext( + ctx, + 'botpress.integrations.list', + auditPayload(input, ['name']), + 'completed', + ); + return { integrations, nextToken: result.meta?.nextToken }; +}; + +/** Validates that an integration update would succeed, without applying it. */ +export const validateUpdate: BotpressEndpoints['integrationsValidateUpdate'] = + async (ctx, input) => { + await botpressCall( + ctx, + `/v1/admin/integrations/${encodeURIComponent(input.id)}/validate`, + { + method: 'PUT', + body: compactBody({ + configuration: input.configuration, + configurations: input.configurations, + states: input.states, + events: input.events, + actions: input.actions, + entities: input.entities, + channels: input.channels, + user: input.user, + interfaces: input.interfaces, + identifier: input.identifier, + extraOperations: input.extraOperations, + sdkVersion: input.sdkVersion, + maxExecutionTime: input.maxExecutionTime, + secrets: input.secrets, + icon: input.icon, + readme: input.readme, + title: input.title, + description: input.description, + url: input.url, + public: input.public, + visibility: input.visibility, + layers: input.layers, + }), + }, + ); + + await logEventFromContext( + ctx, + 'botpress.integrations.validateUpdate', + auditPayload(input, ['id']), + 'completed', + ); + return {}; + }; + +/** + * Submits an integration for verification. + * + * No id in the path — the target integration is named entirely by the body's + * `integrationId` — so the acting workspace is resolved and required here. + */ +export const requestVerification: BotpressEndpoints['integrationsRequestVerification'] = + async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + await botpressCall(ctx, '/v1/admin/integrations/request-verification', { + method: 'POST', + body: { integrationId: input.integrationId }, + workspaceId, + }); + + await logEventFromContext( + ctx, + 'botpress.integrations.requestVerification', + auditPayload(input, ['integrationId']), + 'completed', + ); + return {}; + }; + +/** + * Lists Integration API Keys (IAKs) for an integration. + * + * Confirmed live: `GET /v1/admin/integrations/iaks` answers 400 requiring + * both the `integrationId` query param and `x-workspace-id`. + */ +export const listApiKeys: BotpressEndpoints['integrationsListApiKeys'] = async ( + ctx, + input, +) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ + iaks: BotpressEndpointOutputs['integrationsListApiKeys']; + }>(ctx, '/v1/admin/integrations/iaks', { + method: 'GET', + query: { integrationId: input.integrationId }, + workspaceId, + }); + + await logEventFromContext( + ctx, + 'botpress.integrations.listApiKeys', + auditPayload(input, ['integrationId']), + 'completed', + ); + return result.iaks ?? []; +}; + +/** Deletes the shareable id for a bot-integration pair (sandbox feature). */ +export const deleteShareableId: BotpressEndpoints['integrationsDeleteShareableId'] = + async (ctx, input) => { + await botpressCall( + ctx, + `/v1/admin/bots/${encodeURIComponent(input.botId)}/integrations/${encodeURIComponent(input.integrationId)}/shareable-id`, + { + method: 'DELETE', + query: compactQuery({ + integrationInstanceAlias: input.integrationInstanceAlias, + }), + }, + ); + + await logEventFromContext( + ctx, + 'botpress.integrations.deleteShareableId', + auditPayload(input, ['botId', 'integrationId']), + 'completed', + ); + return {}; + }; diff --git a/packages/botpress/endpoints/knowledge-bases.ts b/packages/botpress/endpoints/knowledge-bases.ts new file mode 100644 index 000000000..58a2938c0 --- /dev/null +++ b/packages/botpress/endpoints/knowledge-bases.ts @@ -0,0 +1,55 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactQuery } from './shared'; +import type { BotpressKnowledgeBase } from './types'; + +/** Lists a bot's knowledge bases, optionally filtered by tags. */ +export const list: BotpressEndpoints['knowledgeBasesList'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + knowledgeBases?: BotpressKnowledgeBase[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/files/knowledge-bases', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + tags: input.tags, + }), + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.knowledgeBases.list', + auditPayload(input, ['botId']), + 'completed', + ); + return { + knowledgeBases: result.knowledgeBases ?? [], + nextToken: result.meta?.nextToken, + }; +}; + +/** Permanently deletes a knowledge base from a bot. [DESTRUCTIVE] */ +export const remove: BotpressEndpoints['knowledgeBasesDelete'] = async ( + ctx, + input, +) => { + await botpressCall( + ctx, + `/v1/files/knowledge-bases/${encodeURIComponent(input.id)}`, + { method: 'DELETE', botId: input.botId }, + ); + + await logEventFromContext( + ctx, + 'botpress.knowledgeBases.delete', + auditPayload(input, ['botId', 'id']), + 'completed', + ); + return {}; +}; diff --git a/packages/botpress/endpoints/logging.ts b/packages/botpress/endpoints/logging.ts new file mode 100644 index 000000000..51c781c40 --- /dev/null +++ b/packages/botpress/endpoints/logging.ts @@ -0,0 +1,31 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * `logEventFromContext` persists whatever it is handed, and those rows + * inherit the event log's retention. Spreading a raw endpoint input would + * therefore park user-authored content — message payloads, VRL scripts, + * workspace/bot names, invoice ids — in the log indefinitely. + * + * So only explicitly named identifier fields are recorded. The names of the + * other supplied fields are kept, without their values, so an operator can + * still see what a call attempted to change. + */ +export function auditPayload>( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Record { + const payload: Record = {}; + + for (const key of identifierKeys) { + if (input[key] !== undefined) { + payload[key] = input[key]; + } + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + payload.fields = supplied; + } + + return payload; +} diff --git a/packages/botpress/endpoints/persist.ts b/packages/botpress/endpoints/persist.ts new file mode 100644 index 000000000..ef2f9bad0 --- /dev/null +++ b/packages/botpress/endpoints/persist.ts @@ -0,0 +1,139 @@ +import type { + BotpressBotEntity, + BotpressIntegrationEntity, + BotpressWorkspaceEntity, +} from '../schema/database'; +import type { + BotpressBot, + BotpressIntegration, + BotpressWorkspace, +} from './types'; + +/** + * Minimal structural view of a Corsair entity store. Only the two operations + * the Botpress endpoints need are declared, so the helpers below stay usable + * whatever else the concrete store exposes. + */ +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; + deleteByEntityId?: (entityId: string) => Promise; +}; + +/** + * Caching is best-effort: a plugin call must not fail because the local + * mirror could not be written. Failures are warned about and swallowed. + */ +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[BOTPRESS] failed to cache ${what}:`, error); + } +} + +/** Mirrors a workspace into the local cache. */ +export async function cacheWorkspace( + store: EntityStore | undefined, + workspace: BotpressWorkspace | undefined | null, +) { + if (!store || !workspace?.id) return; + await safely( + () => + store.upsertByEntityId(workspace.id, { + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + createdAt: workspace.createdAt ? new Date(workspace.createdAt) : null, + updatedAt: workspace.updatedAt ? new Date(workspace.updatedAt) : null, + blocked: workspace.blocked, + plan: workspace.plan, + billingVersion: workspace.billingVersion, + spendingLimit: workspace.spendingLimit, + botCount: workspace.botCount, + about: workspace.about, + profilePicture: workspace.profilePicture, + contactEmail: workspace.contactEmail, + website: workspace.website, + isPublic: workspace.isPublic, + handle: workspace.handle, + activeTrialId: workspace.activeTrialId, + }), + `workspace ${workspace.id}`, + ); +} + +/** Mirrors a bot into the local cache. */ +export async function cacheBot( + store: EntityStore | undefined, + bot: BotpressBot | undefined | null, +) { + if (!store || !bot?.id) return; + await safely( + () => + store.upsertByEntityId(bot.id, { + id: bot.id, + name: bot.name, + createdAt: bot.createdAt ? new Date(bot.createdAt) : null, + updatedAt: bot.updatedAt ? new Date(bot.updatedAt) : null, + createdBy: bot.createdBy, + dev: bot.dev, + alwaysAlive: bot.alwaysAlive, + status: bot.status, + type: bot.type, + tags: bot.tags, + }), + `bot ${bot.id}`, + ); +} + +/** Mirrors an integration into the local cache. */ +export async function cacheIntegration( + store: EntityStore | undefined, + integration: BotpressIntegration | undefined | null, +) { + if (!store || !integration?.id) return; + await safely( + () => + store.upsertByEntityId(integration.id, { + id: integration.id, + name: integration.name, + version: integration.version, + title: integration.title, + description: integration.description, + createdAt: integration.createdAt + ? new Date(integration.createdAt) + : null, + updatedAt: integration.updatedAt + ? new Date(integration.updatedAt) + : null, + visibility: integration.visibility, + dev: integration.dev, + url: integration.url, + iconUrl: integration.iconUrl, + readmeUrl: integration.readmeUrl, + }), + `integration ${integration.id}`, + ); +} + +/** + * Drops a cached record after the provider confirmed the delete. + * + * This takes only the delete half of the store: referencing the upsert + * signature here would make the parameter invariant in the entity type and + * reject the concrete per-entity clients. + */ +type DeletableStore = { + deleteByEntityId?: (entityId: string) => Promise; +}; + +/** Drops a cached record once the provider confirmed the delete. */ +export async function evictEntity( + store: DeletableStore | undefined, + id: string, + what: string, +) { + const remove = store?.deleteByEntityId; + if (!remove || !id) return; + await safely(() => remove(id), `${what} ${id}`); +} diff --git a/packages/botpress/endpoints/plugins.ts b/packages/botpress/endpoints/plugins.ts new file mode 100644 index 000000000..a3ea26f86 --- /dev/null +++ b/packages/botpress/endpoints/plugins.ts @@ -0,0 +1,36 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, compactQuery, resolveWorkspaceId } from './shared'; +import type { BotpressPublicPlugin } from './types'; + +/** + * Lists plugins installed in the workspace (distinct from the public + * `hub.listPlugins` catalog). Confirmed live: `GET /v1/admin/plugins` + * answers 400 without `x-workspace-id`. + */ +export const list: BotpressEndpoints['pluginsList'] = async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall<{ + plugins?: BotpressPublicPlugin[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/plugins', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + name: input.name, + version: input.version, + }), + workspaceId, + }); + + await logEventFromContext( + ctx, + 'botpress.plugins.list', + auditPayload(input, ['name']), + 'completed', + ); + return { plugins: result.plugins ?? [], nextToken: result.meta?.nextToken }; +}; diff --git a/packages/botpress/endpoints/shared.ts b/packages/botpress/endpoints/shared.ts new file mode 100644 index 000000000..cad6063fe --- /dev/null +++ b/packages/botpress/endpoints/shared.ts @@ -0,0 +1,81 @@ +import type { BotpressRequestOptions } from '../client'; +import { discoverBotpressWorkspaceId, makeBotpressRequest } from '../client'; + +/** + * Minimal structural view of the plugin context the endpoints need. + * + * Declaring only the members used here keeps the helpers testable without + * constructing a full Corsair context, and keeps them working whatever else + * the concrete context exposes. + */ +type BotpressCallContext = { + key: string; + options: { workspaceId?: string | undefined }; + keys?: { get_workspace_id?: () => Promise }; +}; + +/** + * Resolves the workspace id for a call that strictly needs one. + * + * Botpress needs a workspace id alongside the token because one Personal + * Access Token can reach several workspaces. Configuration wins; a stored key + * is next; discovery via `GET /v1/admin/workspaces` is the last resort and + * only succeeds when the token can reach exactly one workspace. + * + * Only call this for operations confirmed live to require `x-workspace-id` + * (see `error-handlers.ts` and the per-group comments) — operations that + * already identify their target by id in the path do not need it. + */ +export async function resolveWorkspaceId( + ctx: BotpressCallContext, +): Promise { + const configured = ctx.options.workspaceId?.trim(); + if (configured) return configured; + + const stored = (await ctx.keys?.get_workspace_id?.())?.trim(); + if (stored) return stored; + + return await discoverBotpressWorkspaceId(ctx.key); +} + +/** Issues a Botpress request under the plugin's Personal Access Token. */ +export async function botpressCall( + ctx: BotpressCallContext, + path: string, + options: BotpressRequestOptions = {}, +): Promise { + return await makeBotpressRequest(path, ctx.key, options); +} + +/** + * Drops keys whose value is `undefined`. + * + * Botpress distinguishes an absent field from an explicit `null` on update + * bodies: omitting a field leaves it alone, `null` clears it. Serialising + * `undefined` would produce neither, so unset fields are removed before the + * body is built. + */ +export function compactBody( + body: Record, +): Record { + const compacted: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value !== undefined) compacted[key] = value; + } + return compacted; +} + +/** Same as {@link compactBody}, for query strings. */ +export function compactQuery< + T extends Record< + string, + string | number | boolean | string[] | Record | undefined + >, +>(query: T): T { + const compacted = {} as T; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) + (compacted as Record)[key] = value; + } + return compacted; +} diff --git a/packages/botpress/endpoints/tools.ts b/packages/botpress/endpoints/tools.ts new file mode 100644 index 000000000..bcdc2dbd9 --- /dev/null +++ b/packages/botpress/endpoints/tools.ts @@ -0,0 +1,66 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { botpressCall, resolveWorkspaceId } from './shared'; +import type { BotpressEndpointOutputs } from './types'; + +/** + * Executes a VRL (Vector Remap Language) script against input data. + * + * Needs a scoping header — confirmed live: `POST /v1/admin/helper/vrl` + * answers 400 `request/headers must have required property + * 'x-workspace-id'` with only the bearer token, and succeeds with either + * `x-workspace-id` or `x-bot-id` attached. This resolves and requires the + * workspace, matching the rest of the `/v1/admin/helper/*` family rather than + * introducing a bot-scoping requirement this operation does not otherwise + * need. + */ +export const runVrl: BotpressEndpoints['toolsRunVrl'] = async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + const result = await botpressCall( + ctx, + '/v1/admin/helper/vrl', + { + method: 'POST', + body: { data: input.data, script: input.script }, + workspaceId, + }, + ); + + await logEventFromContext( + ctx, + 'botpress.tools.runVrl', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** + * Fetches a single row from a table by id. + * + * Tables are bot-scoped resources like files and knowledge bases, so this + * assumes `x-bot-id` scoping by the same pattern confirmed live for those — + * not independently confirmed live for the tables route itself. + */ +export const getTableRow: BotpressEndpoints['toolsGetTableRow'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + row: BotpressEndpointOutputs['toolsGetTableRow']; + }>(ctx, `/v1/tables/${encodeURIComponent(input.table)}/row`, { + method: 'GET', + query: { id: input.id }, + botId: input.botId, + }); + + await logEventFromContext( + ctx, + 'botpress.tools.getTableRow', + auditPayload(input, ['botId', 'table', 'id']), + 'completed', + ); + return result.row; +}; diff --git a/packages/botpress/endpoints/types.ts b/packages/botpress/endpoints/types.ts new file mode 100644 index 000000000..15a91d527 --- /dev/null +++ b/packages/botpress/endpoints/types.ts @@ -0,0 +1,1443 @@ +import { z } from 'zod'; + +/** + * Shared entity and operation shapes for the Botpress Admin/Billing/Files/ + * Chat surface. + * + * Ground truth for every route (method, path, request body/query fields) + * comes from `@botpress/client` v2.2.0's bundled source (npm, current major + * v2) — not from docs prose, per the playbook. Response shapes come from a + * mix of that same package's TypeScript declarations and live captures + * against a real account on 2026-08-16 (noted per schema below). Where a + * response is a deeply nested provider-defined manifest (bot, integration) + * that was never fully captured live, it is modeled loosely with `.loose()` + * rather than guessed field-by-field. + * + * @see https://botpress.com/docs/api-reference + */ + +const S = z.string().nullable().optional(); +const N = z.number().nullable().optional(); +const B = z.boolean().nullable().optional(); + +/* -------------------------------------------------------------------------- */ +/* entities */ +/* -------------------------------------------------------------------------- */ + +/** Live-captured 2026-08-16 from `GET /v1/admin/workspaces`. */ +export const BotpressWorkspaceSchema = z + .object({ + id: z.string(), + name: z.string(), + ownerId: S, + createdAt: S, + updatedAt: S, + blocked: B, + plan: S, + billingVersion: S, + spendingLimit: N, + botCount: N, + about: S, + profilePicture: S, + contactEmail: S, + website: S, + socialAccounts: z.array(z.unknown()).nullable().optional(), + isPublic: B, + handle: S, + activeTrialId: S, + }) + .loose(); +export type BotpressWorkspace = z.infer; + +/** + * The bot manifest nests states/message/user/conversation/events/actions/ + * integrations/plugins/configuration, each an open-ended, provider-defined + * schema. Only the identifying fields are typed; the rest is unknown to this + * plugin rather than guessed. + */ +export const BotpressBotSchema = z + .object({ + id: z.string(), + name: S, + createdAt: S, + updatedAt: S, + createdBy: S, + dev: B, + alwaysAlive: B, + status: S, + type: S, + signingSecret: S, + tags: z.record(z.string(), z.string()).nullable().optional(), + }) + .loose(); +export type BotpressBot = z.infer; + +/** + * The integration manifest is even larger than the bot's (configuration + * schema, per-channel message schemas, actions, events, entities, + * interfaces). Only identifying/listing fields are typed. + */ +export const BotpressIntegrationSchema = z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + title: S, + description: S, + createdAt: S, + updatedAt: S, + visibility: z.enum(['public', 'private', 'unlisted']).nullable().optional(), + verificationStatus: S, + dev: B, + url: S, + iconUrl: S, + readmeUrl: S, + signingSecret: S, + }) + .loose(); +export type BotpressIntegration = z.infer; + +/** + * Live-captured 2026-08-16 from `GET /v1/admin/hub/integrations` (list). + * `hub.getIntegration`/`getIntegrationById` return a fuller manifest per + * `GetPublicIntegrationResponse` (configuration, actions, events, channels, + * entities, secrets by name, interfaces) - `.loose()` preserves those extra + * fields without validating them, same rationale as `BotpressIntegration`. + */ +export const BotpressPublicIntegrationSchema = z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + title: S, + description: S, + iconUrl: S, + createdAt: S, + updatedAt: S, + public: B, + visibility: S, + verificationStatus: S, + lifecycleStatus: S, + ownerWorkspace: z + .object({ id: S, handle: S, name: S }) + .loose() + .nullable() + .optional(), + }) + .loose(); +export type BotpressPublicIntegration = z.infer< + typeof BotpressPublicIntegrationSchema +>; + +/** Live-captured 2026-08-16 from `GET /v1/admin/hub/plugins`. */ +export const BotpressPublicPluginSchema = z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + title: S, + description: S, + iconUrl: S, + createdAt: S, + updatedAt: S, + public: B, + visibility: S, + lifecycleStatus: S, + }) + .loose(); +export type BotpressPublicPlugin = z.infer; + +/** Live-captured 2026-08-16 from `GET /v1/admin/hub/interfaces`. */ +export const BotpressPublicInterfaceSchema = z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + title: S, + description: S, + iconUrl: S, + readmeUrl: S, + createdAt: S, + updatedAt: S, + public: B, + }) + .loose(); +export type BotpressPublicInterface = z.infer< + typeof BotpressPublicInterfaceSchema +>; + +/** From `CreateConversationResponse`/`ListConversationsResponse` in @botpress/client v2.2.0. */ +export const BotpressConversationSchema = z + .object({ + id: z.string(), + createdAt: S, + updatedAt: S, + channel: S, + integration: S, + tags: z.record(z.string(), z.string()).nullable().optional(), + messageCount: N, + properties: z.record(z.string(), z.string()).nullable().optional(), + }) + .loose(); +export type BotpressConversation = z.infer; + +/** From `CreateMessageResponse` in @botpress/client v2.2.0. */ +export const BotpressMessageSchema = z + .object({ + id: z.string(), + createdAt: S, + updatedAt: S, + type: S, + payload: z.record(z.string(), z.unknown()).nullable().optional(), + direction: z.enum(['incoming', 'outgoing']).nullable().optional(), + userId: S, + conversationId: S, + tags: z.record(z.string(), z.string()).nullable().optional(), + origin: z.literal('synthetic').nullable().optional(), + }) + .loose(); +export type BotpressMessage = z.infer; + +/** From `UpdateWorkflowResponse` in @botpress/client v2.2.0. */ +export const BotpressWorkflowSchema = z + .object({ + id: z.string(), + name: S, + status: z + .enum([ + 'pending', + 'in_progress', + 'failed', + 'completed', + 'listening', + 'paused', + 'timedout', + 'cancelled', + ]) + .nullable() + .optional(), + input: z.record(z.string(), z.unknown()).nullable().optional(), + output: z.record(z.string(), z.unknown()).nullable().optional(), + parentWorkflowId: S, + conversationId: S, + userId: S, + createdAt: S, + updatedAt: S, + completedAt: S, + failureReason: S, + timeoutAt: S, + tags: z.record(z.string(), z.string()).nullable().optional(), + }) + .loose(); +export type BotpressWorkflow = z.infer; + +/** From `ListWorkspaceInvoicesResponse` in @botpress/client v2.2.0. */ +export const BotpressInvoiceSchema = z + .object({ + id: z.string(), + period: z.object({ month: z.number(), year: z.number() }).loose(), + date: S, + amount: N, + currency: S, + paymentStatus: z + .enum(['deleted', 'draft', 'open', 'paid', 'uncollectible', 'void']) + .nullable() + .optional(), + dueDate: S, + paymentAttemptCount: N, + nextPaymentAttemptDate: S, + pdfUrl: S, + }) + .loose(); +export type BotpressInvoice = z.infer; + +/** + * Live-captured 2026-08-16 from `GET .../billing/upcoming-invoice` (empty + * `lineItems`); the line item shape comes from `GetUpcomingInvoiceResponse` + * in `@botpress/client` v2.2.0's type declarations. + */ +export const BotpressUpcomingInvoiceSchema = z + .object({ + total: N, + lineItems: z + .array( + z + .object({ + id: z.string(), + description: S, + totalInCents: N, + currency: S, + pricePerUnitInCents: N, + quantity: N, + type: z.enum(['invoiceitem', 'subscription']).nullable().optional(), + periodStart: S, + periodEnd: S, + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose(); +export type BotpressUpcomingInvoice = z.infer< + typeof BotpressUpcomingInvoiceSchema +>; + +const BotpressQuotaTypeSchema = z.enum([ + 'invocation_timeout', + 'invocation_calls', + 'storage_count', + 'bot_count', + 'knowledgebase_vector_storage', + 'workspace_ratelimit', + 'table_row_count', + 'workspace_member_count', + 'integrations_owned_count', + 'ai_spend', + 'openai_spend', + 'bing_search_spend', + 'always_alive', + 'indexed_file_count', + 'file_max_size_bytes', +]); +export type BotpressQuotaType = z.infer; + +/** From `GetWorkspaceQuotaResponse` in @botpress/client v2.2.0. */ +export const BotpressQuotaSchema = z + .object({ + period: S, + value: N, + type: BotpressQuotaTypeSchema.nullable().optional(), + }) + .loose(); +export type BotpressQuota = z.infer; + +/** From `ListUsageHistoryResponse` in @botpress/client v2.2.0. */ +export const BotpressUsageHistoryItemSchema = z + .object({ + id: S, + period: S, + value: N, + quota: N, + type: BotpressQuotaTypeSchema.nullable().optional(), + }) + .loose(); +export type BotpressUsageHistoryItem = z.infer< + typeof BotpressUsageHistoryItemSchema +>; + +/** From `BreakDownWorkspaceUsageByBotResponse` in @botpress/client v2.2.0. */ +export const BotpressUsageByBotItemSchema = z + .object({ botId: S, value: N }) + .loose(); +export type BotpressUsageByBotItem = z.infer< + typeof BotpressUsageByBotItemSchema +>; + +/** + * `GetAllWorkspaceQuotaCompletionResponse` is a map keyed by workspace id + * rather than a list (confirmed live: `GET .../usages/quota-completion` + * returned `{}` for an account with a single workspace). + */ +export const BotpressQuotaCompletionMapSchema = z.record( + z.string(), + z + .object({ + type: BotpressQuotaTypeSchema.nullable().optional(), + completion: N, + }) + .loose(), +); +export type BotpressQuotaCompletionMap = z.infer< + typeof BotpressQuotaCompletionMapSchema +>; + +/** From `ListActionRunsResponse` in @botpress/client v2.2.0. */ +export const BotpressActionRunSchema = z + .object({ + timestamp: S, + integrationName: S, + actionType: S, + input: z.record(z.string(), z.unknown()).nullable().optional(), + inputTruncated: B, + output: z.record(z.string(), z.unknown()).nullable().optional(), + outputTruncated: B, + status: z.enum(['SUCCESS', 'FAILURE']).nullable().optional(), + durationMs: N, + cached: B, + errorMessage: S, + }) + .loose(); +export type BotpressActionRun = z.infer; + +/** From `ListBotIssuesResponse` in @botpress/client v2.2.0. */ +export const BotpressBotIssueSchema = z + .object({ + id: z.string(), + code: S, + createdAt: S, + lastSeenAt: S, + title: S, + description: S, + eventsCount: N, + category: z + .enum(['user_code', 'limits', 'configuration', 'other']) + .nullable() + .optional(), + resolutionLink: S, + }) + .loose(); +export type BotpressBotIssue = z.infer; + +/** From `ListIntegrationApiKeysResponse` in @botpress/client v2.2.0. */ +export const BotpressIntegrationApiKeySchema = z + .object({ id: z.string(), createdAt: S, note: S }) + .loose(); +export type BotpressIntegrationApiKey = z.infer< + typeof BotpressIntegrationApiKeySchema +>; + +/** From `ListKnowledgeBasesResponse` in @botpress/client v2.2.0. */ +export const BotpressKnowledgeBaseSchema = z + .object({ + id: z.string(), + name: z.string(), + description: S, + createdAt: S, + tags: z.record(z.string(), z.string()).nullable().optional(), + }) + .loose(); +export type BotpressKnowledgeBase = z.infer; + +/** + * From `GetTableRowResponse` in @botpress/client v2.2.0. Rows carry + * user-defined table columns beyond the fixed fields below, hence `.loose()`. + */ +export const BotpressTableRowSchema = z + .object({ + id: z.number(), + createdAt: S, + updatedAt: S, + }) + .loose(); +export type BotpressTableRow = z.infer; + +/** Live-captured 2026-08-16 from `GET /v1/admin/account/me`. */ +export const BotpressAccountSchema = z + .object({ + id: z.string(), + email: S, + displayName: S, + createdAt: S, + emailVerified: B, + }) + .loose(); +export type BotpressAccount = z.infer; + +/** From `CheckHandleAvailabilityResponse` in @botpress/client v2.2.0. */ +export const BotpressHandleAvailabilitySchema = z + .object({ + available: z.boolean(), + suggestions: z.array(z.string()).nullable().optional(), + usedBy: S, + }) + .loose(); +export type BotpressHandleAvailability = z.infer< + typeof BotpressHandleAvailabilitySchema +>; + +/** From `ChargeWorkspaceUnpaidInvoicesResponse` in @botpress/client v2.2.0. */ +export const BotpressChargeUnpaidInvoicesResultSchema = z + .object({ + chargedInvoices: z + .array(z.object({ id: z.string(), amount: z.number() }).loose()) + .nullable() + .optional(), + failedInvoices: z + .array( + z + .object({ + id: z.string(), + amount: z.number(), + failedReason: z.string(), + }) + .loose(), + ) + .nullable() + .optional(), + }) + .loose(); +export type BotpressChargeUnpaidInvoicesResult = z.infer< + typeof BotpressChargeUnpaidInvoicesResultSchema +>; + +/** Live-captured 2026-08-16 from `POST /v1/admin/helper/vrl`. */ +export const BotpressVrlResultSchema = z + .object({ + data: z.unknown(), + result: z.unknown(), + }) + .loose(); +export type BotpressVrlResult = z.infer; + +/** Empty-body 2xx responses (delete, request-verification, set-preference). */ +export const EmptyResultSchema = z.object({}).loose(); +export type EmptyResult = z.infer; + +const EmptyInputSchema = z.object({}); +export type EmptyInput = z.infer; + +const nonempty = z.string().trim().min(1); + +/* Shared pagination envelope for the list operations that accept it. */ +const PageInputSchema = z.object({ + nextToken: nonempty.optional(), + pageSize: z.number().int().positive().optional(), +}); + +/* -------------------------------------------------------------------------- */ +/* account */ +/* -------------------------------------------------------------------------- */ + +const AccountGetInputSchema = z.object({}); +export type AccountGetInput = z.infer; + +const AccountUpdateInputSchema = z.object({ + displayName: z.string().optional(), + profilePicture: z.string().optional(), + /** Re-fetches the display name/picture from the SSO provider when true. */ + refresh: z.boolean().optional(), +}); +export type AccountUpdateInput = z.infer; + +const AccountGetPreferenceInputSchema = z.object({ + key: nonempty, +}); +export type AccountGetPreferenceInput = z.infer< + typeof AccountGetPreferenceInputSchema +>; + +const AccountSetPreferenceInputSchema = z.object({ + key: nonempty, + /** + * Botpress types the stored preference value as `any` — never observed a + * stable shape across preference keys, so it is passed through unknown. + */ + value: z.unknown(), +}); +export type AccountSetPreferenceInput = z.infer< + typeof AccountSetPreferenceInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* workspaces */ +/* -------------------------------------------------------------------------- */ + +const WorkspacesCreateInputSchema = z.object({ + name: nonempty, + billingVersion: z.string().optional(), +}); +export type WorkspacesCreateInput = z.infer; + +const WorkspacesGetInputSchema = z.object({ + id: nonempty, +}); +export type WorkspacesGetInput = z.infer; + +const WorkspacesUpdateInputSchema = z.object({ + id: nonempty, + name: z.string().optional(), + spendingLimit: z.number().optional(), + about: z.string().optional(), + profilePicture: z.string().optional(), + contactEmail: z.string().optional(), + website: z.string().optional(), + socialAccounts: z.array(z.unknown()).optional(), + isPublic: z.boolean().optional(), + handle: z.string().optional(), +}); +export type WorkspacesUpdateInput = z.infer; + +const WorkspacesDeleteInputSchema = z.object({ + id: nonempty, +}); +export type WorkspacesDeleteInput = z.infer; + +const WorkspacesListInputSchema = PageInputSchema.extend({ + /** Substring match on workspace handle. */ + handle: z.string().optional(), +}); +export type WorkspacesListInput = z.infer; + +const WorkspacesListPublicInputSchema = PageInputSchema.extend({ + workspaceIds: z.array(z.string()).optional(), + search: z.string().optional(), +}); +export type WorkspacesListPublicInput = z.infer< + typeof WorkspacesListPublicInputSchema +>; + +const WorkspacesCheckHandleAvailabilityInputSchema = z.object({ + handle: nonempty, +}); +export type WorkspacesCheckHandleAvailabilityInput = z.infer< + typeof WorkspacesCheckHandleAvailabilityInputSchema +>; + +/** + * No workspace id in the path (confirmed live: `PUT + * /v1/admin/workspaces/preferences/{key}`), so the acting workspace comes + * from `x-workspace-id`. + */ +const WorkspacesSetPreferenceInputSchema = z.object({ + key: nonempty, + value: z.unknown(), +}); +export type WorkspacesSetPreferenceInput = z.infer< + typeof WorkspacesSetPreferenceInputSchema +>; + +const WorkspacesGetQuotaInputSchema = z.object({ + id: nonempty, + type: BotpressQuotaTypeSchema, + period: z.string().optional(), +}); +export type WorkspacesGetQuotaInput = z.infer< + typeof WorkspacesGetQuotaInputSchema +>; + +const WorkspacesGetAllQuotaCompletionInputSchema = z.object({}); +export type WorkspacesGetAllQuotaCompletionInput = z.infer< + typeof WorkspacesGetAllQuotaCompletionInputSchema +>; + +const WorkspacesBreakDownUsageByBotInputSchema = z.object({ + id: nonempty, + type: BotpressQuotaTypeSchema, + period: z.string().optional(), +}); +export type WorkspacesBreakDownUsageByBotInput = z.infer< + typeof WorkspacesBreakDownUsageByBotInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* billing */ +/* -------------------------------------------------------------------------- */ + +const BillingListInvoicesInputSchema = z.object({ + workspaceId: nonempty, +}); +export type BillingListInvoicesInput = z.infer< + typeof BillingListInvoicesInputSchema +>; + +const BillingGetUpcomingInvoiceInputSchema = z.object({ + workspaceId: nonempty, +}); +export type BillingGetUpcomingInvoiceInput = z.infer< + typeof BillingGetUpcomingInvoiceInputSchema +>; + +/** + * A real financial action — see `error-handlers.ts` (`isNonIdempotent`) and + * the PR description. Never exercised against a live account with a real + * payment method during development. + * + * `ChargeWorkspaceUnpaidInvoicesRequestBody.invoiceIds` is optional in the + * real API (`@botpress/client` v2.2.0 types it `invoiceIds?: string[]`, + * `@minItems 1` when present) — omitting it likely charges every unpaid + * invoice on the workspace. This plugin requires it deliberately, narrower + * than what the API allows, so an agent cannot trigger an unbounded charge + * by omitting the field. + */ +const BillingChargeUnpaidInvoicesInputSchema = z.object({ + workspaceId: nonempty, + invoiceIds: z.array(nonempty).min(1), +}); +export type BillingChargeUnpaidInvoicesInput = z.infer< + typeof BillingChargeUnpaidInvoicesInputSchema +>; + +const BillingListUsageHistoryInputSchema = z.object({ + /** A workspace id or a bot id — this route reports usage for either. */ + id: nonempty, + type: BotpressQuotaTypeSchema, +}); +export type BillingListUsageHistoryInput = z.infer< + typeof BillingListUsageHistoryInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* bots */ +/* -------------------------------------------------------------------------- */ + +/** + * `states`, `events`, `recurringEvents`, `actions`, `configuration`, `user`, + * `conversation`, `message` and `subscriptions` are each provider-defined + * maps (confirmed from `CreateBotRequestBody`/`UpdateBotRequestBody` in + * `@botpress/client` v2.2.0's type declarations) - modeled as opaque records, + * same rationale as `BotpressBot`. + * + * The catalog description also names "integrations" as something a create + * call configures, but `CreateBotRequestBody` has no such field - only + * `UpdateBotRequestBody` does (`bots.update`'s `integrations` field below). + * A newly created bot installs integrations through that follow-up call, so + * no `integrations` field is exposed here; adding one that the real API + * ignores would look accepted while silently doing nothing. + */ +const BotsCreateInputSchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + tags: z.record(z.string(), z.string()).optional(), + dev: z.boolean().optional(), + code: z.string().optional(), + url: z.string().optional(), + states: z.record(z.string(), z.unknown()).optional(), + events: z.record(z.string(), z.unknown()).optional(), + recurringEvents: z.record(z.string(), z.unknown()).optional(), + actions: z.record(z.string(), z.unknown()).optional(), + configuration: z.record(z.string(), z.unknown()).optional(), + user: z.record(z.string(), z.unknown()).optional(), + conversation: z.record(z.string(), z.unknown()).optional(), + message: z.record(z.string(), z.unknown()).optional(), + subscriptions: z.record(z.string(), z.unknown()).optional(), + maxExecutionTime: z.number().optional(), + medias: z.record(z.string(), z.unknown()).optional(), + secrets: z.record(z.string(), z.string().nullable()).optional(), + type: z.enum(['studio', 'adk']).optional(), + moduleFormat: z.enum(['cjs', 'esm']).optional(), +}); +export type BotsCreateInput = z.infer; + +const BotsUpdateInputSchema = z.object({ + id: nonempty, + name: z.string().optional(), + description: z.string().optional(), + tags: z.record(z.string(), z.string()).optional(), + blocked: z.boolean().optional(), + alwaysAlive: z.boolean().optional(), + maxExecutionTime: z.number().optional(), + url: z.string().nullable().optional(), + authentication: z.enum(['iam', 'hmac-sha256']).optional(), + configuration: z.record(z.string(), z.unknown()).optional(), + user: z.record(z.string(), z.unknown()).optional(), + message: z.record(z.string(), z.unknown()).optional(), + conversation: z.record(z.string(), z.unknown()).optional(), + events: z.record(z.string(), z.unknown()).optional(), + actions: z.record(z.string(), z.unknown()).optional(), + states: z.record(z.string(), z.unknown()).optional(), + recurringEvents: z.record(z.string(), z.unknown()).optional(), + /** Installed integrations, keyed by integration id or alias. */ + integrations: z.record(z.string(), z.unknown()).optional(), + /** Installed plugins, keyed by plugin alias. */ + plugins: z.record(z.string(), z.unknown()).optional(), + subscriptions: z.record(z.string(), z.unknown()).optional(), + code: z.string().optional(), + medias: z.record(z.string(), z.unknown()).optional(), + secrets: z.record(z.string(), z.string().nullable()).optional(), + layers: z.array(z.string()).optional(), + type: z.enum(['studio', 'adk']).optional(), + moduleFormat: z.enum(['cjs', 'esm']).optional(), +}); +export type BotsUpdateInput = z.infer; + +const BotsListActionRunsInputSchema = PageInputSchema.extend({ + id: nonempty, + integrationName: z.string().optional(), + timestampFrom: z.string().optional(), + timestampUntil: z.string().optional(), +}); +export type BotsListActionRunsInput = z.infer< + typeof BotsListActionRunsInputSchema +>; + +const BotsListIssuesInputSchema = PageInputSchema.extend({ + id: nonempty, +}); +export type BotsListIssuesInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* chat (requires botId — see client.ts BotpressBotIdMissingError) */ +/* -------------------------------------------------------------------------- */ + +const ChatCreateConversationInputSchema = z.object({ + botId: nonempty, + channel: nonempty, + tags: z.record(z.string(), z.string()), + properties: z.record(z.string(), z.string()).optional(), +}); +export type ChatCreateConversationInput = z.infer< + typeof ChatCreateConversationInputSchema +>; + +const ChatListConversationsInputSchema = PageInputSchema.extend({ + botId: nonempty, + tags: z.record(z.string(), z.string()).optional(), + sortField: z.enum(['createdAt', 'updatedAt']).optional(), + sortDirection: z.enum(['asc', 'desc']).optional(), + participantIds: z.array(z.string()).optional(), + integrationName: z.string().optional(), + channel: z.string().optional(), + afterDate: z.string().optional(), + beforeDate: z.string().optional(), + minMessageCount: z.number().optional(), + maxMessageCount: z.number().optional(), +}); +export type ChatListConversationsInput = z.infer< + typeof ChatListConversationsInputSchema +>; + +const ChatSendMessageInputSchema = z.object({ + botId: nonempty, + conversationId: nonempty, + userId: nonempty, + type: nonempty, + payload: z.record(z.string(), z.unknown()), + /** Required by `CreateMessageRequestBody` - pass `{}` for no tags. */ + tags: z.record(z.string(), z.string()), + /** Either `dateTime` or `delay` must be set to actually schedule the send. */ + schedule: z + .object({ + dateTime: z.string().optional(), + delay: z.number().optional(), + }) + .optional(), + /** Marks the message as system-generated rather than sent by the bot/user. */ + origin: z.literal('synthetic').optional(), +}); +export type ChatSendMessageInput = z.infer; + +/** + * `UpdateWorkflowRequestBody`'s status enum (6 values) is narrower than the + * `Workflow` entity's own status field (which also has `pending` and + * `timedout`) - those two are system-set and not accepted on update. + */ +const ChatUpdateWorkflowInputSchema = z.object({ + botId: nonempty, + id: nonempty, + status: z + .enum([ + 'completed', + 'cancelled', + 'listening', + 'paused', + 'failed', + 'in_progress', + ]) + .optional(), + output: z.record(z.string(), z.unknown()).optional(), + timeoutAt: z.string().optional(), + failureReason: z.string().optional(), + /** Set a key to `null` to remove that tag. */ + tags: z.record(z.string(), z.string().nullable()).optional(), + userId: z.string().optional(), + /** Required when `status` is set to `in_progress`. */ + eventId: z.string().optional(), +}); +export type ChatUpdateWorkflowInput = z.infer< + typeof ChatUpdateWorkflowInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* integrations */ +/* -------------------------------------------------------------------------- */ + +/** + * `configuration`, `configurations`, `states`, `events`, `actions`, + * `entities`, `channels`, `user`, `interfaces` and `extraOperations` are each + * a map keyed by name to a provider-defined JSON-schema-style definition + * (confirmed from `CreateIntegrationRequestBody` in `@botpress/client` + * v2.2.0's type declarations) - modeled as opaque records rather than typed + * field-by-field, per the same rationale as the `BotpressIntegration` output + * schema. + */ +const IntegrationsCreateInputSchema = z.object({ + name: nonempty, + version: nonempty, + title: z.string().optional(), + description: z.string().optional(), + url: z.string().optional(), + code: z.string().optional(), + configuration: z.record(z.string(), z.unknown()).optional(), + configurations: z.record(z.string(), z.unknown()).optional(), + states: z.record(z.string(), z.unknown()).optional(), + events: z.record(z.string(), z.unknown()).optional(), + actions: z.record(z.string(), z.unknown()).optional(), + entities: z.record(z.string(), z.unknown()).optional(), + channels: z.record(z.string(), z.unknown()).optional(), + user: z.record(z.string(), z.unknown()).optional(), + interfaces: z.record(z.string(), z.unknown()).optional(), + identifier: z + .object({ + fallbackHandlerScript: z.string().optional(), + extractScript: z.string().optional(), + }) + .optional(), + extraOperations: z + .record(z.string(), z.object({ enabled: z.boolean() })) + .optional(), + sdkVersion: z.string().optional(), + /** Integration-wide `SECRET_*` environment variables. */ + secrets: z.record(z.string(), z.string().nullable()).optional(), + /** Base64-encoded SVG icon. */ + icon: z.string().optional(), + /** Base64-encoded markdown readme. */ + readme: z.string().optional(), + /** @deprecated Use `visibility` instead - kept because the API still accepts it. */ + public: z.boolean().optional(), + visibility: z.enum(['public', 'private', 'unlisted']).optional(), + layers: z.array(z.string()).optional(), + attributes: z.record(z.string(), z.string()).optional(), +}); +export type IntegrationsCreateInput = z.infer< + typeof IntegrationsCreateInputSchema +>; + +/** + * `BOTPRESS_GET_INTEGRATION` is described as "by name and version... supports + * retrieving specific versions or the latest version" - that is + * `getIntegrationByName`, not the by-id `getIntegration`. Confirmed live: + * `GET /v1/admin/integrations/{name}/{version}` needs `x-workspace-id` + * (unlike the by-id route, whose id is unambiguous on its own), and + * `version: "latest"` resolves to the newest version (confirmed against the + * public hub's `agi/edge` integration, which has many versions). + */ +const IntegrationsGetInputSchema = z.object({ + name: nonempty, + /** A specific version, or `"latest"` for the newest one. */ + version: nonempty, +}); +export type IntegrationsGetInput = z.infer; + +const IntegrationsListInputSchema = PageInputSchema.extend({ + limit: z.number().int().positive().optional(), + name: z.string().optional(), + version: z.string().optional(), + interfaceId: z.string().optional(), + interfaceName: z.string().optional(), + installedByBotId: z.string().optional(), + verificationStatus: z + .enum(['unapproved', 'pending', 'approved', 'rejected']) + .optional(), + search: z.string().optional(), + sortBy: z + .enum(['popularity', 'name', 'createdAt', 'updatedAt', 'installCount']) + .optional(), + direction: z.enum(['asc', 'desc']).optional(), + visibility: z.enum(['public', 'private']).optional(), + dev: z.boolean().optional(), +}); +export type IntegrationsListInput = z.infer; + +const IntegrationsValidateUpdateInputSchema = z.object({ + id: nonempty, + configuration: z.record(z.string(), z.unknown()).optional(), + configurations: z.record(z.string(), z.unknown()).optional(), + states: z.record(z.string(), z.unknown()).optional(), + events: z.record(z.string(), z.unknown()).optional(), + actions: z.record(z.string(), z.unknown()).optional(), + entities: z.record(z.string(), z.unknown()).optional(), + channels: z.record(z.string(), z.unknown()).optional(), + user: z.record(z.string(), z.unknown()).optional(), + interfaces: z.record(z.string(), z.unknown()).optional(), + identifier: z + .object({ + fallbackHandlerScript: z.string().optional(), + extractScript: z.string().optional(), + }) + .optional(), + extraOperations: z + .record(z.string(), z.object({ enabled: z.boolean() })) + .optional(), + sdkVersion: z.string().optional(), + maxExecutionTime: z.number().optional(), + secrets: z.record(z.string(), z.string().nullable()).optional(), + icon: z.string().optional(), + readme: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + url: z.string().optional(), + public: z.boolean().optional(), + visibility: z.enum(['public', 'private', 'unlisted']).optional(), + layers: z.array(z.string()).optional(), +}); +export type IntegrationsValidateUpdateInput = z.infer< + typeof IntegrationsValidateUpdateInputSchema +>; + +const IntegrationsRequestVerificationInputSchema = z.object({ + integrationId: nonempty, +}); +export type IntegrationsRequestVerificationInput = z.infer< + typeof IntegrationsRequestVerificationInputSchema +>; + +const IntegrationsListApiKeysInputSchema = z.object({ + integrationId: nonempty, +}); +export type IntegrationsListApiKeysInput = z.infer< + typeof IntegrationsListApiKeysInputSchema +>; + +const IntegrationsDeleteShareableIdInputSchema = z.object({ + botId: nonempty, + integrationId: nonempty, + integrationInstanceAlias: z.string().optional(), +}); +export type IntegrationsDeleteShareableIdInput = z.infer< + typeof IntegrationsDeleteShareableIdInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* hub (public catalog — no workspace scoping; confirmed live) */ +/* -------------------------------------------------------------------------- */ + +const HubListIntegrationsInputSchema = PageInputSchema.extend({ + limit: z.number().int().positive().optional(), + name: z.string().optional(), + version: z.string().optional(), + interfaceId: z.string().optional(), + interfaceName: z.string().optional(), + installedByBotId: z.string().optional(), + verificationStatus: z + .enum(['unapproved', 'pending', 'approved', 'rejected']) + .optional(), + search: z.string().optional(), + sortBy: z + .enum(['popularity', 'name', 'createdAt', 'updatedAt', 'installCount']) + .optional(), + direction: z.enum(['asc', 'desc']).optional(), +}); +export type HubListIntegrationsInput = z.infer< + typeof HubListIntegrationsInputSchema +>; + +const HubGetIntegrationInputSchema = z.object({ + name: nonempty, + version: nonempty, +}); +export type HubGetIntegrationInput = z.infer< + typeof HubGetIntegrationInputSchema +>; + +const HubGetIntegrationByIdInputSchema = z.object({ + id: nonempty, +}); +export type HubGetIntegrationByIdInput = z.infer< + typeof HubGetIntegrationByIdInputSchema +>; + +const HubListInterfacesInputSchema = PageInputSchema.extend({ + name: z.string().optional(), + version: z.string().optional(), +}); +export type HubListInterfacesInput = z.infer< + typeof HubListInterfacesInputSchema +>; + +const HubGetInterfaceInputSchema = z.object({ + name: nonempty, + version: nonempty, +}); +export type HubGetInterfaceInput = z.infer; + +const HubGetInterfaceByIdInputSchema = z.object({ + id: nonempty, +}); +export type HubGetInterfaceByIdInput = z.infer< + typeof HubGetInterfaceByIdInputSchema +>; + +const HubListPluginsInputSchema = PageInputSchema.extend({ + name: z.string().optional(), + version: z.string().optional(), +}); +export type HubListPluginsInput = z.infer; + +const HubGetPluginInputSchema = z.object({ + name: nonempty, + version: nonempty, +}); +export type HubGetPluginInput = z.infer; + +const HubGetPluginByIdInputSchema = z.object({ + id: nonempty, +}); +export type HubGetPluginByIdInput = z.infer; + +const HubGetPluginCodeInputSchema = z.object({ + id: nonempty, + platform: z.enum(['node', 'browser']), +}); +export type HubGetPluginCodeInput = z.infer; + +const HubGetDereferencedPluginByIdInputSchema = z.object({ + id: nonempty, + /** + * Required mapping of interface alias -> backing integration id + * (`GetDereferencedPublicPluginByIdRequestQuery` in `@botpress/client` + * v2.2.0's type declarations - not optional, and not a plain list of + * interface names as the query parameter's minified field name alone + * suggested). + */ + interfaces: z.record(z.string(), z.string()), +}); +export type HubGetDereferencedPluginByIdInput = z.infer< + typeof HubGetDereferencedPluginByIdInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* plugins (workspace-installed, not the public hub) */ +/* -------------------------------------------------------------------------- */ + +const PluginsListInputSchema = PageInputSchema.extend({ + name: z.string().optional(), + version: z.string().optional(), +}); +export type PluginsListInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* files (requires botId) */ +/* -------------------------------------------------------------------------- */ + +const FilesDeleteInputSchema = z.object({ + botId: nonempty, + id: nonempty, +}); +export type FilesDeleteInput = z.infer; + +const FilesListTagsInputSchema = PageInputSchema.extend({ + botId: nonempty, +}); +export type FilesListTagsInput = z.infer; + +const FilesListTagValuesInputSchema = PageInputSchema.extend({ + botId: nonempty, + tag: nonempty, +}); +export type FilesListTagValuesInput = z.infer< + typeof FilesListTagValuesInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* knowledgeBases (requires botId) */ +/* -------------------------------------------------------------------------- */ + +const KnowledgeBasesListInputSchema = PageInputSchema.extend({ + botId: nonempty, + tags: z.record(z.string(), z.string()).optional(), +}); +export type KnowledgeBasesListInput = z.infer< + typeof KnowledgeBasesListInputSchema +>; + +const KnowledgeBasesDeleteInputSchema = z.object({ + botId: nonempty, + id: nonempty, +}); +export type KnowledgeBasesDeleteInput = z.infer< + typeof KnowledgeBasesDeleteInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* tools (VRL is workspace-agnostic; table row requires botId) */ +/* -------------------------------------------------------------------------- */ + +const ToolsRunVrlInputSchema = z.object({ + /** Arbitrary input data made available to the script as `.` in VRL. */ + data: z.record(z.string(), z.unknown()), + script: nonempty, +}); +export type ToolsRunVrlInput = z.infer; + +const ToolsGetTableRowInputSchema = z.object({ + botId: nonempty, + table: nonempty, + id: z.number(), +}); +export type ToolsGetTableRowInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* input/output maps */ +/* -------------------------------------------------------------------------- */ + +export type BotpressEndpointInputs = { + accountGet: AccountGetInput; + accountUpdate: AccountUpdateInput; + accountGetPreference: AccountGetPreferenceInput; + accountSetPreference: AccountSetPreferenceInput; + workspacesCreate: WorkspacesCreateInput; + workspacesGet: WorkspacesGetInput; + workspacesUpdate: WorkspacesUpdateInput; + workspacesDelete: WorkspacesDeleteInput; + workspacesList: WorkspacesListInput; + workspacesListPublic: WorkspacesListPublicInput; + workspacesCheckHandleAvailability: WorkspacesCheckHandleAvailabilityInput; + workspacesSetPreference: WorkspacesSetPreferenceInput; + workspacesGetQuota: WorkspacesGetQuotaInput; + workspacesGetAllQuotaCompletion: WorkspacesGetAllQuotaCompletionInput; + workspacesBreakDownUsageByBot: WorkspacesBreakDownUsageByBotInput; + billingListInvoices: BillingListInvoicesInput; + billingGetUpcomingInvoice: BillingGetUpcomingInvoiceInput; + billingChargeUnpaidInvoices: BillingChargeUnpaidInvoicesInput; + billingListUsageHistory: BillingListUsageHistoryInput; + botsCreate: BotsCreateInput; + botsUpdate: BotsUpdateInput; + botsListActionRuns: BotsListActionRunsInput; + botsListIssues: BotsListIssuesInput; + chatCreateConversation: ChatCreateConversationInput; + chatListConversations: ChatListConversationsInput; + chatSendMessage: ChatSendMessageInput; + chatUpdateWorkflow: ChatUpdateWorkflowInput; + integrationsCreate: IntegrationsCreateInput; + integrationsGet: IntegrationsGetInput; + integrationsList: IntegrationsListInput; + integrationsValidateUpdate: IntegrationsValidateUpdateInput; + integrationsRequestVerification: IntegrationsRequestVerificationInput; + integrationsListApiKeys: IntegrationsListApiKeysInput; + integrationsDeleteShareableId: IntegrationsDeleteShareableIdInput; + hubListIntegrations: HubListIntegrationsInput; + hubGetIntegration: HubGetIntegrationInput; + hubGetIntegrationById: HubGetIntegrationByIdInput; + hubListInterfaces: HubListInterfacesInput; + hubGetInterface: HubGetInterfaceInput; + hubGetInterfaceById: HubGetInterfaceByIdInput; + hubListPlugins: HubListPluginsInput; + hubGetPlugin: HubGetPluginInput; + hubGetPluginById: HubGetPluginByIdInput; + hubGetPluginCode: HubGetPluginCodeInput; + hubGetDereferencedPluginById: HubGetDereferencedPluginByIdInput; + pluginsList: PluginsListInput; + filesDelete: FilesDeleteInput; + filesListTags: FilesListTagsInput; + filesListTagValues: FilesListTagValuesInput; + knowledgeBasesList: KnowledgeBasesListInput; + knowledgeBasesDelete: KnowledgeBasesDeleteInput; + toolsRunVrl: ToolsRunVrlInput; + toolsGetTableRow: ToolsGetTableRowInput; +}; + +export type BotpressEndpointOutputs = { + accountGet: BotpressAccount; + accountUpdate: BotpressAccount; + accountGetPreference: { value?: unknown }; + accountSetPreference: EmptyResult; + workspacesCreate: BotpressWorkspace; + workspacesGet: BotpressWorkspace; + workspacesUpdate: BotpressWorkspace; + workspacesDelete: EmptyResult; + workspacesList: { workspaces: BotpressWorkspace[]; nextToken?: string }; + workspacesListPublic: { workspaces: BotpressWorkspace[]; nextToken?: string }; + workspacesCheckHandleAvailability: BotpressHandleAvailability; + workspacesSetPreference: EmptyResult; + workspacesGetQuota: BotpressQuota; + workspacesGetAllQuotaCompletion: BotpressQuotaCompletionMap; + workspacesBreakDownUsageByBot: BotpressUsageByBotItem[]; + billingListInvoices: BotpressInvoice[]; + billingGetUpcomingInvoice: BotpressUpcomingInvoice; + billingChargeUnpaidInvoices: BotpressChargeUnpaidInvoicesResult; + billingListUsageHistory: BotpressUsageHistoryItem[]; + botsCreate: BotpressBot; + botsUpdate: BotpressBot; + botsListActionRuns: { data: BotpressActionRun[]; nextToken?: string }; + botsListIssues: { issues: BotpressBotIssue[]; nextToken?: string }; + chatCreateConversation: BotpressConversation; + chatListConversations: { + conversations: BotpressConversation[]; + nextToken?: string; + }; + chatSendMessage: BotpressMessage; + chatUpdateWorkflow: BotpressWorkflow; + integrationsCreate: BotpressIntegration; + integrationsGet: BotpressIntegration; + integrationsList: { integrations: BotpressIntegration[]; nextToken?: string }; + integrationsValidateUpdate: EmptyResult; + integrationsRequestVerification: EmptyResult; + integrationsListApiKeys: BotpressIntegrationApiKey[]; + integrationsDeleteShareableId: EmptyResult; + hubListIntegrations: { + integrations: BotpressPublicIntegration[]; + nextToken?: string; + }; + hubGetIntegration: BotpressPublicIntegration; + hubGetIntegrationById: BotpressPublicIntegration; + hubListInterfaces: { + interfaces: BotpressPublicInterface[]; + nextToken?: string; + }; + hubGetInterface: BotpressPublicInterface; + hubGetInterfaceById: BotpressPublicInterface; + hubListPlugins: { plugins: BotpressPublicPlugin[]; nextToken?: string }; + hubGetPlugin: BotpressPublicPlugin; + hubGetPluginById: BotpressPublicPlugin; + hubGetPluginCode: { code: string }; + hubGetDereferencedPluginById: Record; + pluginsList: { plugins: BotpressPublicPlugin[]; nextToken?: string }; + filesDelete: EmptyResult; + filesListTags: { tags: string[]; nextToken?: string }; + filesListTagValues: { values: string[]; nextToken?: string }; + knowledgeBasesList: { + knowledgeBases: BotpressKnowledgeBase[]; + nextToken?: string; + }; + knowledgeBasesDelete: EmptyResult; + toolsRunVrl: BotpressVrlResult; + toolsGetTableRow: BotpressTableRow; +}; + +export const BotpressEndpointInputSchemas = { + accountGet: AccountGetInputSchema, + accountUpdate: AccountUpdateInputSchema, + accountGetPreference: AccountGetPreferenceInputSchema, + accountSetPreference: AccountSetPreferenceInputSchema, + workspacesCreate: WorkspacesCreateInputSchema, + workspacesGet: WorkspacesGetInputSchema, + workspacesUpdate: WorkspacesUpdateInputSchema, + workspacesDelete: WorkspacesDeleteInputSchema, + workspacesList: WorkspacesListInputSchema, + workspacesListPublic: WorkspacesListPublicInputSchema, + workspacesCheckHandleAvailability: + WorkspacesCheckHandleAvailabilityInputSchema, + workspacesSetPreference: WorkspacesSetPreferenceInputSchema, + workspacesGetQuota: WorkspacesGetQuotaInputSchema, + workspacesGetAllQuotaCompletion: WorkspacesGetAllQuotaCompletionInputSchema, + workspacesBreakDownUsageByBot: WorkspacesBreakDownUsageByBotInputSchema, + billingListInvoices: BillingListInvoicesInputSchema, + billingGetUpcomingInvoice: BillingGetUpcomingInvoiceInputSchema, + billingChargeUnpaidInvoices: BillingChargeUnpaidInvoicesInputSchema, + billingListUsageHistory: BillingListUsageHistoryInputSchema, + botsCreate: BotsCreateInputSchema, + botsUpdate: BotsUpdateInputSchema, + botsListActionRuns: BotsListActionRunsInputSchema, + botsListIssues: BotsListIssuesInputSchema, + chatCreateConversation: ChatCreateConversationInputSchema, + chatListConversations: ChatListConversationsInputSchema, + chatSendMessage: ChatSendMessageInputSchema, + chatUpdateWorkflow: ChatUpdateWorkflowInputSchema, + integrationsCreate: IntegrationsCreateInputSchema, + integrationsGet: IntegrationsGetInputSchema, + integrationsList: IntegrationsListInputSchema, + integrationsValidateUpdate: IntegrationsValidateUpdateInputSchema, + integrationsRequestVerification: IntegrationsRequestVerificationInputSchema, + integrationsListApiKeys: IntegrationsListApiKeysInputSchema, + integrationsDeleteShareableId: IntegrationsDeleteShareableIdInputSchema, + hubListIntegrations: HubListIntegrationsInputSchema, + hubGetIntegration: HubGetIntegrationInputSchema, + hubGetIntegrationById: HubGetIntegrationByIdInputSchema, + hubListInterfaces: HubListInterfacesInputSchema, + hubGetInterface: HubGetInterfaceInputSchema, + hubGetInterfaceById: HubGetInterfaceByIdInputSchema, + hubListPlugins: HubListPluginsInputSchema, + hubGetPlugin: HubGetPluginInputSchema, + hubGetPluginById: HubGetPluginByIdInputSchema, + hubGetPluginCode: HubGetPluginCodeInputSchema, + hubGetDereferencedPluginById: HubGetDereferencedPluginByIdInputSchema, + pluginsList: PluginsListInputSchema, + filesDelete: FilesDeleteInputSchema, + filesListTags: FilesListTagsInputSchema, + filesListTagValues: FilesListTagValuesInputSchema, + knowledgeBasesList: KnowledgeBasesListInputSchema, + knowledgeBasesDelete: KnowledgeBasesDeleteInputSchema, + toolsRunVrl: ToolsRunVrlInputSchema, + toolsGetTableRow: ToolsGetTableRowInputSchema, +} as const; + +export const BotpressEndpointOutputSchemas = { + accountGet: BotpressAccountSchema, + accountUpdate: BotpressAccountSchema, + accountGetPreference: z.object({ value: z.unknown().optional() }).loose(), + accountSetPreference: EmptyResultSchema, + workspacesCreate: BotpressWorkspaceSchema, + workspacesGet: BotpressWorkspaceSchema, + workspacesUpdate: BotpressWorkspaceSchema, + workspacesDelete: EmptyResultSchema, + workspacesList: z.object({ + workspaces: z.array(BotpressWorkspaceSchema), + nextToken: z.string().optional(), + }), + workspacesListPublic: z.object({ + workspaces: z.array(BotpressWorkspaceSchema), + nextToken: z.string().optional(), + }), + workspacesCheckHandleAvailability: BotpressHandleAvailabilitySchema, + workspacesSetPreference: EmptyResultSchema, + workspacesGetQuota: BotpressQuotaSchema, + workspacesGetAllQuotaCompletion: BotpressQuotaCompletionMapSchema, + workspacesBreakDownUsageByBot: z.array(BotpressUsageByBotItemSchema), + billingListInvoices: z.array(BotpressInvoiceSchema), + billingGetUpcomingInvoice: BotpressUpcomingInvoiceSchema, + billingChargeUnpaidInvoices: BotpressChargeUnpaidInvoicesResultSchema, + billingListUsageHistory: z.array(BotpressUsageHistoryItemSchema), + botsCreate: BotpressBotSchema, + botsUpdate: BotpressBotSchema, + botsListActionRuns: z.object({ + data: z.array(BotpressActionRunSchema), + nextToken: z.string().optional(), + }), + botsListIssues: z.object({ + issues: z.array(BotpressBotIssueSchema), + nextToken: z.string().optional(), + }), + chatCreateConversation: BotpressConversationSchema, + chatListConversations: z.object({ + conversations: z.array(BotpressConversationSchema), + nextToken: z.string().optional(), + }), + chatSendMessage: BotpressMessageSchema, + chatUpdateWorkflow: BotpressWorkflowSchema, + integrationsCreate: BotpressIntegrationSchema, + integrationsGet: BotpressIntegrationSchema, + integrationsList: z.object({ + integrations: z.array(BotpressIntegrationSchema), + nextToken: z.string().optional(), + }), + integrationsValidateUpdate: EmptyResultSchema, + integrationsRequestVerification: EmptyResultSchema, + integrationsListApiKeys: z.array(BotpressIntegrationApiKeySchema), + integrationsDeleteShareableId: EmptyResultSchema, + hubListIntegrations: z.object({ + integrations: z.array(BotpressPublicIntegrationSchema), + nextToken: z.string().optional(), + }), + hubGetIntegration: BotpressPublicIntegrationSchema, + hubGetIntegrationById: BotpressPublicIntegrationSchema, + hubListInterfaces: z.object({ + interfaces: z.array(BotpressPublicInterfaceSchema), + nextToken: z.string().optional(), + }), + hubGetInterface: BotpressPublicInterfaceSchema, + hubGetInterfaceById: BotpressPublicInterfaceSchema, + hubListPlugins: z.object({ + plugins: z.array(BotpressPublicPluginSchema), + nextToken: z.string().optional(), + }), + hubGetPlugin: BotpressPublicPluginSchema, + hubGetPluginById: BotpressPublicPluginSchema, + hubGetPluginCode: z.object({ code: z.string() }), + /** + * A dereferenced plugin resolves interface entity references against the + * backing integrations supplied in the request — an open-ended, per-call + * shape rather than a fixed schema. + */ + hubGetDereferencedPluginById: z.record(z.string(), z.unknown()), + pluginsList: z.object({ + plugins: z.array(BotpressPublicPluginSchema), + nextToken: z.string().optional(), + }), + filesDelete: EmptyResultSchema, + filesListTags: z.object({ + tags: z.array(z.string()), + nextToken: z.string().optional(), + }), + filesListTagValues: z.object({ + values: z.array(z.string()), + nextToken: z.string().optional(), + }), + knowledgeBasesList: z.object({ + knowledgeBases: z.array(BotpressKnowledgeBaseSchema), + nextToken: z.string().optional(), + }), + knowledgeBasesDelete: EmptyResultSchema, + toolsRunVrl: BotpressVrlResultSchema, + toolsGetTableRow: BotpressTableRowSchema, +} as const; diff --git a/packages/botpress/endpoints/workspaces.ts b/packages/botpress/endpoints/workspaces.ts new file mode 100644 index 000000000..001371f58 --- /dev/null +++ b/packages/botpress/endpoints/workspaces.ts @@ -0,0 +1,299 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BotpressEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheWorkspace, evictEntity } from './persist'; +import { + botpressCall, + compactBody, + compactQuery, + resolveWorkspaceId, +} from './shared'; +import type { + BotpressEndpointOutputs, + BotpressQuotaCompletionMap, + BotpressWorkspace, +} from './types'; + +/** Creates a workspace under the authenticated account. */ +export const create: BotpressEndpoints['workspacesCreate'] = async ( + ctx, + input, +) => { + const result = await botpressCall( + ctx, + '/v1/admin/workspaces', + { + method: 'POST', + body: compactBody({ + name: input.name, + billingVersion: input.billingVersion, + }), + }, + ); + + await cacheWorkspace(ctx.db?.workspaces, result); + + await logEventFromContext( + ctx, + 'botpress.workspaces.create', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Gets a workspace by id. */ +export const get: BotpressEndpoints['workspacesGet'] = async (ctx, input) => { + const result = await botpressCall( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.id)}`, + ); + + await cacheWorkspace(ctx.db?.workspaces, result); + + await logEventFromContext( + ctx, + 'botpress.workspaces.get', + auditPayload(input, ['id']), + 'completed', + ); + return result; +}; + +/** Updates workspace settings (name, spending limit, profile, visibility). */ +export const update: BotpressEndpoints['workspacesUpdate'] = async ( + ctx, + input, +) => { + const result = await botpressCall( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.id)}`, + { + method: 'PUT', + body: compactBody({ + name: input.name, + spendingLimit: input.spendingLimit, + about: input.about, + profilePicture: input.profilePicture, + contactEmail: input.contactEmail, + website: input.website, + socialAccounts: input.socialAccounts, + isPublic: input.isPublic, + handle: input.handle, + }), + }, + ); + + await cacheWorkspace(ctx.db?.workspaces, result); + + await logEventFromContext( + ctx, + 'botpress.workspaces.update', + auditPayload(input, ['id']), + 'completed', + ); + return result; +}; + +/** + * Permanently deletes a workspace and evicts it from the cache. [DESTRUCTIVE] + * + * The audit event is logged immediately once the API confirms the delete, + * before the (best-effort, non-throwing) cache eviction - it asserts "the + * remote record is gone", which is true the moment the DELETE call returns, + * independent of whether the local mirror is cleaned up afterward. + */ +export const remove: BotpressEndpoints['workspacesDelete'] = async ( + ctx, + input, +) => { + await botpressCall( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.id)}`, + { + method: 'DELETE', + }, + ); + + await logEventFromContext( + ctx, + 'botpress.workspaces.delete', + auditPayload(input, ['id']), + 'completed', + ); + + await evictEntity(ctx.db?.workspaces, input.id, 'workspace'); + + return {}; +}; + +/** + * Lists workspaces owned by the authenticated account. + * + * Returns the provider's `nextToken` alongside the page: dropping it would + * strand a caller on the first page with no way to reach the rest of a + * result set larger than `pageSize`. + */ +export const list: BotpressEndpoints['workspacesList'] = async (ctx, input) => { + const result = await botpressCall<{ + workspaces?: BotpressWorkspace[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/workspaces', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + handle: input.handle, + }), + }); + + const workspaces = result.workspaces ?? []; + await Promise.all( + workspaces.map((workspace) => + cacheWorkspace(ctx.db?.workspaces, workspace), + ), + ); + + await logEventFromContext( + ctx, + 'botpress.workspaces.list', + auditPayload(input, ['handle']), + 'completed', + ); + return { workspaces, nextToken: result.meta?.nextToken }; +}; + +/** Lists workspaces that opted into public visibility. */ +export const listPublic: BotpressEndpoints['workspacesListPublic'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + workspaces?: BotpressWorkspace[]; + meta?: { nextToken?: string }; + }>(ctx, '/v1/admin/workspaces/public', { + method: 'GET', + query: compactQuery({ + nextToken: input.nextToken, + pageSize: input.pageSize, + workspaceIds: input.workspaceIds, + search: input.search, + }), + }); + + await logEventFromContext( + ctx, + 'botpress.workspaces.listPublic', + auditPayload(input, ['search']), + 'completed', + ); + return { + workspaces: result.workspaces ?? [], + nextToken: result.meta?.nextToken, + }; +}; + +/** Checks whether a workspace handle is available, with suggestions if not. */ +export const checkHandleAvailability: BotpressEndpoints['workspacesCheckHandleAvailability'] = + async (ctx, input) => { + const result = await botpressCall< + BotpressEndpointOutputs['workspacesCheckHandleAvailability'] + >(ctx, '/v1/admin/workspaces/handle-availability', { + method: 'PUT', + body: { handle: input.handle }, + }); + + await logEventFromContext( + ctx, + 'botpress.workspaces.checkHandleAvailability', + auditPayload(input, ['handle']), + 'completed', + ); + return result; + }; + +/** + * Sets a workspace preference by key. + * + * No workspace id in the path (confirmed live), so the acting workspace comes + * from `x-workspace-id`, resolved and required here. + */ +export const setPreference: BotpressEndpoints['workspacesSetPreference'] = + async (ctx, input) => { + const workspaceId = await resolveWorkspaceId(ctx); + + await botpressCall( + ctx, + `/v1/admin/workspaces/preferences/${encodeURIComponent(input.key)}`, + { method: 'POST', body: { value: input.value }, workspaceId }, + ); + + await logEventFromContext( + ctx, + 'botpress.workspaces.setPreference', + auditPayload(input, ['key']), + 'completed', + ); + return {}; + }; + +/** Gets a workspace's usage against a single quota type. */ +export const getQuota: BotpressEndpoints['workspacesGetQuota'] = async ( + ctx, + input, +) => { + const result = await botpressCall<{ + quota: BotpressEndpointOutputs['workspacesGetQuota']; + }>(ctx, `/v1/admin/workspaces/${encodeURIComponent(input.id)}/quota`, { + method: 'GET', + query: compactQuery({ type: input.type, period: input.period }), + }); + + await logEventFromContext( + ctx, + 'botpress.workspaces.getQuota', + auditPayload(input, ['id', 'type']), + 'completed', + ); + return result.quota; +}; + +/** Gets the highest quota completion rate for every workspace the account can see. */ +export const getAllQuotaCompletion: BotpressEndpoints['workspacesGetAllQuotaCompletion'] = + async (ctx) => { + const result = await botpressCall( + ctx, + '/v1/admin/workspaces/usages/quota-completion', + ); + + await logEventFromContext( + ctx, + 'botpress.workspaces.getAllQuotaCompletion', + {}, + 'completed', + ); + return result; + }; + +/** Breaks down a workspace's usage of a quota type by bot. */ +export const breakDownUsageByBot: BotpressEndpoints['workspacesBreakDownUsageByBot'] = + async (ctx, input) => { + const result = await botpressCall<{ + data: BotpressEndpointOutputs['workspacesBreakDownUsageByBot']; + }>( + ctx, + `/v1/admin/workspaces/${encodeURIComponent(input.id)}/usages/by-bot`, + { + method: 'GET', + query: compactQuery({ type: input.type, period: input.period }), + }, + ); + + await logEventFromContext( + ctx, + 'botpress.workspaces.breakDownUsageByBot', + auditPayload(input, ['id', 'type']), + 'completed', + ); + return result.data ?? []; + }; diff --git a/packages/botpress/error-handlers.test.ts b/packages/botpress/error-handlers.test.ts new file mode 100644 index 000000000..d8f340eed --- /dev/null +++ b/packages/botpress/error-handlers.test.ts @@ -0,0 +1,71 @@ +import type { ErrorContext } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +function apiError(status: number, message = 'failed', retryAfter?: number) { + return new ApiError( + { method: 'GET', url: '/v1/admin/workspaces' }, + { + url: 'https://api.botpress.cloud/v1/admin/workspaces', + ok: false, + status, + statusText: 'Error', + body: { message }, + }, + message, + { retryAfter }, + ); +} + +function ctx(operation: string, error: Error): ErrorContext { + return { + pluginId: 'botpress', + operation, + input: {}, + originalError: error, + }; +} + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('errorHandlers', () => { + it('does not retry a 429 on a charge', async () => { + const error = apiError(429, 'too many requests', 2000); + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + error, + ctx('billing.chargeUnpaidInvoices', error), + ); + + expect(result).toEqual({ maxRetries: 0, headersRetryAfterMs: 2000 }); + }); + + it('retries a 429 on a read', async () => { + const error = apiError(429, 'too many requests', 2000); + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + error, + ctx('workspaces.list', error), + ); + + expect(result).toEqual({ maxRetries: 3, headersRetryAfterMs: 2000 }); + }); + + it('does not log provider error bodies', async () => { + const secret = 'secret invoice payload xyz'; + const error = apiError(403, secret); + await errorHandlers.PERMISSION_ERROR.handler( + error, + ctx('workspaces.get', error), + ); + + const logged = (console.warn as jest.Mock).mock.calls.join(' '); + expect(logged).not.toContain(secret); + expect(logged).toContain('status 403'); + }); +}); diff --git a/packages/botpress/error-handlers.ts b/packages/botpress/error-handlers.ts new file mode 100644 index 000000000..fb9543dd7 --- /dev/null +++ b/packages/botpress/error-handlers.ts @@ -0,0 +1,150 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { + BotpressBotIdMissingError, + BotpressWorkspaceIdMissingError, +} from './client'; + +function safeStatus(error: Error): number | 'unknown' { + return error instanceof ApiError ? error.status : 'unknown'; +} + +/** + * Whether replaying an operation could duplicate a record or a charge. + * + * Corsair re-invokes the whole endpoint when a handler asks for a retry, so a + * network failure raised *after* Botpress committed a POST would create a + * second bot, integration, workspace or conversation — or, for + * `billing.chargeUnpaidInvoices`, charge a payment method twice. Botpress + * accepts no idempotency key on these routes, so the only safe answer is not + * to retry at all. + * + * `runVrl` and `setAccountPreference`/`setWorkspacePreference` are POST but + * excluded: VRL execution has no persisted side effect (verified live — + * `POST /v1/admin/helper/vrl` just transforms the supplied `data` and returns + * a `result`), and the preference routes are absolute setters keyed by name + * (`body: { value }`), so replaying either leaves the same state rather than + * duplicating anything. + * + * `endpoints.test.ts` asserts this predicate against the full routing table + * so it cannot drift away from the operations it describes. + */ +export const isNonIdempotent = (operation: string): boolean => + [ + 'billing.chargeUnpaidInvoices', + 'integrations.create', + 'integrations.requestVerification', + 'workspaces.create', + 'bots.create', + 'chat.createConversation', + 'chat.sendMessage', + ].includes(operation); + +export const errorHandlers = { + /** + * A missing workspace or bot id is a configuration fault rather than a + * transport failure, so it is matched first and never retried — every + * attempt would fail identically. + */ + CONFIGURATION_ERROR: { + match: (error) => + error instanceof BotpressWorkspaceIdMissingError || + error instanceof BotpressBotIdMissingError, + handler: async (error, context) => { + console.warn(`[BOTPRESS:${context.operation}] ${error.message}`); + return { maxRetries: 0 }; + }, + }, + /** + * Botpress answers over-limit requests with a plain 429 and no documented + * rate-limit headers to pace against proactively. + */ + RATE_LIMIT_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 429) return true; + return error.message.toLowerCase().includes('too many requests'); + }, + handler: async (error, context) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { + maxRetries: isNonIdempotent(context.operation) ? 0 : 3, + headersRetryAfterMs: retryAfterMs, + }; + }, + }, + AUTH_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 401) return true; + return error.message.toLowerCase().includes('unauthorized'); + }, + handler: async (error, context) => { + console.warn( + `[BOTPRESS:${context.operation}] Authentication failed - check the Personal Access Token`, + ); + return { maxRetries: 0 }; + }, + }, + PERMISSION_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 403) return true; + return error.message.toLowerCase().includes('forbidden'); + }, + handler: async (error, context) => { + console.warn( + `[BOTPRESS:${context.operation}] Permission denied (status ${safeStatus(error)})`, + ); + return { maxRetries: 0 }; + }, + }, + NOT_FOUND_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 404) return true; + return error.message.toLowerCase().includes('resourcenotfound'); + }, + handler: async (error, context) => { + console.warn( + `[BOTPRESS:${context.operation}] Resource not found (status ${safeStatus(error)})`, + ); + return { maxRetries: 0 }; + }, + }, + VALIDATION_ERROR: { + match: (error) => error instanceof ApiError && error.status === 400, + handler: async (error, context) => { + console.warn( + `[BOTPRESS:${context.operation}] Invalid request (status ${safeStatus(error)})`, + ); + return { maxRetries: 0 }; + }, + }, + NETWORK_ERROR: { + match: (error) => { + const message = error.message.toLowerCase(); + return ( + message.includes('network') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('fetch failed') + ); + }, + handler: async (error, context) => { + console.warn( + `[BOTPRESS:${context.operation}] Network error (status ${safeStatus(error)})`, + ); + return { maxRetries: isNonIdempotent(context.operation) ? 0 : 3 }; + }, + }, + DEFAULT: { + match: () => true, + handler: async (error, context) => { + console.error( + `[BOTPRESS:${context.operation}] Unhandled error (status ${safeStatus(error)})`, + ); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/botpress/index.ts b/packages/botpress/index.ts new file mode 100644 index 000000000..ff9446d22 --- /dev/null +++ b/packages/botpress/index.ts @@ -0,0 +1,754 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Account, + Billing, + Bots, + Chat, + Files, + Hub, + Integrations, + KnowledgeBases, + Plugins, + Tools, + Workspaces, +} from './endpoints'; +import type { + BotpressEndpointInputs, + BotpressEndpointOutputs, +} from './endpoints/types'; +import { + BotpressEndpointInputSchemas, + BotpressEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { BotpressSchema } from './schema'; +import { resolveBotpressOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchBotpressTenantWebhook } from './webhooks/tenant-matcher'; + +export type BotpressPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + /** + * The Botpress workspace the token should act against. + * + * A Personal Access Token can reach several workspaces, so the workspace is + * a second credential rather than something the token implies. When it is + * omitted the plugin falls back to the stored `workspace_id` key, and + * finally to workspace discovery, which only resolves when the token can + * reach exactly one workspace. + */ + workspaceId?: string; + hooks?: InternalBotpressPlugin['hooks']; + webhookHooks?: InternalBotpressPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export const botpressAuthConfig = { + api_key: { + account: ['workspace_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BotpressContext = CorsairPluginContext< + typeof BotpressSchema, + BotpressPluginOptions, + undefined, + typeof botpressAuthConfig +>; + +export type BotpressKeyBuilderContext = + KeyBuilderContext; + +export type BotpressBoundEndpoints = BindEndpoints< + typeof botpressEndpointsNested +>; + +type BotpressEndpoint = + CorsairEndpoint< + BotpressContext, + BotpressEndpointInputs[K], + BotpressEndpointOutputs[K] + >; + +export type BotpressEndpoints = { + accountGet: BotpressEndpoint<'accountGet'>; + accountUpdate: BotpressEndpoint<'accountUpdate'>; + accountGetPreference: BotpressEndpoint<'accountGetPreference'>; + accountSetPreference: BotpressEndpoint<'accountSetPreference'>; + workspacesCreate: BotpressEndpoint<'workspacesCreate'>; + workspacesGet: BotpressEndpoint<'workspacesGet'>; + workspacesUpdate: BotpressEndpoint<'workspacesUpdate'>; + workspacesDelete: BotpressEndpoint<'workspacesDelete'>; + workspacesList: BotpressEndpoint<'workspacesList'>; + workspacesListPublic: BotpressEndpoint<'workspacesListPublic'>; + workspacesCheckHandleAvailability: BotpressEndpoint<'workspacesCheckHandleAvailability'>; + workspacesSetPreference: BotpressEndpoint<'workspacesSetPreference'>; + workspacesGetQuota: BotpressEndpoint<'workspacesGetQuota'>; + workspacesGetAllQuotaCompletion: BotpressEndpoint<'workspacesGetAllQuotaCompletion'>; + workspacesBreakDownUsageByBot: BotpressEndpoint<'workspacesBreakDownUsageByBot'>; + billingListInvoices: BotpressEndpoint<'billingListInvoices'>; + billingGetUpcomingInvoice: BotpressEndpoint<'billingGetUpcomingInvoice'>; + billingChargeUnpaidInvoices: BotpressEndpoint<'billingChargeUnpaidInvoices'>; + billingListUsageHistory: BotpressEndpoint<'billingListUsageHistory'>; + botsCreate: BotpressEndpoint<'botsCreate'>; + botsUpdate: BotpressEndpoint<'botsUpdate'>; + botsListActionRuns: BotpressEndpoint<'botsListActionRuns'>; + botsListIssues: BotpressEndpoint<'botsListIssues'>; + chatCreateConversation: BotpressEndpoint<'chatCreateConversation'>; + chatListConversations: BotpressEndpoint<'chatListConversations'>; + chatSendMessage: BotpressEndpoint<'chatSendMessage'>; + chatUpdateWorkflow: BotpressEndpoint<'chatUpdateWorkflow'>; + integrationsCreate: BotpressEndpoint<'integrationsCreate'>; + integrationsGet: BotpressEndpoint<'integrationsGet'>; + integrationsList: BotpressEndpoint<'integrationsList'>; + integrationsValidateUpdate: BotpressEndpoint<'integrationsValidateUpdate'>; + integrationsRequestVerification: BotpressEndpoint<'integrationsRequestVerification'>; + integrationsListApiKeys: BotpressEndpoint<'integrationsListApiKeys'>; + integrationsDeleteShareableId: BotpressEndpoint<'integrationsDeleteShareableId'>; + hubListIntegrations: BotpressEndpoint<'hubListIntegrations'>; + hubGetIntegration: BotpressEndpoint<'hubGetIntegration'>; + hubGetIntegrationById: BotpressEndpoint<'hubGetIntegrationById'>; + hubListInterfaces: BotpressEndpoint<'hubListInterfaces'>; + hubGetInterface: BotpressEndpoint<'hubGetInterface'>; + hubGetInterfaceById: BotpressEndpoint<'hubGetInterfaceById'>; + hubListPlugins: BotpressEndpoint<'hubListPlugins'>; + hubGetPlugin: BotpressEndpoint<'hubGetPlugin'>; + hubGetPluginById: BotpressEndpoint<'hubGetPluginById'>; + hubGetPluginCode: BotpressEndpoint<'hubGetPluginCode'>; + hubGetDereferencedPluginById: BotpressEndpoint<'hubGetDereferencedPluginById'>; + pluginsList: BotpressEndpoint<'pluginsList'>; + filesDelete: BotpressEndpoint<'filesDelete'>; + filesListTags: BotpressEndpoint<'filesListTags'>; + filesListTagValues: BotpressEndpoint<'filesListTagValues'>; + knowledgeBasesList: BotpressEndpoint<'knowledgeBasesList'>; + knowledgeBasesDelete: BotpressEndpoint<'knowledgeBasesDelete'>; + toolsRunVrl: BotpressEndpoint<'toolsRunVrl'>; + toolsGetTableRow: BotpressEndpoint<'toolsGetTableRow'>; +}; + +export type BotpressWebhooks = Record; + +export type BotpressBoundWebhooks = BindWebhooks; + +const botpressEndpointsNested = { + account: { + get: Account.get, + update: Account.update, + getPreference: Account.getPreference, + setPreference: Account.setPreference, + }, + workspaces: { + create: Workspaces.create, + get: Workspaces.get, + update: Workspaces.update, + delete: Workspaces.delete, + list: Workspaces.list, + listPublic: Workspaces.listPublic, + checkHandleAvailability: Workspaces.checkHandleAvailability, + setPreference: Workspaces.setPreference, + getQuota: Workspaces.getQuota, + getAllQuotaCompletion: Workspaces.getAllQuotaCompletion, + breakDownUsageByBot: Workspaces.breakDownUsageByBot, + }, + billing: { + listInvoices: Billing.listInvoices, + getUpcomingInvoice: Billing.getUpcomingInvoice, + chargeUnpaidInvoices: Billing.chargeUnpaidInvoices, + listUsageHistory: Billing.listUsageHistory, + }, + bots: { + create: Bots.create, + update: Bots.update, + listActionRuns: Bots.listActionRuns, + listIssues: Bots.listIssues, + }, + chat: { + createConversation: Chat.createConversation, + listConversations: Chat.listConversations, + sendMessage: Chat.sendMessage, + updateWorkflow: Chat.updateWorkflow, + }, + integrations: { + create: Integrations.create, + get: Integrations.get, + list: Integrations.list, + validateUpdate: Integrations.validateUpdate, + requestVerification: Integrations.requestVerification, + listApiKeys: Integrations.listApiKeys, + deleteShareableId: Integrations.deleteShareableId, + }, + hub: { + listIntegrations: Hub.listIntegrations, + getIntegration: Hub.getIntegration, + getIntegrationById: Hub.getIntegrationById, + listInterfaces: Hub.listInterfaces, + getInterface: Hub.getInterface, + getInterfaceById: Hub.getInterfaceById, + listPlugins: Hub.listPlugins, + getPlugin: Hub.getPlugin, + getPluginById: Hub.getPluginById, + getPluginCode: Hub.getPluginCode, + getDereferencedPluginById: Hub.getDereferencedPluginById, + }, + plugins: { + list: Plugins.list, + }, + files: { + delete: Files.delete, + listTags: Files.listTags, + listTagValues: Files.listTagValues, + }, + knowledgeBases: { + list: KnowledgeBases.list, + delete: KnowledgeBases.delete, + }, + tools: { + runVrl: Tools.runVrl, + getTableRow: Tools.getTableRow, + }, +} as const; + +/** + * The OSS catalog for this integration lists zero triggers/webhooks — every + * operation covered here is a direct REST call, not a webhook subscription. + */ +const botpressWebhooksNested = {} as const; + +export const botpressEndpointSchemas = { + 'account.get': { + input: BotpressEndpointInputSchemas.accountGet, + output: BotpressEndpointOutputSchemas.accountGet, + }, + 'account.update': { + input: BotpressEndpointInputSchemas.accountUpdate, + output: BotpressEndpointOutputSchemas.accountUpdate, + }, + 'account.getPreference': { + input: BotpressEndpointInputSchemas.accountGetPreference, + output: BotpressEndpointOutputSchemas.accountGetPreference, + }, + 'account.setPreference': { + input: BotpressEndpointInputSchemas.accountSetPreference, + output: BotpressEndpointOutputSchemas.accountSetPreference, + }, + 'workspaces.create': { + input: BotpressEndpointInputSchemas.workspacesCreate, + output: BotpressEndpointOutputSchemas.workspacesCreate, + }, + 'workspaces.get': { + input: BotpressEndpointInputSchemas.workspacesGet, + output: BotpressEndpointOutputSchemas.workspacesGet, + }, + 'workspaces.update': { + input: BotpressEndpointInputSchemas.workspacesUpdate, + output: BotpressEndpointOutputSchemas.workspacesUpdate, + }, + 'workspaces.delete': { + input: BotpressEndpointInputSchemas.workspacesDelete, + output: BotpressEndpointOutputSchemas.workspacesDelete, + }, + 'workspaces.list': { + input: BotpressEndpointInputSchemas.workspacesList, + output: BotpressEndpointOutputSchemas.workspacesList, + }, + 'workspaces.listPublic': { + input: BotpressEndpointInputSchemas.workspacesListPublic, + output: BotpressEndpointOutputSchemas.workspacesListPublic, + }, + 'workspaces.checkHandleAvailability': { + input: BotpressEndpointInputSchemas.workspacesCheckHandleAvailability, + output: BotpressEndpointOutputSchemas.workspacesCheckHandleAvailability, + }, + 'workspaces.setPreference': { + input: BotpressEndpointInputSchemas.workspacesSetPreference, + output: BotpressEndpointOutputSchemas.workspacesSetPreference, + }, + 'workspaces.getQuota': { + input: BotpressEndpointInputSchemas.workspacesGetQuota, + output: BotpressEndpointOutputSchemas.workspacesGetQuota, + }, + 'workspaces.getAllQuotaCompletion': { + input: BotpressEndpointInputSchemas.workspacesGetAllQuotaCompletion, + output: BotpressEndpointOutputSchemas.workspacesGetAllQuotaCompletion, + }, + 'workspaces.breakDownUsageByBot': { + input: BotpressEndpointInputSchemas.workspacesBreakDownUsageByBot, + output: BotpressEndpointOutputSchemas.workspacesBreakDownUsageByBot, + }, + 'billing.listInvoices': { + input: BotpressEndpointInputSchemas.billingListInvoices, + output: BotpressEndpointOutputSchemas.billingListInvoices, + }, + 'billing.getUpcomingInvoice': { + input: BotpressEndpointInputSchemas.billingGetUpcomingInvoice, + output: BotpressEndpointOutputSchemas.billingGetUpcomingInvoice, + }, + 'billing.chargeUnpaidInvoices': { + input: BotpressEndpointInputSchemas.billingChargeUnpaidInvoices, + output: BotpressEndpointOutputSchemas.billingChargeUnpaidInvoices, + }, + 'billing.listUsageHistory': { + input: BotpressEndpointInputSchemas.billingListUsageHistory, + output: BotpressEndpointOutputSchemas.billingListUsageHistory, + }, + 'bots.create': { + input: BotpressEndpointInputSchemas.botsCreate, + output: BotpressEndpointOutputSchemas.botsCreate, + }, + 'bots.update': { + input: BotpressEndpointInputSchemas.botsUpdate, + output: BotpressEndpointOutputSchemas.botsUpdate, + }, + 'bots.listActionRuns': { + input: BotpressEndpointInputSchemas.botsListActionRuns, + output: BotpressEndpointOutputSchemas.botsListActionRuns, + }, + 'bots.listIssues': { + input: BotpressEndpointInputSchemas.botsListIssues, + output: BotpressEndpointOutputSchemas.botsListIssues, + }, + 'chat.createConversation': { + input: BotpressEndpointInputSchemas.chatCreateConversation, + output: BotpressEndpointOutputSchemas.chatCreateConversation, + }, + 'chat.listConversations': { + input: BotpressEndpointInputSchemas.chatListConversations, + output: BotpressEndpointOutputSchemas.chatListConversations, + }, + 'chat.sendMessage': { + input: BotpressEndpointInputSchemas.chatSendMessage, + output: BotpressEndpointOutputSchemas.chatSendMessage, + }, + 'chat.updateWorkflow': { + input: BotpressEndpointInputSchemas.chatUpdateWorkflow, + output: BotpressEndpointOutputSchemas.chatUpdateWorkflow, + }, + 'integrations.create': { + input: BotpressEndpointInputSchemas.integrationsCreate, + output: BotpressEndpointOutputSchemas.integrationsCreate, + }, + 'integrations.get': { + input: BotpressEndpointInputSchemas.integrationsGet, + output: BotpressEndpointOutputSchemas.integrationsGet, + }, + 'integrations.list': { + input: BotpressEndpointInputSchemas.integrationsList, + output: BotpressEndpointOutputSchemas.integrationsList, + }, + 'integrations.validateUpdate': { + input: BotpressEndpointInputSchemas.integrationsValidateUpdate, + output: BotpressEndpointOutputSchemas.integrationsValidateUpdate, + }, + 'integrations.requestVerification': { + input: BotpressEndpointInputSchemas.integrationsRequestVerification, + output: BotpressEndpointOutputSchemas.integrationsRequestVerification, + }, + 'integrations.listApiKeys': { + input: BotpressEndpointInputSchemas.integrationsListApiKeys, + output: BotpressEndpointOutputSchemas.integrationsListApiKeys, + }, + 'integrations.deleteShareableId': { + input: BotpressEndpointInputSchemas.integrationsDeleteShareableId, + output: BotpressEndpointOutputSchemas.integrationsDeleteShareableId, + }, + 'hub.listIntegrations': { + input: BotpressEndpointInputSchemas.hubListIntegrations, + output: BotpressEndpointOutputSchemas.hubListIntegrations, + }, + 'hub.getIntegration': { + input: BotpressEndpointInputSchemas.hubGetIntegration, + output: BotpressEndpointOutputSchemas.hubGetIntegration, + }, + 'hub.getIntegrationById': { + input: BotpressEndpointInputSchemas.hubGetIntegrationById, + output: BotpressEndpointOutputSchemas.hubGetIntegrationById, + }, + 'hub.listInterfaces': { + input: BotpressEndpointInputSchemas.hubListInterfaces, + output: BotpressEndpointOutputSchemas.hubListInterfaces, + }, + 'hub.getInterface': { + input: BotpressEndpointInputSchemas.hubGetInterface, + output: BotpressEndpointOutputSchemas.hubGetInterface, + }, + 'hub.getInterfaceById': { + input: BotpressEndpointInputSchemas.hubGetInterfaceById, + output: BotpressEndpointOutputSchemas.hubGetInterfaceById, + }, + 'hub.listPlugins': { + input: BotpressEndpointInputSchemas.hubListPlugins, + output: BotpressEndpointOutputSchemas.hubListPlugins, + }, + 'hub.getPlugin': { + input: BotpressEndpointInputSchemas.hubGetPlugin, + output: BotpressEndpointOutputSchemas.hubGetPlugin, + }, + 'hub.getPluginById': { + input: BotpressEndpointInputSchemas.hubGetPluginById, + output: BotpressEndpointOutputSchemas.hubGetPluginById, + }, + 'hub.getPluginCode': { + input: BotpressEndpointInputSchemas.hubGetPluginCode, + output: BotpressEndpointOutputSchemas.hubGetPluginCode, + }, + 'hub.getDereferencedPluginById': { + input: BotpressEndpointInputSchemas.hubGetDereferencedPluginById, + output: BotpressEndpointOutputSchemas.hubGetDereferencedPluginById, + }, + 'plugins.list': { + input: BotpressEndpointInputSchemas.pluginsList, + output: BotpressEndpointOutputSchemas.pluginsList, + }, + 'files.delete': { + input: BotpressEndpointInputSchemas.filesDelete, + output: BotpressEndpointOutputSchemas.filesDelete, + }, + 'files.listTags': { + input: BotpressEndpointInputSchemas.filesListTags, + output: BotpressEndpointOutputSchemas.filesListTags, + }, + 'files.listTagValues': { + input: BotpressEndpointInputSchemas.filesListTagValues, + output: BotpressEndpointOutputSchemas.filesListTagValues, + }, + 'knowledgeBases.list': { + input: BotpressEndpointInputSchemas.knowledgeBasesList, + output: BotpressEndpointOutputSchemas.knowledgeBasesList, + }, + 'knowledgeBases.delete': { + input: BotpressEndpointInputSchemas.knowledgeBasesDelete, + output: BotpressEndpointOutputSchemas.knowledgeBasesDelete, + }, + 'tools.runVrl': { + input: BotpressEndpointInputSchemas.toolsRunVrl, + output: BotpressEndpointOutputSchemas.toolsRunVrl, + }, + 'tools.getTableRow': { + input: BotpressEndpointInputSchemas.toolsGetTableRow, + output: BotpressEndpointOutputSchemas.toolsGetTableRow, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof botpressEndpointsNested +>; + +const botpressWebhookSchemas = + {} as const satisfies RequiredPluginWebhookSchemas< + typeof botpressWebhooksNested + >; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const botpressEndpointMeta = { + 'account.get': { + riskLevel: 'read', + description: 'Get the authenticated account', + }, + 'account.update': { + riskLevel: 'write', + description: 'Update the authenticated account profile', + }, + 'account.getPreference': { + riskLevel: 'read', + description: 'Get an account preference by key', + }, + 'account.setPreference': { + riskLevel: 'write', + description: 'Set an account preference by key', + }, + 'workspaces.create': { + riskLevel: 'write', + description: 'Create a workspace', + }, + 'workspaces.get': { riskLevel: 'read', description: 'Get a workspace by id' }, + 'workspaces.update': { + riskLevel: 'write', + description: 'Update workspace settings', + }, + 'workspaces.delete': { + riskLevel: 'destructive', + description: 'Permanently delete a workspace [DESTRUCTIVE]', + }, + 'workspaces.list': { + riskLevel: 'read', + description: 'List workspaces owned by the account', + }, + 'workspaces.listPublic': { + riskLevel: 'read', + description: 'List public workspaces', + }, + 'workspaces.checkHandleAvailability': { + riskLevel: 'read', + description: 'Check whether a workspace handle is available', + }, + 'workspaces.setPreference': { + riskLevel: 'write', + description: 'Set a workspace preference by key', + }, + 'workspaces.getQuota': { + riskLevel: 'read', + description: "Get a workspace's usage against a quota type", + }, + 'workspaces.getAllQuotaCompletion': { + riskLevel: 'read', + description: 'Get the highest quota completion rate for every workspace', + }, + 'workspaces.breakDownUsageByBot': { + riskLevel: 'read', + description: "Break down a workspace's usage of a quota type by bot", + }, + 'billing.listInvoices': { + riskLevel: 'read', + description: 'List invoices billed to a workspace', + }, + 'billing.getUpcomingInvoice': { + riskLevel: 'read', + description: 'Preview the upcoming invoice for a workspace', + }, + 'billing.chargeUnpaidInvoices': { + riskLevel: 'destructive', + description: + 'Charge outstanding invoices for a workspace [DESTRUCTIVE - real financial action]', + }, + 'billing.listUsageHistory': { + riskLevel: 'read', + description: 'List usage history for a workspace or bot', + }, + 'bots.create': { + riskLevel: 'write', + description: 'Create a bot in a workspace', + }, + 'bots.update': { riskLevel: 'write', description: 'Update a bot' }, + 'bots.listActionRuns': { + riskLevel: 'read', + description: "List a bot's action-run history", + }, + 'bots.listIssues': { + riskLevel: 'read', + description: 'List configuration and runtime issues for a bot', + }, + 'chat.createConversation': { + riskLevel: 'write', + description: 'Create a conversation on a channel', + }, + 'chat.listConversations': { + riskLevel: 'read', + description: "List a bot's conversations", + }, + 'chat.sendMessage': { + riskLevel: 'write', + description: 'Send a message into a conversation', + }, + 'chat.updateWorkflow': { + riskLevel: 'write', + description: "Update a workflow's status, output or failure reason", + }, + 'integrations.create': { + riskLevel: 'write', + description: 'Create an integration in a workspace', + }, + 'integrations.get': { + riskLevel: 'read', + description: 'Get an integration by id', + }, + 'integrations.list': { + riskLevel: 'read', + description: 'List integrations owned by the workspace', + }, + 'integrations.validateUpdate': { + riskLevel: 'read', + description: 'Validate that an integration update would succeed', + }, + 'integrations.requestVerification': { + riskLevel: 'write', + description: 'Submit an integration for verification', + }, + 'integrations.listApiKeys': { + riskLevel: 'read', + description: 'List Integration API Keys (IAKs) for an integration', + }, + 'integrations.deleteShareableId': { + riskLevel: 'destructive', + description: + 'Delete the shareable id for a bot-integration pair [DESTRUCTIVE]', + }, + 'hub.listIntegrations': { + riskLevel: 'read', + description: 'List public integrations in the hub', + }, + 'hub.getIntegration': { + riskLevel: 'read', + description: 'Get a public integration by name and version', + }, + 'hub.getIntegrationById': { + riskLevel: 'read', + description: 'Get a public integration by id', + }, + 'hub.listInterfaces': { + riskLevel: 'read', + description: 'List public interfaces in the hub', + }, + 'hub.getInterface': { + riskLevel: 'read', + description: 'Get a public interface by name and version', + }, + 'hub.getInterfaceById': { + riskLevel: 'read', + description: 'Get a public interface by id', + }, + 'hub.listPlugins': { + riskLevel: 'read', + description: 'List public plugins in the hub', + }, + 'hub.getPlugin': { + riskLevel: 'read', + description: 'Get a public plugin by name and version', + }, + 'hub.getPluginById': { + riskLevel: 'read', + description: 'Get a public plugin by id', + }, + 'hub.getPluginCode': { + riskLevel: 'read', + description: "Get a public plugin's source code for a platform", + }, + 'hub.getDereferencedPluginById': { + riskLevel: 'read', + description: + 'Get a public plugin with interface entity references resolved', + }, + 'plugins.list': { + riskLevel: 'read', + description: 'List plugins installed in the workspace', + }, + 'files.delete': { + riskLevel: 'destructive', + description: "Delete a file from a bot's storage [DESTRUCTIVE]", + }, + 'files.listTags': { + riskLevel: 'read', + description: "List tags used across a bot's files", + }, + 'files.listTagValues': { + riskLevel: 'read', + description: 'List all values seen for a given file tag', + }, + 'knowledgeBases.list': { + riskLevel: 'read', + description: "List a bot's knowledge bases", + }, + 'knowledgeBases.delete': { + riskLevel: 'destructive', + description: 'Permanently delete a knowledge base [DESTRUCTIVE]', + }, + 'tools.runVrl': { + riskLevel: 'write', + description: 'Execute a VRL script against input data', + }, + 'tools.getTableRow': { + riskLevel: 'read', + description: 'Fetch a single row from a table by id', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export type BaseBotpressPlugin = CorsairPlugin< + 'botpress', + typeof BotpressSchema, + typeof botpressEndpointsNested, + typeof botpressWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalBotpressPlugin = BaseBotpressPlugin; + +export type ExternalBotpressPlugin = + BaseBotpressPlugin; + +/** + * Builds the Botpress plugin. + * + * Botpress authenticates with a Personal Access Token and has no OAuth flow + * for this catalog's admin-level operations, so only `api_key` auth is + * offered — confirmed live (`GET /v1/admin/account/me` with a bare Bearer + * token returned real account data). + */ +export function botpress( + incomingOptions: BotpressPluginOptions & T = {} as BotpressPluginOptions & T, +): ExternalBotpressPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'botpress', + authConfig: botpressAuthConfig, + schema: BotpressSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: botpressEndpointsNested, + webhooks: botpressWebhooksNested, + endpointMeta: botpressEndpointMeta, + endpointSchemas: botpressEndpointSchemas, + webhookSchemas: botpressWebhookSchemas, + pluginWebhookMatcher: () => false, + pluginTenantWebhookMatcher: matchBotpressTenantWebhook, + oauthWebhookTenantLinkResolver: resolveBotpressOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: BotpressKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key?.trim()) { + return options.key.trim(); + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (res?.trim()) return res.trim(); + } + + throw new AuthMissingError('botpress', 'api_key'); + }, + } satisfies InternalBotpressPlugin; +} + +export type { + BotpressAccount, + BotpressActionRun, + BotpressBot, + BotpressBotIssue, + BotpressConversation, + BotpressEndpointInputs, + BotpressEndpointOutputs, + BotpressIntegration, + BotpressIntegrationApiKey, + BotpressInvoice, + BotpressKnowledgeBase, + BotpressMessage, + BotpressPublicIntegration, + BotpressPublicInterface, + BotpressPublicPlugin, + BotpressQuota, + BotpressTableRow, + BotpressWorkflow, + BotpressWorkspace, +} from './endpoints/types'; +export type { BotpressWebhookOutputs } from './webhooks/types'; diff --git a/packages/botpress/integration.test.ts b/packages/botpress/integration.test.ts new file mode 100644 index 000000000..d67a8d51f --- /dev/null +++ b/packages/botpress/integration.test.ts @@ -0,0 +1,173 @@ +/** + * Live checks against a real Botpress account. + * + * Skipped unless `BOTPRESS_PERSONAL_ACCESS_TOKEN` is set (`BOTPRESS_WORKSPACE_ID` + * is optional — workspace discovery is exercised when it is absent), so CI and + * contributors without credentials are unaffected. Every operation here is + * read-only or has no persisted side effect (`tools.runVrl`): nothing is + * created, changed or deleted, and `billing.chargeUnpaidInvoices` in + * particular is never called. + */ +import { + Account, + Billing, + Hub, + Integrations, + Plugins, + Tools, + Workspaces, +} from './endpoints'; +import { + BotpressAccountSchema, + BotpressHandleAvailabilitySchema, + BotpressPublicIntegrationSchema, + BotpressPublicPluginSchema, + BotpressVrlResultSchema, + BotpressWorkspaceSchema, +} from './endpoints/types'; + +const personalAccessToken = process.env.BOTPRESS_PERSONAL_ACCESS_TOKEN; +const workspaceId = process.env.BOTPRESS_WORKSPACE_ID; + +const describeLive = personalAccessToken ? describe : describe.skip; + +type Ctx = Parameters[0]; + +function makeStore() { + return { + upsertByEntityId: async (_id: string, _data: unknown) => undefined, + // Never reached: every operation below is read-only. + deleteByEntityId: async (_id: string) => true, + }; +} + +function makeCtx(): Ctx { + return { + key: personalAccessToken ?? '', + options: { workspaceId }, + db: { + workspaces: makeStore(), + bots: makeStore(), + integrations: makeStore(), + }, + $getAccountId: async () => 'integration-test', + } as unknown as Ctx; +} + +describeLive('Botpress live API', () => { + it('returns the authenticated account matching the declared schema', async () => { + const result = await Account.get(makeCtx(), {}); + + expect(BotpressAccountSchema.safeParse(result).success).toBe(true); + expect(result.id).toBeTruthy(); + }); + + it('resolves a workspace and returns it matching the declared schema', async () => { + const list = await Workspaces.list(makeCtx(), {}); + expect(Array.isArray(list.workspaces)).toBe(true); + expect(list.workspaces.length).toBeGreaterThan(0); + + const first = list.workspaces[0]; + expect(first).toBeDefined(); + expect(BotpressWorkspaceSchema.safeParse(first).success).toBe(true); + + if (!first?.id) return; + const single = await Workspaces.get(makeCtx(), { id: first.id }); + expect(single.id).toBe(first.id); + }); + + it('reports a workspace quota completion map', async () => { + const result = await Workspaces.getAllQuotaCompletion(makeCtx(), {}); + expect(typeof result).toBe('object'); + }); + + it('checks handle availability without side effects', async () => { + const result = await Workspaces.checkHandleAvailability(makeCtx(), { + handle: `corsair-live-check-${Date.now()}`, + }); + + expect(BotpressHandleAvailabilitySchema.safeParse(result).success).toBe( + true, + ); + }); + + it('lists integrations owned by the workspace', async () => { + const result = await Integrations.list(makeCtx(), {}); + expect(Array.isArray(result.integrations)).toBe(true); + }); + + it('gets an integration by name+version, scoped by x-workspace-id', async () => { + // This workspace owns no integrations, so the real assertion is that the + // call reaches the API and is rejected as "not found" rather than as + // "missing x-workspace-id" (status 400) - confirming the corrected + // by-name route and its scoping header both work end to end. + let failure: { error: unknown } | undefined; + try { + await Integrations.get(makeCtx(), { + name: 'corsair-live-check-nonexistent', + version: '1.0.0', + }); + } catch (error) { + failure = { error }; + } + + expect(failure).toBeDefined(); + const status = (failure?.error as { status?: number } | undefined)?.status; + expect(status).toBe(404); + }); + + it('lists plugins installed in the workspace', async () => { + const result = await Plugins.list(makeCtx(), {}); + expect(Array.isArray(result.plugins)).toBe(true); + }); + + it('lists workspace invoices without charging anything', async () => { + const { workspaces } = await Workspaces.list(makeCtx(), {}); + const target = workspaces[0]; + // A deliberate, visible skip rather than a silent pass: this account is + // expected to always own at least one workspace, so a missing id means + // the fixture account changed, not that the assertion below is unneeded. + if (!target?.id) { + console.warn( + '[integration.test] skipping invoice check: live account has no workspace', + ); + return; + } + + const invoices = await Billing.listInvoices(makeCtx(), { + workspaceId: target.id, + }); + expect(Array.isArray(invoices)).toBe(true); + }); + + it('browses the public hub with no workspace scoping', async () => { + const { integrations } = await Hub.listIntegrations(makeCtx(), { + pageSize: 1, + }); + expect(Array.isArray(integrations)).toBe(true); + if (integrations[0]) { + expect( + BotpressPublicIntegrationSchema.safeParse(integrations[0]).success, + ).toBe(true); + } + + const { plugins } = await Hub.listPlugins(makeCtx(), { pageSize: 1 }); + expect(Array.isArray(plugins)).toBe(true); + if (plugins[0]) { + expect(BotpressPublicPluginSchema.safeParse(plugins[0]).success).toBe( + true, + ); + } + }); + + it('runs a VRL script with no persisted side effect', async () => { + const result = await Tools.runVrl(makeCtx(), { + data: { a: 1 }, + // A self-referential arithmetic script (`.a = .a + 1`) 500s server + // side; a literal assignment is the confirmed-working shape. + script: '.a = 99', + }); + + expect(BotpressVrlResultSchema.safeParse(result).success).toBe(true); + }); +}); diff --git a/packages/botpress/jest.config.cjs b/packages/botpress/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/botpress/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/botpress/package.json b/packages/botpress/package.json new file mode 100644 index 000000000..1f46bcc5b --- /dev/null +++ b/packages/botpress/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/botpress", + "version": "0.1.0", + "description": "Botpress plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "botpress", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/botpress/schema.test.ts b/packages/botpress/schema.test.ts new file mode 100644 index 000000000..766f5a089 --- /dev/null +++ b/packages/botpress/schema.test.ts @@ -0,0 +1,167 @@ +/** + * Guards the persisted entity schemas against the two ways they go wrong: + * dropping a field Botpress actually returns, and requiring a field Botpress + * sometimes omits. + * + * `workspace` and `bot` key lists were captured from live responses + * (2026-08-16: `GET /v1/admin/workspaces`, `POST /v1/admin/bots`). + * `integration` was not created live in this pass — its list comes from + * `CreateIntegrationResponse` in `@botpress/client` v2.2.0's bundled type + * declarations instead, noted explicitly because it carries less confidence + * than a live capture. + */ + +import { BotpressSchema } from './schema'; +import { + BotpressBotEntity, + BotpressIntegrationEntity, + BotpressWorkspaceEntity, +} from './schema/database'; + +describe('Botpress schema', () => { + it('declares a semver version', () => { + expect(BotpressSchema.version).toBeDefined(); + expect(BotpressSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof BotpressSchema.entities).toBe('object'); + expect(BotpressSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(BotpressSchema.entities))).toBe(true); + for (const entity of Object.values(BotpressSchema.entities)) { + expect(entity).toBeDefined(); + } + }); + + it('registers exactly the workspace, bot and integration entities', () => { + expect(Object.keys(BotpressSchema.entities).sort()).toEqual([ + 'bots', + 'integrations', + 'workspaces', + ]); + }); +}); + +const LIVE_KEYS = { + workspaces: [ + 'id', + 'name', + 'ownerId', + 'createdAt', + 'updatedAt', + 'blocked', + 'plan', + 'billingVersion', + 'spendingLimit', + 'botCount', + 'about', + 'profilePicture', + 'contactEmail', + 'website', + 'isPublic', + 'activeTrialId', + ], + bots: [ + 'id', + 'name', + 'createdAt', + 'updatedAt', + 'createdBy', + 'dev', + 'alwaysAlive', + 'status', + 'type', + 'tags', + ], + integrations: [ + 'id', + 'name', + 'version', + 'title', + 'description', + 'createdAt', + 'updatedAt', + 'visibility', + 'url', + 'iconUrl', + 'readmeUrl', + ], +} as const; + +const ENTITIES = { + workspaces: BotpressWorkspaceEntity, + bots: BotpressBotEntity, + integrations: BotpressIntegrationEntity, +} as const; + +describe('entity schemas declare every observed field', () => { + for (const [name, schema] of Object.entries(ENTITIES)) { + it(`${name} declares all ${LIVE_KEYS[name as keyof typeof LIVE_KEYS].length} keys`, () => { + const declared = schema.shape; + for (const key of LIVE_KEYS[name as keyof typeof LIVE_KEYS]) { + expect(declared).toHaveProperty(key); + } + }); + } +}); + +describe('entity schemas require only what the live API always sends', () => { + /** + * Every field beyond the ones below is optional: Botpress omits or + * defaults fields depending on plan and lifecycle state — a + * community-plan workspace has no `activeTrialId`, a bot mid-creation has + * an empty `signingSecret`. A schema that required more than these would + * reject those valid rows outright, which is the failure mode that + * matters: a rejected row is a lost row. + * + * `workspaces` and `integrations` require more than just their primary + * key (`name`, and `name`+`version`) because the live API guarantees + * those fields are always present, not because this schema chose to + * require them beyond what is observed. `bots` requires only `id`. + */ + const minimal = { + workspaces: { id: 'wkspace_1', name: 'W' }, + bots: { id: 'bot_1' }, + integrations: { id: 'int_1', name: 'n', version: '1.0.0' }, + } as const; + + for (const [name, schema] of Object.entries(ENTITIES)) { + it(`${name} parses a record carrying only its required fields`, () => { + const result = schema.safeParse(minimal[name as keyof typeof minimal]); + expect(result.success).toBe(true); + }); + } +}); + +describe('entity schemas keep unknown fields', () => { + it('preserves a field Botpress adds later rather than dropping it', () => { + const parsed = BotpressWorkspaceEntity.parse({ + id: 'wkspace_1', + name: 'Example', + some_future_field: 'kept', + }); + + expect(parsed).toHaveProperty('some_future_field', 'kept'); + }); +}); + +describe('entity schemas reject a record with no key', () => { + it('rejects a workspace with no id', () => { + expect( + BotpressWorkspaceEntity.safeParse({ name: 'Nameless' }).success, + ).toBe(false); + }); + + it('rejects a bot with no id', () => { + expect(BotpressBotEntity.safeParse({ name: 'Nameless' }).success).toBe( + false, + ); + }); + + it('rejects an integration with no id', () => { + expect( + BotpressIntegrationEntity.safeParse({ name: 'n', version: '1.0.0' }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/botpress/schema/database.ts b/packages/botpress/schema/database.ts new file mode 100644 index 000000000..fa326a25d --- /dev/null +++ b/packages/botpress/schema/database.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +/** + * Locally persisted Botpress entities. + * + * Only slow-changing structural records are mirrored: workspaces, bots and + * integrations. Conversations, messages, events and table rows are + * high-volume and continuously appended, so per the playbook they are + * deliberately NOT stored — they are always wanted as a live view. + * + * Field lists for `workspace` and `bot` come from live responses captured + * 2026-08-16 against a real Botpress account (`GET /v1/admin/workspaces`, + * `POST /v1/admin/bots`). `integration` was not created live in this pass + * (creating one requires a full manifest with configuration/actions/events + * schemas); its shape comes from `CreateIntegrationResponse` in + * `@botpress/client` v2.2.0's bundled type declarations, so only the fields + * used to identify and list an integration are typed strictly — the rest of + * the manifest is opaque and unknown to this mirror. + */ + +const S = z.string().nullable().optional(); +const B = z.boolean().nullable().optional(); +const N = z.number().nullable().optional(); + +export const BotpressWorkspaceEntity = z + .object({ + id: z.string(), + name: z.string(), + ownerId: S, + createdAt: z.coerce.date().nullable().optional(), + updatedAt: z.coerce.date().nullable().optional(), + blocked: B, + plan: S, + billingVersion: S, + spendingLimit: N, + botCount: N, + about: S, + profilePicture: S, + contactEmail: S, + website: S, + isPublic: B, + handle: S, + activeTrialId: S, + }) + .loose(); +export type BotpressWorkspaceEntity = z.infer; + +export const BotpressBotEntity = z + .object({ + id: z.string(), + name: S, + createdAt: z.coerce.date().nullable().optional(), + updatedAt: z.coerce.date().nullable().optional(), + createdBy: S, + dev: B, + alwaysAlive: B, + status: S, + type: S, + /** + * Deeply nested provider-defined manifest sections (states, message, + * user, conversation, events, actions, integrations, plugins, + * configuration, tags). Never observed as a stable, enumerable field + * list across bots — kept as opaque records rather than modeled + * field-by-field. + */ + tags: z.record(z.string(), z.string()).nullable().optional(), + }) + .loose(); +export type BotpressBotEntity = z.infer; + +export const BotpressIntegrationEntity = z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + title: S, + description: S, + createdAt: z.coerce.date().nullable().optional(), + updatedAt: z.coerce.date().nullable().optional(), + visibility: S, + dev: B, + url: S, + iconUrl: S, + readmeUrl: S, + }) + .loose(); +export type BotpressIntegrationEntity = z.infer< + typeof BotpressIntegrationEntity +>; diff --git a/packages/botpress/schema/index.ts b/packages/botpress/schema/index.ts new file mode 100644 index 000000000..410d4b3e3 --- /dev/null +++ b/packages/botpress/schema/index.ts @@ -0,0 +1,14 @@ +import { + BotpressBotEntity, + BotpressIntegrationEntity, + BotpressWorkspaceEntity, +} from './database'; + +export const BotpressSchema = { + version: '1.0.0', + entities: { + workspaces: BotpressWorkspaceEntity, + bots: BotpressBotEntity, + integrations: BotpressIntegrationEntity, + }, +} as const; diff --git a/packages/botpress/tsconfig.json b/packages/botpress/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/botpress/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/botpress/tsup.config.ts b/packages/botpress/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/botpress/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/botpress/webhooks/index.ts b/packages/botpress/webhooks/index.ts new file mode 100644 index 000000000..07a99b8fe --- /dev/null +++ b/packages/botpress/webhooks/index.ts @@ -0,0 +1,3 @@ +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/botpress/webhooks/oauth-tenant-link.ts b/packages/botpress/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..6fa69815f --- /dev/null +++ b/packages/botpress/webhooks/oauth-tenant-link.ts @@ -0,0 +1,13 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; + +/** + * Botpress authenticates with a Personal Access Token and exposes no OAuth + * flow for this catalog, so there is no token response to derive a routing + * id from. Tenant linking is handled entirely by + * `matchBotpressTenantWebhook` instead. + */ +export async function resolveBotpressOAuthWebhookTenantLink( + _tokens: TokenResponse, +): Promise { + return null; +} diff --git a/packages/botpress/webhooks/tenant-matcher.ts b/packages/botpress/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..b8f22a187 --- /dev/null +++ b/packages/botpress/webhooks/tenant-matcher.ts @@ -0,0 +1,28 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +/** + * Routes an inbound Botpress webhook to a tenant. + * + * No webhook handlers are registered yet — the OSS catalog lists zero + * triggers for Botpress — so in practice this is not reached. It is kept + * correct, routing on `workspaceId` to line up with + * `botpressAuthConfig.api_key.account`, so enabling webhooks later does not + * require rework. + */ +export function matchBotpressTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + const externalId = firstString([ + body.workspaceId, + body.workspace_id, + asRecord(body.data)?.workspaceId, + ]); + + if (!externalId) return null; + + return { linkType: 'workspace_id', externalId }; +} diff --git a/packages/botpress/webhooks/types.ts b/packages/botpress/webhooks/types.ts new file mode 100644 index 000000000..866e82864 --- /dev/null +++ b/packages/botpress/webhooks/types.ts @@ -0,0 +1,5 @@ +/** + * The OSS catalog for this integration lists zero triggers, and this plugin + * registers no webhook handlers. + */ +export type BotpressWebhookOutputs = Record; diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 41011f6e7..aa56c64df 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -42,6 +42,7 @@ export const BaseProviders = [ 'bitwarden', 'bluesky', 'boloforms', + 'botpress', 'box', 'canvas', 'cal', @@ -172,6 +173,7 @@ export const ProviderDisplayNames = { bitwarden: 'Bitwarden', bluesky: 'Bluesky', boloforms: 'Boloforms', + botpress: 'Botpress', box: 'Box', canvas: 'Canvas LMS', cal: 'Cal', @@ -309,6 +311,7 @@ export type AllProviders = | 'bitwarden' | 'bluesky' | 'boloforms' + | 'botpress' | 'box' | 'cal' | 'calendly' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5f3e3a29..22e49ccea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1039,6 +1039,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/botpress: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/box: devDependencies: '@types/jest':