UNPKG

@microsoft/agents-hosting

Version:

Microsoft 365 Agents SDK for JavaScript

744 lines (692 loc) 27.2 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { debug } from '@microsoft/agents-activity/logger' import { TurnContext } from './turnContext' import { Activity, ActivityTypes, Channels } from '@microsoft/agents-activity' import { StatusCodes } from './statusCodes' import { InvokeResponse } from './invoke/invokeResponse' import { InvokeException } from './invoke/invokeException' import { AdaptiveCardInvokeValue } from './invoke/adaptiveCardInvokeValue' import { SearchInvokeValue } from './invoke/searchInvokeValue' import { SearchInvokeResponse } from './invoke/searchInvokeResponse' import { AdaptiveCardInvokeResponse } from './invoke/adaptiveCardInvokeResponse' import { tokenResponseEventName } from './tokenResponseEventName' /** Symbol key for invoke response */ export const INVOKE_RESPONSE_KEY = Symbol('invokeResponse') /** * Type definition for agent handler function * @param context - The turn context for the current turn of conversation * @param next - The function to call to continue to the next middleware or handler * @returns A promise representing the asynchronous operation */ export type AgentHandler = (context: TurnContext, next: () => Promise<void>) => Promise<any> const logger = debug('agents:activity-handler') /** * Handles incoming activities from channels and dispatches them to the appropriate handlers. * * @remarks * * This class is provided to simplify the migration from Bot Framework SDK v4 to the Agents Hosting framework. * * The ActivityHandler serves as the central hub for processing incoming activities in conversational AI applications. * It provides a comprehensive framework for handling various activity types including messages, conversation updates, * message reactions, typing indicators, installation updates, and invoke operations such as adaptive cards and search. * * ## Key Features: * - **Activity Routing**: Automatically routes activities to appropriate handlers based on activity type * - **Handler Registration**: Provides fluent API methods (onMessage, onConversationUpdate, etc.) for registering event handlers * - **Invoke Support**: Built-in handling for adaptive card actions and search invoke operations * - **Error Handling**: Robust error handling with proper HTTP status codes for invoke operations * - **Extensibility**: Designed for inheritance to allow custom behavior and specialized handlers * * ## Usage: * ```typescript * const handler = new ActivityHandler() * .onMessage(async (context, next) => { * await context.sendActivity('Hello!'); * await next(); * }) * .onMembersAdded(async (context, next) => { * // Welcome new members * await next(); * }); * ``` * * Developers can extend this class to implement domain-specific logic, override default behaviors, * or add support for custom activity types and invoke operations. */ export class ActivityHandler { /** * Collection of handlers registered for different activity types * @protected */ protected readonly handlers: { [type: string]: AgentHandler[] } = {} /** * Registers a handler for the Turn activity type. * This is called for all activities regardless of type. * @param handler - The handler to register * @returns The current instance for method chaining */ onTurn (handler: AgentHandler): this { return this.on('Turn', handler) } /** * Registers a handler for the MembersAdded activity type. * This is called when new members are added to the conversation. * @param handler - The handler to register * @returns The current instance for method chaining */ onMembersAdded (handler: AgentHandler): this { return this.on('MembersAdded', handler) } /** * Registers a handler for the MembersRemoved activity type. * This is called when members are removed from the conversation. * @param handler - The handler to register * @returns The current instance for method chaining */ onMembersRemoved (handler: AgentHandler): this { return this.on('MembersRemoved', handler) } /** * Registers a handler for the Message activity type. * This is called when a message is received from the user. * @param handler - The handler to register * @returns The current instance for method chaining */ onMessage (handler: AgentHandler): this { return this.on('Message', handler) } /** * Registers a handler for the MessageUpdate activity type. * This is called when a message is updated. * @param handler - The handler to register * @returns The current instance for method chaining */ onMessageUpdate (handler: AgentHandler): this { return this.on('MessageUpdate', handler) } /** * Registers a handler for the MessageDelete activity type. * This is called when a message is deleted. * @param handler - The handler to register * @returns The current instance for method chaining */ onMessageDelete (handler: AgentHandler): this { return this.on('MessageDelete', handler) } /** * Registers a handler for the ConversationUpdate activity type. * This is called when the conversation is updated, such as when members are added or removed. * @param handler - The handler to register * @returns The current instance for method chaining */ onConversationUpdate (handler: AgentHandler): this { return this.on('ConversationUpdate', handler) } /** * Registers a handler for the MessageReaction activity type. * This is called when reactions are added or removed from messages. * @param handler - The handler to register * @returns The current instance for method chaining */ onMessageReaction (handler: AgentHandler): this { return this.on('MessageReaction', handler) } /** * Registers a handler for the ReactionsAdded activity type. * This is called when reactions are added to messages. * @param handler - The handler to register * @returns The current instance for method chaining */ onReactionsAdded (handler: AgentHandler): this { return this.on('ReactionsAdded', handler) } /** * Registers a handler for the ReactionsRemoved activity type. * This is called when reactions are removed from messages. * @param handler - The handler to register * @returns The current instance for method chaining */ onReactionsRemoved (handler: AgentHandler): this { return this.on('ReactionsRemoved', handler) } /** * Registers a handler for the Typing activity type. * This is called when a typing indicator is received. * @param handler - The handler to register * @returns The current instance for method chaining */ onTyping (handler: AgentHandler): this { return this.on('Typing', handler) } /** * Registers a handler for the InstallationUpdate activity type. * This is called when an agent is installed or uninstalled. * @param handler - The handler to register * @returns The current instance for method chaining */ onInstallationUpdate (handler: AgentHandler): this { return this.on('InstallationUpdate', handler) } /** * Registers a handler for the InstallationUpdateAdd activity type. * This is called when an agent is installed or upgraded. * @param handler - The handler to register * @returns The current instance for method chaining */ onInstallationUpdateAdd (handler: AgentHandler): this { return this.on('InstallationUpdateAdd', handler) } /** * Registers a handler for the InstallationUpdateRemove activity type. * This is called when an agent is uninstalled or downgraded. * @param handler - The handler to register * @returns The current instance for method chaining */ onInstallationUpdateRemove (handler: AgentHandler): this { return this.on('InstallationUpdateRemove', handler) } /** * Registers a handler for the EndOfConversation activity type. * This is called when the conversation ends. * @param handler - The handler to register * @returns The current instance for method chaining */ onEndOfConversation (handler: AgentHandler): this { return this.on('EndOfConversation', handler) } /** * Registers a handler for the SignInInvoke activity type. * This is called when a sign-in is requested. * @param handler - The handler to register * @returns The current instance for method chaining */ onSignInInvoke (handler: AgentHandler): this { return this.on('SignInInvoke', handler) } /** * Registers a handler for unrecognized activity types. * This is called when an activity type is not recognized. * @param handler - The handler to register * @returns The current instance for method chaining */ onUnrecognizedActivityType (handler: AgentHandler): this { return this.on('UnrecognizedActivityType', handler) } /** * Registers an activity event handler for the _dialog_ event, emitted as the last event for an incoming activity. * This handler is called after all other handlers have been processed. * @param handler - The handler to register * @returns The current instance for method chaining */ onDialog (handler: AgentHandler): this { return this.on('Default', handler) } /** * Runs the activity handler pipeline. * This method is called to process an incoming activity through the registered handlers. * @param context - The turn context for the current turn of conversation * @throws Error if context is missing, activity is missing, or activity type is missing */ async run (context: TurnContext): Promise<void> { if (!context) throw new Error('Missing TurnContext parameter') if (!context.activity) throw new Error('TurnContext does not include an activity') if (!context.activity.type) throw new Error('Activity is missing its type') await this.onTurnActivity(context) } /** * Handles the Turn activity. * This method is called for every activity type and dispatches to the appropriate handler. * @param context - The turn context for the current turn of conversation * @protected */ protected async onTurnActivity (context: TurnContext): Promise<void> { switch (context.activity.type) { case ActivityTypes.Message: await this.onMessageActivity(context) break case ActivityTypes.MessageUpdate: await this.onMessageUpdateActivity(context) break case ActivityTypes.MessageDelete: await this.onMessageDeleteActivity(context) break case ActivityTypes.ConversationUpdate: await this.onConversationUpdateActivity(context) break case ActivityTypes.Invoke: { const invokeResponse = await this.onInvokeActivity(context) if (invokeResponse && !context.turnState.get(INVOKE_RESPONSE_KEY)) { const activity = Activity.fromObject({ value: invokeResponse, type: 'invokeResponse' }) await context.sendActivity(activity) } break } case ActivityTypes.MessageReaction: await this.onMessageReactionActivity(context) break case ActivityTypes.Typing: await this.onTypingActivity(context) break case ActivityTypes.InstallationUpdate: await this.onInstallationUpdateActivity(context) break case ActivityTypes.EndOfConversation: await this.onEndOfConversationActivity(context) break default: await this.onUnrecognizedActivity(context) break } } /** * Handles the Message activity. * This method processes incoming message activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onMessageActivity (context: TurnContext): Promise<void> { await this.handle(context, 'Message', this.defaultNextEvent(context)) } /** * Handles the MessageUpdate activity. * This method processes message update activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onMessageUpdateActivity (context: TurnContext): Promise<void> { await this.handle(context, 'MessageUpdate', async () => { await this.dispatchMessageUpdateActivity(context) }) } /** * Handles the MessageDelete activity. * This method processes message deletion activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onMessageDeleteActivity (context: TurnContext): Promise<void> { await this.handle(context, 'MessageDelete', async () => { await this.dispatchMessageDeleteActivity(context) }) } /** * Handles the ConversationUpdate activity. * This method processes conversation update activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onConversationUpdateActivity (context: TurnContext): Promise<void> { await this.handle(context, 'ConversationUpdate', async () => { await this.dispatchConversationUpdateActivity(context) }) } /** * Handles the SignInInvoke activity. * This method processes sign-in invoke activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onSigninInvokeActivity (context: TurnContext): Promise<void> { await this.handle(context, 'SignInInvoke', this.defaultNextEvent(context)) } /** * Handles the Invoke activity. * This method processes various invoke activities based on their name. * @param context - The turn context for the current turn of conversation * @returns An invoke response object with status and body * @protected */ protected async onInvokeActivity (context: TurnContext): Promise<InvokeResponse> { try { switch (context.activity.name) { case 'application/search': { const invokeValue = this.getSearchInvokeValue(context.activity) const response = await this.onSearchInvoke(context, invokeValue) return { status: response.statusCode, body: response } } case 'adaptiveCard/action': { const invokeValue = this.getAdaptiveCardInvokeValue(context.activity) const response = await this.onAdaptiveCardInvoke(context, invokeValue) return { status: response.statusCode, body: response } } case 'signin/verifyState': case 'signin/tokenExchange': await this.onSigninInvokeActivity(context) return { status: StatusCodes.OK } default: throw new InvokeException(StatusCodes.NOT_IMPLEMENTED) } } catch (err) { const error = err as Error if (error.message === 'NotImplemented') { return { status: StatusCodes.NOT_IMPLEMENTED } } if (err instanceof InvokeException) { return err.createInvokeResponse() } throw err } finally { this.defaultNextEvent(context)() } } /** * Handles the AdaptiveCardInvoke activity. * This method processes adaptive card invoke activities. * @param _context - The turn context for the current turn of conversation * @param _invokeValue - The adaptive card invoke value * @returns A promise that resolves to an adaptive card invoke response * @protected */ protected async onAdaptiveCardInvoke ( _context: TurnContext, _invokeValue: AdaptiveCardInvokeValue ): Promise<AdaptiveCardInvokeResponse> { return await Promise.reject(new InvokeException(StatusCodes.NOT_IMPLEMENTED)) } /** * Handles the SearchInvoke activity. * This method processes search invoke activities. * @param _context - The turn context for the current turn of conversation * @param _invokeValue - The search invoke value * @returns A promise that resolves to a search invoke response * @protected */ protected async onSearchInvoke (_context: TurnContext, _invokeValue: SearchInvokeValue): Promise<SearchInvokeResponse> { return await Promise.reject(new InvokeException(StatusCodes.NOT_IMPLEMENTED)) } /** * Retrieves the SearchInvoke value from the activity. * This method extracts and validates the search invoke value from an activity. * @param activity - The activity to extract the search invoke value from * @returns The validated search invoke value * @private */ private getSearchInvokeValue (activity: Activity): SearchInvokeValue { const value = activity.value as SearchInvokeValue if (!value) { const response = this.createAdaptiveCardInvokeErrorResponse( StatusCodes.BAD_REQUEST, 'BadRequest', 'Missing value property for search' ) throw new InvokeException(StatusCodes.BAD_REQUEST, response) } if (!value.kind) { if (activity.channelId === Channels.Msteams) { value.kind = 'search' } else { const response = this.createAdaptiveCardInvokeErrorResponse( StatusCodes.BAD_REQUEST, 'BadRequest', 'Missing kind property for search.' ) throw new InvokeException(StatusCodes.BAD_REQUEST, response) } } if (!value.queryText) { const response = this.createAdaptiveCardInvokeErrorResponse( StatusCodes.BAD_REQUEST, 'BadRequest', 'Missing queryText for search.' ) throw new InvokeException(StatusCodes.BAD_REQUEST, response) } return value } /** * Retrieves the AdaptiveCardInvoke value from the activity. * This method extracts and validates the adaptive card invoke value from an activity. * @param activity - The activity to extract the adaptive card invoke value from * @returns The validated adaptive card invoke value * @private */ private getAdaptiveCardInvokeValue (activity: Activity): AdaptiveCardInvokeValue { const value = activity.value as AdaptiveCardInvokeValue if (!value) { const response = this.createAdaptiveCardInvokeErrorResponse( StatusCodes.BAD_REQUEST, 'BadRequest', 'Missing value property' ) throw new InvokeException(StatusCodes.BAD_REQUEST, response) } if (value.action.type !== 'Action.Execute') { const response = this.createAdaptiveCardInvokeErrorResponse( StatusCodes.BAD_REQUEST, 'NotSupported', `The action '${value.action.type}' is not supported.` ) throw new InvokeException(StatusCodes.BAD_REQUEST, response) } const { action, authentication, state } = value const { data, id: actionId, type, verb } = action ?? {} const { connectionName, id: authenticationId, token } = authentication ?? {} return { action: { data, id: actionId, type, verb }, authentication: { connectionName, id: authenticationId, token }, state } } /** * Creates an error response for AdaptiveCardInvoke. * This method creates an error response for adaptive card invoke activities. * @param statusCode - The HTTP status code for the response * @param code - The error code * @param message - The error message * @returns An adaptive card invoke error response * @private */ private createAdaptiveCardInvokeErrorResponse ( statusCode: StatusCodes, code: string, message: string ): AdaptiveCardInvokeResponse { return { statusCode, type: 'application/vnd.microsoft.error', value: { code, message } } } /** * Handles the MessageReaction activity. * This method processes message reaction activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onMessageReactionActivity (context: TurnContext): Promise<void> { await this.handle(context, 'MessageReaction', async () => { await this.dispatchMessageReactionActivity(context) }) } /** * Handles the EndOfConversation activity. * This method processes end of conversation activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onEndOfConversationActivity (context: TurnContext): Promise<void> { await this.handle(context, 'EndOfConversation', this.defaultNextEvent(context)) } /** * Handles the Typing activity. * This method processes typing indicator activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onTypingActivity (context: TurnContext): Promise<void> { await this.handle(context, 'Typing', this.defaultNextEvent(context)) } /** * Handles the InstallationUpdate activity. * This method processes installation update activities. * @param context - The turn context for the current turn of conversation * @protected */ protected async onInstallationUpdateActivity (context: TurnContext): Promise<void> { switch (context.activity.action) { case 'add': case 'add-upgrade': await this.handle(context, 'InstallationUpdateAdd', this.defaultNextEvent(context)) return case 'remove': case 'remove-upgrade': await this.handle(context, 'InstallationUpdateRemove', this.defaultNextEvent(context)) } } /** * Handles unrecognized activity types. * This method processes activities with unrecognized types. * @param context - The turn context for the current turn of conversation * @protected */ protected async onUnrecognizedActivity (context: TurnContext): Promise<void> { await this.handle(context, 'UnrecognizedActivityType', this.defaultNextEvent(context)) } /** * Dispatches the ConversationUpdate activity. * This method dispatches conversation update activities to the appropriate handlers. * @param context - The turn context for the current turn of conversation * @protected */ protected async dispatchConversationUpdateActivity (context: TurnContext): Promise<void> { if ((context.activity.membersAdded != null) && context.activity.membersAdded.length > 0) { await this.handle(context, 'MembersAdded', this.defaultNextEvent(context)) } else if ((context.activity.membersRemoved != null) && context.activity.membersRemoved.length > 0) { await this.handle(context, 'MembersRemoved', this.defaultNextEvent(context)) } else { await this.defaultNextEvent(context)() } } /** * Dispatches the MessageReaction activity. * This method dispatches message reaction activities to the appropriate handlers. * @param context - The turn context for the current turn of conversation * @protected */ protected async dispatchMessageReactionActivity (context: TurnContext): Promise<void> { if ((context.activity.reactionsAdded != null) || (context.activity.reactionsRemoved != null)) { if (context.activity.reactionsAdded?.length) { await this.handle(context, 'ReactionsAdded', this.defaultNextEvent(context)) } if (context.activity.reactionsRemoved?.length) { await this.handle(context, 'ReactionsRemoved', this.defaultNextEvent(context)) } } else { await this.defaultNextEvent(context)() } } /** * Dispatches the MessageUpdate activity. * This method dispatches message update activities to the appropriate handlers. * @param context - The turn context for the current turn of conversation * @protected */ protected async dispatchMessageUpdateActivity (context: TurnContext): Promise<void> { await this.defaultNextEvent(context)() } /** * Dispatches the MessageDelete activity. * This method dispatches message delete activities to the appropriate handlers. * @param context - The turn context for the current turn of conversation * @protected */ protected async dispatchMessageDeleteActivity (context: TurnContext): Promise<void> { await this.defaultNextEvent(context)() } /** * Returns the default next event handler. * This method creates a function that calls the default handler. * @param context - The turn context for the current turn of conversation * @returns A function that calls the default handler * @protected */ protected defaultNextEvent (context: TurnContext): () => Promise<void> { const defaultHandler = async (): Promise<void> => { await this.handle(context, 'Default', async () => { // noop }) } return defaultHandler } /** * Registers a handler for a specific activity type. * This method adds a handler to the list of handlers for a specific activity type. * @param type - The activity type to register the handler for * @param handler - The handler to register * @returns The current instance for method chaining * @protected */ protected on (type: string, handler: AgentHandler) { if (!this.handlers[type]) { this.handlers[type] = [handler] } else { this.handlers[type].push(handler) } return this } /** * Executes the handlers for a specific activity type. * This method calls each registered handler for the specified activity type. * @param context - The turn context for the current turn of conversation * @param type - The activity type to handle * @param onNext - The function to call when all handlers have been executed * @returns The value returned by the last handler * @protected */ protected async handle (context: TurnContext, type: string, onNext: () => Promise<void>): Promise<any> { let returnValue: any = null async function runHandler (index: number): Promise<void> { if (index < handlers.length) { const val = await handlers[index](context, async () => await runHandler(index + 1)) if (typeof val !== 'undefined' && returnValue === null) { returnValue = val } } else { const val = await onNext() if (typeof val !== 'undefined') { returnValue = val } } } logger.info(`${type} handler called`) const handlers = this.handlers[type] || [] await runHandler(0) return returnValue } /** * Creates an InvokeResponse object. * This static method creates an invoke response with the specified body. * @param body - The body of the response * @returns An invoke response object with status and body * @protected */ protected static createInvokeResponse (body?: any): InvokeResponse { return { status: 200, body } } /** * Dispatches the Event activity. * This method dispatches event activities to the appropriate handlers. * @param context - The turn context for the current turn of conversation * @protected */ protected async dispatchEventActivity (context: TurnContext): Promise<void> { if (context.activity.name === tokenResponseEventName) { await this.handle(context, 'TokenResponseEvent', this.defaultNextEvent(context)) } else { await this.defaultNextEvent(context)() } } }