Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add interruption support to SkillDialog and runDialog helper method #1867

Merged
merged 2 commits into from
Mar 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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