@microsoft/botbuilder-m365
Version:
M365 extensions for Microsoft BotBuilder, Alpha release.
657 lines (597 loc) • 27.4 kB
text/typescript
/**
* @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
);
};
}
}