UNPKG

botbuilder-dialogs

Version:

A dialog stack based conversation manager for Microsoft BotBuilder.

266 lines (228 loc) 10.6 kB
/** * @module botbuilder-dialogs */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { AuthenticationConstants, ClaimsIdentity, GovernmentConstants, SkillValidation } from 'botframework-connector'; import { Dialog, DialogTurnStatus, DialogTurnResult } from './dialog'; import { DialogContext, DialogState } from './dialogContext'; import { DialogEvents } from './dialogEvents'; import { DialogSet } from './dialogSet'; import { DialogStateManager, DialogStateManagerConfiguration } from './memory'; import { Activity, ActivityEx, ActivityTypes, EndOfConversationCodes, BotTelemetryClient, BotTelemetryClientKey, SkillConversationReference, SkillConversationReferenceKey, StatePropertyAccessor, TurnContext, } from 'botbuilder-core'; /** * Runs a dialog from a given context and accessor. * * @param dialog The [Dialog](xref:botbuilder-dialogs.Dialog) to run. * @param context [TurnContext](xref:botbuilder-core.TurnContext) object for the current turn of conversation with the user. * @param accessor Defined methods for accessing the state property created in a BotState object. */ export async function runDialog( dialog: Dialog, context: TurnContext, accessor: StatePropertyAccessor<DialogState> ): Promise<void> { if (!dialog) { throw new Error('runDialog(): missing dialog'); } if (!context) { throw new Error('runDialog(): missing context'); } if (!context.activity) { throw new Error('runDialog(): missing context.activity'); } if (!accessor) { throw new Error('runDialog(): missing accessor'); } const dialogSet = new DialogSet(accessor); dialogSet.telemetryClient = context.turnState.get<BotTelemetryClient>(BotTelemetryClientKey) ?? dialog.telemetryClient; dialogSet.add(dialog); const dialogContext = await dialogSet.createContext(context); await internalRun(context, dialog.id, dialogContext); } /** * @param context The [TurnContext](xref:botbuilder-core.TurnContext) for the turn. * @param dialogId The dialog ID. * @param dialogContext The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param dialogStateManagerConfiguration Configuration for the dialog state manager. * @returns {Promise<DialogTurnResult>} a promise resolving to the dialog turn result. */ export async function internalRun( context: TurnContext, dialogId: string, dialogContext: DialogContext, dialogStateManagerConfiguration?: DialogStateManagerConfiguration ): Promise<DialogTurnResult> { // map TurnState into root dialog context.services context.turnState.forEach((service, key) => { dialogContext.services.push(key, service); }); const dialogStateManager = new DialogStateManager(dialogContext, dialogStateManagerConfiguration); await dialogStateManager.loadAllScopes(); dialogContext.context.turnState.push('DialogStateManager', dialogStateManager); let dialogTurnResult: DialogTurnResult = null; // Loop as long as we are getting valid OnError handled we should continue executing the actions for the turn. // NOTE: We loop around this block because each pass through we either complete the turn and break out of the loop // or we have had an exception AND there was an OnError action which captured the error. We need to continue the // turn based on the actions the OnError handler introduced. let endOfTurn = false; while (!endOfTurn) { try { dialogTurnResult = await innerRun(context, dialogId, dialogContext); // turn successfully completed, break the loop endOfTurn = true; } catch (err) { // fire error event, bubbling from the leaf. const handled = await dialogContext.emitEvent(DialogEvents.error, err, true, true); if (!handled) { // error was NOT handled, throw the exception and end the turn. // (This will trigger the Adapter.OnError handler and end the entire dialog stack) throw err; } } } // save all state scopes to their respective botState locations. await dialogStateManager.saveAllChanges(); // return the redundant result because the DialogManager contract expects it return dialogTurnResult; } async function innerRun( context: TurnContext, dialogId: string, dialogContext: DialogContext ): Promise<DialogTurnResult> { // Handle EoC and Reprompt event from a parent bot (can be root bot to skill or skill to skill) if (isFromParentToSkill(context)) { // Handle remote cancellation request from parent. if (context.activity.type === ActivityTypes.EndOfConversation) { if (!dialogContext.stack.length) { // No dialogs to cancel, just return. return { status: DialogTurnStatus.empty }; } const activeDialogContext = getActiveDialogContext(dialogContext); // Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. return activeDialogContext.cancelAllDialogs(true); } // Process a reprompt event sent from the parent. if (context.activity.type === ActivityTypes.Event && context.activity.name === DialogEvents.repromptDialog) { if (!dialogContext.stack.length) { // No dialogs to reprompt, just return. return { status: DialogTurnStatus.empty }; } await dialogContext.repromptDialog(); return Dialog.EndOfTurn; } } // Continue or start the dialog. let result = await dialogContext.continueDialog(); if (result.status === DialogTurnStatus.empty) { result = await dialogContext.beginDialog(dialogId); } await sendStateSnapshotTrace(dialogContext); if (result.status === DialogTurnStatus.complete || result.status === DialogTurnStatus.cancelled) { if (shouldSendEndOfConversationToParent(context, result)) { // Send End of conversation at the end. const code = result.status == DialogTurnStatus.complete ? EndOfConversationCodes.CompletedSuccessfully : EndOfConversationCodes.UserCancelled; const activity: Partial<Activity> = { type: ActivityTypes.EndOfConversation, value: result.result, locale: context.activity.locale, code: code, }; await context.sendActivity(activity); } } return result; } /** * Helper to determine if we should send an EoC to the parent or not. * * @param context The [TurnContext](xref:botbuilder-core.TurnContext) for the turn. * @param turnResult The dialog turn result. * @returns True if should send EoC, otherwise false. */ export function shouldSendEndOfConversationToParent(context: TurnContext, turnResult: DialogTurnResult): boolean { if (!(turnResult.status == DialogTurnStatus.complete || turnResult.status == DialogTurnStatus.cancelled)) { // The dialog is still going, don't return EoC. return false; } const claimIdentity = context.turnState.get<ClaimsIdentity>(context.adapter.BotIdentityKey); // Inspect the cached ClaimsIdentity to determine if the bot was called from another bot. if (claimIdentity && SkillValidation.isSkillClaim(claimIdentity.claims)) { // EoC Activities returned by skills are bounced back to the bot by SkillHandler. // In those cases we will have a SkillConversationReference instance in state. const skillConversationReference: SkillConversationReference = context.turnState.get( SkillConversationReferenceKey ); if (skillConversationReference) { // If the skillConversationReference.OAuthScope is for one of the supported channels, we are at the root and we should not send an EoC. return ( skillConversationReference.oAuthScope !== AuthenticationConstants.ToBotFromChannelTokenIssuer && skillConversationReference.oAuthScope !== GovernmentConstants.ToBotFromChannelTokenIssuer ); } return true; } return false; } /** * Recursively walk up the DC stack to find the active DC. * * @param dialogContext [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation with the user. * @returns Active [DialogContext](xref:botbuilder-dialogs.DialogContext). */ export function getActiveDialogContext(dialogContext: DialogContext): DialogContext { const child = dialogContext.child; if (!child) { return dialogContext; } return getActiveDialogContext(child); } /** * Determines if the skill is acting as a skill parent. * * @param context [TurnContext](xref:botbuilder-core.TurnContext) object for the current turn of conversation with the user. * @returns A boolean representing if the skill is acting as a skill parent. */ export function isFromParentToSkill(context: TurnContext): boolean { // If a SkillConversationReference exists, it was likely set by the SkillHandler and the bot is acting as a parent. if (context.turnState.get(SkillConversationReferenceKey)) { return false; } // Inspect the cached ClaimsIdentity to determine if the bot is acting as a skill. const identity = context.turnState.get<ClaimsIdentity>(context.adapter.BotIdentityKey); return identity && SkillValidation.isSkillClaim(identity.claims); } // Helper to send a trace activity with a memory snapshot of the active dialog DC. const sendStateSnapshotTrace = async (dialogContext: DialogContext): Promise<void> => { const adapter = dialogContext.context.adapter; const claimIdentity = dialogContext.context.turnState.get<ClaimsIdentity>(adapter.BotIdentityKey); const traceLabel = claimIdentity && SkillValidation.isSkillClaim(claimIdentity.claims) ? 'Skill State' : 'Bot State'; // Send trace of memory const snapshot = getActiveDialogContext(dialogContext).state.getMemorySnapshot(); const traceActivity = ActivityEx.createTraceActivity( 'BotState', 'https://www.botframework.com/schemas/botState', snapshot, traceLabel ); await dialogContext.context.sendActivity(traceActivity); };