@microsoft/agents-hosting
Version:
Microsoft 365 Agents SDK for JavaScript
999 lines (936 loc) • 37.4 kB
text/typescript
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Activity, ActivityTypes, ConversationReference } from '@microsoft/agents-activity'
import { BaseAdapter } from '../baseAdapter'
import { ResourceResponse } from '../connector-client'
import { debug } from '@microsoft/agents-activity/logger'
import { TurnContext } from '../turnContext'
import { AdaptiveCardsActions } from './adaptiveCards'
import { AgentApplicationOptions } from './agentApplicationOptions'
import { ConversationUpdateEvents } from './conversationUpdateEvents'
import { AgentExtension } from './extensions'
import { Authorization, SignInState } from './authorization'
import { RouteHandler } from './routeHandler'
import { RouteSelector } from './routeSelector'
import { TurnEvents } from './turnEvents'
import { TurnState } from './turnState'
import { RouteRank } from './routeRank'
import { RouteList } from './routeList'
import { TranscriptLoggerMiddleware } from '../transcript'
import { CloudAdapter } from '../cloudAdapter'
const logger = debug('agents:app')
const TYPING_TIMER_DELAY = 1000
/**
* Event handler function type for application events.
* @typeParam TState - The state type extending TurnState.
* @param context - The turn context containing activity information.
* @param state - The current turn state.
* @returns A promise that resolves to a boolean indicating whether to continue execution.
*/
export type ApplicationEventHandler<TState extends TurnState> = (context: TurnContext, state: TState) => Promise<boolean>
/**
* @summary Main application class for handling agent conversations and routing.
*
* @remarks
* The AgentApplication class provides a framework for building conversational agents.
* It handles routing activities to appropriate handlers, manages conversation state,
* supports authentication flows, and provides various event handling capabilities.
*
* Key features:
* - Activity routing based on type, content, or custom selectors
* - State management with automatic load/save
* - OAuth authentication support
* - Typing indicators and long-running message support
* - Extensible architecture with custom extensions
* - Event handlers for before/after turn processing
*
* @example
* ```typescript
* const app = new AgentApplication<MyState>({
* storage: new MemoryStorage(),
* adapter: myAdapter
* });
*
* app.onMessage('hello', async (context, state) => {
* await context.sendActivity('Hello there!');
* });
*
* await app.run(turnContext);
* ```
*
* @typeParam TState - The state type extending TurnState.
*/
export class AgentApplication<TState extends TurnState> {
protected readonly _options: AgentApplicationOptions<TState>
protected readonly _routes: RouteList<TState> = new RouteList<TState>()
protected readonly _beforeTurn: ApplicationEventHandler<TState>[] = []
protected readonly _afterTurn: ApplicationEventHandler<TState>[] = []
private readonly _adapter?: CloudAdapter
private readonly _authorization?: Authorization
private _typingTimer: NodeJS.Timeout | undefined
protected readonly _extensions: AgentExtension<TState>[] = []
private readonly _adaptiveCards: AdaptiveCardsActions<TState>
/**
* @summary Creates a new instance of AgentApplication.
*
* @param options - Optional configuration options for the application.
*
* @remarks
* The constructor initializes the application with default settings and applies
* any provided options. It sets up the adapter, authorization, and other core
* components based on the configuration.
*
* Default options:
* - startTypingTimer: false
* - longRunningMessages: false
* - removeRecipientMention: true
* - turnStateFactory: Creates a new TurnState instance
*
* @example
* ```typescript
* const app = new AgentApplication({
* storage: new MemoryStorage(),
* adapter: myAdapter,
* startTypingTimer: true,
* authorization: { connectionName: 'oauth' },
* transcriptLogger: myTranscriptLogger,
* });
* ```
*/
public constructor (options?: Partial<AgentApplicationOptions<TState>>) {
this._options = {
...options,
turnStateFactory: options?.turnStateFactory || (() => new TurnState() as TState),
startTypingTimer: options?.startTypingTimer !== undefined ? options.startTypingTimer : false,
longRunningMessages: options?.longRunningMessages !== undefined ? options.longRunningMessages : false,
removeRecipientMention: options?.removeRecipientMention !== undefined ? options.removeRecipientMention : true,
transcriptLogger: options?.transcriptLogger || undefined,
}
this._adaptiveCards = new AdaptiveCardsActions<TState>(this)
if (this._options.adapter) {
this._adapter = this._options.adapter
} else {
this._adapter = new CloudAdapter()
}
if (this._options.authorization) {
this._authorization = new Authorization(this._options.storage!, this._options.authorization, this._adapter?.userTokenClient!)
}
if (this._options.longRunningMessages && !this._adapter && !this._options.agentAppId) {
throw new Error('The Application.longRunningMessages property is unavailable because no adapter was configured in the app.')
}
if (this._options.transcriptLogger) {
if (!this._options.adapter) {
throw new Error('The Application.transcriptLogger property is unavailable because no adapter was configured in the app.')
} else {
this._adapter?.use(new TranscriptLoggerMiddleware(this._options.transcriptLogger))
}
}
logger.debug('AgentApplication created with options:', this._options)
}
/**
* @summary Gets the authorization instance for the application.
*
* @returns The authorization instance.
* @throws Error if no authentication options were configured.
*/
public get authorization (): Authorization {
if (!this._authorization) {
throw new Error('The Application.authorization property is unavailable because no authorization options were configured.')
}
return this._authorization
}
/**
* @summary Gets the options used to configure the application.
*
* @returns The application options.
*/
public get options (): AgentApplicationOptions<TState> {
return this._options
}
/**
* @summary Gets the adapter used by the application.
*
* @returns The adapter instance.
*/
public get adapter (): BaseAdapter {
return this._adapter!
}
/**
* @summary Gets the adaptive cards actions handler for the application.
*
* @returns The adaptive cards actions instance.
*
* @remarks
* The adaptive cards actions handler provides functionality for handling
* adaptive card interactions, such as submit actions and other card-based events.
*
* @example
* ```typescript
* app.adaptiveCards.actionSubmit('doStuff', async (context, state, data) => {
* await context.sendActivity(`Received data: ${JSON.stringify(data)}`);
* });
* ```
*/
public get adaptiveCards (): AdaptiveCardsActions<TState> {
return this._adaptiveCards
}
/**
* Sets an error handler for the application.
*
* @param handler - The error handler function to be called when an error occurs.
* @returns The current instance of the application.
*
* @remarks
* This method allows you to handle any errors that occur during turn processing.
* The handler will receive the turn context and the error that occurred.
*
* @example
* ```typescript
* app.onError(async (context, error) => {
* console.error(`An error occurred: ${error.message}`);
* await context.sendActivity('Sorry, something went wrong!');
* });
* ```
*/
public onError (handler: (context: TurnContext, error: Error) => Promise<void>): this {
if (this._adapter) {
this._adapter.onTurnError = handler
}
return this
}
/**
* Adds a new route to the application for handling activities.
*
* @param selector - The selector function that determines if a route should handle the current activity.
* @param handler - The handler function that will be called if the selector returns true.
* @param isInvokeRoute - Whether this route is for invoke activities. Defaults to false.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @param authHandlers - Array of authentication handler names for this route. Defaults to empty array.
* @returns The current instance of the application.
*
* @remarks
* Routes are evaluated by rank order (if provided), otherwise, in the order they are added.
* Invoke-based activities receive special treatment and are matched separately as they typically
* have shorter execution timeouts.
*
* @example
* ```typescript
* app.addRoute(
* async (context) => context.activity.type === ActivityTypes.Message,
* async (context, state) => {
* await context.sendActivity('I received your message');
* },
* false, // isInvokeRoute
* RouteRank.First // rank
* );
* ```
*/
public addRoute (selector: RouteSelector, handler: RouteHandler<TState>, isInvokeRoute: boolean = false, rank: number = RouteRank.Unspecified, authHandlers: string[] = []): this {
this._routes.addRoute(selector, handler, isInvokeRoute, rank, authHandlers)
return this
}
/**
* Adds a handler for specific activity types.
*
* @param type - The activity type(s) to handle. Can be a string, RegExp, RouteSelector, or array of these types.
* @param handler - The handler function that will be called when the specified activity type is received.
* @param authHandlers - Array of authentication handler names for this activity. Defaults to empty array.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @returns The current instance of the application.
*
* @remarks
* This method allows you to register handlers for specific activity types such as 'message', 'conversationUpdate', etc.
* You can specify multiple activity types by passing an array.
*
* @example
* ```typescript
* app.onActivity(ActivityTypes.Message, async (context, state) => {
* await context.sendActivity('I received your message');
* });
* ```
*/
public onActivity (
type: string | RegExp | RouteSelector | (string | RegExp | RouteSelector)[],
handler: (context: TurnContext, state: TState) => Promise<void>,
authHandlers: string[] = [],
rank: RouteRank = RouteRank.Unspecified
): this {
(Array.isArray(type) ? type : [type]).forEach((t) => {
const selector = this.createActivitySelector(t)
this.addRoute(selector, handler, false, rank, authHandlers)
})
return this
}
/**
* Adds a handler for conversation update events.
*
* @param event - The conversation update event to handle (e.g., 'membersAdded', 'membersRemoved').
* @param handler - The handler function that will be called when the specified event occurs.
* @param authHandlers - Array of authentication handler names for this event. Defaults to empty array.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @returns The current instance of the application.
* @throws Error if the handler is not a function.
*
* @remarks
* Conversation update events occur when the state of a conversation changes, such as when members join or leave.
*
* @example
* ```typescript
* app.onConversationUpdate('membersAdded', async (context, state) => {
* const membersAdded = context.activity.membersAdded;
* for (const member of membersAdded) {
* if (member.id !== context.activity.recipient.id) {
* await context.sendActivity('Hello and welcome!');
* }
* }
* });
* ```
*/
public onConversationUpdate (
event: ConversationUpdateEvents,
handler: (context: TurnContext, state: TState) => Promise<void>,
authHandlers: string[] = [],
rank: RouteRank = RouteRank.Unspecified
): this {
if (typeof handler !== 'function') {
throw new Error(
`ConversationUpdate 'handler' for ${event} is ${typeof handler}. Type of 'handler' must be a function.`
)
}
const selector = this.createConversationUpdateSelector(event)
this.addRoute(selector, handler, false, rank, authHandlers)
return this
}
/**
* Continues a conversation asynchronously.
*
* @param conversationReferenceOrContext - The conversation reference or turn context.
* @param logic - The logic to execute during the conversation.
* @returns A promise that resolves when the conversation logic has completed.
* @throws Error if the adapter is not configured.
*/
protected async continueConversationAsync (
conversationReferenceOrContext: ConversationReference | TurnContext,
logic: (context: TurnContext) => Promise<void>
): Promise<void> {
if (!this._adapter) {
throw new Error(
"You must configure the Application with an 'adapter' before calling Application.continueConversationAsync()"
)
}
if (!this.options.agentAppId) {
logger.warn("Calling Application.continueConversationAsync() without a configured 'agentAppId'. In production environments, a 'agentAppId' is required.")
}
let reference: ConversationReference
if ('activity' in conversationReferenceOrContext) {
reference = conversationReferenceOrContext.activity.getConversationReference()
} else {
reference = conversationReferenceOrContext
}
await this._adapter.continueConversation(reference, logic)
}
/**
* Adds a handler for message activities that match the specified keyword or pattern.
*
* @param keyword - The keyword, pattern, or selector function to match against message text.
* Can be a string, RegExp, RouteSelector, or array of these types.
* @param handler - The handler function that will be called when a matching message is received.
* @param authHandlers - Array of authentication handler names for this message handler. Defaults to empty array.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @returns The current instance of the application.
*
* @remarks
* This method allows you to register handlers for specific message patterns.
* If keyword is a string, it matches messages containing that string.
* If keyword is a RegExp, it tests the message text against the regular expression.
* If keyword is a function, it calls the function with the context to determine if the message matches.
*
* @example
* ```typescript
* app.onMessage('hello', async (context, state) => {
* await context.sendActivity('Hello there!');
* });
*
* app.onMessage(/help/i, async (context, state) => {
* await context.sendActivity('How can I help you?');
* });
* ```
*/
public onMessage (
keyword: string | RegExp | RouteSelector | (string | RegExp | RouteSelector)[],
handler: (context: TurnContext, state: TState) => Promise<void>,
authHandlers: string[] = [],
rank: RouteRank = RouteRank.Unspecified
): this {
(Array.isArray(keyword) ? keyword : [keyword]).forEach((k) => {
const selector = this.createMessageSelector(k)
this.addRoute(selector, handler, false, rank, authHandlers)
})
return this
}
/**
* Sets a handler to be called when a user successfully signs in.
*
* @param handler - The handler function to be called after successful sign-in.
* @returns The current instance of the application.
* @throws Error if authentication options were not configured.
*
* @remarks
* This method allows you to perform actions after a user has successfully authenticated.
* The handler will receive the turn context and state.
*
* @example
* ```typescript
* app.onSignInSuccess(async (context, state) => {
* await context.sendActivity('You have successfully signed in!');
* });
* ```
*/
public onSignInSuccess (handler: (context: TurnContext, state: TurnState, id?: string) => Promise<void>): this {
if (this.options.authorization) {
this.authorization.onSignInSuccess(handler)
} else {
throw new Error(
'The Application.authorization property is unavailable because no authorization options were configured.'
)
}
return this
}
/**
* Sets a handler to be called when a sign-in attempt fails.
*
* @param handler - The handler function to be called after a failed sign-in attempt.
* @returns The current instance of the application.
* @throws Error if authentication options were not configured.
*
* @remarks
* This method allows you to handle cases where a user fails to authenticate,
* such as when they cancel the sign-in process or an error occurs.
*
* @example
* ```typescript
* app.onSignInFailure(async (context, state) => {
* await context.sendActivity('Sign-in failed. Please try again.');
* });
* ```
*/
public onSignInFailure (handler: (context: TurnContext, state: TurnState, id?: string) => Promise<void>): this {
if (this.options.authorization) {
this.authorization.onSignInFailure(handler)
} else {
throw new Error(
'The Application.authorization property is unavailable because no authorization options were configured.'
)
}
return this
}
/**
* Adds a handler for message reaction added events.
*
* @param handler - The handler function that will be called when a message reaction is added.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @returns The current instance of the application.
*
* @remarks
* This method registers a handler that will be invoked when a user adds a reaction to a message,
* such as a like, heart, or other emoji reaction.
*
* @example
* ```typescript
* app.onMessageReactionAdded(async (context, state) => {
* const reactionsAdded = context.activity.reactionsAdded;
* if (reactionsAdded && reactionsAdded.length > 0) {
* await context.sendActivity(`Thanks for your ${reactionsAdded[0].type} reaction!`);
* }
* });
* ```
*/
public onMessageReactionAdded (
handler: (context: TurnContext, state: TState) => Promise<void>,
rank: RouteRank = RouteRank.Unspecified): this {
const selector = async (context: TurnContext): Promise<boolean> => {
return context.activity.type === ActivityTypes.MessageReaction &&
Array.isArray(context.activity.reactionsAdded) &&
context.activity.reactionsAdded.length > 0
}
this.addRoute(selector, handler, false, rank)
return this
}
/**
* Adds a handler for message reaction removed events.
*
* @param handler - The handler function that will be called when a message reaction is removed.
* @param rank - The rank of the route, used to determine the order of evaluation. Defaults to RouteRank.Unspecified.
* @returns The current instance of the application.
*
* @remarks
* This method registers a handler that will be invoked when a user removes a reaction from a message,
* such as unliking or removing an emoji reaction.
*
* @example
* ```typescript
* app.onMessageReactionRemoved(async (context, state) => {
* const reactionsRemoved = context.activity.reactionsRemoved;
* if (reactionsRemoved && reactionsRemoved.length > 0) {
* await context.sendActivity(`You removed your ${reactionsRemoved[0].type} reaction.`);
* }
* });
* ```
*/
public onMessageReactionRemoved (
handler: (context: TurnContext, state: TState) => Promise<void>,
rank: RouteRank = RouteRank.Unspecified): this {
const selector = async (context: TurnContext): Promise<boolean> => {
return context.activity.type === ActivityTypes.MessageReaction &&
Array.isArray(context.activity.reactionsRemoved) &&
context.activity.reactionsRemoved.length > 0
}
this.addRoute(selector, handler, false, rank)
return this
}
/**
* Executes the application logic for a given turn context.
*
* @param turnContext - The context for the current turn of the conversation.
* @returns A promise that resolves when the application logic has completed.
*
* @remarks
* This method is the entry point for processing a turn in the conversation.
* It delegates the actual processing to the `runInternal` method, which handles
* the core logic for routing and executing handlers.
*
* @example
* ```typescript
* const app = new AgentApplication();
* await app.run(turnContext);
* ```
*/
public async run (turnContext:TurnContext): Promise<void> {
await this.runInternal(turnContext)
}
/**
* Executes the application logic for a given turn context.
*
* @param turnContext - The context for the current turn of the conversation.
* @returns A promise that resolves to true if a handler was executed, false otherwise.
*
* @remarks
* This is the core internal method that processes a turn in the conversation.
* It handles routing and executing handlers based on the activity type and content.
* While this method is public, it's typically called internally by the `run` method.
*
* The method performs the following operations:
* 1. Starts typing timer if configured
* 2. Processes mentions if configured
* 3. Loads turn state
* 4. Handles authentication flows
* 5. Executes before-turn event handlers
* 6. Downloads files if file downloaders are configured
* 7. Routes to appropriate handlers
* 8. Executes after-turn event handlers
* 9. Saves turn state
*
* @example
* ```typescript
* const handled = await app.runInternal(turnContext);
* if (!handled) {
* console.log('No handler matched the activity');
* }
* ```
*/
public async runInternal (turnContext: TurnContext): Promise<boolean> {
logger.info('Running application with activity:', turnContext.activity.id!)
return await this.startLongRunningCall(turnContext, async (context) => {
try {
if (this._options.startTypingTimer) {
this.startTypingTimer(context)
}
if (this._options.removeRecipientMention && context.activity.type === ActivityTypes.Message) {
context.activity.removeRecipientMention()
}
if (this._options.normalizeMentions && context.activity.type === ActivityTypes.Message) {
context.activity.normalizeMentions()
}
const { storage, turnStateFactory } = this._options
const state = turnStateFactory()
await state.load(context, storage)
const signInState : SignInState = state.getValue('user.__SIGNIN_STATE_')
logger.debug('SignIn State:', signInState)
if (this._authorization && signInState && signInState.completed === false) {
const flowState = await this._authorization.authHandlers[signInState.handlerId!]?.flow?.getFlowState(context)
logger.debug('Flow State:', flowState)
if (flowState && flowState.flowStarted === true) {
const tokenResponse = await this._authorization.beginOrContinueFlow(turnContext, state, signInState?.handlerId!)
const savedAct = Activity.fromObject(signInState?.continuationActivity!)
if (tokenResponse?.token && tokenResponse.token.length > 0) {
logger.info('resending continuation activity:', savedAct.text)
await this.run(new TurnContext(context.adapter, savedAct))
await state.deleteValue('user.__SIGNIN_STATE_')
return true
}
}
// return true
}
if (!(await this.callEventHandlers(context, state, this._beforeTurn))) {
await state.save(context, storage)
return false
}
if (Array.isArray(this._options.fileDownloaders) && this._options.fileDownloaders.length > 0) {
const inputFiles = state.temp.inputFiles ?? []
for (let i = 0; i < this._options.fileDownloaders.length; i++) {
const files = await this._options.fileDownloaders[i].downloadFiles(context, state)
inputFiles.push(...files)
}
state.temp.inputFiles = inputFiles
}
for (const route of this._routes) {
if (await route.selector(context)) {
if (route.authHandlers === undefined || route.authHandlers.length === 0) {
await route.handler(context, state)
} else {
let signInComplete = false
for (const authHandlerId of route.authHandlers) {
logger.info(`Executing route handler for authHandlerId: ${authHandlerId}`)
const tokenResponse = await this._authorization?.beginOrContinueFlow(turnContext, state, authHandlerId)
signInComplete = (tokenResponse?.token !== undefined && tokenResponse?.token.length > 0)
if (!signInComplete) {
break
}
}
if (signInComplete) {
await route.handler(context, state)
}
}
if (await this.callEventHandlers(context, state, this._afterTurn)) {
await state.save(context, storage)
}
return true
}
}
if (await this.callEventHandlers(context, state, this._afterTurn)) {
await state.save(context, storage)
}
return false
} catch (err: any) {
logger.error(err)
throw err
} finally {
this.stopTypingTimer()
}
})
}
/**
* Sends a proactive message to a conversation.
*
* @param context - The turn context or conversation reference to use.
* @param activityOrText - The activity or text to send.
* @param speak - Optional text to be spoken by the bot on a speech-enabled channel.
* @param inputHint - Optional input hint for the activity.
* @returns A promise that resolves to the resource response from sending the activity.
*
* @remarks
* This method allows you to send messages proactively to a conversation, outside the normal turn flow.
*
* @example
* ```typescript
* // With conversation reference
* await app.sendProactiveActivity(conversationReference, 'Important notification!');
*
* // From an existing context
* await app.sendProactiveActivity(turnContext, 'Important notification!');
* ```
*/
public async sendProactiveActivity (
context: TurnContext | ConversationReference,
activityOrText: string | Activity,
speak?: string,
inputHint?: string
): Promise<ResourceResponse | undefined> {
let response: ResourceResponse | undefined
await this.continueConversationAsync(context, async (ctx) => {
response = await ctx.sendActivity(activityOrText, speak, inputHint)
})
return response
}
/**
* Starts a typing indicator timer for the current turn context.
*
* @param context - The turn context for the current conversation.
* @returns void
*
* @remarks
* This method starts a timer that sends typing activity indicators to the user
* at regular intervals. The typing indicator continues until a message is sent
* or the timer is explicitly stopped.
*
* The typing indicator helps provide feedback to users that the agent is processing
* their message, especially when responses might take time to generate.
*
* @example
* ```typescript
* app.startTypingTimer(turnContext);
* // Do some processing...
* await turnContext.sendActivity('Response after processing');
* // Typing timer automatically stops when sending a message
* ```
*/
public startTypingTimer (context: TurnContext): void {
if (context.activity.type === ActivityTypes.Message && !this._typingTimer) {
let timerRunning = true
context.onSendActivities(async (context, activities, next) => {
if (timerRunning) {
for (let i = 0; i < activities.length; i++) {
if (activities[i].type === ActivityTypes.Message || activities[i].channelData?.streamType) {
this.stopTypingTimer()
timerRunning = false
await lastSend
break
}
}
}
return next()
})
let lastSend: Promise<any> = Promise.resolve()
const onTimeout = async () => {
try {
lastSend = context.sendActivity(Activity.fromObject({ type: ActivityTypes.Typing }))
await lastSend
} catch (err: any) {
logger.error(err)
this._typingTimer = undefined
timerRunning = false
lastSend = Promise.resolve()
}
if (timerRunning) {
this._typingTimer = setTimeout(onTimeout, TYPING_TIMER_DELAY)
}
}
this._typingTimer = setTimeout(onTimeout, TYPING_TIMER_DELAY)
}
}
/**
* Registers an extension with the application.
*
* @typeParam T - The extension type extending AgentExtension.
* @param extension - The extension instance to register.
* @param regcb - Callback function called after successful registration.
* @throws Error if the extension is already registered.
*
* @remarks
* Extensions provide a way to add custom functionality to the application.
* Each extension can only be registered once to prevent conflicts.
*
* @example
* ```typescript
* const myExtension = new MyCustomExtension();
* app.registerExtension(myExtension, (ext) => {
* console.log('Extension registered:', ext.name);
* });
* ```
*/
public registerExtension<T extends AgentExtension<TState>> (extension: T, regcb : (ext:T) => void): void {
if (this._extensions.includes(extension)) {
throw new Error('Extension already registered')
}
this._extensions.push(extension)
regcb(extension)
}
/**
* Stops the typing indicator timer if it's currently running.
*
* @returns void
*
* @remarks
* This method clears the typing indicator timer to prevent further typing indicators
* from being sent. It's typically called automatically when a message is sent, but
* can also be called manually to stop the typing indicator.
*
* @example
* ```typescript
* app.startTypingTimer(turnContext);
* // Do some processing...
* app.stopTypingTimer(); // Manually stop the typing indicator
* ```
*/
public stopTypingTimer (): void {
if (this._typingTimer) {
clearTimeout(this._typingTimer)
this._typingTimer = undefined
}
}
/**
* Adds an event handler for specified turn events.
*
* @param event - The turn event(s) to handle. Can be 'beforeTurn', 'afterTurn', or other custom events.
* @param handler - The handler function that will be called when the event occurs.
* @returns The current instance of the application.
*
* @remarks
* Turn events allow you to execute logic before or after the main turn processing.
* Handlers added for 'beforeTurn' are executed before routing logic.
* Handlers added for 'afterTurn' are executed after routing logic.
*
* @example
* ```typescript
* app.onTurn('beforeTurn', async (context, state) => {
* console.log('Processing before turn');
* return true; // Continue execution
* });
* ```
*/
public onTurn (
event: TurnEvents | TurnEvents[],
handler: (context: TurnContext, state: TState) => Promise<boolean>
): this {
(Array.isArray(event) ? event : [event]).forEach((e) => {
switch (e) {
case 'beforeTurn':
this._beforeTurn.push(handler)
break
case 'afterTurn':
this._afterTurn.push(handler)
break
default:
this._beforeTurn.push(handler)
break
}
})
return this
}
/**
* Calls a series of event handlers in sequence.
*
* @param context - The turn context for the current conversation.
* @param state - The current turn state.
* @param handlers - Array of event handlers to call.
* @returns A promise that resolves to true if all handlers returned true, false otherwise.
*/
protected async callEventHandlers (
context: TurnContext,
state: TState,
handlers: ApplicationEventHandler<TState>[]
): Promise<boolean> {
for (let i = 0; i < handlers.length; i++) {
const continueExecution = await handlers[i](context, state)
if (!continueExecution) {
return false
}
}
return true
}
/**
* Starts a long-running call, potentially in a new conversation context.
*
* @param context - The turn context for the current conversation.
* @param handler - The handler function to execute.
* @returns A promise that resolves to the result of the handler.
*/
protected startLongRunningCall (
context: TurnContext,
handler: (context: TurnContext) => Promise<boolean>
): Promise<boolean> {
if (context.activity.type === ActivityTypes.Message && this._options.longRunningMessages) {
return new Promise<boolean>((resolve, reject) => {
this.continueConversationAsync(context, async (ctx) => {
try {
for (const key in context.activity) {
(ctx.activity as any)[key] = (context.activity as any)[key]
}
const result = await handler(ctx)
resolve(result)
} catch (err: any) {
logger.error(err)
reject(err)
}
})
})
} else {
return handler(context)
}
}
/**
* Creates a selector function for activity types.
*
* @param type - The activity type to match. Can be a string, RegExp, or RouteSelector function.
* @returns A RouteSelector function that matches the specified activity type.
*/
private createActivitySelector (type: string | RegExp | RouteSelector): RouteSelector {
if (typeof type === 'function') {
return type
} else if (type instanceof RegExp) {
return (context: TurnContext) => {
return Promise.resolve(context?.activity?.type ? type.test(context.activity.type) : false)
}
} else {
const typeName = type.toString().toLocaleLowerCase()
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type ? context.activity.type.toLocaleLowerCase() === typeName : false
)
}
}
}
/**
* Creates a selector function for conversation update events.
*
* @param event - The conversation update event to match.
* @returns A RouteSelector function that matches the specified conversation update event.
*/
private createConversationUpdateSelector (event: ConversationUpdateEvents): RouteSelector {
switch (event) {
case 'membersAdded':
return (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
Array.isArray(context?.activity?.membersAdded) &&
context.activity.membersAdded.length > 0
)
}
case 'membersRemoved':
return (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
Array.isArray(context?.activity?.membersRemoved) &&
context.activity.membersRemoved.length > 0
)
}
default:
return (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
context?.activity?.channelData?.eventType === event
)
}
}
}
/**
* Creates a selector function for message content matching.
*
* @param keyword - The keyword, pattern, or selector function to match against message text.
* @returns A RouteSelector function that matches messages based on the specified keyword.
*/
private createMessageSelector (keyword: string | RegExp | RouteSelector): RouteSelector {
if (typeof keyword === 'function') {
return keyword
} else if (keyword instanceof RegExp) {
return (context: TurnContext) => {
if (context?.activity?.type === ActivityTypes.Message && context.activity.text) {
return Promise.resolve(keyword.test(context.activity.text))
} else {
return Promise.resolve(false)
}
}
} else {
const k = keyword.toString().toLocaleLowerCase()
return (context: TurnContext) => {
if (context?.activity?.type === ActivityTypes.Message && context.activity.text) {
return Promise.resolve(context.activity.text.toLocaleLowerCase() === k)
} else {
return Promise.resolve(false)
}
}
}
}
}