UNPKG

botbuilder-dialogs

Version:

A dialog stack based conversation manager for Microsoft BotBuilder.

457 lines (402 loc) 19.9 kB
/** * @module botbuilder-dialogs */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { Activity, ActivityTypes, Attachment, CardFactory, ConversationReference, DeliveryModes, ExpectedReplies, ExtendedUserTokenProvider, OAuthCard, SkillConversationIdFactoryOptions, StatusCodes, TokenExchangeInvokeRequest, TokenResponse, TurnContext, tokenExchangeOperationName, } from 'botbuilder-core'; import { BeginSkillDialogOptions } from './beginSkillDialogOptions'; import { Dialog, DialogInstance, DialogReason, DialogTurnResult } from './dialog'; import { DialogContext } from './dialogContext'; import { DialogEvents } from './dialogEvents'; import { SkillDialogOptions } from './skillDialogOptions'; import { TurnPath } from './memory/turnPath'; /** * A specialized Dialog that can wrap remote calls to a skill. * * @remarks * The options parameter in beginDialog must be a BeginSkillDialogOptions instance * with the initial parameters for the dialog. */ export class SkillDialog extends Dialog<Partial<BeginSkillDialogOptions>> { protected dialogOptions: SkillDialogOptions; // This key uses a simple namespace as Symbols are not serializable. private readonly DeliveryModeStateKey: string = 'SkillDialog.deliveryMode'; private readonly SkillConversationIdStateKey: string = 'SkillDialog.skillConversationId'; /** * A sample dialog that can wrap remote calls to a skill. * * @remarks * The options parameter in `beginDialog()` must be a `SkillDialogArgs` object with the initial parameters * for the dialog. * * @param dialogOptions The options to execute the skill dialog. * @param dialogId The id of the dialog. */ constructor(dialogOptions: SkillDialogOptions, dialogId?: string) { super(dialogId); if (!dialogOptions) { throw new TypeError('Missing dialogOptions parameter'); } this.dialogOptions = dialogOptions; } /** * Called when the skill dialog is started and pushed onto the dialog stack. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param options Initial information to pass to the dialog. * @returns A Promise representing the asynchronous operation. * @remarks * If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. */ async beginDialog(dc: DialogContext, options: BeginSkillDialogOptions): Promise<DialogTurnResult> { const dialogArgs = this.validateBeginDialogArgs(options); // Create deep clone of the original activity to avoid altering it before forwarding it. const clonedActivity = this.cloneActivity(dialogArgs.activity); // Apply conversation reference and common properties from incoming activity before sending. const skillActivity = TurnContext.applyConversationReference( clonedActivity, TurnContext.getConversationReference(dc.context.activity), true ) as Activity; // Store delivery mode and connection name in dialog state for later use. dc.activeDialog.state[this.DeliveryModeStateKey] = dialogArgs.activity.deliveryMode; // Create the conversationId and store it in the dialog context state so we can use it later. const skillConversationId = await this.createSkillConversationId(dc.context, dc.context.activity); dc.activeDialog.state[this.SkillConversationIdStateKey] = skillConversationId; // Send the activity to the skill. const eocActivity = await this.sendToSkill(dc.context, skillActivity, skillConversationId); if (eocActivity) { return await dc.endDialog(eocActivity.value); } return Dialog.EndOfTurn; } /** * Called when the skill dialog is _continued_, where it is the active dialog and the * user replies with a new [Activity](xref:botframework-schema.Activity). * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @returns A Promise representing the asynchronous operation. * @remarks * If the task is successful, the result indicates whether the dialog is still * active after the turn has been processed by the dialog. The result may also contain a * return value. */ async continueDialog(dc: DialogContext): Promise<DialogTurnResult> { // with adaptive dialogs, ResumeDialog is not called directly. Instead the Interrupted flag is set, which // acts as the signal to the SkillDialog to resume the skill. if (dc.state.getValue<boolean>(TurnPath.interrupted)) { // resume dialog execution dc.state.setValue(TurnPath.interrupted, false); return this.resumeDialog(dc, DialogReason.endCalled); } if (!this.onValidateActivity(dc.context.activity)) { return Dialog.EndOfTurn; } // Handle EndOfConversation from the skill (this will be sent to the this dialog by the SkillHandler if received from the Skill) if (dc.context.activity.type === ActivityTypes.EndOfConversation) { return dc.endDialog(dc.context.activity.value); } // Create deep clone of the original activity to avoid altering it before forwarding it. const skillActivity: Activity = this.cloneActivity(dc.context.activity); skillActivity.deliveryMode = dc.activeDialog.state[this.DeliveryModeStateKey] as string; const skillConversationId: string = dc.activeDialog.state[this.SkillConversationIdStateKey]; // Just forward to the remote skill const eocActivity = await this.sendToSkill(dc.context, skillActivity, skillConversationId); if (eocActivity) { return dc.endDialog(eocActivity.value); } return Dialog.EndOfTurn; } /** * Called when the skill dialog is ending. * * @param context The [TurnContext](xref:botbuilder-core.TurnContext) object for this turn. * @param instance State information associated with the instance of this dialog on the dialog stack. * @param reason [Reason](xref:botbuilder-dialogs.DialogReason) why the dialog ended. * @returns A Promise representing the asynchronous operation. */ async endDialog(context: TurnContext, instance: DialogInstance, reason: DialogReason): Promise<void> { // Send of of conversation to the skill if the dialog has been cancelled. if (reason == DialogReason.cancelCalled || reason == DialogReason.replaceCalled) { const reference = TurnContext.getConversationReference(context.activity); // Apply conversation reference and common properties from incoming activity before sending. const activity = TurnContext.applyConversationReference( { type: ActivityTypes.EndOfConversation }, reference, true ); activity.channelData = context.activity.channelData; const skillConversationId: string = this.getSkillConversationIdFromInstance(instance); // connectionName is not applicable during endDialog as we don't expect an OAuthCard in response. await this.sendToSkill(context, activity as Activity, skillConversationId); } await super.endDialog(context, instance, reason); } /** * Called when the skill dialog should re-prompt the user for input. * * @param context The [TurnContext](xref:botbuilder-core.TurnContext) object for this turn. * @param instance State information for this dialog. * @returns A Promise representing the asynchronous operation. */ async repromptDialog(context: TurnContext, instance: DialogInstance): Promise<void> { // Create and send an envent to the skill so it can resume the dialog. const repromptEvent = { type: ActivityTypes.Event, name: DialogEvents.repromptDialog }; const reference = TurnContext.getConversationReference(context.activity); // Apply conversation reference and common properties from incoming activity before sending. const activity: Activity = TurnContext.applyConversationReference(repromptEvent, reference, true) as Activity; const skillConversationId: string = this.getSkillConversationIdFromInstance(instance); // connectionName is not applicable for a reprompt as we don't expect an OAuthCard in response. await this.sendToSkill(context, activity, skillConversationId); } /** * Called when a child skill dialog completed its turn, returning control to this dialog. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of the conversation. * @param _reason [Reason](xref:botbuilder-dialogs.DialogReason) why the dialog resumed. * @param _result Optional, value returned from the dialog that was called. The type * of the value returned is dependent on the child dialog. * @returns A Promise representing the asynchronous operation. */ async resumeDialog(dc: DialogContext, _reason: DialogReason, _result?: any): Promise<DialogTurnResult> { await this.repromptDialog(dc.context, dc.activeDialog); return Dialog.EndOfTurn; } /** * @protected * Validates the activity sent during continueDialog. * @remarks * Override this method to implement a custom validator for the activity being sent during the continueDialog. * This method can be used to ignore activities of a certain type if needed. * If this method returns false, the dialog will end the turn without processing the activity. * @param _activity The Activity for the current turn of conversation. * @returns True if the activity is valid, false if not. */ protected onValidateActivity(_activity: Activity): boolean { return true; } /** * @private * Clones the Activity entity. * @param activity Activity to clone. */ private cloneActivity(activity: Partial<Activity>): Activity { return JSON.parse(JSON.stringify(activity)); } /** * @private */ private validateBeginDialogArgs(options: BeginSkillDialogOptions): BeginSkillDialogOptions { if (!options) { throw new TypeError('Missing options parameter'); } if (!options.activity) { throw new TypeError('"activity" is undefined or null in options.'); } return options; } /** * @private */ private async sendToSkill( context: TurnContext, activity: Activity, skillConversationId: string ): Promise<Activity> { if (activity.type === ActivityTypes.Invoke) { // Force ExpectReplies for invoke activities so we can get the replies right away and send them back to the channel if needed. // This makes sure that the dialog will receive the Invoke response from the skill and any other activities sent, including EoC. activity.deliveryMode = DeliveryModes.ExpectReplies; } // Always save state before forwarding // (the dialog stack won't get updated with the skillDialog and things won't work if you don't) const skillInfo = this.dialogOptions.skill; await this.dialogOptions.conversationState.saveChanges(context, true); const response = await this.dialogOptions.skillClient.postActivity<ExpectedReplies>( this.dialogOptions.botId, skillInfo.appId, skillInfo.skillEndpoint, this.dialogOptions.skillHostEndpoint, skillConversationId, activity ); // Inspect the skill response status if (!isSuccessStatusCode(response.status)) { throw new Error( `Error invoking the skill id: "${skillInfo.id}" at "${skillInfo.skillEndpoint}" (status is ${response.status}). \r\n ${response.body}` ); } let eocActivity: Activity; let sentInvokeResponses = false; const activitiesFromSkill = response.body && response.body.activities; if (activity.deliveryMode === DeliveryModes.ExpectReplies && Array.isArray(activitiesFromSkill)) { for (const activityFromSkill of activitiesFromSkill) { if (activityFromSkill.type === ActivityTypes.EndOfConversation) { // Capture the EndOfConversation activity if it was sent from skill eocActivity = activityFromSkill; // The conversation has ended, so cleanup the conversation id. await this.dialogOptions.conversationIdFactory.deleteConversationReference(skillConversationId); } else if ( !sentInvokeResponses && (await this.interceptOAuthCards(context, activityFromSkill, this.dialogOptions.connectionName)) ) { // Do nothing. The token exchange succeeded, so no OAuthCard needs to be shown to the user. sentInvokeResponses = true; } else { // If an invoke response has already been sent we should ignore future invoke responses as this // represents a bug in the skill. if (activityFromSkill.type === ActivityTypes.InvokeResponse) { if (sentInvokeResponses) { continue; } sentInvokeResponses = true; } await context.sendActivity(activityFromSkill); } } } return eocActivity; } /** * Tells us if we should intercept the OAuthCard message. * @remarks * The SkillDialog only attempts to intercept OAuthCards when the following criteria are met: * 1. An OAuthCard was sent from the skill * 2. The SkillDialog was called with a connectionName * 3. The current adapter supports token exchange * If any of these criteria are false, return false. * @private */ private async interceptOAuthCards( context: TurnContext, activity: Activity, connectionName: string ): Promise<boolean> { if (!connectionName || !('exchangeToken' in context.adapter)) { // The adapter may choose not to support token exchange, in which case we fallback to showing skill's OAuthCard to the user. return false; } const oAuthCardAttachment: Attachment = (activity.attachments || []).find( (c) => c.contentType === CardFactory.contentTypes.oauthCard ); if (oAuthCardAttachment) { const tokenExchangeProvider: ExtendedUserTokenProvider = (context.adapter as unknown) as ExtendedUserTokenProvider; const oAuthCard: OAuthCard = oAuthCardAttachment.content; const uri = oAuthCard && oAuthCard.tokenExchangeResource && oAuthCard.tokenExchangeResource.uri; if (uri) { try { const result: TokenResponse = await tokenExchangeProvider.exchangeToken( context, connectionName, context.activity.from.id, { uri } ); if (result && result.token) { // If token above is null or undefined, then SSO has failed and we return false. // If not, send an invoke to the skill with the token. return await this.sendTokenExchangeInvokeToSkill( activity, oAuthCard.tokenExchangeResource.id, oAuthCard.connectionName, result.token ); } } catch { // Failures in token exchange are not fatal. They simply mean that the user needs to be shown the skill's OAuthCard. return false; } } } return false; } /** * @private */ private async sendTokenExchangeInvokeToSkill( incomingActivity: Activity, id: string, connectionName: string, token: string ): Promise<boolean> { const ref: Partial<ConversationReference> = TurnContext.getConversationReference(incomingActivity); const activity: Activity = TurnContext.applyConversationReference({ ...incomingActivity }, ref) as any; activity.type = ActivityTypes.Invoke; activity.name = tokenExchangeOperationName; activity.value = { connectionName, id, token } as TokenExchangeInvokeRequest; // Send the activity to the Skill const skillInfo = this.dialogOptions.skill; const response = await this.dialogOptions.skillClient.postActivity<ExpectedReplies>( this.dialogOptions.botId, skillInfo.appId, skillInfo.skillEndpoint, this.dialogOptions.skillHostEndpoint, incomingActivity.conversation.id, activity ); // Check response status: true if success, false if failure return isSuccessStatusCode(response.status); } /** * @private * Create a conversationId to interact with the skill and send the [Activity](xref:botframework-schema.Activity). * @param context [TurnContext](xref:botbuilder-core.TurnContext) for the current turn of conversation with the user. * @param activity [Activity](xref:botframework-schema.Activity) to send. * @returns The Skill Conversation ID. */ private async createSkillConversationId(context: TurnContext, activity: Activity) { const conversationIdFactoryOptions: SkillConversationIdFactoryOptions = { fromBotOAuthScope: context.turnState.get(context.adapter.OAuthScopeKey), fromBotId: this.dialogOptions.botId, activity: activity, botFrameworkSkill: this.dialogOptions.skill, }; // Create a conversationId to interact with the skill and send the activity let skillConversationId: string; try { skillConversationId = await this.dialogOptions.conversationIdFactory.createSkillConversationIdWithOptions( conversationIdFactoryOptions ); } catch (err) { if (err.message !== 'Not Implemented') throw err; // If the SkillConversationIdFactoryBase implementation doesn't support createSkillConversationIdWithOptions(), // use createSkillConversationId() instead. skillConversationId = await this.dialogOptions.conversationIdFactory.createSkillConversationId( TurnContext.getConversationReference(activity) as ConversationReference ); } return skillConversationId; } /** * @private * Gets the Skill Conversation ID from a given instance. * @param instance [DialogInstance](xref:botbuilder-dialogs.DialogInstance) from which to look for its ID. * @returns Instance conversation ID. */ private getSkillConversationIdFromInstance(instance: DialogInstance): string { if (instance && instance.state) { return instance.state[this.SkillConversationIdStateKey]; } return null; } } function isSuccessStatusCode(status: number): boolean { return status >= StatusCodes.OK && status < StatusCodes.MULTIPLE_CHOICES; }