UNPKG

@microsoft/botbuilder-m365

Version:

M365 extensions for Microsoft BotBuilder, Alpha release.

657 lines (597 loc) 27.4 kB
/** * @module botbuilder-m365 */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { TurnContext, Storage, ActivityTypes, BotAdapter, ConversationReference, Activity, ResourceResponse } from 'botbuilder'; import { TurnState, TurnStateManager } from './TurnState'; import { DefaultTurnState, DefaultTurnStateManager } from './DefaultTurnStateManager'; import { AdaptiveCards, AdaptiveCardsOptions } from './AdaptiveCards'; import { MessageExtensions } from './MessageExtensions'; import { AI, AIOptions } from './AI'; import { TaskModules, TaskModulesOptions } from './TaskModules'; const TYPING_TIMER_DELAY = 1000; export interface Query<TParams extends Record<string, any>> { count: number; skip: number; parameters: TParams; } export interface ApplicationOptions<TState extends TurnState> { adapter?: BotAdapter; botAppId?: string; storage?: Storage; ai?: AIOptions<TState>; turnStateManager?: TurnStateManager<TState>; adaptiveCards?: AdaptiveCardsOptions; taskModules?: TaskModulesOptions; removeRecipientMention?: boolean; startTypingTimer?: boolean; longRunningMessages?: boolean; } export type ApplicationEventHandler<TState extends TurnState> = ( context: TurnContext, state: TState ) => Promise<boolean>; export type ConversationUpdateEvents = | 'channelCreated' | 'channelRenamed' | 'channelDeleted' | 'channelRestored' | 'membersAdded' | 'membersRemoved' | 'teamRenamed' | 'teamDeleted' | 'teamArchived' | 'teamUnarchived' | 'teamRestored'; export type RouteHandler<TState extends TurnState> = (context: TurnContext, state: TState) => Promise<void>; export type RouteSelector = (context: TurnContext) => Promise<boolean>; export type MessageReactionEvents = 'reactionsAdded' | 'reactionsRemoved'; /* Actions to be performed before or after a task */ export type TurnEvents = 'beforeTurn' | 'afterTurn'; export class Application<TState extends TurnState = DefaultTurnState> { private readonly _options: ApplicationOptions<TState>; private readonly _routes: AppRoute<TState>[] = []; private readonly _invokeRoutes: AppRoute<TState>[] = []; private readonly _adaptiveCards: AdaptiveCards<TState>; private readonly _messageExtensions: MessageExtensions<TState>; private readonly _taskModules: TaskModules<TState>; private readonly _ai?: AI<TState>; private readonly _beforeTurn: ApplicationEventHandler<TState>[] = []; private readonly _afterTurn: ApplicationEventHandler<TState>[] = []; private _typingTimer: any; public constructor(options?: ApplicationOptions<TState>) { this._options = Object.assign( { removeRecipientMention: true, startTypingTimer: true } as ApplicationOptions<TState>, options ) as ApplicationOptions<TState>; // Create default turn state manager if needed if (!this._options.turnStateManager) { this._options.turnStateManager = new DefaultTurnStateManager() as any; } // Create AI component if configured with a planner if (this._options.ai) { this._ai = new AI(this._options.ai); } this._adaptiveCards = new AdaptiveCards<TState>(this); this._messageExtensions = new MessageExtensions<TState>(this); this._taskModules = new TaskModules<TState>(this); // Validate long running messages configuration if (this._options.longRunningMessages && (!this._options.adapter || !this._options.botAppId)) { throw new Error(`The Application.longRunningMessages property is unavailable because no adapter or botAppId was configured.`); } } public get adaptiveCards(): AdaptiveCards<TState> { return this._adaptiveCards; } public get ai(): AI<TState> { if (!this._ai) { throw new Error(`The Application.ai property is unavailable because no AI options were configured.`); } return this._ai; } public get messageExtensions(): MessageExtensions<TState> { return this._messageExtensions; } public get options(): ApplicationOptions<TState> { return this._options; } public get taskModules(): TaskModules<TState> { return this._taskModules; } /** * Adds a new route to the application. * * Routes will be matched in the order they're added to the application. The first selector to * return `true` when an activity is received will have its handler called. * * @param {RouteSelector} selector Promise to determine if the route should be triggered. * @param {RouteHandler<TurnState>} handler Function to call when the route is triggered. * @param {boolean} isInvokeRoute boolean indicating if the RouteSelector is an invokable Teams activity as part of its routing logic. Defaults to `false`. * @returns {this} The application instance for chaining purposes. */ public addRoute(selector: RouteSelector, handler: RouteHandler<TState>, isInvokeRoute = false): this { if (isInvokeRoute) { this._invokeRoutes.push({ selector, handler }); } else { this._routes.push({ selector, handler }); } return this; } /** * Handles incoming activities of a given type. * * @param {string | RegExp | RouteSelector | string[] | RegExp[] | RouteSelector[] } type Name of the activity type to match or a regular expression to match against the incoming activity type. An array of type names or expression can also be passed in. * @param {Promise<void>} handler Function to call when the route is triggered. * @returns {this} The application instance for chaining purposes. */ public activity( type: string | RegExp | RouteSelector | (string | RegExp | RouteSelector)[], handler: (context: TurnContext, state: TState) => Promise<void> ): this { (Array.isArray(type) ? type : [type]).forEach((t) => { const selector = createActivitySelector(t); this.addRoute(selector, handler); }); return this; } /** * Handles conversation update events. * * @param {ConversationUpdateEvents | ConversationUpdateEvents[]} event Name of the conversation update event(s) to handle. * @param {Promise<void>} handler Function to call when the route is triggered. * @returns {this} The application instance for chaining purposes. */ public conversationUpdate( event: ConversationUpdateEvents | ConversationUpdateEvents[], handler: (context: TurnContext, state: TState) => Promise<void> ): this { (Array.isArray(event) ? event : [event]).forEach((e) => { const selector = createConversationUpdateSelector(e); this.addRoute(selector, handler); }); return this; } /** * Starts a new "proactive" session with a conversation the bot is already a member of. * * @param {TurnContext} context Context of the conversation to proactively message. This can be derived from either a TurnContext, ConversationReference, or Activity. * @param {Promise<void>} logic The bot's logic that should be run using the new proactive turn context. */ public continueConversationAsync( context: TurnContext, logic: (context: TurnContext) => Promise<void> ): Promise<void>; public continueConversationAsync( conversationReference: Partial<ConversationReference>, logic: (context: TurnContext) => Promise<void> ): Promise<void>; public continueConversationAsync( activity: Partial<Activity>, logic: (context: TurnContext) => Promise<void> ): Promise<void>; public async continueConversationAsync( context: TurnContext | Partial<ConversationReference> | Partial<Activity>, logic: (context: TurnContext) => Promise<void> ): Promise<void> { if (!this._options.adapter) { throw new Error( `You must configure the Application with an 'adapter' before calling Application.continueConversationAsync()` ); } if (!this._options.botAppId) { console.warn( `Calling Application.continueConversationAsync() without a configured 'botAppId'. In production environments a 'botAppId' is required.` ); } // Identify conversation reference let reference: Partial<ConversationReference>; if (typeof (context as TurnContext).activity == 'object') { reference = TurnContext.getConversationReference((context as TurnContext).activity); } else if (typeof (context as Partial<Activity>).type == 'string') { reference = TurnContext.getConversationReference(context as Partial<Activity>); } else { reference = context as Partial<ConversationReference>; } await this._options.adapter.continueConversationAsync(this._options.botAppId ?? '', reference, logic); } /** * Handles incoming messages with a given keyword. * * @param {string | RegExp | RouteSelector | (string | RegExp | RouteSelector[])} keyword Substring of text or a regular expression to match against the text of an incoming message. An array of keywords or expression can also be passed in. * @param {Promise<void>} handler Function to call when the route is triggered. * @returns {this} The application instance for chaining purposes. */ public message( keyword: string | RegExp | RouteSelector | (string | RegExp | RouteSelector)[], handler: (context: TurnContext, state: TState) => Promise<void> ): this { (Array.isArray(keyword) ? keyword : [keyword]).forEach((k) => { const selector = createMessageSelector(k); this.addRoute(selector, handler); }); return this; } /** * Handles message reaction events. * * @param {MessageReactionEvents | MessageReactionEvents[]} event Name of the message reaction event to handle. * @param {Promise<void>} handler Function to call when the route is triggered. * @returns {this} The application instance for chaining purposes. */ public messageReactions( event: MessageReactionEvents | MessageReactionEvents[], handler: (context: TurnContext, state: TState) => Promise<void> ): this { (Array.isArray(event) ? event : [event]).forEach((e) => { const selector = createMessageReactionSelector(e); this.addRoute(selector, handler); }); return this; } /** * Dispatches an incoming activity to a handler registered with the application. * * @param {TurnContext} turnContext Context class for the current turn of conversation with the user. * @returns {boolean} True if the activity was successfully dispatched to a handler. False if no matching handlers could be found. */ public async run(turnContext: TurnContext): Promise<boolean> { return await this.startLongRunningCall(turnContext, async (context) => { // Start typing indicator timer this.startTypingTimer(context); try { // Remove @mentions if (this._options.removeRecipientMention && context.activity.type == ActivityTypes.Message) { context.activity.text = TurnContext.removeRecipientMention(context.activity); } // Load turn state const { storage, turnStateManager } = this._options; const state = await turnStateManager!.loadState(storage, context); // Call beforeTurn event handlers if (!(await this.callEventHandlers(context, state, this._beforeTurn))) { return false; } // Run any RouteSelectors in this._invokeRoutes first if the incoming Teams activity.type is "Invoke". // Invoke Activities from Teams need to be responded to in less than 5 seconds. if (context.activity.type === ActivityTypes.Invoke) { for (let i = 0; i < this._invokeRoutes.length; i++) { // TODO: fix security/detect-object-injection // eslint-disable-next-line security/detect-object-injection const route = this._invokeRoutes[i]; if (await route.selector(context)) { // Execute route handler await route.handler(context, state); // Call afterTurn event handlers if (await this.callEventHandlers(context, state, this._afterTurn)) { // Save turn state await turnStateManager!.saveState(storage, context, state); } // End dispatch return true; } } } // All other ActivityTypes and any unhandled Invokes are run through the remaining routes. for (let i = 0; i < this._routes.length; i++) { // TODO: // eslint-disable-next-line security/detect-object-injection const route = this._routes[i]; if (await route.selector(context)) { // Execute route handler await route.handler(context, state); // Call afterTurn event handlers if (await this.callEventHandlers(context, state, this._afterTurn)) { // Save turn state await turnStateManager!.saveState(storage, context, state); } // End dispatch return true; } } // Call AI module if configured if (this._ai && context.activity.type == ActivityTypes.Message && context.activity.text) { // Begin a new chain of AI calls await this._ai.chain(context, state); // Call afterTurn event handlers if (await this.callEventHandlers(context, state, this._afterTurn)) { // Save turn state await turnStateManager!.saveState(storage, context, state); } // End dispatch return true; } // activity wasn't handled return false; } finally { this.stopTypingTimer(); } }); } /** * Sends a proactive activity to an existing conversation the bot is a member of. * * @param context Context of the conversation to proactively message. This can be derived from either a TurnContext, ConversationReference, or Activity. * @param activityOrText Activity or message to send to the conversation. * @param speak Optional. Text to speak for channels that support voice. * @param inputHint Optional. Input hint for channels that support voice. * @returns A Resource response containing the ID of the activity that was sent. */ public sendProactiveActivity( context: TurnContext, activityOrText: string | Partial<Activity>, speak?: string, inputHint?: string ): Promise<ResourceResponse | undefined>; public sendProactiveActivity( conversationReference: Partial<ConversationReference>, activityOrText: string | Partial<Activity>, speak?: string, inputHint?: string ): Promise<ResourceResponse | undefined>; public sendProactiveActivity( activity: Partial<Activity>, activityOrText: string | Partial<Activity>, speak?: string, inputHint?: string ): Promise<ResourceResponse | undefined>; public async sendProactiveActivity( context: TurnContext | Partial<ConversationReference> | Partial<Activity>, activityOrText: string | Partial<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; } /** * Manually start a timer to periodically send "typing" activities. * * * The timer will automatically end once an outgoing activity has been sent. If the timer is * already running or the current activity, is not a "message" the call is ignored. * * @param {TurnContext} context The context for the current turn with the user. */ public startTypingTimer(context: TurnContext): void { if (context.activity.type == ActivityTypes.Message && !this._typingTimer) { // Listen for outgoing activities context.onSendActivities((context, activities, next) => { // Listen for any messages to be sent from the bot if (timerRunning) { for (let i = 0; i < activities.length; i++) { // TODO: // eslint-disable-next-line security/detect-object-injection if (activities[i].type == ActivityTypes.Message) { // Stop the timer this.stopTypingTimer(); timerRunning = false; break; } } } return next(); }); let timerRunning = true; const onTimeout = async () => { try { // Send typing activity await context.sendActivity({ type: ActivityTypes.Typing }); } catch (err) { // Seeing a random proxy violation error from the context object. This is because // we're in the middle of sending an activity on a background thread when the turn ends. // The context object throws when we try to update "this.responded = true". We can just // eat the error but lets make sure our states cleaned up a bit. this._typingTimer = undefined; timerRunning = false; } // Restart timer if (timerRunning) { this._typingTimer = setTimeout(onTimeout, TYPING_TIMER_DELAY); } }; this._typingTimer = setTimeout(onTimeout, TYPING_TIMER_DELAY); } } /** * Manually stop the typing timer. * * * If the timer isn't running nothing happens. */ public stopTypingTimer(): void { if (this._typingTimer) { clearTimeout(this._typingTimer); this._typingTimer = undefined; } } /** * Registers a turn event handler. * * @param {TurnEvents | TurnEvents[]} event Name of the turn event to handle. * @param {Promise<void>} handler Function to call when the event is triggered. * @returns {this} The application instance for chaining purposes. */ public turn(event: TurnEvents | TurnEvents[], handler: ApplicationEventHandler<TState>): this { (Array.isArray(event) ? event : [event]).forEach((e) => { switch (event) { case 'beforeTurn': default: this._beforeTurn.push(handler); break; case 'afterTurn': this._afterTurn.push(handler); break; } }); return this; } private async callEventHandlers( context: TurnContext, state: TState, handlers: ApplicationEventHandler<TState>[] ): Promise<boolean> { for (let i = 0; i < handlers.length; i++) { // TODO: // eslint-disable-next-line security/detect-object-injection const continueExecution = await handlers[i](context, state); if (!continueExecution) { return false; } } // Continue execution return true; } private 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 { // Copy original activity to new context for (const key in context.activity) { (ctx.activity as any)[key] = (context.activity as any)[key]; } // Call handler const result = await handler(ctx); resolve(result); } catch (err) { reject(err); } }); }); } else { return handler(context); } } } interface AppRoute<TState extends TurnState> { selector: RouteSelector; handler: RouteHandler<TState>; } /** * * @param {string | RegExp | RouteSelector} type The activity to match against. * @returns {RouteSelector} A Promise that resolves to true if the event matches the selector. */ function createActivitySelector(type: string | RegExp | RouteSelector): RouteSelector { if (typeof type == 'function') { // Return the passed in selector function return type; } else if (type instanceof RegExp) { // Return a function that matches the activities type using a RegExp return (context: TurnContext) => { return Promise.resolve(context?.activity?.type ? type.test(context.activity.type) : false); }; } else { // Return a function that attempts to match type name const typeName = type.toString().toLocaleLowerCase(); return (context: TurnContext) => { return Promise.resolve( context?.activity?.type ? context.activity.type.toLocaleLowerCase() === typeName : false ); }; } } /** * * @param {ConversationUpdateEvents} event The type of event to match against. * @returns {RouteSelector} A promise that resolves to true if the event matches the selector. */ function createConversationUpdateSelector(event: ConversationUpdateEvents): RouteSelector { switch (event) { case 'membersAdded': return (context: TurnContext) => { return Promise.resolve( context?.activity?.type == ActivityTypes.ConversationUpdate && Array.isArray(context?.activity?.membersAdded) && context.activity.membersAdded.length > 0 ); }; case 'membersRemoved': return (context: TurnContext) => { return Promise.resolve( context?.activity?.type == ActivityTypes.ConversationUpdate && Array.isArray(context?.activity?.membersRemoved) && context.activity.membersRemoved.length > 0 ); }; default: return (context: TurnContext) => { return Promise.resolve( context?.activity?.type == ActivityTypes.ConversationUpdate && context?.activity?.channelData?.eventType == event ); }; } } /** * * @param {string | RegExp | RouteSelector} keyword The message keyword to match against. * @returns {RouteSelector} A promise that resolves to true if the event matches the selector. */ function createMessageSelector(keyword: string | RegExp | RouteSelector): RouteSelector { if (typeof keyword == 'function') { // Return the passed in selector function return keyword; } else if (keyword instanceof RegExp) { // Return a function that matches a messages text using a 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 { // Return a function that attempts to match a messages text using a substring const k = keyword.toString().toLocaleLowerCase(); return (context: TurnContext) => { if (context?.activity?.type === ActivityTypes.Message && context.activity.text) { return Promise.resolve(context.activity.text.toLocaleLowerCase().indexOf(k) >= 0); } else { return Promise.resolve(false); } }; } } /** * * @param {MessageReactionEvents} event The type of reaction event to handle. * @returns {RouteSelector} A Promise that resolves to true if the event matches the selector. */ function createMessageReactionSelector(event: MessageReactionEvents): RouteSelector { switch (event) { case 'reactionsAdded': default: return (context: TurnContext) => { return Promise.resolve( context?.activity?.type == ActivityTypes.MessageReaction && Array.isArray(context?.activity?.reactionsAdded) && context.activity.reactionsAdded.length > 0 ); }; case 'reactionsRemoved': return (context: TurnContext) => { return Promise.resolve( context?.activity?.type == ActivityTypes.MessageReaction && Array.isArray(context?.activity?.reactionsRemoved) && context.activity.reactionsRemoved.length > 0 ); }; } }