From 3e027bdc6fb6a93353b6a1bfb7371140794c2add Mon Sep 17 00:00:00 2001 From: Josh Gummersall Date: Sun, 18 Oct 2020 17:21:04 -0700 Subject: [PATCH] Update/Delete activities from skills Fixes #1995 --- .../botbuilder/src/botFrameworkAdapter.ts | 2 +- .../botbuilder/src/skills/skillHandler.ts | 199 +++++-- .../tests/skills/skillHandler.test.js | 504 +++++++++++------- 3 files changed, 457 insertions(+), 248 deletions(-) diff --git a/libraries/botbuilder/src/botFrameworkAdapter.ts b/libraries/botbuilder/src/botFrameworkAdapter.ts index a376dc4394..07d9319421 100644 --- a/libraries/botbuilder/src/botFrameworkAdapter.ts +++ b/libraries/botbuilder/src/botFrameworkAdapter.ts @@ -1596,7 +1596,7 @@ export class BotFrameworkAdapter * Override this in a derived class to modify how the adapter creates a turn context. */ protected createContext(request: Partial): TurnContext { - return new TurnContext(this as any, request); + return new TurnContext(this, request); } /** diff --git a/libraries/botbuilder/src/skills/skillHandler.ts b/libraries/botbuilder/src/skills/skillHandler.ts index 3af665e78c..4ece5c1639 100644 --- a/libraries/botbuilder/src/skills/skillHandler.ts +++ b/libraries/botbuilder/src/skills/skillHandler.ts @@ -31,6 +31,14 @@ import { import { ChannelServiceHandler } from '../channelServiceHandler'; import { BotFrameworkAdapter } from '../botFrameworkAdapter'; +/** + * Casts adapter to BotFrameworkAdapter only if necessary + * @param adapter adapter to maybe cast as BotFrameworkAdapter + */ +function maybeCastAdapter(adapter: BotAdapter): BotFrameworkAdapter { + return adapter instanceof BotFrameworkAdapter ? adapter : (adapter as BotFrameworkAdapter); +} + /** * A Bot Framework Handler for skills. */ @@ -40,10 +48,7 @@ export class SkillHandler extends ChannelServiceHandler { * @remarks * The value is the same as the SkillConversationReferenceKey exported from botbuilder-core. */ - public readonly SkillConversationReferenceKey: symbol = SkillConversationReferenceKey; - private readonly adapter: BotAdapter; - private readonly bot: ActivityHandlerBase; - private readonly conversationIdFactory: SkillConversationIdFactoryBase; + public readonly SkillConversationReferenceKey = SkillConversationReferenceKey; /** * Initializes a new instance of the SkillHandler class. @@ -55,24 +60,26 @@ export class SkillHandler extends ChannelServiceHandler { * @param channelService The string indicating if the bot is working in Public Azure or in Azure Government (https://aka.ms/AzureGovDocs). */ public constructor( - adapter: BotAdapter, - bot: ActivityHandlerBase, - conversationIdFactory: SkillConversationIdFactoryBase, + private readonly adapter: BotAdapter, + private readonly bot: ActivityHandlerBase, + private readonly conversationIdFactory: SkillConversationIdFactoryBase, credentialProvider: ICredentialProvider, authConfig: AuthenticationConfiguration, channelService?: string ) { super(credentialProvider, authConfig, channelService); + if (!adapter) { throw new Error('missing adapter.'); } + + if (!bot) { + throw new Error('missing bot.'); + } + if (!conversationIdFactory) { throw new Error('missing conversationIdFactory.'); } - - this.adapter = adapter; - this.bot = bot; - this.conversationIdFactory = conversationIdFactory; } /** @@ -133,7 +140,10 @@ export class SkillHandler extends ChannelServiceHandler { return await this.processActivity(claimsIdentity, conversationId, activityId, activity); } - private static applyEoCToTurnContextActivity(turnContext: TurnContext, endOfConversationActivity: Activity): void { + private static applyEoCToTurnContextActivity( + turnContext: TurnContext, + endOfConversationActivity: Partial + ): void { // transform the turnContext.activity to be an EndOfConversation Activity. turnContext.activity.type = endOfConversationActivity.type; turnContext.activity.text = endOfConversationActivity.text; @@ -148,7 +158,7 @@ export class SkillHandler extends ChannelServiceHandler { turnContext.activity.channelData = endOfConversationActivity.channelData; } - private static applyEventToTurnContextActivity(turnContext: TurnContext, eventActivity: Activity): void { + private static applyEventToTurnContextActivity(turnContext: TurnContext, eventActivity: Partial): void { // transform the turnContext.activity to be an Event Activity. turnContext.activity.type = eventActivity.type; turnContext.activity.name = eventActivity.name; @@ -164,13 +174,12 @@ export class SkillHandler extends ChannelServiceHandler { turnContext.activity.channelData = eventActivity.channelData; } - private async processActivity( - claimsIdentity: ClaimsIdentity, - conversationId: string, - replyToActivityId: string, - activity: Activity - ): Promise { + /** + * @private + */ + private async getSkillConversationReference(conversationId: string): Promise { let skillConversationReference: SkillConversationReference; + try { skillConversationReference = await this.conversationIdFactory.getSkillConversationReference(conversationId); } catch (err) { @@ -193,70 +202,154 @@ export class SkillHandler extends ChannelServiceHandler { if (!skillConversationReference) { throw new Error('skillConversationReference not found'); - } - if (!skillConversationReference.conversationReference) { + } else if (!skillConversationReference.conversationReference) { throw new Error('conversationReference not found.'); } + return skillConversationReference; + } + + /** + * Helper method for forwarding a conversation through the adapter + */ + private async continueConversation( + claimsIdentity: ClaimsIdentity, + conversationId: string, + callback: (adapter: BotFrameworkAdapter, ref: SkillConversationReference, context: TurnContext) => Promise + ): Promise { + const ref = await this.getSkillConversationReference(conversationId); + + // Add the channel service URL to the trusted services list so we can send messages back. + // the service URL for skills is trusted because it is applied based on the original request + // received by the root bot. + AppCredentials.trustServiceUrl(ref.conversationReference.serviceUrl); + + return maybeCastAdapter(this.adapter).continueConversation( + ref.conversationReference, + ref.oAuthScope, + async (context: TurnContext): Promise => { + const adapter = maybeCastAdapter(context.adapter); + + // Cache the claimsIdentity and conversation reference + context.turnState.set(adapter.BotIdentityKey, claimsIdentity); + context.turnState.set(this.SkillConversationReferenceKey, ref); + + return callback(adapter, ref, context); + } + ); + } + + private async processActivity( + claimsIdentity: ClaimsIdentity, + conversationId: string, + activityId: string, + activity: Activity + ): Promise { // If an activity is sent, return the ResourceResponse let resourceResponse: ResourceResponse; /** - * Callback passed to the BotFrameworkAdapter.createConversation() call. - * This function does the following: - * - Caches the ClaimsIdentity on the TurnContext.turnState + * This callback does the following: * - Applies the correct ConversationReference to the Activity for sending to the user-router conversation. * - For EndOfConversation Activities received from the Skill, removes the ConversationReference from the * ConversationIdFactory */ - const callback = async (context: TurnContext): Promise => { - const adapter: BotFrameworkAdapter = context.adapter as BotFrameworkAdapter; - // Cache the ClaimsIdentity and ConnectorClient on the context so that it's available inside of the bot's logic. - context.turnState.set(adapter.BotIdentityKey, claimsIdentity); - context.turnState.set(this.SkillConversationReferenceKey, skillConversationReference); - activity = TurnContext.applyConversationReference( - activity, - skillConversationReference.conversationReference - ) as Activity; - const client = adapter.createConnectorClient(activity.serviceUrl); - context.turnState.set(adapter.ConnectorClientKey, client); - - context.activity.id = replyToActivityId; + await this.continueConversation(claimsIdentity, conversationId, async (adapter, ref, context) => { + const newActivity = TurnContext.applyConversationReference(activity, ref.conversationReference); + context.activity.id = activityId; context.activity.callerId = `${CallerIdConstants.BotToBotPrefix}${JwtTokenValidation.getAppIdFromClaims( claimsIdentity.claims )}`; - switch (activity.type) { + + // Cache connector client in turn context + const client = adapter.createConnectorClient(newActivity.serviceUrl); + context.turnState.set(adapter.ConnectorClientKey, client); + + switch (newActivity.type) { case ActivityTypes.EndOfConversation: await this.conversationIdFactory.deleteConversationReference(conversationId); - SkillHandler.applyEoCToTurnContextActivity(context, activity); + SkillHandler.applyEoCToTurnContextActivity(context, newActivity); await this.bot.run(context); break; case ActivityTypes.Event: - SkillHandler.applyEventToTurnContextActivity(context, activity); + SkillHandler.applyEventToTurnContextActivity(context, newActivity); await this.bot.run(context); break; default: - resourceResponse = await context.sendActivity(activity); + resourceResponse = await context.sendActivity(newActivity); break; } - }; - - // Add the channel service URL to the trusted services list so we can send messages back. - // the service URL for skills is trusted because it is applied based on the original request - // received by the root bot. - AppCredentials.trustServiceUrl(skillConversationReference.conversationReference.serviceUrl); - - await (this.adapter as BotFrameworkAdapter).continueConversation( - skillConversationReference.conversationReference, - skillConversationReference.oAuthScope, - callback - ); + }); if (!resourceResponse) { resourceResponse = { id: uuid() }; } + return resourceResponse; } + + /** + * + * UpdateActivity() API for Skill. + * @remarks + * Edit an existing activity. + * + * Some channels allow you to edit an existing activity to reflect the new + * state of a bot conversation. + * + * For example, you can remove buttons after someone has clicked "Approve" button. + * @param claimsIdentity ClaimsIdentity for the bot, should have AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation ID. + * @param activityId activityId to update. + * @param activity replacement Activity. + */ + protected async onUpdateActivity( + claimsIdentity: ClaimsIdentity, + conversationId: string, + activityId: string, + activity: Activity + ): Promise { + await this.continueConversation(claimsIdentity, conversationId, async (adapter, ref, context) => { + const newActivity = TurnContext.applyConversationReference(activity, ref.conversationReference); + + context.activity.id = activityId; + context.activity.callerId = `${CallerIdConstants.BotToBotPrefix}${JwtTokenValidation.getAppIdFromClaims( + claimsIdentity.claims + )}`; + + return context.updateActivity(newActivity); + }); + + return { id: uuid() }; + } + + /** + * DeleteActivity() API for Skill. + * @remarks + * Delete an existing activity. + * + * Some channels allow you to delete an existing activity, and if successful + * this method will remove the specified activity. + * + * + * @param claimsIdentity ClaimsIdentity for the bot, should have AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation ID. + * @param activityId activityId to delete. + */ + protected async onDeleteActivity( + claimsIdentity: ClaimsIdentity, + conversationId: string, + activityId: string + ): Promise { + // Callback method handles deleting activity + return this.continueConversation( + claimsIdentity, + conversationId, + async (adapter, ref, context): Promise => { + return context.deleteActivity(activityId); + } + ); + } } // Helper function to generate an UUID. diff --git a/libraries/botbuilder/tests/skills/skillHandler.test.js b/libraries/botbuilder/tests/skills/skillHandler.test.js index 1b66c88283..fb5339f3a9 100644 --- a/libraries/botbuilder/tests/skills/skillHandler.test.js +++ b/libraries/botbuilder/tests/skills/skillHandler.test.js @@ -1,262 +1,378 @@ -const { ok: assert, strictEqual } = require('assert'); -const { ActivityHandler, ActivityTypes, CallerIdConstants, SkillConversationReferenceKey } = require('botbuilder-core'); +const assert = require('assert'); +const sinon = require('sinon'); +const { BotFrameworkAdapter, SkillHandler } = require('../../'); +const { ConversationIdFactory } = require('./conversationIdFactory'); + +const { + ActivityHandler, + ActivityTypes, + CallerIdConstants, + SkillConversationReferenceKey, + TurnContext, +} = require('botbuilder-core'); + const { AppCredentials, AuthenticationConfiguration, AuthenticationConstants, ClaimsIdentity, - SimpleCredentialProvider } = require('botframework-connector'); -const { BotFrameworkAdapter, SkillHandler } = require('../../'); -const { ConversationIdFactory } = require('./conversationIdFactory'); + SimpleCredentialProvider, +} = require('botframework-connector'); -describe('SkillHandler', function() { - this.timeout(3000); +describe('SkillHandler', function () { const adapter = new BotFrameworkAdapter({}); const bot = new ActivityHandler(); const factory = new ConversationIdFactory(); - factory.disableCreateWithOptions = true; - factory.disableGetSkillConversationReference = true; const creds = new SimpleCredentialProvider('', ''); const authConfig = new AuthenticationConfiguration(); + const handler = new SkillHandler(adapter, bot, factory, creds, authConfig); - it('should fail construction without required adapter', () => { - try { - const handler = new SkillHandler(undefined, {}, {}, {}, {}); - } catch (e) { - strictEqual(e.message, 'missing adapter.'); - } + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); }); - it('should fail construction without required factory', () => { - try { - const handler = new SkillHandler({}, undefined, {}, {}, {}); - } catch (e) { - strictEqual(e.message, 'missing conversationIdFactory.'); - } + // Supports mocking the turn context and registering expectations on it + const expectsContext = (callback) => { + sandbox.replace(adapter, 'createContext', (activity) => { + const context = new TurnContext(adapter, activity); + callback(sandbox.mock(context)); + return context; + }); + }; + + // Registers expectations that verify a particular skill conversation is returned from the factory + const expectsFactoryGetSkillConversationReference = (ref) => + sandbox + .mock(factory) + .expects('getSkillConversationReference') + .withArgs('convId') + .once() + .returns(Promise.resolve(ref)); + + // Mocks the handler get skill conversation reference method to return a particular conversation reference + const expectsGetSkillConversationReference = (conversationId, serviceUrl = 'http://localhost/api/messages') => + sandbox + .mock(handler) + .expects('getSkillConversationReference') + .withArgs(conversationId) + .returns( + Promise.resolve({ + conversationReference: { serviceUrl }, + oAuthScope: 'oAuthScope', + }) + ); + + // Registers expectation that handler.processActivity is invoked with a particular set of arguments + const expectsProcessActivity = (...args) => + sandbox + .mock(handler) + .expects('processActivity') + .withArgs(...args) + .once(); + + // Registers expectation that bot.run is invoked with a particular set of arguments + const expectsBotRun = (activity, identity) => + sandbox + .mock(bot) + .expects('run') + .withArgs( + sinon + .match((context) => { + assert.deepStrictEqual(context.turnState.get(adapter.BotIdentityKey), identity); + return true; + }) + .and(sinon.match({ activity })) + ) + .once(); + + describe('constructor()', () => { + const testCases = [ + { label: 'adapter', args: [undefined, bot, factory, {}, {}, {}] }, + { label: 'bot', args: [adapter, undefined, factory, {}, {}, {}] }, + { label: 'conversationIdFactory', args: [adapter, bot, undefined, {}, {}, {}] }, + ]; + + testCases.forEach((testCase) => { + it(`should fail without required ${testCase.label}`, () => { + try { + new SkillHandler(...testCase.args); + assert.fail('should have thrown'); + } catch (err) { + assert.strictEqual(err.message, `missing ${testCase.label}.`); + } + }); + }); + + it('should succeed', () => { + new SkillHandler(adapter, bot, factory, creds, authConfig); + }); }); - it('should successfully construct', () => { - const handler = new SkillHandler(adapter, bot, factory, creds, authConfig); + describe('onReplyToActivity()', () => { + it('should call processActivity()', async () => { + const identity = new ClaimsIdentity([]); + const convId = 'convId'; + const activityId = 'activityId'; + const skillActivity = { type: ActivityTypes.Message }; + + expectsProcessActivity(identity, convId, activityId, skillActivity); + await handler.onReplyToActivity(identity, convId, activityId, skillActivity); + sandbox.verify(); + }); }); - it('should call processActivity() from onReplyToActivity()', async () => { - const identity = new ClaimsIdentity([]); - const convId = 'convId'; - const actualReplyToId = '1'; - const skillActivity = { type: ActivityTypes.Message }; - - const handler = new SkillHandler(adapter, bot, {}, creds, authConfig); - handler.processActivity = async function(skillIdentity, conversationId, replyToId, activity) { - strictEqual(skillIdentity, identity); - strictEqual(conversationId, convId); - strictEqual(replyToId, actualReplyToId); - strictEqual(activity, skillActivity); - return { id: 1 }; - }; - - const response = await handler.onReplyToActivity(identity, convId, actualReplyToId, skillActivity); - strictEqual(response.id, 1); + describe('onSendToConversation()', () => { + it('should call processActivity()', async () => { + const identity = new ClaimsIdentity([]); + const convId = 'convId'; + const skillActivity = { type: ActivityTypes.Message }; + + expectsProcessActivity(identity, convId, null, skillActivity); + await handler.onSendToConversation(identity, convId, skillActivity); + sandbox.verify(); + }); }); - it('should call processActivity() from onSendToConversation()', async () => { - const identity = new ClaimsIdentity([]); - const convId = 'convId'; - const skillActivity = { type: ActivityTypes.Message }; - - const handler = new SkillHandler(adapter, bot, {}, creds, authConfig); - handler.processActivity = async function(skillIdentity, conversationId, replyToId, activity) { - strictEqual(skillIdentity, identity); - strictEqual(conversationId, convId); - strictEqual(activity, skillActivity); - strictEqual(replyToId, null); - return { id: 1 }; - }; - - const response = await handler.onSendToConversation(identity, convId, skillActivity); - strictEqual(response.id, 1); + describe('updateActivity()', () => { + it('should call updateActivity on context', async () => { + const convId = 'convId'; + const activityId = 'activityId'; + const updatedActivity = { + type: ActivityTypes.Event, + name: 'eventName', + serviceUrl: 'http://localhost/api/messages', + }; + + expectsGetSkillConversationReference(convId, updatedActivity.serviceUrl); + + expectsContext((context) => + context.expects('updateActivity').withArgs(sinon.match(updatedActivity)).once().resolves() + ); + + await handler.onUpdateActivity(new ClaimsIdentity([]), convId, activityId, updatedActivity); + sandbox.verify(); + }); + }); + + describe('deleteActivity()', () => { + it('should call deleteActivity on context', async () => { + const convId = 'convId'; + const activityId = 'activityId'; + + expectsGetSkillConversationReference(convId); + expectsContext((context) => context.expects('deleteActivity').withArgs(activityId).once().resolves()); + + await handler.onDeleteActivity(new ClaimsIdentity([]), convId, activityId); + sandbox.verify(); + }); }); describe('private methods', () => { - const handler = new SkillHandler(adapter, bot, factory, creds, authConfig); + describe('continueConversation()', () => { + const identity = new ClaimsIdentity([{ type: 'aud', value: 'audience' }]); + const conversationId = 'conversationId'; + const serviceUrl = 'http://initiallyUntrusted/api/messages'; + + it(`should add the original activity's ServiceUrl to the TrustedServiceUrls in AppCredentials`, (done) => { + expectsGetSkillConversationReference(conversationId, serviceUrl); + + assert(!AppCredentials.isTrustedServiceUrl(serviceUrl), 'service URL should initially be untrusted'); + + handler + .continueConversation(identity, conversationId, () => { + assert(AppCredentials.isTrustedServiceUrl(serviceUrl), 'service URL should now be trusted'); + sandbox.verify(); + done(); + }) + .catch(done); + }); + + it('should cache the ClaimsIdentity, ConnectorClient and SkillConversationReference on the turnState', (done) => { + expectsGetSkillConversationReference(conversationId, serviceUrl); + + handler + .continueConversation(identity, conversationId, async (adapter, ref, context) => { + assert.deepStrictEqual( + context.turnState.get(adapter.BotIdentityKey), + identity, + 'cached identity exists' + ); + + assert.deepStrictEqual( + context.turnState.get(handler.SkillConversationReferenceKey), + ref, + 'cached conversation ref exists' + ); + + sandbox.verify(); + done(); + }) + .catch(done); + }); + }); describe('processActivity()', () => { - it('should fail without a conversationReference', async () => { + it('should fail without a skillConversationReference', async () => { + expectsFactoryGetSkillConversationReference(null); + try { await handler.processActivity({}, 'convId', 'replyId', {}); + assert.fail('should have thrown'); } catch (e) { - strictEqual(e.message, 'conversationReference not found.'); + assert.strictEqual(e.message, 'skillConversationReference not found'); } - }); - /* This test should be the first successful test to pass using the built-in logic for SkillHandler.processActivity() */ - it(`should add the original activity's ServiceUrl to the TrustedServiceUrls in AppCredentials`, async () => { - const serviceUrl = 'http://localhost/api/messages'; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; - const skillActivity = { - type: ActivityTypes.Event, - serviceUrl, - }; - bot.run = async (context) => { - assert(context); - assert(AppCredentials.isTrustedServiceUrl(serviceUrl), `ServiceUrl "${ serviceUrl }" should have been trusted and added to AppCredentials ServiceUrl cache.`); - }; - assert(!AppCredentials.isTrustedServiceUrl(serviceUrl)); - await handler.processActivity(identity, 'convId', 'replyId', skillActivity); + sandbox.verify(); }); - const identity = new ClaimsIdentity([{ type: 'aud', value: 'audience' }]); - it('should cache the ClaimsIdentity, ConnectorClient and SkillConversationReference on the turnState', async () => { - const serviceUrl = 'http://localhost/api/messages'; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; - const skillActivity = { - type: ActivityTypes.Event, - serviceUrl, - }; - bot.run = async (context) => { - assert(context); - strictEqual(context.turnState.get(context.adapter.BotIdentityKey), identity); - assert(context.turnState.get(context.adapter.ConnectorClientKey)); - assert(context.turnState.get(handler.SkillConversationReferenceKey)); - }; - const resourceResponse = await handler.processActivity(identity, 'convId', 'replyId', skillActivity); - assert(resourceResponse); - assert(resourceResponse.id); + it('should fail without a conversationReference', async () => { + expectsFactoryGetSkillConversationReference({}); + + try { + await handler.processActivity({}, 'convId', 'replyId', {}); + assert.fail('should have thrown'); + } catch (e) { + assert.strictEqual(e.message, 'conversationReference not found.'); + } + + sandbox.verify(); }); it('should call bot logic for Event activities from a skill and modify context.activity', async () => { - const serviceUrl = 'http://localhost/api/messages'; - const name = 'eventName'; - const relatesTo = { activityId: 'activityId' }; - const replyToId = 'replyToId'; - const entities = [1]; - const localTimestamp = '1'; - const value = '418'; - const timestamp = '1Z'; - const channelData = { channelData: 'data' }; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; + const identity = new ClaimsIdentity([{ type: 'aud', value: 'audience' }]); + const skillActivity = { type: ActivityTypes.Event, - name, relatesTo, entities, - localTimestamp, value, - timestamp, channelData, - serviceUrl, replyToId, - }; - bot.run = async (context) => { - assert(context); - strictEqual(context.turnState.get(context.adapter.BotIdentityKey), identity); - const a = context.activity; - strictEqual(a.name, name); - strictEqual(a.relatesTo, relatesTo); - strictEqual(a.replyToId, replyToId); - strictEqual(a.entities, entities); - strictEqual(a.localTimestamp, localTimestamp); - strictEqual(a.value, value); - strictEqual(a.timestamp, timestamp); - strictEqual(a.channelData, channelData); - strictEqual(a.replyToId, replyToId); + name: 'eventName', + relatesTo: { activityId: 'activityId' }, + entities: [1], + localTimestamp: '1', + value: '418', + timestamp: '1Z', + channelData: { channelData: 'data' }, + serviceUrl: 'http://localhost/api/messages', + replyToId: 'replyToId', }; + + expectsGetSkillConversationReference('convId', skillActivity.serviceUrl); + expectsBotRun(skillActivity, identity); + await handler.processActivity(identity, 'convId', 'replyId', skillActivity); + sandbox.verify(); }); it('should call bot logic for EndOfConversation activities from a skill and modify context.activity', async () => { - const skillConsumerAppId = '00000000-0000-0000-0000-000000000001'; - const skillAppId = '00000000-0000-0000-0000-000000000000'; + const identity = new ClaimsIdentity( + [ + { type: AuthenticationConstants.AudienceClaim, value: '00000000-0000-0000-0000-000000000001' }, + { type: AuthenticationConstants.AppIdClaim, value: '00000000-0000-0000-0000-000000000000' }, + { type: AuthenticationConstants.VersionClaim, value: '1.0' }, + ], + true + ); - const serviceUrl = 'http://localhost/api/messages'; - const text = 'bye'; - const replyToId = 'replyToId'; - const entities = [1]; - const localTimestamp = '1'; - const code = 418; - const timestamp = '1Z'; - const channelData = { channelData: 'data' }; - const value = { three: 3 }; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; const skillActivity = { type: ActivityTypes.EndOfConversation, - text, code, replyToId, entities, - localTimestamp, timestamp, - value, channelData, serviceUrl - }; - const identity = new ClaimsIdentity([ - { type: AuthenticationConstants.AudienceClaim, value: skillConsumerAppId }, - { type: AuthenticationConstants.AppIdClaim, value: skillAppId }, - { type: AuthenticationConstants.VersionClaim, value: '1.0' }, - ], true); - bot.run = async (context) => { - assert(context); - strictEqual(context.turnState.get(context.adapter.BotIdentityKey), identity); - const a = context.activity; - strictEqual(a.type, ActivityTypes.EndOfConversation); - strictEqual(a.text, text); - strictEqual(a.code, code); - strictEqual(a.replyToId, replyToId); - strictEqual(a.entities, entities); - strictEqual(a.localTimestamp, localTimestamp); - strictEqual(a.value, value); - strictEqual(a.timestamp, timestamp); - strictEqual(a.channelData, channelData); - strictEqual(a.replyToId, replyToId); - strictEqual(a.callerId, `${ CallerIdConstants.BotToBotPrefix }${ skillAppId }`); + text: 'bye', + code: 418, + replyToId: 'replyToId', + entities: [1], + localTimestamp: '1', + timestamp: '1Z', + value: { three: 3 }, + channelData: { channelData: 'data' }, + serviceUrl: 'http://localhost/api/messages', }; + + expectsGetSkillConversationReference('convId', skillActivity.serviceUrl); + expectsBotRun(skillActivity, identity); + await handler.processActivity(identity, 'convId', 'replyId', skillActivity); - strictEqual(skillActivity.callerId, undefined); + sandbox.verify(); }); it('should forward activity from Skill for other ActivityTypes', async () => { - const serviceUrl = 'http://localhost/api/messages'; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; - const text = 'Test'; + const identity = new ClaimsIdentity([{ type: 'aud', value: 'audience' }]); + const skillActivity = { type: ActivityTypes.Message, - serviceUrl, text + serviceUrl: 'http://localhost/api/messages', + text: 'Test', }; - const rid = 'rId'; - // Override sendActivities to do nothing. - adapter.sendActivities = async (context, activities) => { - assert(context); - assert(activities); - strictEqual(activities.length, 1); - strictEqual(activities[0].type, ActivityTypes.Message); - strictEqual(activities[0].text, text); - strictEqual(skillActivity.callerId, undefined); - return [{ id: rid }]; - }; + expectsGetSkillConversationReference('convId', skillActivity.serviceUrl); + + sandbox + .mock(adapter) + .expects('sendActivities') + .withArgs(sinon.match.instanceOf(TurnContext), sinon.match.some(sinon.match(skillActivity))) + .once() + .returns(Promise.resolve([{ id: 'responseId' }])); - const resourceResponse = await handler.processActivity(identity, 'convId', 'replyId', skillActivity); - strictEqual(rid, resourceResponse.id); + const response = await handler.processActivity(identity, 'convId', 'replyId', skillActivity); + assert.strictEqual(response.id, 'responseId'); + sandbox.verify(); }); it(`should use the skill's appId to set the callback's activity.callerId`, async () => { const skillAppId = '00000000-0000-0000-0000-000000000000'; const skillConsumerAppId = '00000000-0000-0000-0000-000000000001'; - const adapter = new BotFrameworkAdapter({}); - adapter.credentialsProvider.isAuthenticationDisabled = async () => false; - const identity = new ClaimsIdentity([ - { type: AuthenticationConstants.AudienceClaim, value: skillConsumerAppId }, - { type: AuthenticationConstants.AppIdClaim, value: skillAppId }, - { type: AuthenticationConstants.VersionClaim, value: '1.0' }, - ], true); - const creds = new SimpleCredentialProvider(skillConsumerAppId, ''); - const handler = new SkillHandler(adapter, bot, factory, creds, authConfig); - const serviceUrl = 'http://localhost/api/messages'; - factory.refs['convId'] = { serviceUrl, conversation: { id: 'conversationId' } }; + const identity = new ClaimsIdentity( + [ + { type: AuthenticationConstants.AudienceClaim, value: skillConsumerAppId }, + { type: AuthenticationConstants.AppIdClaim, value: skillAppId }, + { type: AuthenticationConstants.VersionClaim, value: '1.0' }, + ], + true + ); + + // sandbox + // .mock(adapter.credentialsProvider) + // .expects('isAuthenticationDisabled') + // .once() + // .returns(Promise.resolve(false)); + const skillActivity = { type: ActivityTypes.Event, - serviceUrl, - }; - bot.run = async (context) => { - const fromKey = context.turnState.get(SkillConversationReferenceKey); - const fromHandlerKey = context.turnState.get(handler.SkillConversationReferenceKey); - assert(fromKey, 'skillConversationReference was not cached in TurnState'); - assert(fromHandlerKey, 'the key on the SkillHandler did not return TurnState cached value'); - strictEqual(fromKey, fromHandlerKey, 'the keys should return the same cached values'); - strictEqual(context.activity.callerId, `${ CallerIdConstants.BotToBotPrefix }${ skillAppId }`); + serviceUrl: 'http://localhost/api/messages', }; + + expectsGetSkillConversationReference('convId', skillActivity.serviceUrl); + + sandbox + .mock(bot) + .expects('run') + .withArgs( + sinon.match((context) => { + const fromKey = context.turnState.get(SkillConversationReferenceKey); + assert(fromKey, 'skillConversationReference was not cached in TurnState'); + + const fromHandlerKey = context.turnState.get(handler.SkillConversationReferenceKey); + assert(fromHandlerKey, 'the key on the SkillHandler did not return TurnState cached value'); + + assert.strictEqual( + fromKey, + fromHandlerKey, + 'the keys should return the same cached values' + ); + + assert.strictEqual( + context.activity.callerId, + `${CallerIdConstants.BotToBotPrefix}${skillAppId}` + ); + + return true; + }) + ) + .once(); + await handler.processActivity(identity, 'convId', 'replyId', skillActivity); - strictEqual(skillActivity.callerId, undefined); + sandbox.verify(); }); }); });