Skip to content

Commit

Permalink
Add interruption support to SkillDialog and runDialog helper method (#…
Browse files Browse the repository at this point in the history
…1867)

* add resume and reprompt to SkillDialog
* add basic tests

* add runDialog
  • Loading branch information
stevengum authored Mar 6, 2020
1 parent 7f6c6df commit cbafba3
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 35 deletions.
20 changes: 10 additions & 10 deletions libraries/botbuilder-dialogs/src/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export abstract class Dialog<O extends object = {}> extends Configurable {
*
* @param dialogId Optional. unique ID of the dialog.
*/
constructor(dialogId?: string) {
public constructor(dialogId?: string) {
super();
this.id = dialogId;
}
Expand All @@ -245,16 +245,16 @@ export abstract class Dialog<O extends object = {}> extends Configurable {
* @remarks
* This will be automatically generated if not specified.
*/
public get id(): string {
if (this._id === undefined) {
this._id = this.onComputeId();
}
return this._id;
}
public get id(): string {
if (this._id === undefined) {
this._id = this.onComputeId();
}
return this._id;
}

public set id(value: string) {
this._id = value;
}
public set id(value: string) {
this._id = value;
}

/**
* Gets the telemetry client for this dialog.
Expand Down
70 changes: 70 additions & 0 deletions libraries/botbuilder-dialogs/src/dialogHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Activity, ActivityTypes, TurnContext, StatePropertyAccessor } from 'botbuilder-core';
import { DialogContext, DialogState } from './dialogContext';
import { Dialog, DialogTurnStatus } from './dialog';
import { DialogEvents } from './dialogEvents';
import { DialogSet } from './dialogSet';
import { isSkillClaim } from './prompts/skillsHelpers';

export async function runDialog(dialog: Dialog, context: TurnContext, accessor: StatePropertyAccessor<DialogState>): Promise<void> {
const dialogSet = new DialogSet(accessor);
dialogSet.telemetryClient = dialog.telemetryClient;
dialogSet.add(dialog);

const dialogContext = await dialogSet.createContext(context);
const telemetryEventName = `runDialog(${ dialog.constructor.name })`;

const identity = context.turnState.get(context.adapter.BotIdentityKey);
if (identity && isSkillClaim(identity.claims)) {
// The bot is running as a skill.
if (context.activity.type === ActivityTypes.EndOfConversation && dialogContext.stack.length > 0) {
// Handle remote cancellation request if we have something in the stack.
const activeDialogContext = getActiveDialogContext(dialogContext);

const remoteCancelText = 'Skill was canceled by a request from the host.';
await context.sendTraceActivity(telemetryEventName, undefined, undefined, `${ remoteCancelText }`);

// Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order.
await activeDialogContext.cancelAllDialogs(true);
} else {
// Process a reprompt event sent from the parent.
if (context.activity.type === ActivityTypes.Event && context.activity.name === DialogEvents.repromptDialog && dialogContext.stack.length > 0) {
await dialogContext.repromptDialog();
return;
}

// Run the Dialog with the new message Activity and capture the results so we can send end of conversation if needed.
let result = await dialogContext.continueDialog();
if (result.status === DialogTurnStatus.empty) {
const startMessageText = `Starting ${ dialog.id }.`;
await context.sendTraceActivity(telemetryEventName, undefined, undefined, `${ startMessageText }`);
result = await dialogContext.beginDialog(dialog.id, null);
}

// Send end of conversation if it is completed or cancelled.
if (result.status === DialogTurnStatus.complete || result.status === DialogTurnStatus.cancelled) {
const endMessageText = `Dialog ${ dialog.id } has **completed**. Sending EndOfConversation.`;
await context.sendTraceActivity(telemetryEventName, result.result, undefined, `${ endMessageText }`);

// Send End of conversation at the end.
const activity: Partial<Activity> = { type: ActivityTypes.EndOfConversation, value: result.result };
await context.sendActivity(activity);
}
}
} else {
// The bot is running as a standard bot.
const results = await dialogContext.continueDialog();
if (results.status === DialogTurnStatus.empty) {
await dialogContext.beginDialog(dialog.id);
}
}
}

// Recursively walk up the DC stack to find the active DC.
function getActiveDialogContext(dialogContext: DialogContext): DialogContext {
const child = dialogContext.child;
if (!child) {
return dialogContext;
}

return getActiveDialogContext(child);
}
10 changes: 6 additions & 4 deletions libraries/botbuilder-dialogs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

export * from './beginSkillDialogOptions';
export * from './choices';
export * from './memory';
export * from './prompts';
export * from './dialog';
export * from './componentDialog';
export * from './configurable';
export * from './dialog';
export * from './dialogContainer';
export * from './dialogContext';
export * from './dialogEvents';
export { runDialog } from './dialogHelper';
export * from './dialogSet';
export * from './memory';
export * from './prompts';
export * from './skillDialog';
export * from './skillDialogOptions';
export * from './beginSkillDialogOptions';
export * from './waterfallDialog';
export * from './waterfallStepContext';
4 changes: 2 additions & 2 deletions libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Activity, ActivityTypes, Attachment, AppCredentials, CardFactory, Channels, InputHints, MessageFactory, OAuthLoginTimeoutKey, TokenResponse, TurnContext, OAuthCard, ActionTypes, ExtendedUserTokenProvider, verifyStateOperationName, StatusCodes, tokenExchangeOperationName, tokenResponseEventName } from 'botbuilder-core';
import { Activity, ActivityTypes, Attachment, AppCredentials, BotAdapter, CardFactory, Channels, InputHints, MessageFactory, OAuthLoginTimeoutKey, TokenResponse, TurnContext, OAuthCard, ActionTypes, ExtendedUserTokenProvider, verifyStateOperationName, StatusCodes, tokenExchangeOperationName, tokenResponseEventName } from 'botbuilder-core';
import { Dialog, DialogTurnResult } from '../dialog';
import { DialogContext } from '../dialogContext';
import { PromptOptions, PromptRecognizerResult, PromptValidator } from './prompt';
Expand Down Expand Up @@ -279,7 +279,7 @@ export class OAuthPrompt extends Dialog {
let cardActionType = ActionTypes.Signin;
const signInResource = await (context.adapter as ExtendedUserTokenProvider).getSignInResource(context, this.settings.connectionName, context.activity.from.id, null, this.settings.oAuthAppCredentials);
let link = signInResource.signInLink;
const identity = context.turnState.get((context.adapter as any).BotIdentityKey);
const identity = context.turnState.get((context.adapter as BotAdapter).BotIdentityKey);
if((identity && isSkillClaim(identity.claims)) || OAuthPrompt.isFromStreamingConnection(context.activity)) {
if(context.activity.channelId === Channels.Emulator) {
cardActionType = ActionTypes.OpenUrl;
Expand Down
30 changes: 15 additions & 15 deletions libraries/botbuilder-dialogs/src/prompts/skillsHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const AuthConstants = {

export const GovConstants = {
ToBotFromChannelTokenIssuer: 'https://api.botframework.us'
}
};

/**
* @ignore
Expand Down Expand Up @@ -82,20 +82,20 @@ export function getAppIdFromClaims(claims: { [key: string]: any }[]): string {
}
let appId: string;

// Depending on Version, the AppId is either in the
// appid claim (Version 1) or the 'azp' claim (Version 2).
const versionClaim = claims.find(c => c.type === AuthConstants.VersionClaim);
const versionValue = versionClaim && versionClaim.value;
if (!versionValue || versionValue === '1.0') {
// No version or a version of '1.0' means we should look for
// the claim in the 'appid' claim.
const appIdClaim = claims.find(c => c.type === AuthConstants.AppIdClaim);
appId = appIdClaim && appIdClaim.value;
} else if (versionValue === '2.0') {
// Version '2.0' puts the AppId in the 'azp' claim.
const azpClaim = claims.find(c => c.type === AuthConstants.AuthorizedParty);
appId = azpClaim && azpClaim.value;
}
// Depending on Version, the AppId is either in the
// appid claim (Version 1) or the 'azp' claim (Version 2).
const versionClaim = claims.find(c => c.type === AuthConstants.VersionClaim);
const versionValue = versionClaim && versionClaim.value;
if (!versionValue || versionValue === '1.0') {
// No version or a version of '1.0' means we should look for
// the claim in the 'appid' claim.
const appIdClaim = claims.find(c => c.type === AuthConstants.AppIdClaim);
appId = appIdClaim && appIdClaim.value;
} else if (versionValue === '2.0') {
// Version '2.0' puts the AppId in the 'azp' claim.
const azpClaim = claims.find(c => c.type === AuthConstants.AuthorizedParty);
appId = azpClaim && azpClaim.value;
}

return appId;
}
19 changes: 18 additions & 1 deletion libraries/botbuilder-dialogs/src/skillDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import {
SkillConversationIdFactoryOptions,
TurnContext
} from 'botbuilder-core';
import { BeginSkillDialogOptions } from './beginSkillDialogOptions';
import {
Dialog,
DialogInstance,
DialogReason,
DialogTurnResult
} from './dialog';
import { DialogContext } from './dialogContext';
import { BeginSkillDialogOptions } from './beginSkillDialogOptions';
import { DialogEvents } from './dialogEvents';
import { SkillDialogOptions } from './skillDialogOptions';

export class SkillDialog extends Dialog {
Expand Down Expand Up @@ -112,6 +113,22 @@ export class SkillDialog extends Dialog {
await super.endDialog(context, instance, reason);
}

public 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;

await this.sendToSkill(context, activity);
}

public async resumeDialog(dc: DialogContext, reason: DialogReason, result?: any): Promise<DialogTurnResult> {
await this.repromptDialog(dc.context, dc.activeDialog);
return Dialog.EndOfTurn;
}

/**
* Clones the Activity entity.
* @param activity Activity to clone.
Expand Down
33 changes: 30 additions & 3 deletions libraries/botbuilder-dialogs/tests/skillDialog.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { equal, ok: assert, strictEqual } = require('assert');
const { ActivityTypes, TestAdapter, SkillConversationIdFactoryBase, TurnContext } = require('botbuilder-core');
const { DialogContext, SkillDialog } = require('../');
const { Dialog, DialogContext, SkillDialog } = require('../');

const DEFAULT_OAUTHSCOPE = 'https://api.botframework.com';
const DEFAULT_GOV_OAUTHSCOPE = 'https://api.botframework.us';
Expand All @@ -21,8 +21,35 @@ function typeErrorValidator(e, expectedMessage) {
describe('SkillDialog', function() {
this.timeout(3000);

it('', async () => {
it('repromptDialog() should call sendToSkill()', async () => {
const adapter = new TestAdapter(/* logic param not required */);
const context = new TurnContext(adapter, { type: ActivityTypes.Message, id: 'activity-id' });
context.turnState.set(adapter.OAuthScopeKey, DEFAULT_OAUTHSCOPE);
const dialog = new SkillDialog({} , 'SkillDialog');

let sendToSkillCalled = false;
dialog.sendToSkill = () => {
sendToSkillCalled = true;
};

await dialog.repromptDialog(context, {});
assert(sendToSkillCalled, 'sendToSkill not called');
});

it('resumeDialog() should call repromptDialog()', async () => {
const adapter = new TestAdapter(/* logic param not required */);
const context = new TurnContext(adapter, { type: ActivityTypes.Message, id: 'activity-id' });
context.turnState.set(adapter.OAuthScopeKey, DEFAULT_OAUTHSCOPE);
const dialog = new SkillDialog({} , 'SkillDialog');

let repromptDialogCalled = false;
dialog.repromptDialog = () => {
repromptDialogCalled = true;
};

const result = await dialog.resumeDialog(context, {});
assert(repromptDialogCalled, 'sendToSkill not called');
strictEqual(result, Dialog.EndOfTurn);
});

describe('(private) validateBeginDialogArgs()', () => {
Expand Down Expand Up @@ -66,7 +93,7 @@ describe('SkillDialog', function() {
describe('(private) sendToSkill()', () => {
it(`should rethrow the error if its message is not "Not Implemented" error`, async () => {
const adapter = new TestAdapter(/* logic param not required */);
const context = new TurnContext(adapter, { activity: {} });
const context = new TurnContext(adapter, { type: ActivityTypes.Message });
context.turnState.set(adapter.OAuthScopeKey, DEFAULT_OAUTHSCOPE);
const dialog = new SkillDialog({
botId: 'botId',
Expand Down

0 comments on commit cbafba3

Please sign in to comment.