From 036d592e082622f82dd577027900e6e6f41a2ddd Mon Sep 17 00:00:00 2001 From: Sylvain Perron <1315508+slvnperron@users.noreply.github.com> Date: Sat, 2 May 2026 12:48:43 -0400 Subject: [PATCH] feat(sdk): add conversation & user to message plugin hooks (#15156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: François Levasseur --- packages/cli/package.json | 4 +- packages/cli/templates/empty-bot/package.json | 2 +- .../templates/empty-integration/package.json | 2 +- .../cli/templates/empty-plugin/package.json | 2 +- .../cli/templates/hello-world/package.json | 2 +- .../templates/webhook-message/package.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/bot/server/index.ts | 12 +- packages/sdk/src/bot/server/types.ts | 34 ++++- .../src/plugin/conversation-proxy/types.ts | 2 +- packages/sdk/src/plugin/implementation.ts | 51 +++++-- packages/sdk/src/plugin/server/types.test.ts | 18 +++ packages/sdk/src/plugin/server/types.ts | 126 +++++++++++++----- plugins/logger/plugin.definition.ts | 2 +- plugins/logger/src/index.ts | 24 ++-- pnpm-lock.yaml | 12 +- 16 files changed, 220 insertions(+), 77 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d4b8fbe0dfa..d00b33b32b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.6.1", + "version": "6.6.2", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", @@ -28,7 +28,7 @@ "@apidevtools/json-schema-ref-parser": "^11.7.0", "@botpress/chat": "0.5.5", "@botpress/client": "1.43.0", - "@botpress/sdk": "6.8.0", + "@botpress/sdk": "6.9.0", "@bpinternal/const": "^0.1.0", "@bpinternal/tunnel": "^0.1.1", "@bpinternal/verel": "^0.2.0", diff --git a/packages/cli/templates/empty-bot/package.json b/packages/cli/templates/empty-bot/package.json index bdb672465b5..d1ab0291795 100644 --- a/packages/cli/templates/empty-bot/package.json +++ b/packages/cli/templates/empty-bot/package.json @@ -6,7 +6,7 @@ "private": true, "dependencies": { "@botpress/client": "1.43.0", - "@botpress/sdk": "6.8.0" + "@botpress/sdk": "6.9.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-integration/package.json b/packages/cli/templates/empty-integration/package.json index 7075ab2e2b3..e7c89bfe709 100644 --- a/packages/cli/templates/empty-integration/package.json +++ b/packages/cli/templates/empty-integration/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.43.0", - "@botpress/sdk": "6.8.0" + "@botpress/sdk": "6.9.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/empty-plugin/package.json b/packages/cli/templates/empty-plugin/package.json index 1803cdd97bd..214f5f36b8c 100644 --- a/packages/cli/templates/empty-plugin/package.json +++ b/packages/cli/templates/empty-plugin/package.json @@ -6,7 +6,7 @@ }, "private": true, "dependencies": { - "@botpress/sdk": "6.8.0" + "@botpress/sdk": "6.9.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/hello-world/package.json b/packages/cli/templates/hello-world/package.json index 45758149eef..263caf6e4ec 100644 --- a/packages/cli/templates/hello-world/package.json +++ b/packages/cli/templates/hello-world/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.43.0", - "@botpress/sdk": "6.8.0" + "@botpress/sdk": "6.9.0" }, "devDependencies": { "@types/node": "^22.16.4", diff --git a/packages/cli/templates/webhook-message/package.json b/packages/cli/templates/webhook-message/package.json index acce1891bac..fcd969384a7 100644 --- a/packages/cli/templates/webhook-message/package.json +++ b/packages/cli/templates/webhook-message/package.json @@ -7,7 +7,7 @@ "private": true, "dependencies": { "@botpress/client": "1.43.0", - "@botpress/sdk": "6.8.0", + "@botpress/sdk": "6.9.0", "axios": "^1.6.8" }, "devDependencies": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 432bb0eee90..158a6b0260d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/sdk", - "version": "6.8.0", + "version": "6.9.0", "description": "Botpress SDK", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/sdk/src/bot/server/index.ts b/packages/sdk/src/bot/server/index.ts index f2768e323a2..cae6d3a06af 100644 --- a/packages/sdk/src/bot/server/index.ts +++ b/packages/sdk/src/bot/server/index.ts @@ -1,4 +1,4 @@ -import { isApiError, Client, RuntimeError, Message, State } from '@botpress/client' +import { isApiError, Client, RuntimeError, Message, State, User, Conversation } from '@botpress/client' import { retryConfig } from '../../retry' import { Request, Response, parseBody } from '../../serve' import * as utils from '../../utils/type-utils' @@ -171,6 +171,8 @@ const onEventReceived = async (serverProps: types.ServerProps): Promise[0] = { ...common, - user: event.payload.user, - conversation: event.payload.conversation, + user, + conversation, message, event, } @@ -206,6 +210,8 @@ const onEventReceived = async (serverProps: types.ServerProps): Promise = { ) => Promise } -type BaseHookDefinition = { stoppable?: boolean; data: any } +type BaseHookDefinition = { + stoppable?: boolean + data: any + /** + * Per-hook-type extra props injected into the hook handler input. Only set + * for hooks where the bot runtime already has the relevant context (e.g. + * incoming message hooks). + */ + extraInputs?: object +} type HookDefinition = THookDef /** @@ -254,6 +263,10 @@ export type HookDefinitions = { before_incoming_message: HookDefinition<{ stoppable: true data: _IncomingMessages & { '*': AnyIncomingMessage } + extraInputs: { + user?: client.User + conversation?: client.Conversation + } }> before_outgoing_message: HookDefinition<{ stoppable: false @@ -274,6 +287,10 @@ export type HookDefinitions = { after_incoming_message: HookDefinition<{ stoppable: true data: _IncomingMessages & { '*': AnyIncomingMessage } + extraInputs: { + user?: client.User + conversation?: client.Conversation + } }> after_outgoing_message: HookDefinition<{ stoppable: false @@ -297,11 +314,20 @@ export type HookData = { } } +type _HookExtraInputs = { + [THookType in utils.StringKeys>]: HookDefinitions[THookType] extends { + extraInputs: infer T + } + ? T + : {} +} + export type HookInputs = { [THookType in utils.StringKeys>]: { - [THookDataName in utils.StringKeys[THookType]>]: ExtendedHandlerProps & { - data: HookData[THookType][THookDataName] - } + [THookDataName in utils.StringKeys[THookType]>]: ExtendedHandlerProps & + _HookExtraInputs[THookType] & { + data: HookData[THookType][THookDataName] + } } } diff --git a/packages/sdk/src/plugin/conversation-proxy/types.ts b/packages/sdk/src/plugin/conversation-proxy/types.ts index 80253cabfc5..7a9cf0377b2 100644 --- a/packages/sdk/src/plugin/conversation-proxy/types.ts +++ b/packages/sdk/src/plugin/conversation-proxy/types.ts @@ -78,7 +78,7 @@ export type ActionableConversation< Omit, { tags?: commonTypes.ToTags> } > - ) => Promise> + ) => Promise> getMessage: (props: { id: string }) => Promise> getOrCreateMessage: ( props: Omit & diff --git a/packages/sdk/src/plugin/implementation.ts b/packages/sdk/src/plugin/implementation.ts index ed8b4c05ce4..08ef27b661e 100644 --- a/packages/sdk/src/plugin/implementation.ts +++ b/packages/sdk/src/plugin/implementation.ts @@ -1,3 +1,4 @@ +import * as client from '@botpress/client' import type { MessageHandlersMap as BotMessageHandlersMap, EventHandlersMap as BotEventHandlersMap, @@ -46,16 +47,21 @@ export type PluginImplementationProps = actions: ActionHandlers } +type WithMessageContext = T & { user: client.User; conversation: client.Conversation } + +const _hookInputHasMessageContext = (input: T): input is WithMessageContext => + 'user' in input && 'conversation' in input + type Tools = InjectedHandlerProps export class PluginImplementation implements BotHandlers { private _runtimeProps: PluginRuntimeProps | undefined private _actionHandlers: ActionHandlers - private _messageHandlers: OrderedMessageHandlersMap = {} - private _eventHandlers: OrderedEventHandlersMap = {} - private _stateExpiredHandlers: OrderedStateExpiredHandlersMap = {} - private _hookHandlers: OrderedHookHandlersMap = { + private _messageHandlers: OrderedMessageHandlersMap = {} + private _eventHandlers: OrderedEventHandlersMap = {} + private _stateExpiredHandlers: OrderedStateExpiredHandlersMap = {} + private _hookHandlers: OrderedHookHandlersMap = { before_incoming_event: {}, before_incoming_message: {}, before_outgoing_message: {}, @@ -67,7 +73,7 @@ export class PluginImplementation imple after_outgoing_call_action: {}, after_incoming_call_action: {}, } - private _workflowHandlers: OrderedWorkflowHandlersMap = { + private _workflowHandlers: OrderedWorkflowHandlersMap = { started: {}, continued: {}, timed_out: {}, @@ -264,12 +270,35 @@ export class PluginImplementation imple return handlers.map(({ handler }) => utils.functions.setName( - (input: utils.types.ValueOf>['*']) => - handler({ - ...input, - data: unprefixTagsOwnedByPlugin(input.data, { alias: this._runtime.alias }), - ...this._getTools(input.client), - }), + (input: utils.types.ValueOf>['*']) => { + const data = unprefixTagsOwnedByPlugin(input.data, { alias: this._runtime.alias }) + const tools = this._getTools(input.client) + + let extraProps: Record = {} + + // `before_incoming_message` and `after_incoming_message` carry the raw + // `user` and `conversation` from the incoming event payload; wrap them + // as proxies so handlers see the same shape as `plugin.on.message(...)`. + // Other hook types don't carry these fields, so we just forward. + if (_hookInputHasMessageContext(input)) { + const user = proxyUser({ + client: input.client, + user: input.user, + conversationId: input.conversation.id, + pluginAlias: this._runtime.alias, + }) + + const conversation = proxyConversation({ + client: input.client, + plugin: this._runtime, + conversation: input.conversation, + }) + + extraProps = { user, conversation } + } + + return handler({ ...input, data, ...extraProps, ...tools }) + }, handler.name ) ) diff --git a/packages/sdk/src/plugin/server/types.test.ts b/packages/sdk/src/plugin/server/types.test.ts index 7407cbd1d41..4b88a756aed 100644 --- a/packages/sdk/src/plugin/server/types.test.ts +++ b/packages/sdk/src/plugin/server/types.test.ts @@ -40,3 +40,21 @@ test('MessageRequest with base plugin should be loose type', () => { ] > }) + +test('HookHandlers with implemented plugin should always extend AnyHookHandler', () => { + type Actual = types.HookHandlers + type Expected = Record>> + type _assertion = utils.AssertExtends +}) + +test('HookHandlers with empty plugin should always extend AnyHookHandler', () => { + type Actual = types.HookHandlers + type Expected = Record>> + type _assertion = utils.AssertExtends +}) + +test('HookHandlers with base plugin should always extend AnyHookHandler', () => { + type Actual = types.HookHandlers + type Expected = Record>> + type _assertion = utils.AssertExtends +}) diff --git a/packages/sdk/src/plugin/server/types.ts b/packages/sdk/src/plugin/server/types.ts index 19004b94d4c..7cb7112f8d0 100644 --- a/packages/sdk/src/plugin/server/types.ts +++ b/packages/sdk/src/plugin/server/types.ts @@ -337,7 +337,17 @@ export type WorkflowHandlers = { ) => Promise } -type BaseHookDefinition = { stoppable?: boolean; data: any } +type BaseHookDefinition = { + stoppable?: boolean + data: any + /** + * Per-hook-type extra props injected into the hook handler input. Only set + * for hooks where the bot runtime already has the relevant context (e.g. + * incoming message hooks). The `raw` shape uses `client.User`/ + * `client.Conversation`; the `injected` shape replaces them with proxies. + */ + extraInputs?: { raw: object; injected: object } +} type HookDefinition = THookDef /** @@ -346,8 +356,6 @@ type HookDefinition = * - after_register * - before_state_expired * - after_state_expired - * - before_incoming_call_action - * - after_incoming_call_action */ export type HookDefinitionType = keyof HookDefinitions @@ -360,6 +368,16 @@ export type HookDefinitions = { before_incoming_message: HookDefinition<{ stoppable: true data: _IncomingMessages & { '*': AnyIncomingMessage } + extraInputs: { + raw: { + user?: client.User + conversation?: client.Conversation + } + injected: { + user?: userProxy.ActionableUser + conversation?: conversationProxy.ActionableConversation + } + } }> before_outgoing_message: HookDefinition<{ stoppable: false @@ -380,6 +398,16 @@ export type HookDefinitions = { after_incoming_message: HookDefinition<{ stoppable: true data: _IncomingMessages & { '*': AnyIncomingMessage } + extraInputs: { + raw: { + user?: client.User + conversation?: client.Conversation + } + injected: { + user?: userProxy.ActionableUser + conversation?: conversationProxy.ActionableConversation + } + } }> after_outgoing_message: HookDefinition<{ stoppable: false @@ -396,30 +424,48 @@ export type HookDefinitions = { } export type HookData = { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys< HookDefinitions[THookType]['data'] >]: HookDefinitions[THookType]['data'][THookDataName] } } +type _HookExtraInputsRaw = { + [THookType in HookDefinitionType]: HookDefinitions[THookType] extends { + extraInputs: { raw: infer T } + } + ? T + : {} +} + +type _HookExtraInputsInjected = { + [THookType in HookDefinitionType]: HookDefinitions[THookType] extends { + extraInputs: { injected: infer T } + } + ? T + : {} +} + export type HookInputsWithoutInjectedProps = { - [THookType in utils.StringKeys>]: { - [THookDataName in utils.StringKeys[THookType]>]: CommonHandlerProps & { - data: HookData[THookType][THookDataName] - } + [THookType in HookDefinitionType]: { + [THookDataName in utils.StringKeys[THookType]>]: CommonHandlerProps & + _HookExtraInputsRaw[THookType] & { + data: HookData[THookType][THookDataName] + } } } export type HookInputs = { - [THookType in utils.StringKeys>]: _WithInjectedProps< + [THookType in HookDefinitionType]: _WithInjectedProps< HookInputsWithoutInjectedProps[THookType], - TPlugin + TPlugin, + _HookExtraInputsInjected[THookType] > } export type HookOutputs = { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys[THookType]>]: { data?: HookData[THookType][THookDataName] } & (HookDefinitions[THookType]['stoppable'] extends true ? { stop?: boolean } : {}) @@ -427,7 +473,7 @@ export type HookOutputs = { } export type HookHandlersWithoutInjectedProps = { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys[THookType]>]: ( input: HookInputsWithoutInjectedProps[THookType][THookDataName] ) => Promise[THookType][THookDataName] | undefined> @@ -435,14 +481,27 @@ export type HookHandlersWithoutInjectedProps } export type HookHandlers = { - [THookType in utils.StringKeys>]: _WithInjectedPropsFn< + [THookType in HookDefinitionType]: _WithInjectedPropsFn< HookHandlersWithoutInjectedProps[THookType], - TPlugin + TPlugin, + _HookExtraInputsInjected[THookType] > } +export type AnyHookHandler = ( + input: CommonHandlerProps & { + data: any + } & InjectedHandlerProps +) => Promise< + | { + data?: any + stop?: boolean + } + | undefined +> + export type HookHandlersMap = { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys< HookData[THookType] >]?: HookHandlersWithoutInjectedProps[THookType][THookDataName][] @@ -462,40 +521,40 @@ export type WorkflowHandlersMap = { } } -export type OrderedMessageHandlersMap = { - [TMessageName in utils.StringKeys>]?: { - handler: MessageHandlers[TMessageName] +export type OrderedMessageHandlersMap = { + [TMessageName in string]?: { + handler: MessageHandlers[TMessageName] order: number }[] } -export type OrderedEventHandlersMap = { - [TEventName in utils.StringKeys>]?: { - handler: EventHandlers[TEventName] +export type OrderedEventHandlersMap = { + [TEventName in string]?: { + handler: EventHandlers['*'] order: number }[] } -export type OrderedStateExpiredHandlersMap = { - [TStateName in utils.StringKeys>]?: { - handler: StateExpiredHandlers[TStateName] +export type OrderedStateExpiredHandlersMap = { + [TStateName in string]?: { + handler: StateExpiredHandlers['*'] order: number }[] } -export type OrderedHookHandlersMap = { - [THookType in utils.StringKeys>]: { - [THookDataName in utils.StringKeys[THookType]>]?: { - handler: HookHandlers[THookType][THookDataName] +export type OrderedHookHandlersMap = { + [THookType in HookDefinitionType]: { + [THookDataName in string]?: { + handler: AnyHookHandler order: number }[] } } -export type OrderedWorkflowHandlersMap = { +export type OrderedWorkflowHandlersMap = { [TWorkflowUpdateType in bot.WorkflowUpdateType]: { - [TWorkflowName in utils.StringKeys]?: { - handler: WorkflowHandlers[TWorkflowName] + [TWorkflowName in string]?: { + handler: WorkflowHandlers['*'] order: number }[] } @@ -518,7 +577,7 @@ export type PluginHandlers = { >]?: StateExpiredHandlersWithoutInjectedProps[TStateName][] } hookHandlers: { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys< HookData[THookType] >]?: HookHandlersWithoutInjectedProps[THookType][THookDataName][] @@ -532,6 +591,7 @@ export type PluginHandlers = { } } } + /** identical to PluginHandlers, but contains the injected properties */ export type InjectedPluginHandlers = { actionHandlers: ActionHandlers @@ -545,7 +605,7 @@ export type InjectedPluginHandlers = { [TStateName in utils.StringKeys>]?: StateExpiredHandlers[TStateName][] } hookHandlers: { - [THookType in utils.StringKeys>]: { + [THookType in HookDefinitionType]: { [THookDataName in utils.StringKeys< HookData[THookType] >]?: HookHandlers[THookType][THookDataName][] diff --git a/plugins/logger/plugin.definition.ts b/plugins/logger/plugin.definition.ts index 1eecb2da3bb..2f44cc44c76 100644 --- a/plugins/logger/plugin.definition.ts +++ b/plugins/logger/plugin.definition.ts @@ -2,6 +2,6 @@ import * as sdk from '@botpress/sdk' export default new sdk.PluginDefinition({ name: 'logger', - version: '0.0.1', + version: '0.1.0', configuration: { schema: sdk.z.object({}) }, }) diff --git a/plugins/logger/src/index.ts b/plugins/logger/src/index.ts index 1e9f29f64de..795e8427097 100644 --- a/plugins/logger/src/index.ts +++ b/plugins/logger/src/index.ts @@ -9,15 +9,19 @@ const log = (...x: any[]): undefined => { return } -plugin.on.beforeIncomingEvent('*', async (x) => log('before_incoming_event', x.data)) -plugin.on.beforeIncomingMessage('*', async (x) => log('before_incoming_message', x.data)) -plugin.on.beforeOutgoingMessage('*', async (x) => log('before_outgoing_message', x.data)) -plugin.on.beforeOutgoingCallAction('*', async (x) => log('before_call_action', x.data)) -plugin.on.afterIncomingEvent('*', async (x) => log('after_incoming_event', x.data)) -plugin.on.afterIncomingMessage('*', async (x) => log('after_incoming_message', x.data)) -plugin.on.afterOutgoingMessage('*', async (x) => log('after_outgoing_message', x.data)) -plugin.on.afterOutgoingCallAction('*', async (x) => log('after_call_action', x.data)) -plugin.on.beforeIncomingCallAction('*', async (x) => log('before_incoming_call_action', x.data)) -plugin.on.afterIncomingCallAction('*', async (x) => log('after_incoming_call_action', x.data)) +plugin.on.beforeIncomingEvent('*', async (x) => log('before_incoming_event', { data: x.data })) +plugin.on.beforeIncomingMessage('*', async (x) => + log('before_incoming_message', { data: x.data, user: x.user?.id, conversation: x.conversation?.id }) +) +plugin.on.beforeOutgoingMessage('*', async (x) => log('before_outgoing_message', { data: x.data })) +plugin.on.beforeOutgoingCallAction('*', async (x) => log('before_call_action', { data: x.data })) +plugin.on.afterIncomingEvent('*', async (x) => log('after_incoming_event', { data: x.data })) +plugin.on.afterIncomingMessage('*', async (x) => + log('after_incoming_message', { data: x.data, user: x.user?.id, conversation: x.conversation?.id }) +) +plugin.on.afterOutgoingMessage('*', async (x) => log('after_outgoing_message', { data: x.data })) +plugin.on.afterOutgoingCallAction('*', async (x) => log('after_call_action', { data: x.data })) +plugin.on.beforeIncomingCallAction('*', async (x) => log('before_incoming_call_action', { data: x.data })) +plugin.on.afterIncomingCallAction('*', async (x) => log('after_incoming_call_action', { data: x.data })) export default plugin diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b228977461f..652bb89ec78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2670,7 +2670,7 @@ importers: specifier: 1.43.0 version: link:../client '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../sdk '@bpinternal/const': specifier: ^0.1.0 @@ -2794,7 +2794,7 @@ importers: specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2810,7 +2810,7 @@ importers: specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2823,7 +2823,7 @@ importers: packages/cli/templates/empty-plugin: dependencies: '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2839,7 +2839,7 @@ importers: specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../../../sdk devDependencies: '@types/node': @@ -2855,7 +2855,7 @@ importers: specifier: 1.43.0 version: link:../../../client '@botpress/sdk': - specifier: 6.8.0 + specifier: 6.9.0 version: link:../../../sdk axios: specifier: ^1.6.8